個人開発しているメモアプリに、ユーザーが安心して使えるデータバックアップ機能を実装します。
この機能開発を通して、Androidの非同期処理の主役であるKotlinコルーチンや、OSの機能と連携するための作法を学びます。
この記事は、実装に着手できるレベルまで詳細化された**「設計書」**そのものです。
1. この機能で実現したいこと (要件定義)
- ユーザーのゴール: アプリ内の全メモを、単一のJSONファイルとしてスマホの好きな場所に**バックアップ(エクスポート)**したい。
- 開発者のゴール: ファイル書き出しという重い処理をコルーチンでバックグラウンド実行し、UIがフリーズしない非同期処理をマスターする。
2. 登場する主要技術と設計方針
この機能は、以下の4つの主要技術の連携によって実現されます。
技術 | 役割 |
---|---|
ActivityResultLauncher (ファイル選択の受付係 🗂️) | OSのファイル保存画面を安全に呼び出し、ユーザーが選んだ保存場所を受け取ります。 |
ContentResolver (データアクセスの門番 🚪) | アプリの外部にあるファイルに対して、安全に書き込みを行うための唯一の窓口です。 |
viewModelScope.launch (賢い非同期処理アシスタント 👨💼) | UIを止めずに裏側で処理を開始し、画面が閉じられたら自動で後片付けもしてくれる安全な処理の開始役です。 |
withContext(Dispatchers.IO) (力仕事専門の部署 🏭) | ファイル書き込みのような時間のかかる作業を、専門のバックグラウンドスレッドに任せるための切り替え役です。 |
設計方針: これらの技術を使い、UI・ViewModel・Repositoryの責務を明確に分離したMVVMアーキテクチャで実装します。
3. 依存ライブラリの追加
メモオブジェクトをJSON文字列に変換するため、Kotlin公式のkotlinx.serializationライブラリを導入します。
app/build.gradle.kts
に、以下のコードを追加します。
// app/build.gradle.kts
plugins {
// ...
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" // ★追加
}
dependencies {
// ...
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") // ★追加
}
4. 詳細設計
【データ層】Data Layer 🏛️
設計のポイント: LocalDateTime
型は直接JSONに変換できないため、エクスポート専用のデータクラスSerializableMemo
を用意し、役割を分離します。
1. シリアライズ用データクラスの作成
data/model/SerializableMemo.kt
を新規作成します。
// data/model/SerializableMemo.kt (新規作成)
package com.example.simplememoapp_android.data.model
import kotlinx.serialization.Serializable
@Serializable // このアノテーションでJSON変換が可能になる
data class SerializableMemo(
val title: String,
val content: String,
val createdAt: String,
val updatedAt: String
)
2. MemoRepositoryへの機能追加
ファイル書き出しの重い処理を、コルーチンのwithContext(Dispatchers.IO)
を使ってバックグラウンドで実行するsuspend
関数を実装します。
// data/repository/MemoRepository.kt
// ... import ...
import android.content.ContentResolver
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext //
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.example.simplememoapp_android.data.model.SerializableMemo
class MemoRepository(private val memoDao: MemoDao) {
// ... 既存のメソッド ...
// ★★★ 以下を追記 ★★★
suspend fun exportMemosToFile(uri: Uri, contentResolver: ContentResolver) {
withContext(Dispatchers.IO) { // 力仕事専門の部署に切り替える
val memos = memoDao.getAllMemos().first()
// DBエンティティをシリアライズ可能なデータクラスに変換
val serializableMemos = memos.map { memo ->
SerializableMemo(
title = memo.title,
content = memo.content,
createdAt = memo.createdAt.toString(), // Stringに変換
updatedAt = memo.updatedAt.toString() // Stringに変換
)
}
// JSON文字列に変換
val jsonString = Json.encodeToString(serializableMemos)
// ContentResolverを使ってファイルに書き込み
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.writer().use { it.write(jsonString) }
}
}
}
}
【ViewModel層】Logic Layer 🧠
設計のポイント: ViewModelはUIからの指示を受け、RepositoryのメソッドをviewModelScope
で安全に呼び出します。処理の多重実行を防ぐためにJob
で実行状態を管理します。
MemoListViewModelへの機能追加
// ui/viewmodel/MemoListViewModel.kt
// ... import ...
import android.content.ContentResolver
import android.net.Uri
import kotlinx.coroutines.Job // ★追加
class MemoListViewModel(private val repository: MemoRepository) : ViewModel() {
// ... 既存のプロパティ ...
private var exportJob: Job? = null // エクスポート処理を管理するためのリモコン
// ★★★ 以下を追記 ★★★
fun exportMemos(uri: Uri, contentResolver: ContentResolver) {
// Jobがアクティブな場合は、多重実行を防ぐため処理を中断
if (exportJob?.isActive == true) return
exportJob = viewModelScope.launch {
try {
// TODO: ここでUiStateを更新し、ローディング表示を開始する (今後の改善タスク)
// Repositoryを通じてバックグラウンドで処理を実行
repository.exportMemosToFile(uri, contentResolver)
_eventFlow.emit(UiEvent.ShowSnackbar("メモのエクスポートが完了しました。"))
} catch (e: Exception) {
_eventFlow.emit(UiEvent.ShowSnackbar("エクスポートに失敗しました: ${e.message}"))
} finally {
// TODO: ここでUiStateを更新し、ローディング表示を解除する (今後の改善タスク)
}
}
}
// ... 既存のメソッド ...
}
【UI層】UI Layer 🎨
設計のポイント: 「全件エクスポート」は頻繁に使う機能ではないため、TopAppBar
の**三点リーダーメニュー(︙)**に配置します。これにより、UIをクリーンに保ちます。
MemoListScreen.ktへの機能追加
ActivityResultLauncher
をセットアップし、TopAppBar
のactions
にメニューを実装します。
// ui/screen/MemoListScreen.kt
// ... import ...
import androidx.activity.compose.rememberLauncherForActivityResult //
import androidx.activity.result.contract.ActivityResultContracts //
import androidx.compose.material.icons.filled.MoreVert //
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
// ... (その他のimport) ...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MemoListScreen(navController: NavController) {
val application = LocalContext.current.applicationContext as MemoApplication //
val viewModel: MemoListViewModel = viewModel(
factory = MemoListViewModelFactory(application.repository) //
)
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
// 1. ファイル作成ランチャーを登録する (ActivityResultLauncher)
val createFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
onResult = { uri: Uri? ->
uri?.let {
// ファイルの保存先が決定したら、ViewModelに処理を委譲
viewModel.exportMemos(it, context.contentResolver)
}
}
)
Scaffold(
snackbarHost = { /* ... */ },
topBar = {
TopAppBar(
title = { Text("爆速メモアプリ") }, //
// ★★★ ここから TopAppBarのactionsを実装 ★★★
actions = {
// メニューの開閉状態を管理する
var menuExpanded by remember { mutableStateOf(false) }
// 三点リーダーアイコンのボタン
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "メニュー")
}
// ドロップダウンメニュー
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
// 「全件エクスポート」メニュー項目
DropdownMenuItem(
text = { Text("全件エクスポート") },
onClick = {
menuExpanded = false // メニューを閉じる
// ファイル作成ランチャーを起動
createFileLauncher.launch("memos.json")
}
)
// 他にもメニュー項目があればここに追加できる
}
}
// ★★★ ここまで ★★★
)
},
floatingActionButton = { /* ... */ }
) { paddingValues ->
// ... (Column以下の実装は変更なし) ...
}
}