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 (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)
}
}