Android Studio

アプリを閉じてもデータが残るようにする機能

アプリを閉じてもデータが残るようにする機能

現在のアプリの状態(rememberSaveable)では、アプリを完全に閉じるとデータが消えてしまいます。

データを永続化(永続的に保存)する方法はいくつかありますが、Androidアプリ開発でよく使われるのは以下の2つです。

  1. SharedPreferences: キーと値のペア形式で少量のリッチなデータを保存するのに適しています。設定値や簡単な文字列リストなど。
  2. Room Persistence Library (SQLiteデータベース): 構造化された大量のデータを保存するのに適しています。ToDoリストのような複雑なオブジェクトのリストを保存する場合に非常に強力です。

ToDoリストのように、複数の項目(TodoItem オブジェクト)を持つリストを保存するとなると、Room Persistence Library を使うのが最も推奨される方法です。しかし、Roomは少し導入が複雑なので、まずは比較的簡単なSharedPreferencesを使って、TodoItem のリストをJSON文字列に変換して保存する方法から試してみましょう。

この方法では、以下のことを行います。

  1. JSON変換ライブラリの追加: KotlinオブジェクトをJSON文字列に、JSON文字列をKotlinオブジェクトに変換するためのライブラリ(今回はkotlinx.serialization)を追加します。
  2. TodoItem をシリアライズ可能にする: TodoItem データクラスをJSONに変換できるように設定します。
  3. SharedPreferences の利用:
    • アプリ起動時にSharedPreferencesから保存されたToDoリストを読み込む。
    • ToDoリストが変更されるたびに、リスト全体をJSON文字列としてSharedPreferencesに保存する。

具体的な手順

1. build.gradle.kts (Module: app) に依存関係を追加する

build.gradle.kts (Module: app) ファイルを開き、plugins { … } ブロックにkotlin(“plugin.serialization”)を追加し、dependencies { … } ブロックに以下の依存関係を追加してください。バージョン番号は最新のものに合わせてください。

// build.gradle.kts (Module: app)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.serialization") // これを追加
}

android {
    // ...
}

