LoginSignup
17
6

More than 5 years have passed since last update.

JakeWharton/SdkSearchを読んでみるメモ

Last updated at Posted at 2018-06-03

JakeWharton/SdkSearchとは

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

モジュール構成

いろいろplugin入れたりして表示しようとしていたのですが、さすが、標準でタスクが用意されていました。
./gradlew projectDependencyGraph
project.dot.png

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

AndroidManifest.xml

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

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

今回見ていく部分

見ていくとたくさんあってキリがないので入力していくとインクリメンタルサーチされる部分を見ていきます。
image.png

MainActivity.onCreate()でSearchViewBinderクラスを作る

MainActivityは:frontend:androidです。

image.png

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

MainActivity.kt#L58
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の変更検知

image.png

上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に通知するようです。

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

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

SearchPresenterでEventを受け取ってStoreから検索結果を取得する

SearchPresenterは:search:presenterモジュールです。

image.png

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

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として投げ直します。

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

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

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

SqlItemStore.kt#L26

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

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

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しながら流しているようです。

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を流しています。

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

MainActivity.onCreateでModelを受け取る

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

image.png

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

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

17
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
6