visible true

技術的なメモを書く

Android向けロギングライブラリ「Loco」をリリースしました。

AndroidアプリでロギングするといえばPureeかなと思います。 かなり安定しているしいい感じに動く。 ただコードベースがJavaなので、たま〜に不具合でた時などに追っかけるのが結構たいへんだったり、 Gsonに依存しているので、別のJsonライブラリ使ってる場合ちょっとな〜ってなったりすることがあります。

かねてから何らかの形で書き直しできないかな〜と思いつつ時間が取れなかったんですが、

ということでついに手を出してみたところ、結構スッと出来上がったのでリリースします。 LocoはLog Coroutineの略です。

https://github.com/sys1yagi/loco

構造

Locoは次のような構造を持ちます。

  • Smasher: ログをシリアライズする // ここだけ料理つながりな命名。ただ直感的ではない気がするので追々Serializerに変わるかも
  • Store: シリアライズしたログを一旦永続化する
  • Sender: シリアライズしたログを送信する
  • SendingScheduler: 送信間隔などを決める

それぞれInterfaceなので好きな実装ができます。

f:id:sys1yagi:20190519185303p:plain

セットアップ

まだjcenterに公開されてない*1のでbintrayのrepositoryをrootのbuild.gradleに追加する必要があります。

allprojects {
  repositories {
    maven { url "https://dl.bintray.com/sys1yagi/maven" }
  }
}

あとはdependenciesに必要なものを追加するだけです。 予めAndroidで使えるSmasherとStoreを用意してあります。

dependencies {
  // core
  implementation 'com.sys1yagi.loco:loco-core:1.0.0'
  
  // Gsonでシリアライズする。filterを追加して加工ができる  
  implementation 'com.sys1yagi.loco:loco-smasher-filterable-gson:1.0.0'

  // SQLiteでログを保存する
  implementation 'com.sys1yagi.loco:loco-store-android-sqlite:1.0.0'
}

この他にも便利モジュールができたら随時追加していきます。汎用性がありそうなものはPRもらえると嬉しいです。

使う

詳しくはsampleを見ていただきたいですが、概ね次のような感じでセットアップします。

class SampleApplication : Application() {
  override fun onCreate() {
    Loco.start(
      LocoConfig(
        store = LocoAndroidSqliteStore(), // loco-store-android-sqlite
        smasher = FilterableGsonSmasher(Gson()), // loco-smasher-filterable-gson
        senders = NetworkSender(), 
        scheduler = IntervalSendingScheduler(5000L) // 5000msおきに送信する
      ) {
            // SenderとLocoLogをマッピングする
            logToSender[NetworkSender::class] = listOf(
              ClickLog::class, // LocoLogを実装したクラスたち
              ScreenLog::class
           )
      }
    )
  }
}

// SendingSchedulerは今の所自前で用意しとく必要があります
class IntervalSendingScheduler(val interval: Long) : SendingScheduler {
  override suspend fun schedule(
    latestResults: List<Pair<Sender, SendingResult>>,
    config: LocoConfig,
    offer: () -> Unit
  ) {
    delay(interval)
    offer()
  }
}

data class ClickLog(
    val value: String
) : LocoLog

data class ScreenLog(
    val screenName: String
) : LocoLog

あとはどこからでもLogを送信できます。

class MainActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    Loco.send(ScreenLog(this::class.java.simpleName))

    setContentView(R.layout.activity_main)
    // ...
  }
}

終わりに

まだ1.0.0で機能が不足してたり不具合あるかもしれないのでいろいろ触ってみてissue作ったりPRもらえると嬉しいです。

https://github.com/sys1yagi/loco

*1:申請中です

FlowのflowViaChannelを使ってRxBindingを置き換える

※こちらは古いので FlowのchannelFlowを使ってRxBindingを置き換える - visible true を参照ください。

Kotlin Coroutine 1.2.xでFlowというコールドストリームをサポートするクラスや関数群が登場しました。

Flow - kotlinx-coroutines-core

次のような感じでめっちゃRxJavaっぽい雰囲気ですが動作の仕組みはコルーチンでやってる感じです。

val f = flowOf(1, 2, 3) // Flowを固定値で作る
  .map {
    it * 2
  }
  .flowOn(Dispatchers.IO) // 実行コンテキストを設定できる

