ROOMを使わずにSQLiteを扱う、Androidアプリ開発

AndroidアプリでSQLiteを操作する際、最近ではROOMが推奨されている。しかし、ライブラリの設定は面倒であり、特にライブラリを導入する必要性を感じない場合もある。そうした状況では、デフォルトで提供されているSQLiteへのアクセス方法を使用して操作するのが一つの解決策だ。

ここでは、現在製作中のメモアプリを例に、AndroidアプリでSQLiteのデータベースを扱うまでを、ステップごとに解説していく。

ゴール

ここで紹介するアーキテクチャというか、MainActivity から SQLite Databaseまでのフローは次のイメージで制作してく。

【ステップ1】SQLiteOpenHelperを継承したクラスを作る

ソースコード

DbHelper.kt
package com.apppppp.supermemo2.data

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper

const val SQL_CREATE_ENTRIES = """
CREATE TABLE `memo` (
  `id` INTEGER PRIMARY KEY,
  `parent_id` INTEGER,
  `title` TEXT DEFAULT NULL,
  `done_flg` INTEGER DEFAULT 0
);
"""

const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS memo"

class DbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL(SQL_CREATE_ENTRIES)
    }
    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
//        db.execSQL(SQL_DELETE_ENTRIES)
//        onCreate(db)
        if (oldVersion < 2) {
            // バージョン1から2へのアップグレード処理
            val addCreatedColumn = "ALTER TABLE memo ADD COLUMN `created` TEXT DEFAULT NULL"
            val addModifiedColumn = "ALTER TABLE memo ADD COLUMN `modified` TEXT DEFAULT NULL"
            db.execSQL(addCreatedColumn)
            db.execSQL(addModifiedColumn)
        }
    }
    override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        onUpgrade(db, oldVersion, newVersion)
    }
    companion object {
        const val DATABASE_VERSION = 2
        const val DATABASE_NAME = "SuperMemo2.db"
    }
}

解説

このコードは、SQLiteOpenHelperを継承したDbHelperクラスを定義し、データベース作成とバージョン管理のロジックを実装している。

SQL_CREATE_ENTRIES定数では、memoテーブルの作成に必要なSQL文を定義。onCreateメソッド内で、データベースが初めて作成される際にmemoテーブルを作成するためのSQL文が実行される。onUpgradeメソッドでは、データベースのバージョンが上がった場合の処理を記述。ここでは、バージョン1から2へのアップグレード時にcreated列とmodified列をmemoテーブルに追加する処理を行う。DATABASE_VERSIONDATABASE_NAMEの定数は、それぞれデータベースのバージョンとファイル名を指定するために使われる。

【ステップ2】Repositoryクラスでデータソースを抽象化する

ソースコード

MemoRepository.kt
class MemoRepository(private val context: Context) {

    private val dbHelper: DbHelper = DbHelper(context)
    val db = dbHelper.writableDatabase

    /**
     * メモを新規作成
     *
     * @param title メモのタイトル
     * @param parentId 親メモのID
     * @return 作成したメモ
     */
    fun insertMemo(title: String, parentId:Int? = null): Memo {
        val now = LocalDateTime.now()
        val formattedDate = now.formatToString() // "2023-01-01T12:00:00" など

        val values = ContentValues().apply {
            put("parent_id", parentId)
            put("title", title)
            put("done_flg", 0)
            put("created", formattedDate)
        }
        // 新しい行を挿入し、その行のIDを返す
        val newRowId = db.insert("memo", null, values)

        return Memo(
            id = newRowId.toInt(),
            parentId = parentId,
            title = title,
            doneFlg = false
        )
    }

    /**
     * parentIdのdoneFlgを更新
     */
    fun updateDoneFlg(memoId: Int, doneFlg:Boolean) {
        val now = LocalDateTime.now()
        val formattedDate = now.formatToString() // "2023-01-01T12:00:00" など

        val values = ContentValues().apply {
            put("done_flg", if (doneFlg) 1 else 0)
            put("modified", formattedDate)
        }
        db.update("memo", values, "id = ?", arrayOf(memoId.toString()))
    }

    /**
     * parentIdに一致するメモを取得
     *
     * @param parentId 親メモのID
     * @return parentIdに一致するメモのリスト
     */
    fun searchMemos(parentId: Int?): List<Memo> {
        val memos = mutableListOf<Memo>()

        // parentIdがnullかどうかに応じたSQLクエリ
        val sql = if (parentId == null) {
            "SELECT * FROM memo WHERE parent_id IS NULL ORDER BY created ASC"
        } else {
            "SELECT * FROM memo WHERE parent_id = ? ORDER BY created ASC"
        }

        // rawQueryメソッドを使用してSQLクエリを実行
        val cursor = if (parentId == null) {
            db.rawQuery(sql, null)
        } else {
            db.rawQuery(sql, arrayOf(parentId.toString()))
        }

        with(cursor) {
            while (moveToNext()) {
                val id = getInt(getColumnIndexOrThrow("id"))
                val obtainedParentId = if (!isNull(getColumnIndexOrThrow("parent_id"))) getInt(getColumnIndexOrThrow("parent_id")) else null
                val title = getString(getColumnIndexOrThrow("title"))
                val doneFlg = getInt(getColumnIndexOrThrow("done_flg")) != 0

                memos.add(Memo(id, obtainedParentId, title, doneFlg))
            }
            close()
        }

        return memos
    }

