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アプリの作り方はハンズオン記事を見ればわかります。
サンプルもあります。
ざっくりキーボードの作り方
- InputMethodServiceを継承したクラスを作成
- Android Manifestにサービスの定義を行う
- カスタムしていく
本当にざっくりですがこんな感じです。
ハンズオン記事では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をカスタムしていくことになります。
リストにはライフサイクルに関わるメソッドも含まれています。
また、
ライフサイクル
Android Developers - creating-input-methodより転載
というような単純なライフサイクルとなっています。
しかし、思わぬ所でイベントが発火されるケースが幾つかあります。
操作して、ライフサイクルでログを追っていきましょう。
これが最もシンプルな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
が呼ばれていません。
onWindowHidden
はIMEが破棄されないが非表示になるときかタスクマネージャ起動時に発火されます。
従ってonWindowHidden
でTearDownするのは好ましくない実装だと思います。
onStart系のライフサイクルメソッドでスクラップアンドビルドしていくのが良いかなと個人的に思います。
Fragmentが使えない
私はIMEでもFragmentが使えると思いこんでいました。
ところがFragmentはActivityに依存しているのでServiceで使えないのは当然なわけであります。
iOSのLINEのスタンプのようなUIを実装してみようと思っていたのですが、
Fragmentが使えないのでViewPagerを使うにはAdapterの独自実装が必要となります。
LINEのUIなので、画像が多い前提となるため、Fragmentのライフサイクルの様にページを破棄することも考慮に入れる必要があります。
ということで、ひとまず最低限の実装でFragment・FragmentManager・FragmentPagerAdapterの実装を真似て作ってみました。FragmentのTransactionは実装していません。
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単位にコントローラを分離できること・ドメインレイヤーとの繋ぎの所にもできるので欠かせないな〜という所。
ココらへん、ライブラリ化して公開します。多分。