runBlocking {
  f.collect { // この呼出しで初めて値が送出され始める
    println(it)
  }
}

Channelはホットストリームなので取扱いが難しい的な話

本題と逸れるのであんまり語らないですが、こんな話があってFlowが登場しました。

Cold flows, hot channels

produceとかChannelだとObservable的な使い方できなかったり宣言時点で動き出すので管理大変やで〜みたいな感じですね。

例えばRxTextView#textChangesと置き換えてみる

FlowでRxTextView#textChangesのような機能を実装すると次のようになります。

fun TextView.textChangeAsFlow() =
  flowViaChannel<String?> { channel ->
    channel.offer(text.toString())
    val textWatcher = addTextChangedListener {
      channel.offer(it?.toString())
    }
    channel.invokeOnClose {
      removeTextChangedListener(textWatcher)
    }
  }

flowViaChannel関数を使ってChannelを使ったFlowが作れます。

次のように使います。

lifecycleScope.launchWhenCreated {
  binding.editNickName.textChangeAsFlow()
    .map { it?.isNotEmpty() ?: false } 
    .collect {
      binding.submitButton.isEnabled = it
    }
}

collect関数を呼び出した時に、呼び出し元のスコープでFlowが動作し始めます*1。 なので、呼び出し元のスコープがキャンセルされると、自動的にcollect関数も終了します。 例ではlifecycleScopeを使っているので、onStopの時にキャンセルされます。 もしFragmentで使う場合はviewLifecycleOwner.lifecycleScopeで呼び出すなどの工夫が必要そうです。

Flowを1 : nにしたい時はどうするといいかな?

TextViewにaddTextChangedListenerするケースだと、複数回呼び出しても問題ありませんが、 setOnClickListenerなどリスナーが上書きされる性質のものを使う場合は、1 : n の関係でFlowを作りたいですね。

broadcastIn関数を使ってBroadcastChannelを作ると 1 : n のFlowが作れます。

lifecycleScope.launchWhenCreated {
  val textChange = binding.editNickName
    .textChangeAsFlow()
    .broadcastIn(this)
    .asFlow()
  // ... 省略
}

broadcastIn関数は引数にCoroutineScopeを必要とします。BroadcastChannelを作った時点でhot streamになるので、スコープに所属させていないとリークしてしまうからです。broadcastIn関数を呼び出したあとasFlow関数で改めてFlowに変換しています。これでbroadcastIn関数の手前部分がhotになり、それ以降はcoldになります。

使い方は次のようになります。

lifecycleScope.launchWhenCreated {
  val textChange = editNickName
    .textChangeAsFlow()
    .broadcastIn(this)
    .asFlow()

  launch {
    textChange
      .map { it?.isNotEmpty() ?: false }
      .collect {
        sendButton.isEnabled = it
      }
  }
  launch {
    textChange
      .map { 140 - (it?.length ?: 0) }
      .collect { remain ->
        editNickNameLimit.text = "remain: $remain"
      }
  }
}

よさそう。

おわりに

Flow便利そうですね。RxBindingの機能は多岐に渡るので、今回のお試しだけで全部置き換えられるかっていうとわからないですが、概ねいけるんじゃないかなーと思います。

ところでFlowはまだpreviewなのでプロダクトへの投入は推奨されていません。 とはいえ大体うまく動くのでまぁちょっとくらいならいいんじゃないかなと思ったりしますが、 Structured Concurrencyの時のようにめちゃくちゃ変わる可能性もあるんでほどほどにしましょう。

IntelliJ IDEAからDocker上で動くSprint Bootアプリにdebuggerをつなぐ

IntelliJからdocker上で動いてるSpring Bootアプリにデバッガつなぎたいときってありますよね。 ドチャクソハマったのでメモします。

Dockerで動かすSpring Bootアプリでデバッグ用ソケットを起動する

gradleを使っている前提です。 gradleでSpring Bootを起動するタスクはbootRunです。 これに--debug-jvmオプションを付与すると、5005番でデバッグを受け付けてくれます。 docker-composeの設定イメージは次です。

version: '3'
services:
  app:
    image: openjdk:8
    container_name: TodoList
    ports:
      - 8080:8080
      - 5005:5005 # デバッグ用ポートも開けてあげる
    command: ./gradlew app:bootRun --debug-jvm # このオプションをつける

