Room (Android Architecture Components)

Room(Android Room Persistence Library)は、GoogleのAndroid Jetpackプロジェクトの一部として提供される「SQLiteの抽象化レイヤーを提供し、堅牢なデータベースアクセスを可能にする」Android専用のORM(Object-Relational Mapping)ライブラリです。SQLiteの全機能を活用しながら、コンパイル時のSQLクエリ検証、型安全なデータベース操作、LiveDataやKotlin Coroutinesとの統合により、現代的なAndroidアプリ開発における強力なデータ永続化ソリューションを提供します。Kotlin Firstのアプローチで設計され、AndroidのMVVMアーキテクチャとの自然な統合を実現しています。

AndroidKotlinSQLiteORMJetpackDatabasePersistence

ライブラリ

Room (Android Architecture Components)

概要

Room(Android Room Persistence Library)は、GoogleのAndroid Jetpackプロジェクトの一部として提供される「SQLiteの抽象化レイヤーを提供し、堅牢なデータベースアクセスを可能にする」Android専用のORM(Object-Relational Mapping)ライブラリです。SQLiteの全機能を活用しながら、コンパイル時のSQLクエリ検証、型安全なデータベース操作、LiveDataやKotlin Coroutinesとの統合により、現代的なAndroidアプリ開発における強力なデータ永続化ソリューションを提供します。Kotlin Firstのアプローチで設計され、AndroidのMVVMアーキテクチャとの自然な統合を実現しています。

詳細

Room 2025年版は、Kotlin Multiplatform対応、Kotlin Symbol Processing(KSP)サポート、完全なKotlin Coroutines統合により、Android開発における最新のベストプラクティスを体現しています。Entity、DAO、Databaseの3つの主要コンポーネントによる明確なアーキテクチャ設計により、複雑なデータベーススキーマの管理と自動マイグレーション機能を提供。LiveData、RxJava、Kotlin Flowとの自然な統合により、リアクティブプログラミングとデータバインディングを簡単に実装できます。また、関連エンティティの管理、フルテキスト検索(FTS)サポート、暗号化対応により、エンタープライズレベルのAndroidアプリケーション開発を強力にサポートします。

主な特徴

  • コンパイル時検証: SQLクエリのコンパイル時検証でランタイムエラーを防止
  • 型安全性: Kotlinの型システムを活用した完全型安全なデータベース操作
  • Jetpack統合: LiveData、ViewModel、Data Bindingとの完全統合
  • 自動マイグレーション: データベーススキーマの自動変更管理
  • Kotlin Coroutines: 非同期データベース操作の完全サポート
  • リアクティブクエリ: データ変更時の自動UI更新

メリット・デメリット

メリット

  • SQLiteの複雑さを隠蔽しつつ、全機能へのアクセスを提供
  • コンパイル時のクエリ検証でアプリの安定性を大幅向上
  • LiveDataとの統合により自動的なUI更新を実現
  • Kotlin Coroutinesサポートで効率的な非同期処理
  • Android Jetpackとの完全統合でMVVMアーキテクチャを自然に実装
  • Googleによる長期サポートと活発なコミュニティ

デメリット

  • Android専用でクロスプラットフォーム開発には使用不可
  • SQLiteに限定されるため、他のデータベースエンジンは使用不可
  • 大規模なデータセットでは初期化時間が増加する可能性
  • 複雑なクエリでは生のSQLに頼る必要がある場合がある
  • NoSQLのような柔軟なスキーマ設計は制限される
  • マイグレーション設計の不備による本番環境での問題リスク

参考ページ

書き方の例

セットアップ

// app/build.gradle (Module: app)
android {
    compileSdk 34
    
    defaultConfig {
        minSdk 21
        targetSdk 34
    }
    
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }
    
    kotlinOptions {
        jvmTarget = "17"
        freeCompilerArgs += ["-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"]
    }
}

dependencies {
    def room_version = "2.6.1"
    
    // Room core
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    
    // KSP (推奨 - kaptより高速)
    ksp "androidx.room:room-compiler:$room_version"
    
    // 非同期処理とリアクティブプログラミング
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
    
    // テスト
    testImplementation "androidx.room:room-testing:$room_version"
    androidTestImplementation "androidx.arch.core:core-testing:2.2.0"
}

// KSPプラグイン追加
plugins {
    id 'com.google.devtools.ksp' version '1.9.22-1.0.17'
}
// AndroidManifest.xml でバックアップルール設定
<application
    android:name=".MyApplication"
    android:allowBackup="true"
    android:fullBackupContent="@xml/backup_rules"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/Theme.MyApp">
    
    <!-- Room database configuration -->
    <meta-data
        android:name="room.schemaLocation"
        android:value="./schemas" />
</application>

Entityの定義

import androidx.room.*
import java.time.LocalDateTime
import java.time.OffsetDateTime

