will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

InputMethodService(キーボード)開発の勘所となりそうな項目

この記事はAndroid Advent Calendar 2016 - Qiitaの11日目の記事です。

昨日は@yuyakaidoさんのData Binding Tipsでした。 明日は@rei-mさんのDagger2とMockitoでUIテストはじめる話です。

11日目はAndroid StudioでKotlinのプロジェクトが新規で作られるソースを
そろそろKotlinを選べるになってほしいmatsuokahが担当いたします。

InputMethodServiceに触れていて、キーボードのクセを掴まねば!ということで、勘所となりそうな項目を書いてみます。

InputMethodService(以下、ほぼ同義のIME)とは

いわゆるキーボードの継承元となるServiceです。
Simeji、Google日本語入力、POBox、ATOKが日本では有名ですね。

IMEはググっても、ヒット数が少ないのでマイナーな分野ですね。

IMEアプリの作り方はハンズオン記事を見ればわかります。
サンプルもあります。

ざっくりキーボードの作り方

  1. InputMethodServiceを継承したクラスを作成
  2. Android Manifestにサービスの定義を行う
  3. カスタムしていく

本当にざっくりですがこんな感じです。
ハンズオン記事ではMainActivityに<category android:name="android.intent.category.LAUNCHER"/>を記載していないですが、
設定アプリとしてActivityを使うはずなので消さなくて良いでしょう。

何のメソッドをオーバーライドすべきか?

を見て、リストしてみました。

Intentionally emptyとコメントが書かれているメソッド

  • onBindInput
  • onUnbindInput
  • onInitializeInterface
  • onStartInput・・・初期化
  • onStartInputView
  • onStartCandidatesView
  • onWindowShown
  • onWindowHidden
  • onDisplayCompletions
  • onViewClicked
  • onUpdateCursor
  • onUpdateCursorAnchorInfo

デフォルトでnullを返却しているメソッド

  • onCreateInputView・・・ここでビューを作成する
  • onCreateCandidatesView

デフォルトでfalseを返却している

  • onTrackballEvent
  • onGenericMotionEvent

フルスクリーンモード

  • onEvaluateFullscreenMode・・・画面を回転させた時に、フルスクリーンモードにするか否か

ということで、基本的にはここらへんのメソッドをオーバーライドしてIMEをカスタムしていくことになります。
リストにはライフサイクルに関わるメソッドも含まれています。

また、

ライフサイクル

f:id:matsuokah:20161211020251p:plain

Android Developers - creating-input-methodより転載

というような単純なライフサイクルとなっています。
しかし、思わぬ所でイベントが発火されるケースが幾つかあります。

操作して、ライフサイクルでログを追っていきましょう。

f:id:matsuokah:20161211021719g:plain

これが最もシンプルなIMEの操作だと思います。実際のログは以下のとおりです

## MyImeへ切り替えを開始
D/ImeService: onCreate
D/ImeService: onCreateInputMethodInterface
D/ImeService: onCreateInputMethodSessionInterface
D/ImeService: onInitializeInterface
D/ImeService: onBindInput
D/ImeService: onStartInput, restarting : false
D/ImeService: onCreateInputView
D/ImeService: onCreateCandidatesView
D/ImeService: onStartInputView, restarting : false
D/ImeService: onWindowShown
## IMEの表示が完了

## 別のIMEへの切り替えを開始
D/ImeService: onFinishInputView, finishingInput :   true
D/ImeService: onFinishInput
D/ImeService: onStartInput, restarting : false
D/ImeService: onStartInputView, restarting : false
D/ImeService: onUnbindInput
D/ImeService: onFinishInputView, finishingInput :   true
D/ImeService: onFinishInput
D/ImeService: onDestroy

思わぬ所でイベントが発火されるケース

と、述べましたが onStartInput, onStartInputViewが何故かonFinishXXXの後に呼ばれています。
私はStartと書いてあるのでIMEが立ち上がって入力が開始された時だけ発火すると勘違いしていました。

ではなぜ発火されているか。それは、編集していたEditTextのフォーカスが外れた時に、IMEの状態をリフレッシュするためです。

IMEは1つのインスタンスであるのに対し、画面には複数のEditTextが存在することが多いです。
編集するEditTextが切り替わると、すでに入力されているテキストや入力タイプ(Numeric, AlphaNumeric, Passwordなど)が変わります。
したがって、onStartInputでは一時的な入力データなどを破棄し、引数で与えられているEditorInfoオブジェクトの情報から次の入力に備える必要があります

実はSDKのドキュメント冒頭にあるGenerating Textという項目で述べられています。ドキュメントはしっかり読もう。