これでdocker-compse upするとサーバ起動前に5005番で待ち受けてくれます。

IntelliJのRemote Configurationで5005番に接続する

Edit ConfigurationでRemote Configurationを追加する。デフォルトで5005番なのでそのままでOK

f:id:sys1yagi:20190404163913p:plain

あとはつなぐだけ

f:id:sys1yagi:20190404164957p:plain

やったね。

handshake failed - connection prematurally closedが出る時は

localだとつながるのにDocker経由だとこんなエラーでてつながらない現象に苦しみました。

Error running 'debug': Unable to open debugger port (localhost:5005): java.io.IOException "handshake failed - connection prematurally closed"

原因はいろいろあるみたいですが、とりあえず自分の環境で起きた原因は、IntelliJの動作で使っているJVMと、Docker上で動いているJVMのバージョンが異なるからでした。 docker-composeで利用するimageをopenjdk:10からopenjdk:8にしたら繋げられました。 やったね。

おわりに

毎回5005番を待ち受けるのでsuspend=nみたいなオプション渡せるといいんだけどよくわからない。

KotlinTestのBehaviorSpecでGivenの単位でBeforeしたい

KotlinTestいいんですよね。 AndroidじゃなくてSpringBootで使ってるんですが。 Androidでも使えるかな?どうかな?まだ無理っぽい

beforeSpecはSpec開始時に一度だけしか実行されない

KotlinTestのSpec群はbeforeなんとか、afterなんとか系の関数を持っていて、オーバーライドして使うんですが、例えばbeforeSpec(spec: Spec)なんかだと、 Spec開始時に一度だけしか実行されないので、Given単位でDBの状態変えたいなって時に使えないんですよね。

beforeTestでisTopLevelを見る

別のタイミングとしてbeforeTest(testCase: TestCase)というのがあるんですが、こっちはGiven, When, Thenの全部で呼ばれる。これだとGivenでDB作ってもWhen行く時には消えてしまう。 beforeGivenとかないんかなと思ったけどないらしい。

beforeTestにはTestCaseが渡ってくる。TestCaseにいろんな情報があるので、これを使って判定すると良さそうってことでいろいろガチャガチャやってたら、どうもGivenはisTopLevelがtrueになるらしいということがわかった。

ということでbeforeTestでこんな感じでやると良さそう。

class TodoResourceSpec(
  val mockMvc: MockMvc,
  val dataSource: DataSource
) : BehaviorSpec() {

  override fun beforeTest(testCase: TestCase) {
    if (testCase.isTopLevel()) { // Givenの時だけtrueになる
      dbSetup(dataSource) {
        truncate("todo")
      }.launch()
    }
  }

  init {
    Given("I have empty todo list") { // ここと
       When("get todo list") {
         val result = mockMvc.perform(
             get("/api/todo")
               .contentType(MediaType.APPLICATION_JSON)
         )
         Then("should return empty list") {
           result.andExpect(status().isOk)
             .andExpect(content().json("""[]"""))
         }
      }
    }
    Given("I have a one todo") { // ここでtruncateが走る
        // 省略
    }
  } 
}

おわり

BehaviorSpecは抽象クラスなので、継承してbeforeGivenとか関数生やしてもいいかもしれないと思った。 TestCase#nameにはWhenとかThenとか文字列で書いてあるのでそれを見てbeforeWhenとかもできそう。 もっと違う方法あるきもする

Safe Args PluginでParcelable Arrayを使いたい時は型の末尾に[]って書けばいいらしい

ObjectArrayTypeとputParcelableArray

実装を読んでみるとObjectArrayTypeという型があり、putParcelableArraygetParcelableArrayマッピングしてるぽいことがわかる。

https://android.googlesource.com/platform/frameworks/support/+/9d3737306a6092c8ce8a98163b3a8070f0dcab7e/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/Types.kt#152

型の末尾の[]をチェック

https://android.googlesource.com/platform/frameworks/support/+/9d3737306a6092c8ce8a98163b3a8070f0dcab7e/navigation/safe-args-generator/src/main/kotlin/androidx/navigation/safe/args/generator/Types.kt#47

あとここで、型の末尾に[]があるかチェックしている。

なのでこんな感じに書けばよい。