// 基本エンティティ
@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    
    @ColumnInfo(name = "username")
    val username: String,
    
    @ColumnInfo(name = "email")
    val email: String,
    
    @ColumnInfo(name = "created_at", defaultValue = "CURRENT_TIMESTAMP")
    val createdAt: OffsetDateTime = OffsetDateTime.now(),
    
    @ColumnInfo(name = "is_active", defaultValue = "1")
    val isActive: Boolean = true
)

// 複合主キーと外部キー
@Entity(
    tableName = "posts",
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["id"],
            childColumns = ["user_id"],
            onDelete = ForeignKey.CASCADE,
            onUpdate = ForeignKey.CASCADE
        )
    ],
    indices = [
        Index(value = ["user_id"]),
        Index(value = ["title"], unique = false),
        Index(value = ["created_at"])
    ]
)
data class Post(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    
    @ColumnInfo(name = "user_id")
    val userId: Long,
    
    @ColumnInfo(name = "title")
    val title: String,
    
    @ColumnInfo(name = "content")
    val content: String,
    
    @ColumnInfo(name = "created_at")
    val createdAt: OffsetDateTime = OffsetDateTime.now(),
    
    @ColumnInfo(name = "updated_at")
    val updatedAt: OffsetDateTime? = null
)

// 埋め込み型オブジェクト
data class Address(
    val street: String,
    val city: String,
    val country: String,
    val postalCode: String
)

@Entity(tableName = "user_profiles")
data class UserProfile(
    @PrimaryKey val userId: Long,
    
    @ColumnInfo(name = "full_name")
    val fullName: String,
    
    @Embedded
    val address: Address,
    
    @ColumnInfo(name = "phone_number")
    val phoneNumber: String?
)

DAOの実装

import androidx.room.*
import androidx.lifecycle.LiveData
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {
    // 基本的なCRUD操作
    @Query("SELECT * FROM users ORDER BY created_at DESC")
    fun getAllUsers(): Flow<List<User>>
    
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Long): User?
    
    @Query("SELECT * FROM users WHERE username = :username")
    suspend fun getUserByUsername(username: String): User?
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User): Long
    
    @Insert
    suspend fun insertUsers(users: List<User>): List<Long>
    
    @Update
    suspend fun updateUser(user: User)
    
    @Delete
    suspend fun deleteUser(user: User)
    
    @Query("DELETE FROM users WHERE id = :userId")
    suspend fun deleteUserById(userId: Long)
    
    // 複雑なクエリ
    @Query("""
        SELECT * FROM users 
        WHERE is_active = 1 
        AND created_at >= :fromDate 
        ORDER BY username ASC
    """)
    fun getActiveUsersFrom(fromDate: OffsetDateTime): Flow<List<User>>
    
    @Query("""
        SELECT u.*, COUNT(p.id) as post_count 
        FROM users u 
        LEFT JOIN posts p ON u.id = p.user_id 
        GROUP BY u.id
        HAVING post_count > :minPosts
        ORDER BY post_count DESC
    """)
    suspend fun getUsersWithMinimumPosts(minPosts: Int): List<UserWithPostCount>
    
    // 集約操作
    @Query("SELECT COUNT(*) FROM users WHERE is_active = 1")
    suspend fun getActiveUserCount(): Int
    
    @Query("SELECT username FROM users WHERE created_at >= :date")
    suspend fun getRecentUsernames(date: OffsetDateTime): List<String>
}

@Dao
interface PostDao {
    @Query("SELECT * FROM posts WHERE user_id = :userId ORDER BY created_at DESC")
    fun getPostsByUserId(userId: Long): Flow<List<Post>>
    
    @Query("""
        SELECT p.*, u.username 
        FROM posts p 
        INNER JOIN users u ON p.user_id = u.id 
        WHERE p.title LIKE '%' || :searchTerm || '%' 
        OR p.content LIKE '%' || :searchTerm || '%'
        ORDER BY p.created_at DESC
    """)
    suspend fun searchPosts(searchTerm: String): List<PostWithUser>
    
    @Insert
    suspend fun insertPost(post: Post): Long
    
    @Update
    suspend fun updatePost(post: Post)
    
    @Transaction
    suspend fun updatePostWithTimestamp(post: Post) {
        val updatedPost = post.copy(updatedAt = OffsetDateTime.now())
        updatePost(updatedPost)
    }
}

// 関連エンティティ
data class UserWithPostCount(
    @Embedded val user: User,
    @ColumnInfo(name = "post_count") val postCount: Int
)

data class PostWithUser(
    @Embedded val post: Post,
    @ColumnInfo(name = "username") val username: String
)

data class UserWithPosts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "user_id"
    )
    val posts: List<Post>
)

Databaseクラス

