Kotlin の Proto DataStore + AndroidX 設定

Google AndroidX チームが SharedPreferencesライブラリに代わる新しいDataStore ライブラリを発表してからほぼ 1 年が経過しましたが、新しいライブラリの普及は明らかにアクティブなタスクではありません。それ以外の場合は、1) 不完全なガイドを説明することはできません。これに従うと、ビルド システムに必要なすべての依存関係と追加のビルド タスクが不足しているため、プロジェクトをまったくビルドしません。2) hello 以外が存在しない-CodeLabs での類似した例、1 つを除いて、ライブラリをゼロから使用する例ではなく、SharedPreferences から Preferences DataStore への移行の例として改善されました。... 同様に、Medium のすべての記事は、文字通り、または言い換えれば、Google ガイドに書かれていることをすべて繰り返すか、データストアを操作するための間違ったアプローチを使用して、ui スレッドの runBlocking で非同期 io コードをラップすることを提案します。





また、「リア」と「フロント」を接続するのもいいでしょう。Google にはJetpack クリップのAndroidX Preferencesライブラリがあり、2 回のクリックで既成のマテリアル デザインのフラグメントを挿入できます。アプリケーション設定を管理し、コード生成の好きな方法で、開発者を定型文を書くことから解放します ... ただし、このライブラリは古い SharedPreferences をリポジトリとして使用することを提案しており、データストアに接続するための公式ガイドはありません。このメモでは、説明した 2 つの欠点を私なりの方法で解消したいと思います。





データストアを操作するためのフレームワークの作成

DataStore ライブラリは 2 つの部分に分かれています。Preferences DataStore と呼ばれる以前のものの類似物で、設定値をキーと値のペアで保存し、タイプ セーフではありません。もう 1 つは、設定をプロトコル バッファ ファイルに保存し、タイプセーフです。より柔軟で用途が広いので、実験用に選びました。





設定スキームを説明するには、プロジェクトに追加のファイルを作成する必要があります。まず、スタジオまたはアイデア エクスプローラーをプロジェクト モードに切り替えて、フォルダー構造全体が表示されるようにする必要があります。次に、* .proto拡張子のファイルを app / src / main / proto /フォルダー(pb ではなく、 Google が推奨するように - 構文チェックやオートコンプリートなどのプラグインも、対応するクラスを生成するビルド タスクも機能しません)。





Protocol buffer Google, . , :





syntax = "proto3";

option java_package = "...";
option java_multiple_files = true;

message ProtoSettings {
  bool translate_to_ru = 1;
  map<string, int64> last_sync = 2;
  int32 refresh_interval = 3;
}
      
      



, , , - -long Kotlin, unix- ( c data , simple name ).





build.gradle- :





plugins {
    ...
    id "com.google.protobuf" version "0.8.12"
}
...
dependencies {
	  ...
    //DataStore
    implementation "androidx.datastore:datastore:1.0.0-beta01"
    implementation "com.google.protobuf:protobuf-javalite:3.11.0"
    implementation "androidx.preference:preference-ktx:1.1.1"
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.11.0"
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

      
      



proto- , java DataStore proto.





DataStore: / , Flow. set- builder. Flow , , , collect & Co .





! deprecated- Flow toList toSet, (flow never completes, so this terminal operation never completes).





boilerplate , . , Google , :





@Suppress("BlockingMethodInNonBlockingContext")
object SettingsSerializer : Serializer<ProtoSettings> {
    override val defaultValue: ProtoSettings = ProtoSettings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): ProtoSettings {
        return try {
            ProtoSettings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            Log.e("SETTINGS", "Cannot read proto. Create default.")
            defaultValue
        }
    }

    override suspend fun writeTo(t: ProtoSettings, output: OutputStream) = t.writeTo(output)
}
      
      



Serializer ( ) .





- , : -, , , -, , , -, Hilt :





class Settings @Inject constructor(val settings: DataStore<ProtoSettings>) {

  companion object {
        const val HOUR_TO_MILLIS = 60 * 60 * 1000   // hours to milliseconds
        const val TRANSLATE_SWITCH = "translate_to_ru"
        const val REFRESH_INTERVAL_BAR = "refresh_interval"
        const val IS_PREFERENCES_CHANGED = "preferences_changed"
    }
  
    val saved get() = settings.data.take(1)
    
    suspend fun translateToRu(value: Boolean) = settings.updateData {
        it.toBuilder().setTranslateToRu(value).build()
    }

    suspend fun saveLastSync(cls: String) = settings.updateData {
        it.toBuilder().putLastSync(cls, System.currentTimeMillis()).build()
    }

    suspend fun refreshInterval(hours: Int) = settings.updateData {
        it.toBuilder().setRefreshInterval(hours * HOUR_TO_MILLIS).build()
    }

    fun checkNeedSync(cls: String) = saved.map {
        it.lastSyncMap[cls]?.run {
            System.currentTimeMillis() - this > saved.refreshInterval
        } ?: true
    }
}

@Module
@InstallIn(SingletonComponent::class)
class SettingsModule {

    @Provides
    @Singleton
    fun provideSettings(@ApplicationContext context: Context) = Settings(context.dataStore)

    private val Context.dataStore: DataStore<ProtoSettings> by dataStore(
        fileName = "settings.proto",
        serializer = SettingsSerializer
    )
}
      
      



, saved, flow take(1). , , . collect, , , emit . first(), flow . last(), , .. flow.





DataStore

. , , . Kotlin , sealed :





sealed class Result
    data class Success<out T>(val data: T): Result()
    data class Error(val msg: String, val error: ErrorType): Result()
    object Loading : Result()
      
      



