araki tech

for developers including me

Android Kotlin日本語チュートリアル-⑧Preferencesで環境設定を記憶する

Android Kotlin日本語チュートリアル

Android Kotlin日本語チュートリアル

本連載記事はこれからAndroidアプリ開発を始める人に向けたチュートリアルです。

コンセプトは

  • プログラミングをあまり知らない人でも完走できる
  • プログラミングにある程度詳しい人にも満足できる
  • 実用的な知識を提供する
  • とにかくわかりやすく

で、全9回と長めですが頑張っていきましょう。

このチュートリアルを終える頃には、Android開発の土台が形成されているだけでなくアプリケーションアーキテクチャの知識が出来上がっているはずです。

作成するのは以下のようなメモアプリです。

Android Kotlin日本語チュートリアル

完成品は HiroshiARAKI/AndroidKotlinTutrialで公開していますので適宜参考にしてください。

第8回 : Preferencesで環境設定を記憶する

第8回はPreferencesと呼ばれる、Androidでよく用いられる環境設定管理方法について学びます。

現在のアプリはMemoの順番はバラバラで、ユーザビリティを考えれば「更新日が新しい順」や「締め切りが近い順」など並び替えられるのが理想ですよね。

さらにその順番がアプリを開くたびに指定するのではなく、前回の設定を記憶しておいて反映してほしいですよね。

今回はそのようなユースケースを満たすために、SharedPreferencesとDataStoreを利用してみます。

ソートのUIを作成する

とにかく下地となる処理がないことには始まらないので作成していきます。

ソートの選択にはSpinnerというViewを利用してみましょう。

ドラムロール式のセレクターです。

res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout ... />

    <TextView
        android:id="@+id/main_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/main_text"
        android:textSize="20sp"
        app:layout_constraintBottom_toTopOf="@id/sort_spinner"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <Spinner
        android:id="@+id/sort_spinner"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="10dp"
        app:layout_constraintTop_toBottomOf="@id/main_text"
        app:layout_constraintBottom_toTopOf="@id/main_list"
        app:layout_constraintEnd_toEndOf="parent"/>

    <androidx.recyclerview.widget.RecyclerView ... />

    <Button ... />

</androidx.constraintlayout.widget.ConstraintLayout>
res/values/strings.xml
<resources>

    <!--  省略  -->

    <!--  MainActivity - SortSelector  -->
    <string name="sort_id">追加した順</string>
    <string name="sort_update">更新日が新しい順</string>
    <string name="sort_expire">締め切り日が近い順</string>
</resources>
data/Sort.kt
/**
 * ソートのアイテムを管理する列挙型
 */
enum class Sort(@StringRes val id: Int) {
    ID(R.string.sort_id),
    UPDATE(R.string.sort_update),
    EXPIRE(R.string.sort_expire);

    companion object {
        fun getById(id: Long) = when(id) {
            0L -> ID
            1L -> UPDATE
            2L -> EXPIRE
            else -> ID
        }
    }
}

今回はとりあえず3種類のソート方法を用意して、列挙型で管理することにしましょう。

MainActivity.kt
class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {

    private val viewModel: MainViewModel by viewModels()

    // sortSpinnerのアイテムが選択された時の挙動
    private val sortAdapterListener = object : AdapterView.OnItemSelectedListener {
        override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
            Log.d(this::class.simpleName, "onItemSelected: position=$position, id=$id")
        }

        override fun onNothingSelected(parent: AdapterView<*>?) {
        }

    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val addButton: Button = findViewById(R.id.add_button)

        val recyclerView: RecyclerView = findViewById(R.id.main_list)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = MainAdapter(::onItemClick)
        recyclerView.addItemDecoration(
            DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        )

        // ソートのセレクターを作成する
        // レイアウトは既存のものを使うことにする
        val sortSpinner: Spinner = findViewById(R.id.sort_spinner)
        val sortAdapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_item)
        sortAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
        // 全選択肢を追加
        Sort.values().forEach {
            sortAdapter.add(getString(it.id))
        }
        // Spinnerの設定
        sortSpinner.adapter = sortAdapter
        sortSpinner.onItemSelectedListener = sortAdapterListener

        addButton.setOnClickListener {
            supportFragmentManager.beginTransaction().run {
                add(R.id.main_container, NewMemoFragment())
                addToBackStack(null)
                commit()
            }
        }
        viewModel.memoItems.observe(this) {
            (recyclerView.adapter as MainAdapter).setMemoItems(it)
        }
        viewModel.loadMemoItems()
    }

    // ...
    
}

まだアイテムが選択されたときの挙動は未実装ですが、以下のようにドラムロール式の選択肢が実装できたと思います。

Android Kotlin日本語チュートリアル

ちなみに、

object : AdapterView.OnItemSelectedListener { }