import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Database(
    entities = [User::class, Post::class, UserProfile::class],
    version = 3,
    exportSchema = true,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        AutoMigration(
            from = 2, 
            to = 3, 
            spec = AppDatabase.Migration2to3::class
        )
    ]
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun postDao(): PostDao
    
    @DeleteColumn(tableName = "users", columnName = "temp_column")
    @RenameColumn(tableName = "posts", fromColumnName = "old_title", toColumnName = "title")
    class Migration2to3 : AutoMigrationSpec
    
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null
        
        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                .addMigrations(MIGRATION_1_2)
                .addCallback(DatabaseCallback())
                .fallbackToDestructiveMigration() // 開発時のみ
                .build()
                
                INSTANCE = instance
                instance
            }
        }
        
        // 手動マイグレーション例
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("""
                    ALTER TABLE users 
                    ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1
                """)
                database.execSQL("""
                    CREATE INDEX index_users_is_active 
                    ON users(is_active)
                """)
            }
        }
    }
    
    private class DatabaseCallback : RoomDatabase.Callback() {
        override fun onCreate(db: SupportSQLiteDatabase) {
            super.onCreate(db)
            // 初期データの挿入処理
        }
        
        override fun onOpen(db: SupportSQLiteDatabase) {
            super.onOpen(db)
            // データベースオープン時の処理
        }
    }
}

// 型コンバーター
class Converters {
    @TypeConverter
    fun fromOffsetDateTime(value: OffsetDateTime?): Long? {
        return value?.toInstant()?.toEpochMilli()
    }
    
    @TypeConverter
    fun toOffsetDateTime(value: Long?): OffsetDateTime? {
        return value?.let {
            OffsetDateTime.ofInstant(
                java.time.Instant.ofEpochMilli(it),
                java.time.ZoneOffset.UTC
            )
        }
    }
    
    @TypeConverter
    fun fromStringList(value: List<String>): String {
        return value.joinToString(",")
    }
    
    @TypeConverter
    fun toStringList(value: String): List<String> {
        return if (value.isBlank()) emptyList() else value.split(",")
    }
}

ViewModelとRepository

import androidx.lifecycle.*
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject

// Repository
class UserRepository @Inject constructor(
    private val userDao: UserDao,
    private val postDao: PostDao
) {
    fun getAllUsers(): Flow<List<User>> = userDao.getAllUsers()
    
    suspend fun getUserById(userId: Long): User? = userDao.getUserById(userId)
    
    suspend fun insertUser(user: User): Long = userDao.insertUser(user)
    
    suspend fun updateUser(user: User) = userDao.updateUser(user)
    
    suspend fun deleteUser(user: User) = userDao.deleteUser(user)
    
    fun getUsersWithPosts(): Flow<List<UserWithPosts>> {
        return userDao.getAllUsers().map { users ->
            users.map { user ->
                val posts = postDao.getPostsByUserId(user.id).first()
                UserWithPosts(user, posts)
            }
        }
    }
    
    suspend fun searchUsers(query: String): List<User> {
        return userDao.getAllUsers().first().filter {
            it.username.contains(query, ignoreCase = true) ||
            it.email.contains(query, ignoreCase = true)
        }
    }
}

// ViewModel
class UserViewModel(private val repository: UserRepository) : ViewModel() {
    
    private val _searchQuery = MutableLiveData("")
    val searchQuery: LiveData<String> = _searchQuery
    
    private val _selectedUser = MutableLiveData<User?>()
    val selectedUser: LiveData<User?> = _selectedUser
    
    // リアクティブユーザーリスト
    val users: LiveData<List<User>> = repository.getAllUsers().asLiveData()
    
    // 検索結果
    val searchResults: LiveData<List<User>> = _searchQuery.switchMap { query ->
        if (query.isBlank()) {
            repository.getAllUsers().asLiveData()
        } else {
            liveData {
                emit(repository.searchUsers(query))
            }
        }
    }
    
    // アクティブユーザー数
    val activeUserCount: LiveData<Int> = users.map { userList ->
        userList.count { it.isActive }
    }
    
    fun searchUsers(query: String) {
        _searchQuery.value = query
    }
    
    fun selectUser(user: User) {
        _selectedUser.value = user
    }
    
    fun createUser(username: String, email: String) {
        viewModelScope.launch {
            try {
                val newUser = User(
                    username = username,
                    email = email,
                    createdAt = OffsetDateTime.now()
                )
                repository.insertUser(newUser)
            } catch (e: Exception) {
                // エラーハンドリング
                _errorMessage.value = "ユーザー作成に失敗しました: ${e.message}"
            }
        }
    }
    
    fun updateUser(user: User) {
        viewModelScope.launch {
            repository.updateUser(user)
        }
    }
    
    fun deleteUser(user: User) {
        viewModelScope.launch {
            repository.deleteUser(user)
        }
    }
    