, :





fun <T> fetchItems(
        itemsType: String,
        remoteApiCallback: suspend () -> Response<ApiResponse<T>>,
        localApiCallback: suspend () -> List<T>,
        saveApiCallback: suspend (List<T>) -> Unit,
    ): Flow<Result> = settings.checkNeedSync(itemsType).transform { needSync ->
        var remoteFailed = true
        emit(Loading)
        localApiCallback().let { local ->
            if (needSync || local.isEmpty()) {
                if (networkHelper.isNetworkConnected()) {
                    remoteApiCallback().apply {
                        if (isSuccessful) body()?.docs?.let { remote ->
                            settings.saveLastSync(itemsType)
                            remoteFailed = false
                            emit(Success(remote))
                            saveApiCallback(remote)
                        }
                        else emit(Error(errorBody().toString(), ErrorType.REMOTE_API_ERROR))
                    }
                } else emit(Error("No internet connection!", ErrorType.NO_INTERNET_CONNECTION))
            }

            if (remoteFailed)
                emit(if (local.isNotEmpty()) Success(local) else Error("No local saved data", ErrorType.NO_SAVED_DATA))
        }
    }
        .flowOn(Dispatchers.IO)
        .catch { e ->
            ...
        }
      
      



( ) : , . :





fun getSomething() = fetchItems<Something>("Something", remoteApi::getSomething, localApi::getSomething, localApi::saveSomething)
fun getSmthOther() = fetchItems<Other>("Other", remoteApi::getSmthOther, localApi::getSmthOther, localApi::saveSmthOther)
    
      
      



, reified , , T::class.simpleName, inline, crossinline/noinline, . inline , , /, .





checkNeedSync flow, SettingsRepository, flow Result transform. : Loading ( ui - ), . , , , . , checkNeedSync (take (1)), emit - checkNeedSync fetchItems. - , , , . , .





androidX . AndroidX Preference User interface/Settings, SharedPreferences ( Google DataStore PreferenceDataStore).





preferences.xml
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory android:title="@string/experimentalTitle">

        <SwitchPreferenceCompat
            android:defaultValue="false"
            android:key="translate_to_ru"
            android:summaryOff="@string/aiTranslateOffText"
            android:summaryOn="@string/aiTranslateOnText"
            android:title="@string/aiTranslateTitle" />
    </PreferenceCategory>
    <PreferenceCategory android:title="@string/synchronizeTitle">

        <SeekBarPreference
            android:defaultValue="2"
            android:key="refresh_interval"
            android:title="@string/refreshIntervalTitle"
            android:summary="@string/refreshSummary"
            android:max="24"
            app:min="0"
            app:seekBarIncrement="1"
            app:showSeekBarValue="true" />
    </PreferenceCategory>
</PreferenceScreen>
      
      



:





material design , guides. , summaryOff/summaryOn - , , . default value. key, .





Navigation . , , :





override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            ...
            R.id.preferences -> findNavController().navigate(MainFragmentDirections.actionShowPreferences())
        }
        return super.onOptionsItemSelected(item)
    }
      
      



( , , ), Navigation SavedStateHandle, onCreateView observer BackStack':





findNavController().currentBackStackEntry?.let {
            it.savedStateHandle.getLiveData<Boolean>(Settings.IS_PREFERENCES_CHANGED).observe(viewLifecycleOwner) { isChanged ->
                if (isChanged) {
                    viewModel.armRefresh()
                    it.savedStateHandle.remove<Boolean>(Settings.IS_PREFERENCES_CHANGED)
                }
            }
        }
      
      



, , .. LiveData, , .





, DataStore savedStateHandle . findPreference, findViewById, setOnPreferenceChangeListener:





override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences, rootKey)
        requireActivity().title = getString(R.string.preferencesTitle)

        val translateSwitch = findPreference<SwitchPreferenceCompat>(Settings.TRANSLATE_SWITCH)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.translateToRu(value as Boolean) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        val refreshSeekBar = findPreference<SeekBarPreference>(Settings.REFRESH_INTERVAL_BAR)?.apply {
            setOnPreferenceChangeListener { _, value ->
                lifecycleScope.launch { settings.refreshInterval(value as Int) }
                findNavController().previousBackStackEntry?.let {
                    it.savedStateHandle[Settings.IS_PREFERENCES_CHANGED] = true
                }
                true
            }
        }

        settings.saved.collectOnFragment(this) {
            translateSwitch?.isChecked = it.translateToRu
            refreshSeekBar?.value = it.refreshInterval / Settings.HOUR_TO_MILLIS
        }
    }
      
      



collectOnFragment flow
fun <T> Flow<T>.collectOnFragment(
    fragment: Fragment,
    state: Lifecycle.State = Lifecycle.State.RESUMED,
    block: (T) -> Unit
) {
    fragment.lifecycleScope.launch {
        flowWithLifecycle(fragment.lifecycle, state)
            .collect {
                block(it)
            }
    }
}
      
      



, setOnPreferenceChangeListener value Any, value as Boolean value as Int, .





. , Kotlin DataStore, runBlocking , 4-min-to-read- ( Google, ).





, Jetpack- ui c material design .





コード セクションには、重要でないか明白であるため (たとえば、HOUR_TO_MILLIS 定数の値)、説明または引用を完全に開始しなかった場所がありますが、私のレシピに従って同様のプロジェクトを構築できない場合は、コメント、あいまいな場所をすべて追加しようとします... 完全に機能し、テストされたプロジェクトからコードのすべての部分を取り出したので、パフォーマンスについて心配する必要はありません。





読んでくれてありがとう。








All Articles