そして、ログに出てきてないので気づきにくいのですがonWindowShownに対し、onWindowHiddenが呼ばれていません。
onWindowHiddenIMEが破棄されないが非表示になるときタスクマネージャ起動時に発火されます。

従ってonWindowHiddenでTearDownするのは好ましくない実装だと思います。

onStart系のライフサイクルメソッドでスクラップアンドビルドしていくのが良いかなと個人的に思います。

Fragmentが使えない

私はIMEでもFragmentが使えると思いこんでいました。
ところがFragmentはActivityに依存しているのでServiceで使えないのは当然なわけであります。

iOSのLINEのスタンプのようなUIを実装してみようと思っていたのですが、
Fragmentが使えないのでViewPagerを使うにはAdapterの独自実装が必要となります。

LINEのUIなので、画像が多い前提となるため、Fragmentのライフサイクルの様にページを破棄することも考慮に入れる必要があります。

ということで、ひとまず最低限の実装でFragment・FragmentManager・FragmentPagerAdapterの実装を真似て作ってみました。FragmentのTransactionは実装していません。

f:id:matsuokah:20161211035021g:plain

ImeFragment.kt

abstract class ImeFragment() {
  lateinit var service : InputMethodService
  val context : Context
  get() = service


  var tag : String = ""
  var containerId : Int = 0
  var view : View? = null

  open fun onCreate() {}
  open fun onCreateView(inflater: LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? { return null }
  open fun onAttach() {}
  open fun onDetach() {}
  open fun onDestroyView() {}
  open fun onDestroy() {}
}

ImeFragmentManager.kt

class ImeFragmentManager(val service : InputMethodService, val rootView : View) {

  val fragments : HashMap<String, ImeFragment> = HashMap()
  val attached : HashMap<String, ImeFragment> = HashMap()

  fun add(containerId : Int, imeFragment : ImeFragment, tag : String) {
    if (fragments.containsKey(tag)) throw IllegalArgumentException("already container has fragment")

    imeFragment.containerId = containerId
    imeFragment.tag = tag

    fragments[tag] = imeFragment
    attach(containerId, imeFragment, tag)
  }

  fun remove(tag : String) {
    if (attached.containsKey(tag)) {
      detach(attached[tag]!!)
    }

    val fragment = fragments[tag]!!
    fragment.onDestroy()
    fragments.remove(tag)
  }

  fun findFragmentByTag(tag : String) : ImeFragment? = fragments[tag]

  fun attach(containerId : Int, imeFragment : ImeFragment, tag : String) {
    if (!fragments.containsKey(tag)) throw IllegalArgumentException("fragment is not added")
    if (attached.containsKey(tag)) throw IllegalArgumentException("already container had attached")
    attached[tag] = imeFragment

    imeFragment.service = service
    val container = rootView.findViewById(containerId) as? ViewGroup
    imeFragment.view = imeFragment.onCreateView(LayoutInflater.from(container?.context), container, null)

    container?.addView(imeFragment.view)
    imeFragment.onAttach()
  }

  fun detach(imeFragment : ImeFragment) {
    if (!attached.containsValue(imeFragment)) throw IllegalArgumentException("fragment not found")
    val container = rootView.findViewById(imeFragment.containerId) as? ViewGroup
    container?.removeAllViews()
    imeFragment.onDestroyView()

    attached.remove(imeFragment.tag)
    imeFragment.onDetach()
  }
}

ImeFragmentPagerAdapter.kt

abstract class ImeFragmentPagerAdapter(private val imeFragmentManager : ImeFragmentManager) : PagerAdapter() {

  abstract fun getItem(position : Int) : ImeFragment

  override fun instantiateItem(container : ViewGroup?, position : Int) : Any {
    container ?: throw IllegalArgumentException("")

    val tag = createTag(container.id, position)
    var fragment = imeFragmentManager.findFragmentByTag(tag)

    if (fragment == null) {
      fragment = getItem(position)
      imeFragmentManager.add(container.id, fragment, createTag(container.id, position))
    } else {
      imeFragmentManager.attach(container.id, fragment, createTag(container.id, position))
    }

    return fragment
  }

  override fun destroyItem(container : ViewGroup?, position : Int, obj : Any?) {
    val fragment = obj as ImeFragment
    imeFragmentManager.remove(fragment.tag)
  }

  override fun isViewFromObject(view : View?, obj : Any?) : Boolean = (obj as ImeFragment).view == view

  companion object {
    fun createTag(viewId : Int, itemId : Int) : String = "android:ime_switcher:$viewId:$itemId"
  }
}

Fragmentっぽい実装を用意することで、Page単位にコントローラを分離できること・ドメインレイヤーとの繋ぎの所にもできるので欠かせないな〜という所。
ココらへん、ライブラリ化して公開します。多分。