は、その場でインターフェイスを実装した (もしくはクラスを継承した) 任意のオブジェクトを作成しています。

Kotlinの機能の一つでオブジェクト式 (Object Expression) と言います。

もちろん何も継承や実装をしないただの無名オブジェクトを作成することもできます。

ソート機能を実装する

まだ本題ではありません。

ソートのセレクタから実際にソートできるようにしましょう。

data/Sort.kt
enum class Sort(@StringRes val id: Int) {
    ID(R.string.sort_id),
    UPDATE(R.string.sort_update),
    EXPIRE(R.string.sort_expire);

    /** [memoItems]にソートを適用する */
    fun applyTo(memoItems: List<Memo>) =
        when(this) {
            ID ->  memoItems.sortedBy { it.id }  // ID昇順=追加順
            UPDATE ->  memoItems.sortedByDescending { it.updateTimeMillis }  // 更新日降順
            EXPIRE ->  memoItems.sortedByDescending { it.expireTimeMillis }  // 破棄日降順
        }

    companion object {
        fun getById(id: Long) = when(id) {
            0L -> ID
            1L -> UPDATE
            2L -> EXPIRE
            else -> ID
        }
    }
}
viewmodel/MainViewModel.kt
class MainViewModel(app: Application) : MemoViewModel(app) {

    // ...

    /**
     * 指定されたソート方法適用する
     */
    fun sortBy(sort: Sort) {
        viewModelScope.launch(Dispatchers.Main) {
            val items = _memoItems.value ?: return@launch
            _memoItems.postValue(sort.applyTo(items))
        }
    }
}
MainActivity.kt
class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {

    private val viewModel: MainViewModel by viewModels()

    // sortSpinnerのアイテムが選択された時の挙動
    private val sortAdapterListener = object : AdapterView.OnItemSelectedListener {
        override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
            Log.d(this::class.simpleName, "onItemSelected: position=$position, id=$id")
            viewModel.sortBy(Sort.getById(id))
        }

        override fun onNothingSelected(parent: AdapterView<*>?) {
            viewModel.sortBy(Sort.ID)
        }

    }

    // ...
}

これでソート機能の実装が終わりました。

みなさんもエミュレータで動作確認してみてください。

SharedPreferencesを使って設定を記憶する

今回利用するPreferencesは正確にはSharedPreferencesと言い、アプリ内で簡単なデータを保存し、アプリ内でデータを共有できます。

使い方によっては他のアプリから、SharedPreferencesに保存されているデータを参照することもできますが今回は対象外です。

Activityから使用できるSharedPreferencesには2種類あります。

  • getSharedPreferences() … 名前で識別される複数の共有環境設定ファイルが必要な場合に使用し、最初のパラメータで名前を指定します。このメソッドは、アプリ内の任意の Context から呼び出すことができます。
  • getPreferences() … 1 つのアクティビティに対して共有環境設定ファイルを 1 つだけ使用する必要がある場合に、Activity からこのメソッドを使用します。このメソッドの場合、アクティビティに属するデフォルト共有環境設定ファイルを取得するため、名前を指定する必要はありません。

(Key-Value データを保存する | Android developers)

ということで、今回はActivity内でしかソート設定は利用しませんので後者のメソッドを使ってみましょう。

class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {
    companion object {
        private const val KEY_PREFERENCES_SORT = "preferences_key_sort"
    }

    private val viewModel: MainViewModel by viewModels()

    // このアプリのPreferences
    // (NOTE: PreferenceManagerを使った旧来のSharedPreferencesの取得はDeprecated(非推奨)になりました)
    private val preferences
        get() = getPreferences(Context.MODE_PRIVATE)

    // sortSpinnerのアイテムが選択された時の挙動
    private val sortAdapterListener = object : AdapterView.OnItemSelectedListener {
        override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
            Log.d(this::class.simpleName, "onItemSelected: position=$position, id=$id")
            // SharedPreferencesに選択されているpositionを非同期で格納しておく
            lifecycleScope.launch(Dispatchers.IO) {
                with(preferences.edit()) {
                    putInt(KEY_PREFERENCES_SORT, position)
                    apply()
                }
            }
            viewModel.sortBy(Sort.getById(id))
        }

        override fun onNothingSelected(parent: AdapterView<*>?) {
            viewModel.sortBy(Sort.ID)
        }

    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val addButton: Button = findViewById(R.id.add_button)

        val recyclerView: RecyclerView = findViewById(R.id.main_list)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = MainAdapter(::onItemClick)
        recyclerView.addItemDecoration(
            DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        )