dependencies {
    // ...
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.activity:activity-compose:1.8.1")
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.navigation:navigation-compose:2.7.5") // navigation
    implementation("androidx.compose.material:material-icons-extended") // icons
    implementation("androidx.compose.material3:material3-window-size-class") // material3 ExperimentalMaterial3Apiなどで必要かも

    // kotlinx.serialization の依存関係
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") // これを追加

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

ファイルを保存した後、Android Studioのツールバーに表示される「Sync Now」をクリックして、プロジェクトを同期してください。

2. TodoItem をシリアライズ可能にする

TodoItem データクラスの定義を以下のように変更します。@Serializable アノテーションを追加します。

import kotlinx.serialization.Serializable // これを追加

// タスクのカテゴリを定義
enum class TodoCategory(val displayName: String, val color: Color) {
    WORK("仕事", Color(0xFFADD8E6)),
    PRIVATE("プライベート", Color(0xFFC8E6C9)),
    OTHER("その他", Color(0xFFFFFACD)),
    NONE("未分類", Color.LightGray)
}

// タスクのデータ構造
@Serializable // これを追加
data class TodoItem(
    val id: Long = System.currentTimeMillis(),
    val text: String,
    val category: TodoCategory,
    val createdAt: Long = System.currentTimeMillis()
)
  • @Serializable: このアノテーションを付けることで、kotlinx.serialization がこのデータクラスをJSONに変換したり、JSONから復元したりできるようになります。

3. MainActivity.kt にSharedPreferencesの読み書きロジックを追加する

MainActivity.kt の TodoListScreen 関数を以下のように変更します。

import android.content.Context // Context用
import androidx.compose.runtime.rememberUpdatedState // 状態監視用
import kotlinx.serialization.encodeToString // JSONエンコード用
import kotlinx.serialization.json.Json // JSON処理用
import kotlinx.serialization.decodeFromString // JSONデコード用
import androidx.compose.ui.platform.LocalContext // LocalContext用


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoListScreen(navController: NavController) {
    // Contextを取得
    val context = LocalContext.current
    // SharedPreferencesのインスタンス
    val prefs = remember { context.getSharedPreferences("todo_prefs", Context.MODE_PRIVATE) }
    // JSONシリアライザー
    val json = remember { Json }

    // SharedPreferencesからデータを読み込む関数
    val loadTodoItems: () -> MutableList<TodoItem> = {
        val savedJson = prefs.getString("todo_list", null)
        if (savedJson != null) {
            try {
                json.decodeFromString<MutableList<TodoItem>>(savedJson)
            } catch (e: Exception) {
                // デコードエラーの場合、空のリストを返す
                e.printStackTrace()
                mutableListOf()
            }
        } else {
            mutableListOf()
        }
    }

    // SharedPreferencesにデータを保存する関数
    val saveTodoItems: (List<TodoItem>) -> Unit = { items ->
        val jsonString = json.encodeToString(items)
        prefs.edit().putString("todo_list", jsonString).apply()
    }

    // todoItemsの状態をrememberSaveableではなくrememberにすることで、
    // アプリ終了時に保存、起動時に読み込みを行う
    var todoItems by remember { mutableStateOf(loadTodoItems()) }

    // todoItemsが変更されるたびに保存するEffect
    val currentTodoItems by rememberUpdatedState(todoItems) // todoItemsの最新値を監視
    DisposableEffect(currentTodoItems) {
        onDispose {
            // Composableが破棄されるときに保存
            saveTodoItems(currentTodoItems)
        }
    }


    var newTodoText by rememberSaveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }

    var expanded by remember { mutableStateOf(false) }
    var selectedCategory by remember { mutableStateOf(TodoCategory.NONE) }

    var editingItemId by remember { mutableStateOf<Long?>(null) }
    var editingText by rememberSaveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }
    var editingCategory by remember { mutableStateOf(TodoCategory.NONE) }
    var editingCategoryExpanded by remember { mutableStateOf(false) }

    val dateFormatter = remember { SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.JAPAN) }


    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "ToDoリスト",
            fontSize = 24.sp,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        OutlinedTextField(
            value = newTodoText,
            onValueChange = { newValue -> newTodoText = newValue },
            label = { Text("新しいToDo") },
            singleLine = true,
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 8.dp)
        )

        ExposedDropdownMenuBox(
            expanded = expanded,
            onExpandedChange = { expanded = !expanded },
            modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
        ) {
            OutlinedTextField(
                value = selectedCategory.displayName,
                onValueChange = {},
                readOnly = true,
                label = { Text("カテゴリを選択") },
                trailingIcon = { Icon(Icons.Filled.ArrowDropDown, "ドロップダウン") },
                modifier = Modifier.menuAnchor().fillMaxWidth()
            )
            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = { expanded = false }
            ) {
                TodoCategory.values().forEach { category ->
                    DropdownMenuItem(
                        text = { Text(category.displayName) },
                        onClick = {
                            selectedCategory = category
                            expanded = false
                        }
                    )
                }
            }
        }

        Button(
            onClick = {
                if (newTodoText.text.isNotBlank()) {
                    val newTodo = TodoItem(
                        text = newTodoText.text,
                        category = selectedCategory
                    )
                    todoItems = (todoItems + newTodo).toMutableList()
                    newTodoText = TextFieldValue("")
                    selectedCategory = TodoCategory.NONE
                }
            },
            modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
        ) {
            Text("追加")
        }

        LazyColumn(
            modifier = Modifier.fillMaxSize()
        ) {
            items(todoItems, key = { it.id }) { item ->
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(vertical = 4.dp),
                    border = BorderStroke(1.dp, Color.LightGray)
                ) {
                    if (editingItemId == item.id) {
                        // 編集モードの場合
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .background(editingCategory.color)
                                .padding(horizontal = 16.dp, vertical = 8.dp),
                            verticalAlignment = Alignment.CenterVertically,
                            horizontalArrangement = Arrangement.SpaceBetween
                        ) {
                            BasicTextField(
                                value = editingText,
                                onValueChange = { editingText = it },
                                singleLine = true,
                                modifier = Modifier
                                    .weight(1f)
                                    .padding(end = 8.dp)
                            )
                            ExposedDropdownMenuBox(
                                expanded = editingCategoryExpanded,
                                onExpandedChange = { editingCategoryExpanded = !editingCategoryExpanded }
                            ) {
                                Text(
                                    text = editingCategory.displayName,
                                    modifier = Modifier
                                        .menuAnchor()
                                        .padding(horizontal = 4.dp)
                                        .background(Color.LightGray)
                                        .clickable { editingCategoryExpanded = true }
                                )
                                ExposedDropdownMenu(
                                    expanded = editingCategoryExpanded,
                                    onDismissRequest = { editingCategoryExpanded = false }
                                ) {
                                    TodoCategory.values().forEach { category ->
                                        DropdownMenuItem(
                                            text = { Text(category.displayName) },
                                            onClick = {
                                                editingCategory = category
                                                editingCategoryExpanded = false
                                            }
                                        )
                                    }
                                }
                            }
                            IconButton(onClick = {
                                todoItems = todoItems.map { todo ->
                                    if (todo.id == item.id) {
                                        todo.copy(text = editingText.text, category = editingCategory)
                                    } else {
                                        todo
                                    }
                                }.toMutableList()
                                editingItemId = null
                            }) {
                                Icon(Icons.Filled.Done, contentDescription = "編集を保存")
                            }
                            IconButton(onClick = {
                                editingItemId = null
                            }) {
                                Icon(Icons.Filled.Close, contentDescription = "編集をキャンセル")
                            }
                        }
                    } else {
                        // 通常表示モードの場合
                        Column(
                            modifier = Modifier
                                .fillMaxWidth()
                                .background(item.category.color)
                                .padding(horizontal = 16.dp, vertical = 8.dp)
                        ) {
                            Row(
                                modifier = Modifier.fillMaxWidth(),
                                verticalAlignment = Alignment.CenterVertically,
                                horizontalArrangement = Arrangement.SpaceBetween
                            ) {
                                Text(
                                    text = "[${item.category.displayName}] ${item.text}",
                                    modifier = Modifier
                                        .weight(1f)
                                        .padding(end = 8.dp)
                                )
                                IconButton(onClick = {
                                    editingItemId = item.id
                                    editingText = TextFieldValue(item.text)
                                    editingCategory = item.category
                                }) {
                                    Icon(Icons.Filled.Edit, contentDescription = "編集")
                                }
                                IconButton(onClick = {
                                    todoItems = todoItems.filter { it.id != item.id }.toMutableList()
                                }) {
                                    Icon(Icons.Filled.Delete, contentDescription = "削除")
                                }
                            }
                            Text(
                                text = "追加日時: ${dateFormatter.format(Date(item.createdAt))}",
                                fontSize = 12.sp,
                                color = Color.Gray,
                                modifier = Modifier.padding(top = 4.dp)
                            )
                        }
                    }
                }
            }
        }

        Button(
            onClick = { navController.popBackStack() },
            modifier = Modifier.padding(top = 16.dp)
        ) {
            Text("戻る")
        }
    }
}
  • LocalContext.current: Composable内で Context を取得するためのヘルパーです。SharedPreferencesにアクセスするために必要です。
  • context.getSharedPreferences(“todo_prefs”, Context.MODE_PRIVATE): “todo_prefs”という名前でSharedPreferencesのインスタンスを取得します。
  • Json: kotlinx.serialization のJSON処理用オブジェクトです。
  • loadTodoItems(): SharedPreferencesから “todo_list” というキーでJSON文字列を読み込み、それを MutableList<TodoItem> にデコードします。読み込みに失敗した場合(初めての起動時など)は空のリストを返します。
  • saveTodoItems(items: List<TodoItem>): 受け取った TodoItem のリストをJSON文字列にエンコードし、SharedPreferencesに保存します。
  • var todoItems by remember { mutableStateOf(loadTodoItems()) }: ここが重要です。rememberSaveable ではなく remember を使い、初期値として loadTodoItems() でSharedPreferencesから読み込んだデータを設定しています。
  • DisposableEffect(currentTodoItems) { onDispose { … } }: Composeのライフサイクルエフェクトの一つです。currentTodoItems の値が変更されるたびに、このエフェクトが再実行されます。特に onDispose ブロックは、Composableが画面から消える際(例: アプリがバックグラウンドに行く、画面が遷移する)に実行されます。ここで saveTodoItems を呼び出すことで、アプリが閉じられる直前や他の画面に遷移する直前に最新のデータを保存できます。rememberUpdatedState は、onDispose が呼ばれる際に todoItems の最新の値を取得するために使用します。

