株式会社 ONE COMPATH のナカジマです。主に Android アプリ開発を担当しています。
先日、既存アプリに Jetpack Compose を導入する機会がありました。
導入時の手順やハマったところを紹介できればと思います。
1. はじめに
新規機能の開発をする機会があり、新規で画面を作ることになったタイミングで、以前から従来の Android View での開発を進める中で以下の課題を感じていたので Jetpack Compose の導入に踏み切りました。
- XML によるコードの複雑化: 画面が複雑になるほど XML の行数が増え、可読性が低下。改修が困難になっていた。
-
デバッグのしにくさ:
ListViewやRecyclerViewのデバックする時に、データが入ったケースや、データが空のケースなど複数のケースを確認するために、毎回ソースコードを編集して確認する必要があるので手間だった。 - View の状態管理によるバグ: 各 View の表示内容や表示状態(Visibility)などを個別のロジックで命令的に操作する必要があり、条件が複雑になるほど状態の不整合によるバグが起きやすかった。
既存のアプリの仕組み上、Fragment でないと画面遷移ができない構成だったため、Fragment 自体は残しつつ、Viewの部分にComposeを導入するという構成をとっています。
2. Jetpack Compose の導入
下記のライブラリを build.gradle にインポートしました。BOM(Bill of Materials:各ライブラリのバージョンを一括管理する仕組み)を使用することで、各ライブラリのバージョンを一括管理しています。
val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
3. Jetpack Composeでの実装
前述した通り、 Fragment でないと既存コードの画面遷移の仕組みが利用できないため、新規でFragmentを作成し、onCreateView()でComposeViewを返す形で実装しました。
既存コードでは DataBinding を使用して ViewModel のデータを XML に紐づけていましたが、Compose化にあたっては、表示用のデータをまとめた UiState を定義し、ViewModel から Compose へ流し込む形にしました。
3.1 UiState と ViewModel の構成
画面に必要な情報を一つの UiState にまとめ、ViewModel 側で管理します。
// 画面の表示状態を管理するデータクラス
data class MyUiState(
val itemList: List<MyItem> = emptyList(),
val isLoading: Boolean = false
)
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MyUiState())
val uiState: StateFlow<MyUiState> = _uiState.asStateFlow()
// クリック時の処理
fun onItemClick(item: MyItem) {
}
}
3.2 Fragment内でのCompose呼び出し
onCreateView() で ComposeView を返却し、ViewModel を直接Composable関数に渡してあげます。
class MyFragment : Fragment() {
// ViewModelの取得(プロジェクトのDI基盤に合わせて注入)
@Inject
lateinit var viewModel: MyViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
// FragmentのViewライフサイクルに合わせてCompositionを破棄する設定
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MaterialTheme {
// Composeの画面全体を呼び出し、ViewModelを渡す
MyScreen(viewModel = viewModel)
}
}
}
}
}
3.3 Composable内でのデータ紐づけ
受け取ったViewModelから UiState を取り出し、各コンポーネントに反映させます。
@Composable
fun MyScreen(viewModel: MyViewModel) {
val uiState by viewModel.uiState.collectAsState()
MyScreenContent(
uiState = uiState,
onItemClick = { item -> viewModel.onItemClick(item) }
)
}
@Composable
fun MyScreenContent(
uiState: MyUiState,
onItemClick: (MyItem) -> Unit
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (uiState.isLoading) {
Loading()
} else {
MyList(
itemList = uiState.itemList,
onItemClick = onItemClick
)
}
}
}
3.4 Preview機能の活用
導入のきっかけでも触れた「デバッグのしやすさ」を実現するのが @Preview です。
ViewModel に依存しない「表示専用の Composable(上記例の MyScreenContent)」を用意することで、ダミーデータを流し込んだUIの状態を確認できます。
また @Previewを複数定義することで色々な状態の画面を確認することができます。
// プレビュー用のダミーデータ作成
private val dummyItems = listOf(
MyItem(id = 1, title = "テストアイテム1"),
MyItem(id = 2, title = "テストアイテム2")
)
@Preview(showBackground = true, name = "通常状態")
@Composable
fun PreviewMyScreenContent() {
MaterialTheme {
MyScreenContent(
uiState = MyUiState(itemList = dummyItems, isLoading = false),
onItemClick = {}
)
}
}
@Preview(showBackground = true, name = "空の状態")
@Composable
fun PreviewMyScreenEmptyContent() {
MaterialTheme {
MyScreenContent(
uiState = MyUiState(itemList = emptyList(), isLoading = false),
onItemClick = {}
)
}
}
@Preview(showBackground = true, name = "読み込み中")
@Composable
fun PreviewMyScreenLoading() {
MaterialTheme {
MyScreenContent(
uiState = MyUiState(itemList = emptyList(), isLoading = true),
onItemClick = {}
)
}
}
Android Studio 上で下記のように Preview を確認できます

