JakeWharton/SdkSearchを読んでみるメモ

# JakeWharton/SdkSearchとは
Android SDKのドキュメントを見れるAndroidアプリとChromeの拡張機能です。
ただのアプリではなくて、かなりイケている実装が見れます

https://github.com/JakeWharton/SdkSearch


# モジュール構成
いろいろplugin入れたりして表示しようとしていたのですが、さすが、標準でタスクが用意されていました。
./gradlew projectDependencyGraph
![project.dot.png](https://qiita-image-store.s3.amazonaws.com/0/27388/5d153f86-3fd0-55ee-a45c-90f1bdcc3098.png)

**まずモジュール構成がすごい。AndroidとJSで実装を共通化しているっぽい。。？**

# AndroidManifest.xml

ワンアクティビティ構成になっている。

```xml:frontend/android/src/main/AndroidManifest.xml
<activity
        android:name=".ui.MainActivity"
        android:launchMode="singleInstance"
        >
```

# 今回見ていく部分

見ていくとたくさんあってキリがないので入力していくとインクリメンタルサーチされる部分を見ていきます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/64031866-2a41-57e0-d367-f1f8717f0a17.png)

## MainActivity.onCreate()でSearchViewBinderクラスを作る
MainActivityは:frontend:androidです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/ba050bcf-df74-7745-d639-8bb3cc19b03f.png)


onCreateでSearchViewBinderをインスタンス化します。

https://github.com/JakeWharton/SdkSearch/blob/1f1720af0e5d0d2832c9cd0b1027f99b9a58ca75/frontend/android/src/main/java/com/jakewharton/sdksearch/ui/MainActivity.kt#L58

```kotlin:MainActivity.kt#L58
val binder = SearchViewBinder(window.decorView, presenter.events, onClick, onCopy, onShare, onSource)
```

## SearchViewBinderでeditTextをfindView
SearchViewBinderは:search:ui-androidです。

```kotlin
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の変更検知

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/54d92645-466c-6d76-5afb-732711c80585.png)

上2行はTextが入っているかどうかによって表示を変えているようです。
events.offerとはなんでしょうか、、？

```kotlin
    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イベントを送ります。

```kotlin
class SearchViewBinder
(
  view: View,
  private val events: SendChannel<Event>
```

SearchViewBinderのコンストラクタの呼び出しを思い出してみるとこのようになっていて、どうやらpresenter.eventsに通知するようです。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/ui-android/src/main/java/com/jakewharton/sdksearch/search/ui/SearchViewBinder.kt#L40

```kotlin:SearchViewBinder.kt#L40
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

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/presenter/src/main/java/com/jakewharton/sdksearch/search/presenter/SearchPresenter.kt#L26

```kotlin
  private val _events = RendezvousChannel<Event>()
  val events: SendChannel<Event> get() = _events
```

## SearchPresenterでEventを受け取ってStoreから検索結果を取得する
SearchPresenterは:search:presenterモジュールです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/6214bb83-b0bb-e62e-9cc7-2cb3d5fa38c5.png)

couroutineをlaunchで立ち上げconsumeEachで受け取ります。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/presenter/src/main/java/com/jakewharton/sdksearch/search/presenter/SearchPresenter.kt#L18

```kotlin:SearchPresenter.kt#L18
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として投げ直します。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/presenter/src/main/java/com/jakewharton/sdksearch/search/presenter/SearchPresenter.kt#L56

```kotlin:SearchPresenter.kt#L56
    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から取り出しているようです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/feab73a6-ca44-edd8-66d7-1cbf85850a5b.png)

ちなみにSqlItemStoreはstore:android:モジュールになっています。

queryItemsはこういう形で、**ItemQueries#queryTerm**を呼び出して、それを**asChannel**してmapToList()します。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/store/android/src/main/java/com/jakewharton/sdksearch/store/SqlItemStore.kt#L26

```kotlin:SqlItemStore.kt#L26

  override fun queryItems(term: String) =
      db.queryTerm(term.escapeLike('\\')).asChannel(context).mapToList()
```

ItemQueries.queryTermはこういう形でsqldelightを使って、SQLで実際にアクセスするのがかいてある

```kotlin
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しつつ、最初の一回のイベントだけ送ってあげて、変更を検知できるようになっているようです。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/sqldelight-coroutines-extensions/src/main/kotlin/com/squareup/sqldelight/runtime/coroutines/Channels.kt#L11

```kotlin:Channels.kt#L11
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を流します


![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/9228e31e-92a9-fdc4-b4ac-98ab94b385f4.png)

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/presenter/src/main/java/com/jakewharton/sdksearch/search/presenter/SearchPresenter.kt#L64


```kotlin:SearchPresenter.kt#L64
              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しながら流しているようです。

https://github.com/JakeWharton/SdkSearch/blob/334e4754f0e9f8cfbdc198a3fe0e86590880308a/search/presenter/src/main/java/com/jakewharton/sdksearch/search/presenter/SearchPresenter.kt#L96

```kotlin:SearchPresenter.kt#L96
  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を流しています。

```kotlin
    fun sendModel(newModel: Model) {
      model = newModel
      _models.offer(newModel)
    }
```

### MainActivity.onCreateでModelを受け取る
MainActivity.onCreate内のコルーチンのブロックです。前回のmodelと今回のモデルをbindで流してあげています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/27388/4f7a0c71-dd3d-d0e9-3af9-fd9c789ef3bc.png)

https://github.com/JakeWharton/SdkSearch/blob/1f1720af0e5d0d2832c9cd0b1027f99b9a58ca75/frontend/android/src/main/java/com/jakewharton/sdksearch/ui/MainActivity.kt#L61


```kotlin:MainActivity.kt#L61
    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などなどわかっていないところが結構あるのでちゃんと勉強していきたいです

(後で時間があれば追記したりいろいろするかもです)