        // ソートのセレクターを作成する
        // レイアウトは既存のものを使うことにする
        val sortSpinner: Spinner = findViewById(R.id.sort_spinner)
        val sortAdapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_item)
        sortAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
        // 全選択肢を追加
        Sort.values().forEach {
            sortAdapter.add(getString(it.id))
        }
        // Spinnerの設定
        sortSpinner.adapter = sortAdapter
        sortSpinner.onItemSelectedListener = sortAdapterListener
        sortSpinner.setSelection(preferences.getInt(KEY_PREFERENCES_SORT, 0))

        addButton.setOnClickListener {
            supportFragmentManager.beginTransaction().run {
                add(R.id.main_container, NewMemoFragment())
                addToBackStack(null)
                commit()
            }
        }
        viewModel.memoItems.observe(this) {
            (recyclerView.adapter as MainAdapter).setMemoItems(it)
        }
        viewModel.loadMemoItems()
    }

    // ...
}

これで、一度アプリを閉じて再度開いたときもこのソート設定情報は保持されているはずです。

試してみてください。

SharedPreferencesの中身を見てみる

ここからは余談ですが、デバイス上でSharedPreferencesの中身を確認できるので見てみましょう。

Android Studioの[View] > [Tool Windows] > [Device File Explorer]を選択してみてください。

Android Kotlin日本語チュートリアル

このように今選択されているエミュレータ (もしくは実機) のファイルシステムを見ることができます。

その中から、/data/data/[Package name]/shared_prefs/[Activity name].xmlが、SharedPreferencesの中身になります。

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <long name="preferences_key_sort" value="1" />
</map>

このようにSharedPreferencesはファイルへの読み書きが存在するため非同期での書き込みを行なっています。

基本的にメモリキャッシュが内部にあるので毎回アクセスされるわけではありません。

また、実際にBooleanな戻り値がほしい場合は、apply()の代わりにcommit()を使います。

ただcommit()はメインスレッドで呼ばないようにしましょう。

apply() は、すぐにメモリ内の SharedPreferences オブジェクトを変更しますが、更新内容は非同期でディスクに書き込まれます。他方、commit() を使用すると、データを同期的にディスクに書き込むことができます。ただし、commit() は同期的であり、UI レンダリングを一時停止する可能性があるため、メインスレッドからは呼び出さないようにしてください。
(Key-Value データを保存する | Android developers)




DataStoreで環境設定を管理する

SharedPreferencesの他に、最近ではDataStoreを使った管理方法が主流になってきました。

Jetpack DataStore は、プロトコル バッファを使用して Key-Value ペアや型付きオブジェクトを格納できるデータ ストレージ ソリューションです。DataStore は、Kotlin コルーチンとフローを使用して、データを非同期的に、一貫した形で、トランザクションとして保存します。

現在、データの保存に SharedPreferences を使用しているのであれば、その代わりとして DataStore に移行することを検討してください。

(DataStore | Android developers)

DataStoreはJetpackの一部で、SharedPreferencesよりKotlinで扱いやすいのが一つの特徴です。

先ほどSharedPreferencesで実装を行なってしまいましたが、これからDataStoreに置き換えてみます。
(SharedPreferencesもまだ色々なプロジェクトで見られるので存在だけでも頭の片隅には入れておいてください)

まずは必要なライブラリの依存関係を追記します。

dependencies {
    def room_version = "2.3.0"

    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
    implementation 'androidx.fragment:fragment-ktx:1.4.0'
    implementation 'androidx.datastore:datastore-preferences:1.0.0'
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

そうしたら早速、Top-level (クラス内ではなくファイル内直下)にDataStoreを配置しましょう。

今回はutil/Extension.ktに定義しておきます。

util/Extension.kt
/** Memo環境設定 (どのContextからもアクセスできるようにTop-levelに設置することを推奨) */
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

/** ソート方法に関する設定 */
val PREFERENCES_KEY_SORT_SETTING = intPreferencesKey("sort_setting")

MainActivityで呼び出して使ってみましょう。

class MainActivity
    : AppCompatActivity(), NewMemoFragment.Listener, MemoDetailFragment.Listener {

    private val viewModel: MainViewModel by viewModels()

    // ソート設定に関するFlow
    private val sortSettingFlow by lazy {
        dataStore.data
            .catch { exception ->
                if (exception is IOException) emit(emptyPreferences())
                else throw exception
            }.map { preferences ->
                preferences[PREFERENCES_KEY_SORT_SETTING] ?: 0  // デフォルトは0にしておく
            }
    }

    // sortSpinnerのアイテムが選択された時の挙動
    private val sortAdapterListener = object : AdapterView.OnItemSelectedListener {
        override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
            Log.d(this::class.simpleName, "onItemSelected: position=$position, id=$id")
            // DataStoreに、選択されているposition (ID)を非同期で格納しておく
            lifecycleScope.launch(Dispatchers.IO) {
                dataStore.edit { settings ->
                    settings[PREFERENCES_KEY_SORT_SETTING] = position
                }
            }
            viewModel.sortBy(Sort.getById(id))
        }

        // ...
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val addButton: Button = findViewById(R.id.add_button)

        val recyclerView: RecyclerView = findViewById(R.id.main_list)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = MainAdapter(::onItemClick)
        recyclerView.addItemDecoration(
            DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        )

        // ソートのセレクターを作成する
        // レイアウトは既存のものを使うことにする
        val sortSpinner: Spinner = findViewById(R.id.sort_spinner)
        val sortAdapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_item)
        sortAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
        // 全選択肢を追加
        Sort.values().forEach {
            sortAdapter.add(getString(it.id))
        }
        // Spinnerの設定
        sortSpinner.adapter = sortAdapter
        sortSpinner.onItemSelectedListener = sortAdapterListener
        // DataStoreからデータを非同期で取得する
        lifecycleScope.launch(Dispatchers.Main) {
            sortSpinner.setSelection(getFirstSortSetting())
        }

        addButton.setOnClickListener {
            supportFragmentManager.beginTransaction().run {
                add(R.id.main_container, NewMemoFragment())
                addToBackStack(null)
                commit()
            }
        }
        viewModel.memoItems.observe(this) {
            (recyclerView.adapter as MainAdapter).setMemoItems(it)
        }
        viewModel.loadMemoItems()
    }

    // ...

    // 初期ソート設定を読み込む
    // NOTE: DataStoreへのアクセスはIOスレッドで行うようにする
    private suspend fun getFirstSortSetting() =
        withContext(Dispatchers.IO) { sortSettingFlow.first() }
}