4. Jetpack Compose でつまづいたポイント
LazyColumn での不必要な再コンポーズ
LazyColumn でリストを表示する際、データの中身が変わっていないにもかかわらず、リスト全体が再描画(再コンポーズ)されてしまう問題に遭遇しました。
-
原因
Composeコンパイラにとって、Listインターフェースや独自のデータモデルは、デフォルトでは「不安定(Unstable)」と見なされ、「内容が変わったかもしれない」と判断し、再描画をスキップせずに再コンポーズしていました。この問題を放っておくと、リストのガタつきやパフォーマンス低下につながります -
解決策と
@Immutableを選んだ理由- 独自のデータクラスと
UiStateクラスに@Immutableアノテーションを付与することで、Compose コンパイラに「このオブジェクトは不変である」と明示的に伝え、不要な再コンポーズを抑制しました。 - ここで
@Stableではなく@Immutableを採用したのは、今回のデータが Web API から取得したものであり、一度生成されたアイテムの中身が後から動的に書き換わることがないためでした。
- 独自のデータクラスと
// 1. 各アイテムのデータモデル
@Immutable
data class MyItem(
val id: Int,
val title: String
)
// 2. UiState自体にも @Immutable を付与
// List<T> を持っていても、これで Compose コンパイラは「安定」と判断できる
@Immutable
data class MyScreenUiState(
val items: List<MyItem> = emptyList(),
val isLoading: Boolean = false
)
// 3. ViewModel から StateFlow で公開
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(MyScreenUiState())
val uiState: StateFlow<MyScreenUiState> = _uiState.asStateFlow()
}
@Composable
fun MyScreen(viewModel: MyViewModel) {
// ViewModel から状態を取得
val state by viewModel.uiState.collectAsStateWithLifecycle()
MyList(items = state.items)
}
@Composable
fun MyList(items: List<MyItem>) {
LazyColumn {
items(
items = items,
key = { it.id } // keyを指定して並び替え時の再描画を抑制
) { item ->
ItemRow(item = item)
}
}
}
@Composable
fun ItemRow(item: MyItem) {
Text(text = item.title, modifier = Modifier.padding(16.dp))
}
ちなみに再コンポーズしているかは、Android Studio の Layout Inspector で確認しました。具体的な確認方法については以下のドキュメントを参考にしていただければと思います。
テキストサイズの dp 指定と lineHeight の挙動
Composeの Text は sp 指定が基本ですが、アプリのデザイン仕様で「OSの文字サイズ設定に依存させたくない(dpで固定したい)」というケースがありました。その際、単に文字サイズを固定するだけでは不十分で、lineHeight もセットで指定する必要があることがわかりました。
解決策
-
LocalDensityを用いて、フォントスケール分を逆算して打ち消す拡張関数を作成しました。 -
fontSizeとlineHeightの両方にその関数を適用しました。
// DpをSpに変換(フォントスケールを打ち消して固定サイズにする)拡張関数
@Composable
internal fun Dp.dpToSp(): TextUnit {
return (this.value / LocalDensity.current.fontScale).sp
}
@Composable
fun FixedSizeText() {
Text(
text = "このテキストはフォントサイズも行間も固定されています。",
// fontSize を dpToSp で固定
fontSize = 16.dp.dpToSp(),
// lineHeight もセットで固定することで、行間だけが広がるのを防ぐ
lineHeight = 24.dp.dpToSp()
)
}
5. 導入してわかった良かった点と注意すべき点
実際にComposeを使ってみて、良かった点と注意すべき点をまとめます
良かった点
-
コード量の削減
- XML が不要になり、ファイル数を減らすことができました。また、XML と Kotlin のファイルを行き来する必要がなくなり、ソースコードが読みやすくなりました。
- 特に
LazyColumnの恩恵が大きく、これまでの Adapter や ViewHolder などを作成する必要がなくなり、実装が楽になりました。
-
Preview 機能によるUI確認の効率化
- 「正常系」「エラー系」「読み込み中」「空の状態」など、UIが持つ複数の状態を各
@Previewで定義できるので、わざわざソースコードを編集して各状態を再現する必要がなくなり、各状態のUI確認がしやすくなりました。 - 「ボタンのエフェクト」「スイッチの切り替え」「テキスト入力」といった、従来は実機やエミュレータ上でしか確認できなかった動的な挙動が、プレビュー上で直接操作・検証できるようになったことで、動的な挙動の確認がしやすくなりました。
- 「正常系」「エラー系」「読み込み中」「空の状態」など、UIが持つ複数の状態を各
注意すべき点
-
Android Viewとの見た目の差異
既存のAndroid Viewと同じデザインを Compose で再現しようとすると、マージンやフォントのレンダリングの影響で、若干見た目が違うことがあります。ここは「どこまで厳密に合わせるか」について、チーム内で相談が必要でした。 -
Android Viewとの機能差
例えばLazyColumnには標準でスクロールバーが用意されておらず自前で作る必要があるなど、Android View では当たり前だった機能がデフォルトで用意されていないことがありました。カスタマイズ性が高いという利点はありますが、自作で実装が必要な場合は注意が必要です。
おわりに
Android View では当たり前にあった View が Compose になくて苦労したり、作法の違いに戸惑うこともありました。
しかし、一度導入してしまえばUIの再利用性やデバッグ効率は格段に上がります。一度にCompose化をしなくても今回ご紹介したように部分的に導入することも可能です。
ぜひ、皆さんのプロジェクトでも試していただければと思います!