Proto DataStore + Preferensi AndroidX di Kotlin

Hampir setahun telah berlalu sejak tim Google AndroidX menghadirkan perpustakaan DataStore baru untuk menggantikan perpustakaan SharedPreferences , tetapi mempopulerkan lib baru jelas bukan tugas yang aktif. Kalau tidak, saya tidak bisa menjelaskan 1) panduan yang tidak lengkap, hanya mengikuti yang mana, Anda tidak akan membangun proyek sama sekali karena kurangnya semua dependensi yang diperlukan dan tugas build tambahan untuk sistem build, dan 2) tidak adanya non-halo -contoh serupa dunia di CodeLabs, kecuali satu, dan kemudian, diasah bukan untuk contoh penggunaan perpustakaan dari awal, tetapi untuk migrasi dari SharedPreferences ke Preferences DataStore... Demikian pula, semua artikel di Medium, secara harfiah atau dengan kata lain, ulangi semua yang tertulis di panduan Google, atau gunakan pendekatan yang salah untuk bekerja dengan DataStore, menyarankan untuk membungkus kode io asinkron di runBlocking tepat di utas ui.





Dan juga akan menyenangkan untuk menghubungkan "belakang" dengan "depan", sehingga untuk berbicara: Google memiliki perpustakaan Preferensi AndroidX dari klip Jetpack, yang memungkinkan Anda untuk memasukkan fragmen desain material yang sudah jadi dalam dua klik untuk mengelola pengaturan aplikasi dan, dengan cara favorit pembuatan kode, membebaskan pengembang dari menulis boilerplate ... Namun, perpustakaan ini mengusulkan untuk menggunakan SharedPreferences yang sudah ketinggalan zaman sebagai repositori, dan tidak ada panduan resmi untuk menghubungkan ke DataStore. Dalam catatan ini, saya ingin menghilangkan dua kekurangan yang dijelaskan dengan cara saya sendiri.





Membuat kerangka kerja untuk bekerja dengan DataStore

Pustaka DataStore dibagi menjadi dua bagian: analog dari yang sebelumnya disebut Preferences DataStore, yang menyimpan nilai pengaturan dalam pasangan nilai kunci dan tidak aman untuk tipe, dan yang kedua, yang menyimpan pengaturan dalam file buffer Protokol dan adalah tipe-aman. Ini lebih fleksibel dan serbaguna, jadi saya memilihnya untuk eksperimen saya.





Untuk menjelaskan skema pengaturan, Anda perlu membuat file tambahan dalam proyek. Pertama, Anda perlu mengalihkan studio atau penjelajah ide ke mode Proyek sehingga seluruh struktur folder terlihat, lalu buat file dengan ekstensi * .proto di folder app / src / main / proto / (dan bukan pb, seperti yang direkomendasikan Google - dengan Tidak ada plugin untuk pemeriksaan sintaks, pelengkapan otomatis, dll., atau tugas build yang menghasilkan kelas yang sesuai tidak akan berfungsi).





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 .





Ada tempat di bagian kode yang tidak mulai saya jelaskan atau kutip sepenuhnya karena ketidakpentingan atau kejelasan (misalnya, nilai konstanta HOUR_TO_MILLIS), tetapi jika Anda tidak dapat membuat proyek serupa menurut resep saya, tulis di komentar, saya akan mencoba menambahkan semua tempat yang tidak jelas ... Perhatikan bahwa saya mengambil semua bagian kode dari proyek yang sepenuhnya berfungsi dan diuji, jadi Anda tidak perlu khawatir tentang kinerjanya.





Terima kasih sudah membaca.








All Articles