これで、アプリを再起動したり、一度閉じてから再度開いても、以前追加したToDoリストの項目が残るようになります!

注意点:

  • DisposableEffect を使うことで、TodoListScreen がコンポジションツリーから外れるときにデータが保存されます。しかし、Androidのプロセスが突然終了した場合(メモリ不足など)は保存されない可能性があります。より堅牢な永続化にはRoomデータベースが適していますが、SharedPreferencesとJSONシリアライズは手軽に導入できるのがメリットです。
  • SharedPreferences は大量の複雑なデータを保存するようには設計されていません。今回はJSON文字列として一括で保存していますが、リストが非常に大きくなるとパフォーマンスに影響が出る可能性はあります。

※以下の変更を必要となることがあります。

1.gradle/libs.versions.toml ファイルを開きます。

2.[plugins] セクションに以下の1行を追加します。 (kotlin のバージョンは既存の [versions] セクションで定義されている kotlin のバージョンを参照するようにします)

ファイル: gradle/libs.versions.toml

[versions]
agp = "8.13.0"
kotlin = "2.0.21" # このバージョンが使われます
# ... 他のバージョン定義

[libraries]
# ...

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

# ▼ この行を追加します
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

次に、プロジェクトのルート(一番上の階層)にある build.gradle ファイルを編集して、サブプロジェクト(app モジュールなど)でこのプラグインが使えるようにします。

1.プロジェクトのルートにある build.gradle ファイルを開きます。

2.plugins ブロックに、先ほどカタログに追加したプラグインのエイリアスを追加します。

ファイル: build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    
    // ▼ この行を追加します
    alias(libs.plugins.kotlin.serialization) apply false
}

app/build.gradle ファイルを修正します。

id(“…”) という書き方の代わりに、バージョンカタログで定義したエイリアス alias(…) を使います。

1.app/build.gradle ファイルを開きます。

2.カーソル位置 のある行を、以下のように修正します。

ファイル: app/build.gradle

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    
    // ▼ この行を 'id(...)' から 'alias(...)' に変更します
    alias(libs.plugins.kotlin.serialization)
}

// ... 以降のファイル内容は変更なし

これらの変更を行った後、Android Studioの上部に表示される「Sync Now」をクリックしてプロジェクトを再同期してください。

※Google AI Studioによる回答を参考にしました

目次に戻る