    private val _errorMessage = MutableLiveData<String>()
    val errorMessage: LiveData<String> = _errorMessage
}

トランザクション処理

@Dao
interface TransactionDao {
    @Transaction
    suspend fun insertUserWithProfile(user: User, profile: UserProfile) {
        val userId = insertUser(user)
        val updatedProfile = profile.copy(userId = userId)
        insertProfile(updatedProfile)
    }
    
    @Transaction
    suspend fun transferPosts(fromUserId: Long, toUserId: Long) {
        val posts = getPostsByUserId(fromUserId)
        posts.forEach { post ->
            val transferredPost = post.copy(
                userId = toUserId,
                updatedAt = OffsetDateTime.now()
            )
            updatePost(transferredPost)
        }
    }
    
    @Insert
    suspend fun insertUser(user: User): Long
    
    @Insert
    suspend fun insertProfile(profile: UserProfile)
    
    @Query("SELECT * FROM posts WHERE user_id = :userId")
    suspend fun getPostsByUserId(userId: Long): List<Post>
    
    @Update
    suspend fun updatePost(post: Post)
}

// Repository内でのトランザクション使用
class UserService @Inject constructor(
    private val database: AppDatabase,
    private val userDao: UserDao,
    private val transactionDao: TransactionDao
) {
    suspend fun createUserWithProfile(
        username: String,
        email: String,
        fullName: String,
        address: Address
    ): Result<Long> {
        return try {
            database.withTransaction {
                val user = User(username = username, email = email)
                val userId = userDao.insertUser(user)
                
                val profile = UserProfile(
                    userId = userId,
                    fullName = fullName,
                    address = address,
                    phoneNumber = null
                )
                
                transactionDao.insertProfile(profile)
                userId
            }
            Result.success(userId)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

テスト実装

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.*
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class UserDaoTest {
    
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()
    
    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao
    
    @Before
    fun setup() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        )
        .allowMainThreadQueries()
        .build()
        
        userDao = database.userDao()
    }
    
    @After
    fun teardown() {
        database.close()
    }
    
    @Test
    fun insertAndGetUser() = runTest {
        // Given
        val user = User(
            username = "testuser",
            email = "[email protected]",
            createdAt = OffsetDateTime.now()
        )
        
        // When
        val userId = userDao.insertUser(user)
        val retrievedUser = userDao.getUserById(userId)
        
        // Then
        Assert.assertNotNull(retrievedUser)
        Assert.assertEquals(user.username, retrievedUser?.username)
        Assert.assertEquals(user.email, retrievedUser?.email)
    }
    
    @Test
    fun getAllUsers_returnsAllUsers() = runTest {
        // Given
        val users = listOf(
            User(username = "user1", email = "[email protected]"),
            User(username = "user2", email = "[email protected]"),
            User(username = "user3", email = "[email protected]")
        )
        
        // When
        userDao.insertUsers(users)
        val allUsers = userDao.getAllUsers().first()
        
        // Then
        Assert.assertEquals(3, allUsers.size)
        Assert.assertTrue(allUsers.any { it.username == "user1" })
        Assert.assertTrue(allUsers.any { it.username == "user2" })
        Assert.assertTrue(allUsers.any { it.username == "user3" })
    }
    
    @Test
    fun updateUser_updatesCorrectly() = runTest {
        // Given
        val user = User(username = "original", email = "[email protected]")
        val userId = userDao.insertUser(user)
        
        // When
        val updatedUser = user.copy(id = userId, username = "updated")
        userDao.updateUser(updatedUser)
        val retrievedUser = userDao.getUserById(userId)
        
        // Then
        Assert.assertEquals("updated", retrievedUser?.username)
        Assert.assertEquals("[email protected]", retrievedUser?.email)
    }
    
    @Test
    fun deleteUser_removesUser() = runTest {
        // Given
        val user = User(username = "toDelete", email = "[email protected]")
        val userId = userDao.insertUser(user)
        
        // When
        userDao.deleteUserById(userId)
        val deletedUser = userDao.getUserById(userId)
        
        // Then
        Assert.assertNull(deletedUser)
    }
    
    @Test
    fun searchQuery_worksCorrectly() = runTest {
        // Given
        val now = OffsetDateTime.now()
        val pastDate = now.minusDays(30)
        
        val oldUser = User(username = "old", email = "[email protected]", createdAt = pastDate)
        val newUser = User(username = "new", email = "[email protected]", createdAt = now)
        
        userDao.insertUser(oldUser)
        userDao.insertUser(newUser)
        
        // When
        val recentUsers = userDao.getActiveUsersFrom(now.minusDays(7)).first()
        
        // Then
        Assert.assertEquals(1, recentUsers.size)
        Assert.assertEquals("new", recentUsers[0].username)
    }
}