    /**
     * dbを閉じる
     */
    fun close() {
        dbHelper.close()
    }
}

解説

データベース操作に必要なメソッドをRepositoryに実装していく。 このコードでは、SQLiteデータベースを使ってメモデータのCRUD操作(作成、読み取り、更新、削除)を行うためのメソッドを提供している。DbHelperクラスのインスタンスを使用してデータベースとの接続を管理し、メモデータを操作する。

closeメソッドは、DbHelperを通じてデータベース接続を閉じる。これはリソースの解放を確実にするために重要である。

このクラスは、メモアプリケーションのデータ層を担い、アプリケーションの残りの部分がデータベースに直接アクセスすることなく、必要なデータ操作を行えるようにする。MemoRepositoryを通じて、アプリケーションのロジックはデータベースの詳細から抽象化され、データ管理の責任がはっきりと分離される。

【ステップ3】コンテナを作って、ViewModelへDIする

ソースコード

AppContainer.kt
object AppContainer {
    private var repository: MemoRepository? = null

    fun initialize(context: Context) {
        repository = MemoRepository(context)
    }

    private val uiStateRepository: UiStateRepository by lazy {
        UiStateRepository()
    }

    val viewModel by lazy {
        if (repository == null) throw IllegalStateException("Repository must be initialized")
        MemoViewModel(repository!!, uiStateRepository)
    }
}
MainActivity.kt
class MainActivity : ComponentActivity() {
    private lateinit var memoRepository: MemoRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        AppContainer.initialize(applicationContext)
        val viewModel = AppContainer.viewModel

        setContent {
            AppTheme {
                MainApp(viewModel)
            }
        }
    }

    override fun onDestroy() {
        memoRepository.close()
        super.onDestroy()
    }
}

解説

AppContainerは、プログラム実行中にただ一つのインスタンスしか存在しないシングルトンオブジェクトとして初期化される。MainActivityから渡されるcontextMemoRepositoryインスタンスを初期化し、ViewModel依存性注入(Dependency Injection、DI)している。DIを使うことで、コードのテスト性が向上し、モジュール間の結合が緩くなり、拡張性とメンテナンス性が向上される。MainActivityのライフサイクルの onCreateメソッド内で、AppContainerを使用してアプリケーションの依存関係を初期化し、ViewModelを取得する。その後、setContentを呼び出してUIを設定し、AppThemeを適用してMainAppコンポーザブル関数をレンダリングする。onDestroyメソッドでは、memoRepositoryを閉じることで、データベースとの接続を適切に解放し、リソースのクリーンアップを行う。

【ステップ4】ViewModelからRepositoryを利用する

ソースコード

MemoViewModel.kt
class MemoViewModel(
    private val dbMemoRepos: MemoRepository,
    private val uiStateRepository: UiStateRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> = uiStateRepository.uiState

    init {
        Log.d("mopi", "MemoViewModel init")
        setParentMemos()
    }

    /**
     * 親メモのuiStateを更新
     */
    fun setParentMemos() {
        viewModelScope.launch {
            val memos = dbMemoRepos.searchMemos()
            ...
        }
    }

    /**
     * リポジトリへ新規メモを追加
     */
    fun insertMemo(title: String, parentId: Int? = null) {
        viewModelScope.launch {
            val newMemo = dbMemoRepos.insertMemo(title, parentId)
        }
    }

    // メモのチェックフラグを更新
    fun updateMemoCheckedState(memoId: Int, checked: Boolean) {
        viewModelScope.launch {
            dbMemoRepos.updateDoneFlg(memoId, checked)
            uiStateRepository.setChildMemoDoneFlg(memoId, checked)
        }
    }

    ...

}

解説

このコードは、アプリのビジネスロジックとUIの状態管理を担当するViewModelの一部を示している。MemoRepositoryUiStateRepositoryの二つのリポジトリに依存しており、これらを介してデータベースの操作やUI状態の更新を行っている。 ここでは詳しく解説しないが、 UiStateRepositoryuiStateプロパティを通じて、UIの状態をリアクティブに反映させている。

Repositoryを作成したことで、SQLite操作をViewModelで実装せずに済み、コードが簡潔になり役割分担が明確になった。

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム