Preference DataStoreを使っているアプリで、レアケースながら、InvalidProtocolBufferException
によるクラッシュがCrashlyticsに記録されていました。どうやらファイルが破損してしまい、読み出せなくなっているようです。
再現はできないので推測になりますが、ファイルが破損しているため、再起動しても復帰することができず、繰り返しクラッシュして起動不可に陥っているようです。
スタックトレースは以下のようになっていて、アプリのコードを経由していないのでcatchすることはできません。
CodedInputStream$StreamDecoder.readTag
androidx.datastore.preferences.protobuf.InvalidProtocolBufferException
- Protocol message contained an invalid tag (zero).
Fatal Exception: androidx.datastore.core.CorruptionException
Unable to parse preferences proto.
androidx.datastore.core.CorruptionException.<init> (CorruptionException.java:25)
androidx.datastore.preferences.PreferencesMapCompat$Companion.readFrom (PreferencesMapCompat.java:34)
androidx.datastore.preferences.core.PreferencesSerializer.readFrom (PreferencesSerializer.jvm.kt:46)
androidx.datastore.core.okio.OkioReadScope.readData$suspendImpl (OkioStorage.kt:180)
androidx.datastore.core.okio.OkioReadScope.readData (OkioReadScope.java:40)
androidx.datastore.core.StorageConnectionKt$readData$2.invokeSuspend (StorageConnection.kt:74)
androidx.datastore.core.StorageConnectionKt$readData$2.invoke (StorageConnection.kt:23)
androidx.datastore.core.StorageConnectionKt$readData$2.invoke (StorageConnection.kt:23)
androidx.datastore.core.okio.OkioStorageConnection.readScope (OkioStorage.kt:113)
androidx.datastore.core.StorageConnectionKt.readData (StorageConnection.kt:74)
androidx.datastore.core.DataStoreImpl.readDataFromFileOrDefault (DataStoreImpl.kt:331)
androidx.datastore.core.DataStoreImpl.readDataOrHandleCorruption (DataStoreImpl.kt:373)
androidx.datastore.core.DataStoreImpl.access$readDataOrHandleCorruption (DataStoreImpl.kt:53)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invokeSuspend (DataStoreImpl.kt:445)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invoke (DataStoreImpl.kt:13)
androidx.datastore.core.DataStoreImpl$InitDataStore$doRun$initData$1.invoke (DataStoreImpl.kt:13)
androidx.datastore.core.SingleProcessCoordinator.lock (SingleProcessCoordinator.kt:41)
androidx.datastore.core.DataStoreImpl$InitDataStore.doRun (DataStoreImpl.kt:442)
androidx.datastore.core.RunOnce.runIfNeeded (DataStoreImpl.kt:505)
androidx.datastore.core.DataStoreImpl.readAndInitOrPropagateAndThrowFailure (DataStoreImpl.kt:274)
androidx.datastore.core.DataStoreImpl.handleUpdate (DataStoreImpl.kt:251)
androidx.datastore.core.DataStoreImpl.access$handleUpdate (DataStoreImpl.kt:53)
androidx.datastore.core.DataStoreImpl$writeActor$3.invokeSuspend (DataStoreImpl.kt:215)
androidx.datastore.core.DataStoreImpl$writeActor$3.invoke (DataStoreImpl.kt:12)
androidx.datastore.core.DataStoreImpl$writeActor$3.invoke (DataStoreImpl.kt:12)
androidx.datastore.core.SimpleActor$offer$2.invokeSuspend (SimpleActor.kt:121)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:104)
kotlinx.coroutines.internal.LimitedDispatcher$Worker.run (LimitedDispatcher.java:111)
kotlinx.coroutines.scheduling.TaskImpl.run (Tasks.kt:99)
kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely (CoroutineScheduler.java:585)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask (CoroutineScheduler.kt:802)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker (CoroutineScheduler.kt:706)
kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run (CoroutineScheduler.kt:693)
対処方法:corruptionHandlerを設定する
preferenceDataStore
の定義は以下のようになっており、第二引数にcorruptionHandler
があります。省略可能で、チュートリアルでは説明されていないので省略してしまっているアプリも多いのではないでしょうか?
public fun preferencesDataStore(
name: String,
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
produceMigrations: (Context) -> List<DataMigration<Preferences>> = { listOf() },
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
corruptionHandler
は、ファイルの読み出しでExceptionが発生した場合に、そのExceptionを引数に呼び出されます。
省略した場合、NoOpCorruptionHandler
が使われます。
この実装は以下のようになっており、単に引数のExceptionをthrowするだけです。
internal actual class NoOpCorruptionHandler<T> : CorruptionHandler<T> {
@Throws(CorruptionException::class)
actual override suspend fun handleCorruption(ex: CorruptionException): T {
throw ex
}
}
つまり、省略した状態で、ファイルの破損などで読み出しに失敗した場合、そのままExceptionがthrowされ、アプリがクラッシュしてしまいます。
クラッシュさせたくない場合は、適切なcorruptionHandler
を設定します。
実装方法は、ReplaceFileCorruptionHandler
のコンストラクタにファイルが読み出せない場合のデータを返すラムダを渡すだけです。
ファイルが読み出せない以上、その内容は諦めるしかありません。
空のpreferencesを返すことで、起動初回と同様に初期値を書き込む動作を動かすのが簡単な方法の一つでしょう。空のpreferencesはemtpyPreferences()
というメソッドで作ることができるので、以下のように呼び出せばOKです。
preferencesDataStore(
name = ...,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
produceMigrations = ...,
)
また、削除されたことを記録しておきたい場合もあるでしょう。その場合はpreferencesOf()
メソッドで削除されたことを示す値を持ったpreferencesを返すことで対応できますね。
Proto DataStoreでも同様に、corruptionHandler
で読み出せない場合の値を提供することで対処できるようです。
何が何でもクラッシュさせないことが正しいわけではなく、ここでクラッシュさせ、次回起動時に正常に読み出されることを期待するのが正しい場合もあると思います。保存しているデータの性質や発生したExceptionの内容を元に適切な処理を実装してください。
以上です。
corruptionHandlerがどう使われているか
蛇足ですが、corruptionHandler
がどう使われてるかはDataStoreImplを見れば分かります。
デフォルトでNoOpCorruptionHandler
が使われていることも分かりますね。
internal class DataStoreImpl<T>(
private val storage: Storage<T>,
initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(),
/**
* The handler of [CorruptionException]s when they are thrown during reads or writes. It
* produces the new data to replace the corrupted data on disk. By default it is a no-op which
* simply throws the exception and does not produce new data.
*/
private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler(),
private val scope: CoroutineScope = CoroutineScope(ioDispatcher() + SupervisorJob())
) : DataStore<T> {
corruptionHandler
が使われているのは、readDataOrHandleCorruption
というメソッドです。
private suspend fun readDataOrHandleCorruption(hasWriteFileLock: Boolean): Data<T> {
try {
return if (hasWriteFileLock) {
val data = readDataFromFileOrDefault()
Data(data, data.hashCode(), version = coordinator.getVersion())
} else {
val preLockVersion = coordinator.getVersion()
coordinator.tryLock { locked ->
val data = readDataFromFileOrDefault()
val version = if (locked) coordinator.getVersion() else preLockVersion
Data(
data,
data.hashCode(),
version
)
}
}
} catch (ex: CorruptionException) {
var newData: T = corruptionHandler.handleCorruption(ex)
var version: Int // initialized inside the try block
try {
doWithWriteFileLock(hasWriteFileLock) {
// Confirms the file is still corrupted before overriding
try {
newData = readDataFromFileOrDefault()
version = coordinator.getVersion()
} catch (ignoredEx: CorruptionException) {
version = writeData(newData, updateCache = true)
}
}
} catch (writeEx: Throwable) {
// If we fail to write the handled data, add the new exception as a suppressed
// exception.
ex.addSuppressed(writeEx)
throw ex
}
// If we reach this point, we've successfully replaced the data on disk with newData.
return Data(newData, newData.hashCode(), version)
}
}
Exceptionが発生するとcatchされ、corruptionHandler
が呼び出されます。NoOpCorruptionHandler
だとここでExceptionが再度throwされてアプリがクラッシュします。
corruptionHandler
が値を返した場合も、そのまま使われる訳ではなく、もう一度ファイル読み出しを試みて、それでもダメならcorruptionHandler
の結果を書き込んでいるようです。
単純にemptyPreferencesを返すだけの実装でも、有意義に動作しそうですね。