JakeWharton/SdkSearchとは
Android SDKのドキュメントを見れるAndroidアプリとChromeの拡張機能です。
ただのアプリではなくて、かなりイケている実装が見れます
モジュール構成
いろいろplugin入れたりして表示しようとしていたのですが、さすが、標準でタスクが用意されていました。
./gradlew projectDependencyGraph
まずモジュール構成がすごい。AndroidとJSで実装を共通化しているっぽい。。?
AndroidManifest.xml
ワンアクティビティ構成になっている。
<activity
android:name=".ui.MainActivity"
android:launchMode="singleInstance"
>
今回見ていく部分
見ていくとたくさんあってキリがないので入力していくとインクリメンタルサーチされる部分を見ていきます。
MainActivity.onCreate()でSearchViewBinderクラスを作る
MainActivityは:frontend:androidです。
onCreateでSearchViewBinderをインスタンス化します。
val binder = SearchViewBinder(window.decorView, presenter.events, onClick, onCopy, onShare, onSource)
SearchViewBinderでeditTextをfindView
SearchViewBinderは:search:ui-androidです。
class SearchViewBinder
(
view: View,
private val events: SendChannel<Event>,
private val onClick: ItemHandler,
private val onCopy: ItemHandler,
private val onShare: ItemHandler,
private val onSource: ItemHandler
) {
private val queryInput: EditText = view.findViewById(R.id.query)
SearchViewBinderのコンストラクタ内でEditTextの変更検知
上2行はTextが入っているかどうかによって表示を変えているようです。
events.offerとはなんでしょうか、、?
queryInput.onTextChanged {
queryClear.isVisible = it.isNotEmpty()
queryInput.typeface = if (it.isEmpty()) Typeface.DEFAULT else robotoMono
events.offer(Event.QueryChanged(it.toString()))
}
eventsはKotlin CoroutineのSendChannelになっています。そこにEvent.QueryChangedイベントを送ります。
class SearchViewBinder
(
view: View,
private val events: SendChannel<Event>
SearchViewBinderのコンストラクタの呼び出しを思い出してみるとこのようになっていて、どうやらpresenter.eventsに通知するようです。
val binder = SearchViewBinder(window.decorView, presenter.events, onClick, onCopy, onShare, onSource)
eventsの実態はRendezvousChannelになっています。調べてみるとBufferを持たないChannelらしくこれをEventBusみたいに使っているのかなという感じを受けました
https://github.com/Kotlin/kotlinx.coroutines/blob/379f210f1d6f6ee91d6198348bd16306c93153ce/core/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/channels/RendezvousChannel.kt#L20
private val _events = RendezvousChannel<Event>()
val events: SendChannel<Event> get() = _events
SearchPresenterでEventを受け取ってStoreから検索結果を取得する
SearchPresenterは:search:presenterモジュールです。
couroutineをlaunchで立ち上げconsumeEachで受け取ります。
class SearchPresenter(
private val context: CoroutineDispatcher,
private val store: ItemStore,
private val synchronizer: ItemSynchronizer
) {
private val _models = ConflatedBroadcastChannel<Model>()
val models: ReceiveChannel<Model> get() = _models.openSubscription()
private val _events = RendezvousChannel<Event>()
val events: SendChannel<Event> get() = _events
fun start(): Job {
val job = Job()
var model = Model()
fun sendModel(newModel: Model) {
model = newModel
_models.offer(newModel)
}
...
launch(context, parent = job) {
var activeQuery = ""
var activeQueryJob: Job? = null
_events.consumeEach {
when (it) {
...
consumeEachで受け取ってから、どのイベントかを判定して、クエリの文字列が入っていれば、Coroutineをまた立ち上げて、200ms後にstoreのqueryItems(query)を呼び出し、結果を受け取ります。そして後述になりますが、Modelとして投げ直します。
launch(context, parent = job) {
var activeQuery = ""
var activeQueryJob: Job? = null
_events.consumeEach {
when (it) {
...
is Event.QueryChanged -> {
val query = it.query
if (query != activeQuery) {
activeQuery = query
activeQueryJob?.cancel()
if (query == "") {
sendModel(model.copy(queryResults = Model.QueryResults("", emptyList())))
} else {
activeQueryJob = launch(context) {
delay(200, TimeUnit.MILLISECONDS)
store.queryItems(query).consumeEach {
sendModel(model.copy(queryResults = Model.QueryResults(activeQuery, it)))
}
}
}
}
}
}
}
}
...
return job
}
storeのqueryItems(query)でアイテムを取ってくる
とりあえずDBから取り出しているようです。
ちなみにSqlItemStoreはstore:android:モジュールになっています。
queryItemsはこういう形で、ItemQueries#queryTermを呼び出して、それをasChannelしてmapToList()します。
override fun queryItems(term: String) =
db.queryTerm(term.escapeLike('\\')).asChannel(context).mapToList()
ItemQueries.queryTermはこういう形でsqldelightを使って、SQLで実際にアクセスするのがかいてある
fun <T : Any> queryTerm(value: String, mapper: (
id: Long,
packageName: String,
className: String,
deprecated: Boolean,
link: String
) -> T): Query<T> {
val statement = database.getConnection().prepareStatement("""
|SELECT item.*
|FROM item_index
|JOIN item ON (docid = item.id)
|WHERE content LIKE '%' || ?1 || '%' ESCAPE '\'
...
|LIMIT 50
""".trimMargin(), SqlPreparedStatement.Type.SELECT)
statement.bindString(1, value)
return QueryTerm(value, statement) { resultSet ->
mapper(
resultSet.getLong(0)!!,
resultSet.getString(1)!!,
resultSet.getString(2)!!,
resultSet.getLong(3)!! == 1L,
resultSet.getString(4)!!
)
}
}
QueryをどうやってReceiveChannelに変換するかは以下のように行っています。
addListenerしつつ、最初の一回のイベントだけ送ってあげて、変更を検知できるようになっているようです。
fun <T : Any> Query<T>.asChannel(context: CoroutineContext): ReceiveChannel<Query<T>> {
val channel = RendezvousChannel<Query<T>>()
val listenerChannel = ListenerReceiveChannel(this, context, channel)
addListener(listenerChannel)
// Trigger initial emission.
listenerChannel.queryResultsChanged()
return listenerChannel
}
/** A single type which is both the query listener and receive channel delegate to save memory. */
private class ListenerReceiveChannel<T : Any>(
private val query: Query<T>,
private val context: CoroutineContext,
private val channel: Channel<Query<T>>
) : Query.Listener, ReceiveChannel<Query<T>> by channel {
override fun queryResultsChanged() {
// TODO associate this job with the channel so that it gets canceled
launch(context) {
channel.send(query)
}
}
override fun cancel(cause: Throwable?): Boolean {
query.removeListener(this)
return channel.cancel(cause)
}
}
mapToList()は以下のようになっていました。
fun <T : Any> ReceiveChannel<Query<T>>.mapToList() = map { it.executeAsList() }
PresenterでDBから取得して受け取り結果のModelを流す
以下のようにconsumeEachで受け取って、sendModelでModelを流します
if (query == "") {
sendModel(model.copy(queryResults = Model.QueryResults("", emptyList())))
} else {
activeQueryJob = launch(context) {
delay(200, TimeUnit.MILLISECONDS)
store.queryItems(query).consumeEach {
sendModel(model.copy(queryResults = Model.QueryResults(activeQuery, it)))
}
}
}
Modelは以下のようになっていて、これをcopyしながら流しているようです。
data class Model(
val count: Long = 0,
val queryResults: QueryResults = QueryResults(),
val syncStatus: SyncStatus = SyncStatus.IDLE
) {
data class QueryResults(
val query: String = "",
val items: List<Item> = emptyList()
)
enum class SyncStatus {
IDLE, SYNC, FAILED
}
}
sendModelは以下のようになっていて、modelsを流しています。
fun sendModel(newModel: Model) {
model = newModel
_models.offer(newModel)
}
MainActivity.onCreateでModelを受け取る
MainActivity.onCreate内のコルーチンのブロックです。前回のmodelと今回のモデルをbindで流してあげています。
binderJob = launch(Unconfined) {
var oldModel: SearchPresenter.Model? = null
for (model in presenter.models) {
binder.bind(model, oldModel)
oldModel = model
}
}
感想
UIのイベントをEventとしてPresenterに流して、Presenterで表示のためのModelを管理して、その変更をUIに流すという形で管理していました。
流し方としてはKotlinのChannelを使って流していました。
紹介していなかったのですが、画面回転を経てCoroutineのJobを生き残らせるテクニックとしてonRetainNonConfigurationInstance()を使っていたりして、なかなかすごかったです。
個人的にはCoroutineのスレッドの管理やChannelなどなどわかっていないところが結構あるのでちゃんと勉強していきたいです
(後で時間があれば追記したりいろいろするかもです)