はじめに
長く運用されているAndroidアプリでは、XML + DataBindingで書かれた画面がまだまだ現役、というケースは珍しくありません。しかし新機能はComposeで書きたい、あるいは将来的に画面全体をComposeに寄せていきたい、という要望も同時に出てきます。
とはいえ「明日から全画面Composeで書き直す」というのは現実的ではありません。本記事では、既存のXML + DataBinding画面を段階的にComposeへ移行していく実例を、具体的なコードとともに整理します。
-
第一弾: 既存のFragment(XML + DataBinding)の中に
ComposeViewを差し込み、画面の一部だけをComposeで書く - 第二弾: 画面遷移そのものをNavigation Componentから Navigation Compose に置き換え、画面全体をComposeに統一する
という2段階で進めていきます。
なぜXML + DataBindingからComposeに移行するのか
移行には当然コストがかかります。そのため、手を動かす前に「何のために移すのか」を整理しておくことが重要です。XML + DataBindingの画面をComposeに移す主な意味・メリットは以下の通りです。
1. UIの状態管理がシンプルになる(宣言的UI)
XML + DataBindingでは、「レイアウトの定義(XML)」と「表示の切り替えロジック(Kotlin)」が別々のファイルに分かれます。後述する移行前のコードでも、ローディングや空状態の表示切り替えを isVisible = ... で命令的に書いています。
// 移行前(命令的): Viewの表示状態を自分で更新する
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.loadingIndicator.isVisible = isLoading
}
viewModel.users.observe(viewLifecycleOwner) { users ->
adapter.submitList(users)
binding.emptyView.isVisible = users.isEmpty()
}
// 移行後(宣言的): 状態から表示が決まる
when {
isLoading -> CircularProgressIndicator()
users.isEmpty() -> Text(stringResource(R.string.user_list_empty))
else -> LazyColumn { /* ... */ }
}
Composeでは「今の状態ならUIはこうなる」と書くだけで、Viewの表示/非表示を個別に更新する必要がありません。更新漏れによる「ローディングが消えない」「空状態が残ったまま」といった不整合が起きにくくなります。
2. ファイル数・ボイラープレートが減る
一覧画面ひとつ取っても、XML + DataBindingでは次のような部品が必要です。
- 画面のレイアウトXML
- 一覧アイテムのレイアウトXML
-
RecyclerView.Adapter(またはListAdapter) ViewHolderDiffUtil.ItemCallback- DataBindingの
<data>ブロックやBindingAdapter
Composeでは、これらが LazyColumn + items() + アイテム用のComposable関数に集約されます。XMLとKotlinを行き来する必要がなくなり、1つのKotlinファイルの中で画面が完結するため、修正時の見通しが良くなります。
3. UI部品を再利用しやすい
第一弾で触れるUserRowのように、Composableは「引数を受け取って表示するだけの関数」として切り出せます。状態を呼び出し元に委ねるState Hoistingの考え方と相性が良く、画面をまたいだ部品の再利用やプレビュー、テストがしやすい構造になります。XMLの<include>やカスタムViewと比べて、部品化のハードルが低いのが特徴です。
4. @Preview で確認でき、UIテストも書きやすい
Composableは @Preview を付けるだけでAndroid Studio上で表示を確認できます。ローディング中・空状態・データありといった状態ごとの見た目を、アプリを実行せずに並べて確認できるため、デザイン調整の往復が減ります。また、状態を引数で渡せる設計のため、「この状態を渡したらこう表示される」というUIテストも書きやすくなります。
5. 新しいUIライブラリやAPIの前提がCompose中心になっている
AndroidのUI領域では、Material 3をはじめ、新しいコンポーネントやAPIがCompose向けに先行して提供される流れが続いています。既存のView向けの資産もまだ使えますが、今後新しく追加される機能の恩恵を受けやすいのはCompose側です。「いつか移行する」のであれば、画面を触るタイミングで少しずつ移しておく方が、長期的な負債を抱えにくくなります。
6. DataBindingに起因する開発体験の悪さから離れられる
DataBindingは、レイアウトXML内に式を書けて便利な反面、次のような悩みを抱えやすい仕組みでもあります。
- XML内のバインディング式のエラーメッセージが分かりにくい
- コード生成(kapt/KSP)を伴うため、ビルド時間やビルドエラーの原因になりやすい
- ロジックがXMLに入り込み、テストやデバッグがしづらくなる
Composeに移行することで、UIロジックをすべて通常のKotlinコードとして扱えるようになり、IDEの補完・リファクタリング・デバッグの恩恵をそのまま受けられます。
移行時に知っておきたい注意点
メリットばかりではなく、移行には次のようなコストもあります。
- 学習コスト: 宣言的UI、再コンポジション、State Hoistingなどの考え方をチームで共有する必要がある
- 移行の工数: 動いている画面を書き直すため、リグレッションのリスクとテスト工数が発生する
- View資産との共存: 移行期間中はViewとComposeが混在し、テーマやスタイルの二重管理が一時的に発生する
そのため、「安定して動いていて今後も触る予定のない画面」まで無理に移行する必要はありません。 新規画面や、改修頻度の高い画面から優先的にComposeへ寄せていくのが現実的です。だからこそ、本記事のように一部分だけ(ComposeView)から始め、遷移(Navigation Compose)へと段階的に進められる移行方法が有効になります。
移行前の状態: XML + DataBindingの画面
題材として、シンプルな「ユーザー一覧画面 → ユーザー詳細画面」という2画面構成のアプリを使います。まずは移行前、Fragment + XML + DataBindingで書かれた一覧画面です。
<!-- fragment_user_list.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="viewModel" type="com.example.app.UserListViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/user_list_title"
android:textAppearance="?attr/textAppearanceTitleLarge" />
<EditText
android:id="@+id/searchEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:hint="@string/search_hint"
android:inputType="text" />
<ProgressBar
android:id="@+id/loadingIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="24dp"
android:visibility="gone" />
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/user_list_empty"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</layout>
class UserListFragment : Fragment() {
private var _binding: FragmentUserListBinding? = null
private val binding get() = _binding!!
private val viewModel: UserListViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentUserListBinding.inflate(inflater, container, false)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
val adapter = UserListAdapter { user ->
findNavController().navigate(
UserListFragmentDirections.actionUserListToUserDetail(user.id)
)
}
binding.recyclerView.adapter = adapter
binding.searchEditText.addTextChangedListener { text ->
viewModel.onSearchQueryChanged(text?.toString().orEmpty())
}
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.loadingIndicator.isVisible = isLoading
}
viewModel.users.observe(viewLifecycleOwner) { users ->
adapter.submitList(users)
binding.emptyView.isVisible = users.isEmpty()
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
画面遷移はNavigation Component(XMLで定義したnav_graph)を使い、findNavController().navigate()でユーザー詳細画面に遷移する構成です。タイトル・検索欄・ローディング表示・空状態・一覧、と一般的な一覧画面に必要な要素がひと通り揃っている状態からスタートします。
第一弾: ComposeViewで画面の一部をComposeに置き換える
いきなりFragmentごと書き換えるのではなく、まずは検索欄より下の部分(ローディング・空状態・一覧)だけをComposeに置き換えるところから始めます。ここで使うのがComposeViewです。ComposeViewはAndroidの通常のView階層の中にComposeのUIを埋め込むためのクラスで、既存のXMLレイアウトの中に1行追加するだけで導入できます。タイトルと検索欄はひとまずXMLのまま残し、影響範囲を小さく保ちます。
レイアウトにComposeViewを追加する
<!-- fragment_user_list.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="viewModel" type="com.example.app.UserListViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/user_list_title"
android:textAppearance="?attr/textAppearanceTitleLarge" />
<EditText
android:id="@+id/searchEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:hint="@string/search_hint"
android:inputType="text" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</layout>
タイトルと検索欄はXMLのまま残し、これまでProgressBar・emptyView・RecyclerViewが担っていた「ローディング/空状態/一覧」の表示だけをComposeViewに置き換えています。既存のDataBindingの仕組み(<data>ブロックや他のView)には手を入れる必要がありません。
Fragment側でComposeのUIをセットする
class UserListFragment : Fragment() {
private var _binding: FragmentUserListBinding? = null
private val binding get() = _binding!!
private val viewModel: UserListViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentUserListBinding.inflate(inflater, container, false)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
// 検索欄はこれまで通りXMLのViewとして扱う
binding.searchEditText.addTextChangedListener { text ->
viewModel.onSearchQueryChanged(text?.toString().orEmpty())
}
binding.composeView.apply {
// Composeの再コンポジションをFragmentのライフサイクルに追従させる
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MaterialTheme {
val users by viewModel.users.observeAsState(emptyList())
val isLoading by viewModel.isLoading.observeAsState(false)
UserListScreen(
users = users,
isLoading = isLoading,
onUserClick = { user ->
findNavController().navigate(
UserListFragmentDirections.actionUserListToUserDetail(user.id)
)
}
)
}
}
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@Composable
fun UserListScreen(
users: List<User>,
isLoading: Boolean,
onUserClick: (User) -> Unit
) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
users.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.user_list_empty))
}
else -> LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user = user, onClick = { onUserClick(user) })
}
}
}
}
@Composable
fun UserRow(user: User, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp)
) {
Text(user.name, style = MaterialTheme.typography.bodyLarge)
}
}
ここでのポイントは2つあります。
-
setViewCompositionStrategyを必ず設定する: これを忘れると、FragmentのViewが破棄されたあともComposeの内部状態が生き続けてしまい、メモリリークの原因になります。DisposeOnViewTreeLifecycleDestroyedを指定することで、View階層のライフサイクルに追従して適切に破棄されます。 -
画面遷移は既存のまま:
findNavController().navigate()は変更していません。ComposeのUIから、既存のNavigation ComponentのAPIをそのまま呼び出せるため、「表示だけをComposeにする」という部分的な移行が可能です。
LiveDataをそのままComposeで扱いたい場合は、observeAsState()(androidx.compose.runtime:runtime-livedata)を使うと、ViewModelを変更せずにComposeから購読できます。ViewModelがLiveDataからStateFlowに移行済みであれば、collectAsStateWithLifecycle()を使う形でも問題ありません。
第一弾のポイント
- 画面全体を一気に置き換える必要がなく、リスクを小さく段階的に進められる
- ViewModelやNavigationのコードにはほぼ手を入れずに済む
- ユーザー詳細画面(次の画面)がまだXMLのままでも、
ComposeViewから普通にnavigate()できるため、画面単位で移行を進められる
第二弾: Navigation Composeへの移行
第一弾の状態がしばらく安定して運用できたら、次は画面遷移そのものをComposeに寄せていきます。ここで登場するのがNavigation Composeです。XMLのnav_graph.xmlで定義していた遷移を、KotlinのコードでNavHostとして表現し直します。
移行前: XMLのnav_graph
<!-- nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
app:startDestination="@id/userListFragment">
<fragment
android:id="@+id/userListFragment"
android:name="com.example.app.UserListFragment">
<action
android:id="@+id/action_userList_to_userDetail"
app:destination="@id/userDetailFragment" />
</fragment>
<fragment
android:id="@+id/userDetailFragment"
android:name="com.example.app.UserDetailFragment">
<argument android:name="userId" app:argType="string" />
</fragment>
</navigation>
移行後: Navigation Composeによる NavHost
まず、ユーザー詳細画面側も第一弾と同じ要領でComposable化しておく必要があります(UserDetailScreenのようなComposableを用意する)。また、第一弾ではXMLに残していたタイトルと検索欄も、この段階でUserListScreen側に取り込みます。
@Composable
fun UserListScreen(
users: List<User>,
isLoading: Boolean,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onUserClick: (User) -> Unit
) {
Column {
Text(
text = stringResource(R.string.user_list_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
placeholder = { Text(stringResource(R.string.search_hint)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Box(Modifier.weight(1f)) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
users.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.user_list_empty))
}
else -> LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user = user, onClick = { onUserClick(user) })
}
}
}
}
}
}
両方の画面がComposable化できたら、Fragment/ActivityではなくNavHostで画面遷移全体を管理する形に置き換えます。
sealed class Destination(val route: String) {
data object UserList : Destination("userList")
data object UserDetail : Destination("userDetail/{userId}") {
fun createRoute(userId: String) = "userDetail/$userId"
}
}
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = Destination.UserList.route) {
composable(Destination.UserList.route) {
val viewModel: UserListViewModel = hiltViewModel()
val users by viewModel.users.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
UserListScreen(
users = users,
isLoading = isLoading,
searchQuery = searchQuery,
onSearchQueryChange = viewModel::onSearchQueryChanged,
onUserClick = { user ->
navController.navigate(Destination.UserDetail.createRoute(user.id))
}
)
}
composable(
route = Destination.UserDetail.route,
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId").orEmpty()
val viewModel: UserDetailViewModel = hiltViewModel()
UserDetailScreen(
userId = userId,
onBackClick = { navController.popBackStack() }
)
}
}
}
そして、これまでFragmentを乗せていたActivity側は、ComposeView(あるいはComponentActivityのsetContent)でAppNavHostを直接呼び出すだけになります。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
AppNavHost()
}
}
}
}
この段階まで来ると、Fragment・XMLレイアウト・nav_graph.xml・Safe Argsはすべて不要になり、画面遷移も含めて完全にComposeの世界に統一されます。
第二弾での主な変更点まとめ
| 移行前 | 移行後 | |
|---|---|---|
| 画面遷移の定義 | nav_graph.xml |
NavHost + composable {} のKotlinコード |
| 遷移の実行 |
findNavController().navigate(...)(Safe Args) |
navController.navigate(route) |
| 引数の受け渡し | Safe Argsで生成されたクラス |
navArgument + backStackEntry.arguments
|
| 画面のホスト |
NavHostFragment(Activity内にFragmentが乗る) |
NavHost(Activity内にComposableが乗る) |
| 戻る操作 | findNavController().popBackStack() |
navController.popBackStack() |
APIの名前こそ似ていますが、XMLで宣言していたグラフ構造がKotlinのコードとして表現されるようになるため、画面追加のたびにXMLとコードの両方を触る必要がなくなるというメリットがあります。また、型安全な引数の受け渡しについても、Navigation Composeのkotlinx.serializationベースの型安全なAPI(Navigation 2.8以降)を使えば、Safe Argsに近い安全性を保ったまま移行できます。
移行時に気をつけたいポイント
段階を飛ばさない
いきなり第二弾(Navigation Compose化)から着手しようとすると、「画面のUI」と「画面遷移の仕組み」を同時に書き換えることになり、レビューやデバッグの負荷が跳ね上がります。第一弾でUIの移行だけを先に済ませ、動作が安定してから第二弾に進む方が、問題の切り分けがしやすくなります。
第一弾の間はViewModelを変更しすぎない
第一弾の段階では、LiveDataをそのままobserveAsState()で受け止めるだけでも十分に移行できます。「Composeに移行するならViewModelもStateFlowに統一しよう」と欲張ると、UI層とデータ層の変更が混ざってしまい、問題発生時の切り分けが難しくなります。ViewModelの整理は、UIの移行が落ち着いてから着手するのがおすすめです。
Hiltを使っている場合はhiltViewModel()を使う
Navigation Compose移行時、Fragmentのby viewModels()は使えなくなるため、hiltViewModel()(androidx.hilt:hilt-navigation-compose)に置き換える必要があります。composable {}ブロック内で呼び出すことで、そのバックスタックエントリに紐づいたViewModelのインスタンスを取得できます。
まとめ
-
第一弾: 既存のFragment(XML + DataBinding)内に
ComposeViewを追加し、画面の一部(この例ではRecyclerView部分)だけをComposeに置き換える。setViewCompositionStrategyの設定を忘れないことが重要 -
第二弾: 画面がすべてComposable化できたら、
nav_graph.xmlをNavHostに、Fragment遷移をnavController.navigate()に置き換え、画面遷移全体をComposeに統一する - 一気に全面移行するのではなく、UIの移行→画面遷移の移行という順番で段階を踏むことで、変更の影響範囲を小さく保ちながら移行を進められる
「明日から全部Compose」ではなく、「まずは1画面のUIから」「次に遷移の仕組みから」と小さく刻んでいくことが、既存のXML + DataBindingアプリをComposeへ安全に移行していくための現実的なアプローチです。