私が出会った最高のEMたち
[2020.12.8 追記]
ブコメでEMが何かわからないと書かれていたので補足。EM = Engineering Managerです。EM菌ではありません!!!
[追記ここまで]
今の会社でお世話になったEMの人たちのマネジメントがとてもよかったので育休で全てを忘れる前にメモを残す。EMの話題はよく見かけるけれど、マネジメントされる側の視点で語られることがあまりなかった気がするのでいい記録になるかもしれない。
前提
- 自分: メンバー(マネジメントされる側)。Androidエンジニア。ある程度放置されても自走できる。
- EM: 一人ではなく複数。(歴代という意味。同時に複数人のEMにマネジメントされたという意味ではない。)彼らはみなAndroidエンジニアではないがモバイルもしくはフロントエンドのエンジニア。なので技術の相談はしないが、開発業務そのものについてはとても詳しい。
- 組織: エンジニアリング組織がある程度きっちり作られているので、レポートラインも明確だし評価の基準も明確。OKRで目標を管理する。
ここがよかった!マネジメントポイント
認識を最初に合わせる
マネジメントで個人的に一番重要だと思っているのは、(1)期待値を共有して (2)目標を合意し (3)目標が達成できたか一緒に振り返る(=評価) のサイクルがちゃんと回っていることだと思う。1
(1)の期待値を共有する前に、まずマネジメントされる側(私)がどういうタイプで何を重視しているか聞いてもらえたのが面白いなと思った。確かに、「会社ではそこそこのお給料もらって残業せずに趣味のOSS活動に時間と脳のリソースを割きたい」というタイプの人に「こうすれば昇進できるからもっと頑張ろう!こういう目標どう!?!?」みたいな話しても全く響かないと思う。ちなみに自分は普通に昇進してお給料上げたいタイプ。今の自分がこういうタイプです、という前提を共有して(1)、じゃあ今後どういうキャリアに進みたいか?そのために今期はこういう目標にしましょうという合意をする(2)。(2)で決めた目標は定期的な1on1で二人でチェック。ヤバそうだったら相談し、期末の評価時に決定的な手遅れな状態になるのを防ぐ。2 この1on1も、何のためにやるのか(目標の進捗チェック?それともただの雑談?)を最初に共有するのが大事。ある程度放っておかれてもそれなりに仕事をこなせる人にもちゃんと定期的に時間をかけてくれるのはありがたかった。3
また、1on1ではマネジメントされる側の話を一方的にするのではなく、マネジメントするがわが今どういう状況なのかシェアしてくれるのもよかった。
相談した時のアクションがめちゃ早い
1on1で「これもしかしたら後々問題になるかも」とぽろっと言ったことに対し、数時間後には関係者に連絡がとられ会議が設定され、そのままいい感じに解決されたことがあって本当にすごいと思った。相談した時のアクションが素早いと信頼貯金が爆上がりするし次も相談しようという気になる。次も相談したくなるというのは大事なポイントだと思っていて、「この人に何言ってもしょうがないな…」と思われたらマネジメント関係が成り立たなくなり相互に不幸である。
叱る時のポイント
マネージャーは何かをやらかしたメンバーを叱る必要がある。よく言われる事だが、みんなの前ではなく会議室に呼び出されて注意されたのは注意される側としてよかった。叱るときも「今回は初めてだからいいよ。誰でも失敗するから。でも次からはダメだよ」と仏のような注意だった😇
キャリアアップを手伝ってくれる
「一つ上のレベルに昇進させたいんだけど、ボードメンバーを説得するにはhogehogeの経験がないと厳しい…そういうわけでこのプロジェクトやってみない?」その後本当にレベルが上がって最高オブ最高でした。ありがとうございました。
信頼貯金を失うことをしない
私は初対面の人のことを基本的にいい人だと思っているので、その人が(私にとって)残念なことをしない限りは信頼貯金が失われることはない。信頼貯金を失うような行動というのは人によると思うが、一般的に「約束をちゃんと守る」「人を傷つけるような行動はしない」「自分の職務は責任を持って行う」など人として当たり前のことを守っていれば大丈夫じゃないかなと思っている。当たり前と書いたが、この当たり前を当たり前に実行できる人は意外と少ないのではないかなと思う。
マネジメントされる側としてやるといいこと
ドキュメントを用意する
話したいトピックを1on1までに事前にドキュメントに書いておく。箇条書きレベルで良い。1on1ではそのドキュメントをお互い見ながら話をする。Google docsなどリアルタイムで共同編集できる物があると良い。話しながらアドバイスやNext action、追加で話した話題などを書き込んでいくとそのまま議事録になるし、書かれたことが意図と違うとそこで気がつけるのであとで困ったことにならないで済む。この議事録は期末の自己評価の際にも使えるので便利。
自分からアラートがあげられる
当たり前だけど、困ったことが起きたときは自分からちゃんとアラートをあげられることが大事。定期的な1on1の予定がずっと先で、このままでは手遅れになりそうだったらちゃんとEMを捕まえて報告・相談する。上記の「認識を最初に合わせる」でも書いたが、ここで「EMがどれくらい忙しそうか?」「今EMがやっている仕事内容よりも優先的に聞いてもらうべき内容か?」を判断するために日頃からEMが今何をやっていてどれくらい忙しいかを把握しておいた方がいい。
おまけ:1on1のときにいつも聞いていたこと
- 「私が知っておくべきことは何かありますか?」
- 自分が関わる他チームの動向なんかを教えてもらえることがある
- 「今何に忙しいですか?」
- 上で書いた通り
言いたいことは以上です。
産休・育休中に読んで良かった本のまとめ
はじめに
産休・育休中に読んで良かった本を周囲の方々に教えてもらったので読んで良かったもののメモ。オススメしてもらった量がすごいので、特に自分の興味にあっているものや実際の育児に役立った(子どもの個性に合ったor親のライフスタイルに合った)物を選んで書いていく。
実用的なやつ
ネントレ本
【改訂版】カリスマ・ナニーが教える 赤ちゃんとおかあさんの快眠講座
- 作者:ジーナ・フォード
- 発売日: 2020/01/20
- メディア: 単行本
賛否両論ある(?)ジーナ本、うちの子には合っているようでやって良かった。生活リズムが早いうちから整ったことで家族全員が楽になった。あと1日のスケジュールが細かく決まっているのが逆に助かった。子育て中は意思決定に脳のリソースがかなり持っていかれる実感があるので、「あと1時間でミルク」「これが終わったらお風呂」などタスクを誰かに決めてもらうのが精神的に楽だった。
発達全般
はじめてママ&パパの育児―0~3才赤ちゃんとの暮らし 気がかりがスッキリ! (実用No.1シリーズ)
- 発売日: 2014/09/10
- メディア: 単行本(ソフトカバー)
細かい月齢ごとに何ができるようになる・どんなことに気をつけるかが書いてあるので非常に参考になった。月齢が上がるたびに見返している。
離乳食
これを読んで主食はライスシリアルにしたけど、その他は今のところ手作りしている。うちの子はブレンダーの音が怖いらしくマジ泣きするのでBaby Brezzaを買ってかなり助かった。
ブレッツァ フードメーカー シリコンボトル入り"蒸す・きざむ・つぶす"までの調理が自動タイマーをセットしたらあとはおまかせ
- 発売日: 2018/02/26
- メディア: Baby Product
産後すれ違い対策
もしこれを読んでいるあなたが「今妊娠中で最高に幸せでハッピーな子育てライフが待っているはず!」という人であればここから先は読まない方がいいかもしれない。(私はビビリなので妊娠中にこの手の本をたくさん読んだが、今のところ夫との関係は変わらず良好である。)
産後クライシス
第1章から衝撃的なデータに横っ面を殴られる。そのデータとは、「配偶者といると本当に愛していると実感する」と答えた人の割合が男女でかなり差が出る。2歳時期になると夫が51.7%に対して妻が34.0%...ちなみに妊娠期は夫も妻も同じ74.3%なので2年間に何が起こったのか想像すると恐ろしくなる。こうした事態を避けるために妻向け・夫向けにそれぞれアドバイスが書かれていた。これだけ見ると夫を責める方向に向かうように見えるかもしれないが、実際は今の日本の社会システムのせいでで夫が育児参加したくてもできないなどの問題点が指摘されていて妻側の啓蒙にも良いと思う。
ふたりは同時に親になる
先ほどの「産後クライシス」よりも若干マイルドな気がする。一番記憶に残ったのは挿絵で、妻が赤ちゃんを背負って崖にぶら下がっているところを「(仕事で)俺無理だから家事代行頼めば?」と言って立ち去り家事代行の人と母が妻を引っ張り上げている絵(NGケース)と、「みんなを連れて助けに来たぞ!」と言って家事代行の人と母と一緒に妻を引っ張り上げる男性の絵(OKケース)。文字で説明するのが難しいので何言ってんだって感じだと思うけど、この絵を見たときにとても腹落ちした。
その他の感想はtweetのツリーをどうぞ
会社の先輩パパママにおすすめしてもらった「ふたりは同時に親になる」読んでる
— Atsuko Fukui (@muumuumuumuu) 2020年3月31日
産後はどうやらコミュニケーションがクリティカルな問題になりそうなので、遅くとも夫が育休から仕事復帰のタイミングでweeklyくらいで1 on 1 やってもらったほうがよさそう👀#読書ログhttps://t.co/onl3ODHKVU
科学的な読み物
ここからは完全に趣味の話です。
ヒトの発達の謎を解く
赤ちゃんが大きくなる時に脳の中で何が起こっているのか?読んでいると「人間、よくこんなので社会を維持してるな…」って思える。内容が気になる方はこちらのツリーをどうぞ。
「ヒトの発達の謎を解く」(明和政子著) #読書メモ
— Atsuko Fukui (@muumuumuumuu) 2020年3月7日
ヒトの乳児と養育者が行う遊びの場面は主に3種
1. 刺激的接触(つつくなど)
2. 道具的接触(おもちゃなど)
3. 情愛的接触(抱擁など)
このうち、学習動機を高め主体的な行動を引き出すのはなんと3! 意外だ。
「家族の幸せ」の経済学
「家族の幸せ」の経済学 データ分析でわかった結婚、出産、子育ての真実 (光文社新書)
- 作者:山口 慎太郎
- 発売日: 2019/07/17
- メディア: 新書
これは紹介してもらったのではなく、Kindleセールで見つけて面白そうだったのでポチったやつ。かなり良かった。社会学というか面白い統計データの紹介で、日本だとデータが少ないのか外国の事例が多かった。そのためどこまで日本社会に適用できるかわからないが普通に読んでて楽しかったのでおすすめ。 面白ポイントはこちらのツリーをどうぞ。
「家族の幸せ」の経済学(山口慎太郎著)#読書ログ
— Atsuko Fukui (@muumuumuumuu) 2020年8月11日
人はなぜ結婚するのか、誰とカップルになるか等の問はこれまで社会学的なアプローチが多かったが、マッチングサービスの登場で統計的な処理が可能になったというのは面白い!しかしマッチングサービスで人々はいい方向に詐称しがちらしいw
子どもは40000回質問する
これもKindleセールで買ったやつ。邦題が割とアレで、実際は子供にフォーカスした本ではなく、原題(Curious)の通り好奇心全般について書かれた本。なので育児本だと思って読み始めると肩透かしな可能性が高い。個人的には面白かったので興味がある人はこちらのツリーをどうぞ。
「Curious: The Desire to Know and Why Your Future Depends On It(邦題:子どもは40000回質問する)」イアン・レズリー著#読書ログ
— Atsuko Fukui (@muumuumuumuu) 2020年9月28日
喃語を発したタイミングで物の名前を教えると、その名前を覚える確率が高まるらしい。喃語を発したら何を知りたがっているか想像して対応してみよう。
言いたいことは以上です。
Datastoreのエラーハンドリングの仕組みを調べたメモ
Android JetpackのDatastoreがリリースされた。1
非同期処理でread/writeができるshared preferenceみたいなものかというぼんやりした理解だったが、このつぶやきを拝見して
SharedPreference 、ファイル IO がサイレントに失敗してて保存できてなくて次回起動時?に未保存状態になるらしいんだけど、まぁ気がつけないので問題を問題として認識できないという問題がある
— wada811 (@wada811) 2020年9月24日
この問題を認識しないと DataStore は意味不明だよね
実際どれくらい発生してるのかも謎なので難しい
Datastoreはどうやってエラーハンドリングしているのか興味を持ったので、内部実装を調べてみた。(ver. 1.0.0-alpha01
)
Datastore内部実装
まず、writeに失敗してIOExceptionが出るのはここ
internal class SingleProcessDataStore<T>( internal fun writeData(newData: T) { file.createParentDirectories() val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX) try { FileOutputStream(scratchFile).use { stream -> serializer.writeTo(newData, stream) stream.fd.sync() // TODO(b/151635324): fsync the directory, otherwise a badly timed crash could // result in reverting to a previous state. } if (!scratchFile.renameTo(file)) { throw IOException("$scratchFile could not be renamed to $file") } } catch (ex: IOException) { if (scratchFile.exists()) { scratchFile.delete() } throw ex } }
ここで投げられたexceptionはdownstream channelに流れる。
private val actor: SendChannel<Message<T>> = scope.actor( capacity = UNLIMITED ) { try { messageConsumer@ for (msg in channel) { if (msg.dataChannel.isClosedForSend) { // The message was sent with an old, now closed, dataChannel. This means that // our read failed. continue@messageConsumer } try { readAndInitOnce(msg.dataChannel) } catch (ex: Throwable) { resetDataChannel(ex) continue@messageConsumer } // We have successfully read data and sent it to downstreamChannel. if (msg is Message.Update) { msg.ack.completeWith( runCatching { // この関数の中で上記のwriteData()がcallされている transformAndWrite(msg.transform, downstreamChannel()) } ) } } } finally { // The scope has been cancelled. Cancel downstream in case there are any collectors // still active. downstreamChannel().cancel() } }
runCatching{}
はblock内で発生したexceptionをResultでwrapして返却するので、exceptionはcompleteWith()
の引数に渡される。
Message.Update
のackはupdateData()
でawaitされてreturnしている。
override suspend fun updateData(transform: suspend (t: T) -> T): T { val ack = CompletableDeferred<T>() val dataChannel = downstreamChannel() val updateMsg = Message.Update<T>(transform, ack, dataChannel) actor.send(updateMsg) // If no read has succeeded yet, we need to wait on the result of the next read so we can // bubble exceptions up to the caller. Read exceptions are not bubbled up through ack. if (dataChannel.valueOrNull == null) { dataChannel.asFlow().first() } // Wait with same scope as the actor, so we're not waiting on a cancelled actor. return withContext(scope.coroutineContext) { ack.await() } }
で、このupdateData()
はDataStore#edit()
でcallされる。
suspend fun DataStore<Preferences>.edit( transform: suspend (MutablePreferences) -> Unit ): Preferences { return this.updateData { // It's safe to return MutablePreferences since we make a defensive copy in // PreferencesDataStore.updateData() it.toMutablePreferences().apply { transform(this) } } }
こうやって書けばエラーをハンドリングできる
Datastoreの使い方の概要ページ見たらeditをコールする時にexceptionのcatchとかしてないけど、disk書き込みエラーがたまーに発生するのであればちゃんとcatchした方がいいと思った。
下記は既存の値を読み込んで1足して保存する場合の例。
try { dataStore.edit { settings -> val currentValue = settings[KEY_FOO]?.toMutableSet() ?: mutableSetOf() settings[KEY_FOO] = currentValue + 1 } } catch (e: IOException) { // do something if need (e.g. retry) }
ちなみにshared preferenceのapply)は特にexceptionを投げてくれないのでそもそもエラーを検出できないっぽい。
MotionLayoutでvisibilityを制御するときのメモ書き
MotionLayoutでvisibilityを制御するときにちょっと困ったのでメモを残す。
やりたいこと
- あるイベント契機でアニメーションを表示する
- イベントはコードからtrigger
- アニメーションとして画像を画面中央から上部に動かす(Twitterのお誕生日の風船みたいな感じ)
- アニメーションはMotionLayoutでstartとendを定義するが、startのタイミングでvisibilityを
VISIBLE
にしたい。
こうやれば動くよ
Layout定義
最低限のattributeだけ書く。残りはいい感じに補完すること。
アニメーションしたい画像のvisibilityはGONE
にしておく。
<androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/motion_base" > <ImageView android:id="@+id/animation_image" android:visibility="gone" /> />
MotionScene
ポイントはvisibilityの設定をLayout
タグの中ではなく、PropertySet
の中に記載すること。ちなみにLayout
タグの中でvisibilityをVISIBLE
にしてしまうとxml layout fileが読み込まれたタイミングでvisibilityがVISIBLE
になってしまう。(Layout
タグの中は省略しているので適宜補完。)
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:motion="http://schemas.android.com/apk/res-auto"> <Transition motion:constraintSetEnd="@+id/end" motion:constraintSetStart="@id/start" motion:duration="1000"> </Transition> <ConstraintSet android:id="@+id/start"> <Constraint android:id="@+id/animation_image"> <Layout android:layout_width="0dp" android:layout_height="wrap_content"/> <PropertySet android:visibility="visible" app:visibilityMode="ignore" /> </Constraint> </ConstraintSet> <ConstraintSet android:id="@+id/end"> <Constraint android:id="@+id/animation_image" android:translationY="-300dp" /> </ConstraintSet> </MotionScene>
アニメーションをtriggerするコード
PropertySet
でvisibilityを変更するのに加えて、アニメーションをtriggerする直前にコードでもvisibilityを変えてやらないとVISIBLE
にならない。
findViewById<ImageView>(R.id.animation_image).visibility = View.VISIBLE findViewById<MotionLayout>(R.id.motion_base).transitionToEnd()
言いたいことは以上です。
Scoped Storageでパス指定してファイルにアクセスできなくなった
はじめに
見て見ぬふりをしてきたScoped Storageにハマったのでメモ書きを残す。
Android Q (10) から導入されたScoped Storage。「端末内部のfileやらのアクセス権限周りや保存領域が変わったんだろうなー」くらいの雑な理解で見て見ぬふりを続けてきたが思わぬところでハマってしまった。
Glideで画像を表示できなくなった
以前は動いていたコードが動かなくなった。具体的にはContentResolver経由で MediaStore.Video.Media.DATA
のStringを取ってきて、Glideのinto()
に渡してもpermissionがないというerrorが出て画像が表示されない。
val PROJECTION = listOf( MediaStore.Video.Media.DATA, ) val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(), null, null, MediaStore.Video.Media.DATE_MODIFIED) if (cursor.moveToLast()) { do { val data = cursor.getString(cursor.getColumnIndex(PROJECTION[0])) } while (cursor.moveToPrevious()) } cursor.close()
Glide.with(context.applicationContext) .load(data) .into(imageView) // 表示されない
AndroidManifestに android:requestLegacyExternalStorage="true"
をつけてやると上記のコードでも動くので、Scoped Storage周りで問題が起きているのは間違いない。ちなみに、android:requestLegacyExternalStorage="true"
でとりあえずこの問題は回避できるが、
警告: 来年度のメジャー プラットフォーム リリースでは、ターゲット SDK レベルに関係なく、すべてのアプリで対象範囲別ストレージが必須となります。
とあるので早めに対処することをお勧めする。
^ 上記ページはなぜか言語設定を英語にすると別のページに転送される。同じ内容の英語リソースが見つけられなかった。
対処法
Scoped Storageが導入に伴い、MediaStore.Video.Media.DATA
がdeprecatedになった。代わりにBaseColumns._ID
を取得し、ContentUris.withAppendedId()
を使ってUriを取得すれば行ける。
参考
以下コード例
val PROJECTION = listOf( BaseColumns._ID, ) val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(), null, null, MediaStore.Video.Media.DATE_MODIFIED) if (cursor.moveToLast()) { do { val idColumnIndex = cursor.getColumnIndex(PROJECTION[0]) val contentUri = ContentUris.withAppendedId( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, cursor.getLong(idColumnIndex) ) } while (cursor.moveToPrevious()) } cursor.close()
Glide.with(context.applicationContext)
.load(contentUri.toString) // 同じString型を渡しても今後は表示される
.into(imageView)
何が起きているのか
せっかくなのでGlideの内部実装を追ってみた。(version: 4.11.0)
into()
がコールされた後に画像のロードが走るが、その際にload()
で渡されたstringのパターンを見ている。stringが '/'で始まるかどうかで処理が分岐する。
public class StringLoader<Data> implements ModelLoader<String, Data> { @Nullable private static Uri parseUri(String model) { Uri uri; if (TextUtils.isEmpty(model)) { return null; // See https://pmd.github.io/pmd-6.0.0/pmd_rules_java_performance.html#simplifystartswith } else if (model.charAt(0) == '/') { uri = toFileUri(model); } else { uri = Uri.parse(model); String scheme = uri.getScheme(); if (scheme == null) { uri = toFileUri(model); } } return uri; } private static Uri toFileUri(String path) { return Uri.fromFile(new File(path)); }
MediaStore.Video.Media.DATA
でStringを取ってくる場合は/
で始まるパスになっている。
例:
/storage/emulated/0/DCIM/Camera/VID_20200821_00000001.mp4
一方、BaseColumns._ID
からUriを取得する場合は(当たり前だが)パスではなくUriになっている。
例:
content://media/external/video/media/12345
ここでの処理の分岐で画像が出たり出なかったりする。
前者はパスを指定してFileオブジェクトを生成しようとするが、ここでpermissionがないとしてerrorになる。下記リンクは先ほどと同じページだが、パス経由でファイルにアクセスできないと明記してある。繰り返しになるが、この情報は英語リソースで見つけることができない。誰か見つけたらリンク教えてください…
注: 対象範囲別ストレージするアプリは、「/sdcard/DCIM/IMG1024.JPG」のようなパスに対する直接カーネル アクセス権限を有していません。アプリがこのようなファイルにアクセスするには、MediaStore を使用して、openFile() などのメソッドを呼び出す必要があります。
複数のsubscribersに対応したFlowとテストの書き方
MVVMアーキテクチャにおいて、一つのModelを複数のView Modelで共有している場合、あるイベントを複数のView Modelで同時に受け取りたいことがある。これをKotlin Coroutine Flowを使って書くとどうなるのかなぁと思ったのでやってみたメモ。テストも書いた。
前回のエントリでPaging Library 3の内部実装を読んだ際に同じようなことをやっているようなコード があったので参考にした。
実装例
例として「requestがあったらremote source (api serverとか)から何か値を取ってきてflowにemitする」ようなservice classを実装する。コード例はこちらのgistにまとめてある。
Example of Kotlin Coroutine Flow with multiple subscribers · GitHub
まずはクラス定義。コンストラクタ引数にCoroutineDispatcher
を渡せるようにしておく。これはテストの時にTestCoroutineDispatcher
でcoroutineを動かすために必要。デフォルト引数でDispatchers.IO
を指定し、テスト以外では引数を指定しなくて良いようにしておいた。
class FooService(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {
privateなchannelを定義する。複数のsubscribersに対応できるようにConflatedBroadcastChannel
を使う。
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private val fooChannel = ConflatedBroadcastChannel<Unit>()
上記のchannelをasFlow()
を使ってflowに変換し、class外にexposeする。
@OptIn( kotlinx.coroutines.FlowPreview::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class ) val fooFlow = fooChannel.asFlow().map { getFooFromRemoteSource() }.flowOn(dispatcher) private fun getFooFromRemoteSource(): String { val result = "result" // ここでremote sourceから値をfetchすることを想定 return result }
これでchannelにeventが流れてきた時にmap節の中で変換した値をflowに流すことができる。今回はuserのclick動作など、引数がない場合を想定したのでUnitを流しているが、引数が必要であればUnitの代わりにそのtypeを定義する。mapで変換した後にflowOn()
でどのdispatcherを使うか指定している。(この理由は上記で書いた通りtestの時に必要になるから。)
ここでmap節のなかでwithContext()
などで動作するthreadを指定していると、testが動いているthreadと切り替えが発生し、処理が並列で動いた結果期待した挙動にならないことがある。(私はこれに気がつかずかなり時間を消費してしまった😩)
最後に、channelがprivateなので外からtriggerできるような関数を定義する。
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) fun requestFoo() { fooChannel.offer(Unit) }
テスト例
上記のservice classのunit test codeを書いていく。
Coroutineのtestなので、test scopeとtest dispatcherを用意する。
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private val testScope = TestCoroutineScope() @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private val dispatcher = TestCoroutineDispatcher()
@Before
なfunctionでテスト対象を初期化。
private lateinit var fooService: FooService @Before fun setup() { fooService = FooService(dispatcher) }
requestFoo()
がtriggerとなり、fooFlow
に値が流れてくることを確認するテスト。
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Test fun `requestFoo() should trigger getting foo and fooFlow emit foo`() { testScope.runBlockingTest { val foo = mutableListOf<String>() val job = launch { fooService.fooFlow.collect { foo.add(it) } } // ここではまだfooは空 assertThat(foo).isEmpty() fooService.requestFoo() assertThat(foo).isEqualTo(listOf("result")) job.cancel() } }
TestCoroutineScope.runBlockingTest
はまだまだ不安定という記事をどこかで見かけた気がしたが、自分の環境(1.3.7
)では上記のコードは問題なく動いていた。ただ、この例だと特にdelayが発生しないのでrunBlockingTest
を使う理由も特にない。気になる人はrunBlocking
を使えば良いと思う。
また、foo
をlistにするべきかは議論があるかもしれないが、個人的には「一度のtriggerで一回しか値が流れてこないこと」を確認できるのでlistで良いと思っている。
Paging Library 3の内部実装読んだメモ書き
はじめに
Paging Library 3が発表された際、Full Kotlinで書かれてCoroutineもたくさん使われているということで話題になった。せっかくなので内部実装を読んでみた時のメモを雑に残しておく。
今回みたバージョンは 3.0.0-alpha01
PagingLibraryをどう使うかは下記のCodelabを参考にした。
codelabs.developers.google.com
学びメモ
- 今までRxJavaで「Signalをemitしたい(emitされるvalueに意味はない場合 e.g. ユーザのclickを検出したい時とか)場合」にboolean trueとか適当な値を流していたけど、Kotlin CoroutineのflowでUnit型をemitしているコードがあってなるほどなぁと思った。
Flow
にonStart()
やonCompletion()
,onEmpty()
などの拡張関数が生えている。onEmpty()
ってどういう時に使いたくなるんだろう- class内部では
ConflatedBroadcastChannel
を使っていて、拡張関数ConflatedBroadcastChannel.asFlow()
を使って外部に提供している部分がたくさんある - 上記で提供されるflowは拡張関数
Flow.asLiveData()
を使ってLiveDataに変換することができる LiveData.switchMap()
を使ってMutableLaveData A にvalue a がpostされたタイミングでそのvalueを使って何か処理を行い Mutable Live Data B にtransformしたvalue b をpostすることができるFlow.scan()
便利。initial valueにnullを入れる場合はnull as MulticastedPagingData<T>?
のようにnullをcastして渡していたMergedAdapter
は次のversionくらいでConcatAdapter
に変更されるっぽい。PagingDataAdapter.withLoadStateHeaderAndFooter()
がMergedAdapter
を返却しているので注意。(rename前の今も実際の挙動はmergeではなくconcat)
実装の詳細メモ
1. PagingDataAdapter
RecyclerView.Adapter
をextendしているsubmitData()
がトリガーとなってdetaがセットされる- 引数は
PagingData
型になっている。PagingData
はFlow<PageEvent<T>>
型であるflowとUiReceiverをもつPagingData
の生成はPager.flow
から取ってくることができるPager.flow
はPageFetcher.flow
から作られているPageFetcher.flow
はKotlin Coroutineがchainになっていて読んでて楽しい。
- 実際の処理は
AsyncPagingDataDiffer
に移譲している
- 引数は
Flow<Unit>
型のdataRefreshFlow
が生えていてアプリケーションはこれをcollectしてdataの更新を検知することができる
2. AsyncPagingDataDiffer
PagingDataDiffer
を持っていてdiffを計算してくれる- diffの計算は
Dispatchers.Main
のcontextで走る
- diffの計算は
- Kotlin coroutineのjobを持っていて複数のdiff計算が走らないように制御してくれる
3. PagingDataDiffer
- abstract classなので、実際はこれを実装したobjectが
AsyncPagingDataDiffer
クラスに定義されている - suspend funである
collectFrom()
を持っている。PagingData
のflowからデータを取得してDispatchers.Main
のcontextでdiffを計算しPageEvent
の種類によってPagePresenter
にinsert/dropのeventを処理させる。
4. PagePresenter
- page情報を保持するクラスで
NullPaddedList
を実装している PresenterCallback
というinterfaceを持っていて、dataが更新されるとこのcallbackが発火する。 イベント発火後、AsyncPagingDataDiffer
でPresenterCallback
はrecycler viewのListUpdateCallback
に変換されてリストが更新される。