少し特殊な処理が出てしましたが、DataStoreのデータの実態はFlowと呼ばれる非同期オブジェクトです。

詳細は「」で紹介していますが、簡単に言うと「Flowオブジェクトは非同期で複数のデータを順次送信し、それを回収する側も非同期で順次受け取ることができる」というものです。

これで、最初にSharedPreferencesで実装したものと同じ挙動が実現できていると思います。

コードを最適化する

さてここまでの実装でもうまくアプリは動作していますが、最初のソート情報適用とDB読み込みのよるUI描画とで、UI更新が2度行われています。

これは少し冗長なので解消しましょう。

まず、MainActivity#onCreate()内の

viewModel.loadMemoItems()

は消してしまい、ソート情報の読み込み→適用時に必要であればDBへの取得を行うようにします。

class MainViewModel(app: Application) : MemoViewModel(app) {

    // ...

    /**
     * 指定されたソート方法適用する
     */
    fun sortBy(sort: Sort) {
        viewModelScope.launch(Dispatchers.Main) {
            val items = _memoItems.value ?: withContext(Dispatchers.IO) {
                memoRepository.fetchAllMemo()  // もしまだデータフェッチが行われていない場合は読み込みを行う
            }
            _memoItems.postValue(sort.applyTo(items))
        }
    }
}

これで、余計なUI更新がなくなりました。

こう言った意識は、アプリ開発に限らずソフトウェア開発では重要です。

最後に、ソート情報の適用を新しいメモを追加したときなども行いたいので軽微な修正をします。

MainViewModel.kt
class MainViewModel(app: Application) : MemoViewModel(app) {

    // ...

    // 現在のソート方法
    private var currentSorting: Sort? = null

    // ...

    /**
     * Memoを更新する。
     */
    fun updateMemo(memo: Memo) = viewModelScope.launch(Dispatchers.IO) {
        memoRepository.updateMemo(memo)
        val items = memoRepository.fetchAllMemo()
        _memoItems.postValue(
            currentSorting?.applyTo(items) ?: items
        )
    }

    /**
     * 指定されたソート方法適用する
     */
    fun sortBy(sort: Sort) {
        currentSorting = sort
        viewModelScope.launch(Dispatchers.Main) {
            val items = _memoItems.value ?: withContext(Dispatchers.IO) {
                memoRepository.fetchAllMemo()  // もしまだデータフェッチが行われていない場合は読み込みを行う
            }
            _memoItems.postValue(sort.applyTo(items))
        }
    }
}

これでだいぶ本アプリも実用的に仕上がってきましたね!

第8回のまとめ

  • データベースなどを使う必要がない小さなデータを不揮発で保持したい場合はSharedPreferencesが利用できる
    • SharedPreferencesはデバイス内部のXMLファイルに書き込まれる
  • DataStoreはSharedPreferencesに代わる新しい環境設定保存方法
    • 最近ではDataStoreの利用が推奨される

おわりに

第8回はSharedPreferencesとDataStoreを使った環境設定の保存方法を学びました。

今回のサンプルのように、前回最後に選択した設定を次回起動時にも反映させたい場合などに最適です。

さて本チュートリアルもあと少しです。

次回ラストを飾るのは、ユーザビリティについてです。

Android Kotlin日本語チュートリアル

参考