<argument
  android:name="authors"
  app:argType="jp.dip.sys1.aozora.models.AuthorCard[]" />

AuthorCardはもちろんParcelableでなければならない。

おわりに

うしさんありがとう。

Pass data between destinations  |  Android Developersには、

You can check Array to indicate that the argument should be an array of the selected Type value.

と書いてあるので、いずれAndroid Studio上でもarrayかどうかのチェックをUIで設定できるようになるはず(canaryだと動いてそう)。

リリース前レポート(Firebase Test Lab)で動作しているか判定する

リリース前レポート助かりますよね。Firebase Test Labベースでいろいろ根掘り葉掘りやってくれて最近ではフィードバックもいろいろ充実していて良い感じです

f:id:sys1yagi:20190218164210p:plain

リリース前レポートで動いてるのか判定したい

リリース前レポートいいんですけどロボットがガチャガチャ適当にやるので、あんまり掘ってほしくないルートに行ったりもするんですよ。

たとえば個人アプリでFirebase AuthでGoogleログインをサポートしてたりするんですが、Googleアカウント新規作成みたいなところまでいっちゃうことがよくあって最終的にクラッシュ扱いでレポートが上がってきたりします。

f:id:sys1yagi:20190218164908p:plain:w250
おい

また、広告表示なんかも抑えたいですね。実際に機械によるクリックを防止しろとドキュメントに書いてあったりします。リンクの先はなんか広告側で頑張ってブロックしてね的なドキュメントがあるんですが実施するのは難しい感じで厳しい感じでした。

f:id:sys1yagi:20190218165256p:plain

firebase.test.labフラグを見る

なんとかできないかな〜とつぶやいていたら2年前に自分で実装していたらしい。

完全に忘れてました。ということでFirebase Test Lab and Android Studio  |  FirebaseにあるようにFirebase Test Labの環境で動いているかを次のコードで判定できます。

fun isTestLab(context: Context): Boolean =
    Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"

やったね

おわりに

次は忘れないように書きました。ちばさんありがとう!

java.lang.ClassCastException: com.sun.tools.javac.code.Type$1 cannot be cast to javax.lang.model.type.DeclaredTypeが出たらテストで使うコードがエラーになってるぽい

AndroidX対応を進めていたら自分でつくって自分で使っているFragment生成周りが便利になるhttps://github.com/sys1yagi/fragment-creatorが適切にAndroidX対応できてないぽいことがわかり、いろいろと更新してたらテストで次のエラーがでてこけるようになってしまった。

java.lang.RuntimeException: java.lang.ClassCastException: com.sun.tools.javac.code.Type$1 cannot be cast to javax.lang.model.type.DeclaredType

これはアノテーションプロセッサで型チェックをしている場所で起こる。

https://github.com/sys1yagi/fragment-creator/blob/master/processor/src/main/java/com/sys1yagi/fragmentcreator/model/EnvParser.java#L49

compile-testingで読み込むJavaFileObjectがコンパイルできない場合に起こる

色々調べてみると、https://github.com/google/compile-testingで読み込むテスト用のJavaファイルをロードする時、Javaファイルで使ってるクラスを解決できない場合起こるぽいという事がわかった。設定をいじったから色々変になったのかな〜と思っていたのだけど、テストで使うコードのクラスパスが通ってない結果起こるエラーだとは...。

ということでテストで使うコード(たとえば https://github.com/sys1yagi/fragment-creator/blob/master/processor/src/test/assets/TypeSerializerFragment.java )を見るとandroidx.fragment.app.Fragmentを使うようになっていた。なるほどテストのクラスパスにandroidx.fragment:fragmentを通さないといけないらしい。

先人のコードを参考にする

JSR269周りでAndroidX対応を始めたタイミングとしては大分後発だと思うのできっと先人達がいい感じにしてるに違いないということでいくつかのリポジトリを渡り歩いた。

めちゃくちゃドンピシャだったのがPermissionsDispatcherで、ほぼ全体的に参考にさせてもらった。わいわい。 Replace deprecated "com.google.android" dependency with android.jar · permissions-dispatcher/PermissionsDispatcher@a5e0cac · GitHub

参考になったのは大きく2点

おわりに

AndroidXをサポートした Fragment Creator 2.0.0でました。