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
に変換されてリストが更新される。
MotionLayoutの覚書き
はじめに
MotionLayoutが発表されて随分経った。「Android Studio 4系が必須でMotion Editorが来るのを待っていた」という言い訳で長い間スルーしていたが、そろそろ4系が安定して来たという話をちらほら聞くのでちょっとさわってみた。結果、Motion Editorなくても全然xml tag手書きでいけるということがわかったのでメモを残す。
ちなみにさわったのはこちらのcodelab。
codelabs.developers.google.com
MotionLayoutの基本
ConstraintLayout
を継承したMotionLayout
をparent viewとするMotionLayout
のtag内にapp:layoutDescription="@xml/foo"
といったようにMotionScene
を定義したxmlファイル名を指定するMotionScene
にはTransition
とConstraintSet
を定義
Transition
-
Transition
のtag内にapp:constraintSetStart="@+id/start"
みたいな感じでanimationのstart/endのConstraintSet
を指定 - Animationの契機となる
OnClick
やOnSwipe
などはTransition
の中で定義する - animationのstart/endの間のPathを定義したい場合は
Transition
の中でKeyFrameSet
とその中にKeyPosition
を定義する -
KeyPosition
-
keyPositionType
でどのようにMotionが変化するか指定する - 各typeについてはここを参照
-
parentRelative
はscreenと同じく左上が(0, 0)
-
-
- animationのstart/endの間のview propertyを変えたい場合は
KeyAttribute
を定義する-
KeyAttribute
の中でCustomAttribute
を設定することができる -
CustomAttribute
のapp:attributeName
には対象クラスに定義されたsetter nameを指定する。(setXxxYyy()
を使いたい場合はapp:attributeName="XxxYyy"
になる。)
-
ConstraintSet
-
ConstraintSet
の中ではanimationしたいviewごとにConstraint
を定義 -
Constraint
のandroid:id
には対象のviewを指定 - layout fileに書いた各attributeはConstraintSetのxmlに定義したものに上書きされてしまう。 1そのため、
android:layout_width
とandroid:layout_height
を各Constraint
で指定する必要がある。
覚えておきたい便利な機能・Tips
app:motionDebug="SHOW_PATH"
をMotionLayout
のtagに入れることでmotionのpathをアプリ上で表示することができるtouchAnchorSide
は基本的に一直線に進む方向を指定する必要がある(そうでないとanimationがおかしくなる場合がある)が、複雑なpathを書きたい場合はどの方向にも一直線に進まないケースがある。その場合はinvisibleなviewを一つ作ってそいつをanchorにするとうまくいく。- あるいは
app:dragDirection
を追加で指定することで解決するかもしれない
- あるいは
- コードからmotionを動かしたいときは
MotionLayout#setProgress(float pos)
を使う - 既存のConstraintLayoutをMotionLayoutにconvertしたい場合は、下記手順で自動でconvertしてくれる
- ConstraintSetを使う時は全てのchildにidつけてやらないとDesign tabのpreviewもうまくrenderされない。またapp実行時に下記のRuntimeExceptionがthrowされる
java.lang.RuntimeException: All children of ConstraintLayout must have ids to use ConstraintSet
KoinのDIの仕組み
はじめに
みなさん、Koinはお好きだろうか?
KoinとはKotlin用の軽量DI Frameworkである。
基本的には下記の3 stepでお手軽にDIの仕組みを提供してくれる。
- moduleの宣言
- Koinのstart
- 任意の場所でinject
Injectする際にinject()
というcustomのproperty delegateを使うことができる。おしゃれだ。
val service : BusinessService by inject()
このinject()
の中身で何をしているか気になってコードをのぞいて見た
inline fun <reified T : Any> ComponentCallbacks.inject( qualifier: Qualifier? = null, noinline parameters: ParametersDefinition? = null ) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }
lazy()
でinstance生成するのもまた良い。といういのは置いておいて、このget()
の先で何が起こっているのか気になってコードを読んでみた。
注意事項
今回は2020/5/4時点での最新バージョンである2.1.5
のコードを読んでいく。
KoinはOSS projectであり、ソースコードはGitHubでホスティングされている。
DIの仕組みを読んでいく
1. moduleの宣言
moduleの宣言は下記のように行う。(公式のsample codeより抜粋)
// Given some classes class Controller(val service : BusinessService) class BusinessService() // just declare it val myModule = module { single { Controller(get()) } single { BusinessService() } }
module()
というDSLの中身はこんな感じ。
fun module(createdAtStart: Boolean = false, override: Boolean = false, moduleDeclaration: ModuleDeclaration): Module { val module = Module(createdAtStart, override) moduleDeclaration(module) return module }
ModuleDeclarationはtypealiasである。実体はlambdaである。
typealias ModuleDeclaration = Module.() -> Unit
というわけでmodule定義時にlambdaで渡している2行のsingle()
というDSLが実行される。
single()
の定義はこんな感じ。
inline fun <reified T> single( qualifier: Qualifier? = null, createdAtStart: Boolean = false, override: Boolean = false, noinline definition: Definition<T> ): BeanDefinition<T> { return Definitions.saveSingle( qualifier, definition, rootScope, makeOptions(override, createdAtStart) ) }
Definitions.saveSingle()
の実装を追っていくとBeanDefinition
というclassのinstanceを生成してscopeのdefinitionに登録したのちBeanDefinition
を返却している。(singleは定義の実装から分かるようにroot scopeに登録される。)
inline fun <reified T> saveSingle( qualifier: Qualifier? = null, noinline definition: Definition<T>, scopeDefinition: ScopeDefinition, options: Options ): BeanDefinition<T> { val beanDefinition = createSingle(qualifier, definition, scopeDefinition, options) scopeDefinition.save(beanDefinition) return beanDefinition }
BeanDefinition
が何かと言うとdata classである。
data class BeanDefinition<T>( val scopeDefinition: ScopeDefinition, val primaryType: KClass<*>, val qualifier: Qualifier? = null, val definition: Definition<T>, val kind: Kind, val secondaryTypes: List<KClass<*>> = listOf(), val options: Options = Options(), val properties: Properties = Properties(), val callbacks: Callbacks<T> = Callbacks() )
上記のようにDI対象のKClassやらqualifierやらを保持している。このBeanDefinition
はScopeDefinition
のdefinitions
にSetとして保持される。
class ScopeDefinition(val qualifier: Qualifier, val isRoot: Boolean = false, private val _definitions: HashSet<BeanDefinition<*>> = hashSetOf()) { val definitions: Set<BeanDefinition<*>> get() = _definitions fun save(beanDefinition: BeanDefinition<*>, forceOverride: Boolean = false) { if (definitions.contains(beanDefinition)) { if (beanDefinition.options.override || forceOverride) { _definitions.remove(beanDefinition) } else { val current = definitions.firstOrNull { it == beanDefinition } throw DefinitionOverrideException("Definition '$beanDefinition' try to override existing definition. Please use override option or check for definition '$current'") } } _definitions.add(beanDefinition) }
2. Koinのstart
AndroidであればApplication
クラスのonCreate()
、Kotlinであればmain()
の中でstartKoin()
のDSLを読んでKoinをstartさせる。
fun main(vararg args : String) { startKoin { modules(myModule) } }
modules()
のDSLはModule
のvarargもしくはList<Module>
をparameterにとるが、どちらにしろ最終的にmodules(modules: List<Module>)
がコールされる。
fun modules(modules: List<Module>): KoinApplication { if (koin._logger.isAt(Level.INFO)) { val duration = measureDuration { loadModules(modules) } val count = koin._scopeRegistry.size() koin._logger.info("loaded $count definitions - $duration ms") } else { loadModules(modules) } if (koin._logger.isAt(Level.INFO)) { val duration = measureDuration { koin.createRootScope() } koin._logger.info("create context - $duration ms") } else { koin.createRootScope() } return this }
loggerの設定で条件分岐をやっているけれど、基本的にはやることはloadModules()
とcreateRootScope()
の2点。
まずはloadModules()
をみていく。このfunctionを辿っていくとKoin#loadModules()
にたどり着く。
fun loadModules(modules: List<Module>) = synchronized(this) { _modules.addAll(modules) _scopeRegistry.loadModules(modules) }
_modules
はModule
のHashSetなのであまりここでは気にしない。
ScopeRegistry#loadModules()
はmodules listの要素である各moduleをparameterにしてloadModule()
をコールしている。
private fun loadModule(module: Module) { declareScope(module.rootScope) declareScopes(module.otherScopes) } private fun declareScope(scopeDefinition: ScopeDefinition) { declareDefinitions(scopeDefinition) declareInstances(scopeDefinition) }
declareDefinitions()
はScopeRegistry
のメンバ変数のHashMapにScopeDefinition
を追加する。
次にdeclareInstances()
。まずはScopeRegistry
が保持しているscopeのHashMapからparameterとScopeDefinitionが一致するscopeを見つけ出す。Scope
クラスはInstanceRegistry
を保持しており、ここにscopeDefinition
が保持しているBeanDefinition
(1の最後で追加したやつ)を登録する。
fun saveDefinition(definition: BeanDefinition<*>, override: Boolean) { val defOverride = definition.options.override || override val instanceFactory = createInstanceFactory(_koin, definition) saveInstance( indexKey(definition.primaryType, definition.qualifier), instanceFactory, defOverride ) definition.secondaryTypes.forEach { clazz -> if (defOverride) { saveInstance( indexKey(clazz, definition.qualifier), instanceFactory, defOverride ) } else { saveInstanceIfPossible( indexKey(clazz, definition.qualifier), instanceFactory ) } } } private fun saveInstance(key: IndexKey, factory: InstanceFactory<*>, override: Boolean) { if (_instances.contains(key) && !override) { error("InstanceRegistry already contains index '$key'") } else { _instances[key] = factory } }
ちなみにsaveInstance()
の1st parameterのためにコールしているindexKey()
はclassのfull nameとqualifier(もしあれば)の組み合わせのStringになっている。
fun indexKey(clazz: KClass<*>, qualifier: Qualifier?): String { return if (qualifier?.value != null) { "${clazz.getFullName()}::${qualifier.value}" } else clazz.getFullName() }
3. 任意の場所でinject
「はじめに」で記載した通り、inject()
のproperty delegateはlazyでget()
をコールしている。この先を読んでいく。
inline fun <reified T : Any> ComponentCallbacks.inject( qualifier: Qualifier? = null, noinline parameters: ParametersDefinition? = null ) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }
inline fun <reified T : Any> ComponentCallbacks.get( qualifier: Qualifier? = null, noinline parameters: ParametersDefinition? = null ): T = getKoin().get(qualifier, parameters)
Koin##get()
をみてみると、
@JvmOverloads inline fun <reified T> get( qualifier: Qualifier? = null, noinline parameters: ParametersDefinition? = null ): T = _scopeRegistry.rootScope.get(qualifier, parameters)
Scope#get()
はこんな感じ。
inline fun <reified T> get( qualifier: Qualifier? = null, noinline parameters: ParametersDefinition? = null ): T { return get(T::class, qualifier, parameters) }
fun <T> get( clazz: KClass<*>, qualifier: Qualifier? = null, parameters: ParametersDefinition? = null ): T { return if (_koin._logger.isAt(Level.DEBUG)) { val qualifierString = qualifier?.let { " with qualifier '$qualifier'" } ?: "" _koin._logger.debug("+- '${clazz.getFullName()}'$qualifierString") val (instance: T, duration: Double) = measureDurationForResult { resolveInstance<T>(qualifier, clazz, parameters) } _koin._logger.debug("|- '${clazz.getFullName()}' in $duration ms") return instance } else { resolveInstance(qualifier, clazz, parameters) } }
@Suppress("UNCHECKED_CAST") private fun <T> resolveInstance( qualifier: Qualifier?, clazz: KClass<*>, parameters: ParametersDefinition? ): T { if (_closed) { throw ClosedScopeException("Scope '$id' is closed") } //TODO Resolve in Root or link val indexKey = indexKey(clazz, qualifier) return _instanceRegistry.resolveInstance(indexKey, parameters) ?: findInOtherScope<T>(clazz, qualifier, parameters) ?: getFromSource(clazz) ?: throwDefinitionNotFound(qualifier, clazz) }
Index keyを生成し、Scopeが保持しているInstanceRegistry
からinstanceを取ってくる。
@Suppress("UNCHECKED_CAST") internal fun <T> resolveInstance(indexKey: IndexKey, parameters: ParametersDefinition?): T? { return _instances[indexKey]?.get(defaultInstanceContext(parameters)) as? T }
これで2の最後でInstanceRegistry
に登録されたinstanceを取得してinject完了。
雑感
scopeを実装する必要があるせいか思ったより複雑だった。まぁ複雑と言ってもDaggerみたいにcode generationするようなやつと比べるとシンプルで好き。エラーもわかりやすいし。
言いたいことは以上です。