AndroidOS向けUI開発、いままでとこれから
AndroidOS向けのUI開発といえば、今でも最初に思い浮かぶのは、XMLでしょうか。 長い間AndroidUIの基盤を支えてきて、使い慣れていたものであり、AndroidOSアプリ開発プロジェクトを携わることがある者は誰もが通ってきた道でしょう。 AndroidStudioでは、WYSIWYG形式のデザイナーUIはありますが、あまり利用されていないと思います。
XML形式のUI設計は使い慣れればそれなりに使えますが、やはりレイアウトのViewGroup
を中心に考えがちで、レイアウトの種類が多くある分、使い慣れていなければ無駄の多い設計になりがちが、大きな欠点とも言えます。
XML形式での設計によく言われるものは、なるべくフラットなレイアウトを作って、軽量な画面設計を目指そう、ですね。ただし、いくら頑張っても多少の無駄な要素を含まなければいけないケースもあります。
その他、ビュー関連のデバッグが難しく、数多くの開発者を悩ませる問題の一つになります。
これに対して、なるべくコードとの連携をやりやすくするためのViewBinding
が提供され、実際のビューの現在のプロパティを確認できるLayout Inspector
も提供されますが、まだまだ難点が残っています。
これらは、命令型、または手続型プログラミングの名残で、UI要素ごとの「見せ方」に着目するものです。
そのため、要素の再利用や役割分担など、分離して作ることはなかなか難しく、同じものを繰り返しがちなレイアウトファイルが多く、ファイルの数自体も多くなります。
最近では、このような問題に対して、管理や運用が難しくなる側面も認識され、代用となるものを開発し始めました。
着目点として、命令型ではなく宣言型にし、UI要素ごとの「役割」、「用途」にします。
Androidには、サードパーティが開発されるツールキットは様々ありますが、ファーストパーティからはJetpack Compose
で、この最近で正式リリースされるようになりました。
Jetpack Compose
は、宣言型UIツールキットであり、UI要素ごとを関数で定義し、Composable関数
といい、UI状態の管理やそれぞれのUI関連の機能も同梱され、今までのViewBinding
で実現できないコードとUIの精密の連携が可能です。
その分、考え方が今までのXML形式と全く逆なので、慣れるまでには違和感があると思います。
実際にXML形式の既存プロジェクトをJetpack Compose
に移行してみようと思いますので、その過程と成果から、解決できた問題、ぶつかった壁、残っていた課題まで、順を追って説明したいと思います。
準備
まず、既存プロジェクトにJetpack Compose
を利用できるようにするため、以下の条件を満たす必要があります。
minSdkVersion
が21以上- Javaバージョンが8以上
- Kotlinを利用する環境 Composeバージョンによって、対応するKotlinバージョンがあるため、要注意
- Android Studioバージョンが4以上
Jetpack Compose
を利用するには、以下のGradle設定を追加する必要があります。
android {
buildFeatures {
// Jetpack Composeを有効化
compose true
}
...
composeOptions {
kotlinCompilerExtensionVersion '1.1.1'
}
}
また、以下の依存パッケージを追加する必要があります。アニメーション、ViewModel
のライブラリは任意で追加します。
dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.4.0'
// Compose Material Design
implementation 'androidx.compose.material:material:1.1.1'
// Animations
implementation 'androidx.compose.animation:animation:1.1.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.1.1'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.1.1'
}
また、テーマ関連のパッケージ、以下のパッケージを追加します。
dependencies {
// Materialテーマを利用する場合
implementation "com.google.android.material:compose-theme-adapter:1.1.1"
// アプリのAppCompatテーマを利用する場合
implementation "com.google.accompanist:accompanist-appcompat-theme:0.16.0"
}
これで環境の準備ができました。
Jetpack Composeへ移行
まず、こちらの画面を見てください。


元々のXMLは以下の通りです。DataBinding
を利用して、viewModel
を受け取ってハンドラとして繋いでいる状態です。
XMLコード
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="jp.co.example.upft.obrolan_kotlin.ui.main.chat.ChatBodyViewModel" />
<import type="android.view.View" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.main.chat.ChatBodyActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chats"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clickable="true"
android:clipToPadding="true"
android:focusable="true"
android:scrollbars="vertical"
app:adapter="@{viewModel.adapter}" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F0F0F0"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/actionButtons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="@+id/btnCameraOrBack"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
android:adjustViewBounds="true"
android:background="@android:color/transparent"
android:backgroundTint="@android:color/darker_gray"
android:contentDescription="@string/take_picture_desc"
android:cropToPadding="true"
android:scaleType="fitCenter"
android:src="@drawable/ic_camera"
android:visibility="@{viewModel.cameraAvailable ? View.VISIBLE : View.GONE}" />
<ImageButton
android:id="@+id/btnPicture"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="8dp"
android:adjustViewBounds="true"
android:background="@android:color/transparent"
android:backgroundTint="@android:color/darker_gray"
android:contentDescription="@string/add_picture_desc"
android:cropToPadding="true"
android:scaleType="fitCenter"
android:src="@drawable/ic_photo" />
</LinearLayout>
<EditText
android:id="@+id/txtMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="5dp"
android:layout_marginBottom="10dp"
android:layout_weight="1"
android:background="@drawable/round_edittext"
android:gravity="top"
android:hint="@string/message_hint"
android:importantForAutofill="no"
android:inputType="textMultiLine"
android:maxLines="5"
android:paddingStart="@dimen/textPaddingHorizontal"
android:paddingTop="@dimen/textPaddingVertical"
android:paddingEnd="@dimen/textPaddingHorizontal"
android:paddingBottom="@dimen/textPaddingVertical"
android:scrollbars="vertical"
android:text="@={viewModel.textBody}"
android:textColor="@color/textBoxForeground"
android:textColorHint="@color/textBoxHintColor"
android:textSize="@dimen/textSize" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="5dp"
android:layout_marginEnd="14dp"
android:adjustViewBounds="true"
android:background="@android:color/transparent"
android:backgroundTint="@android:color/darker_gray"
android:contentDescription="@string/submit"
android:cropToPadding="true"
android:rotation="45"
android:scaleType="fitCenter"
android:clickable="@{viewModel.textBody.length() > 0}"
android:onClick="@{() -> viewModel.sendMessage()}"
android:src="@drawable/send" />
</LinearLayout>
</LinearLayout>
</layout>
その過程を見てみましょう!
準備段階として、情報を整理します。
- まず、大まかな要素を定めます。この画面には、チャットリストとなるRecyclerView、画面下端のツールバーと、大きく2つに分けることができます。
- ツールバーの内訳に、ボタンを3つとテキストボックスを1つ、計3つの要素があります。
- RecylerViewに内蔵するアイテムをヘッダー、メッセージと2種類にわけることもできます。
- ここまで切り分けたら、次はそれぞれの用途と表示するためのパラメータを見ていきます。
要素 | データ方向 | 用途 | パラメータ |
---|---|---|---|
RecyclerView | 受信のみ | メッセージとヘッダーを表示 | チャットメッセージのリスト |
メッセージヘッダー | 受信のみ | メッセージを日付ごとにグループごとに表示 | 日付 |
メッセージアイテム | 受信のみ | メッセージ内容を表示 | メッセージ内容と関連情報(送信者、向きなど) |
カメラボタン | 送受信 | カメラを起動 | カメラの使用可能フラグ、クリックハンドラ |
画像ボタン | 送信のみ | 画像選択画面を表示 | クリックハンドラ |
送信ボタン | 送信のみ | メッセージ送信をトリガー | クリックハンドラ |
メッセージ本文 | 送受信 | 送信する予定のメッセージを表示 | 記入するテキストの状態、テキスト変更時のハンドラ |
これらの情報をもとに、UI要素の設計を行い、Activityクラスで構築します。
- まず、ボタン系の要素は特別なものがないため、通常なボタンで十分でしょう。
- 次に、ボタンらとテキストボックスからなるツールバーですが、これを一つにまとめる要素を一つ作成し、パラメータとしては内蔵する要素のパラメータを引き継ぐものとなります。
ツールバー要素(カメラボタン、画像ボタン、送信ボタン、メッセージ本文)
@Composable
fun Toolbar(
onCameraClick: () -> Unit,
onPictureClick: () -> Unit,
onSendClick: () -> Unit,
textState: State<String>,
onTextChanged: (String) -> Unit,
) {
// 横並びのため、Rowで整理する
Row {
// カメラボタン
// Jetpack ComposeではデフォルトでMaterialDesignになるので、色々デフォルト設定を変更する必要がある(色、elevationなど)
Button(
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(id = android.R.color.transparent),
),
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.elevation(defaultElevation = 0.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(30.dp),
onClick = onCameraClick,
) {
Image(
contentDescription = "Camera button",
painter = painterResource(id = R.drawable.ic_camera),
contentScale = ContentScale.Fit,
)
}
// 画像ボタン
Button(
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(id = android.R.color.transparent),
),
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.elevation(defaultElevation = 0.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(30.dp),
onClick = onPictureClick,
) {
Image(
contentDescription = "Picture button",
painter = painterResource(id = R.drawable.ic_photo),
contentScale = ContentScale.Fit,
)
}
// メッセージ本文
BasicTextField(
value = textBody.value,
onValueChange = onTextChanged,
textStyle = TextStyle(fontSize = 16.sp),
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 6.dp, vertical = 5.dp)
.background(shape = RoundedCornerShape(12.dp), color = Color.LightGray)
.padding(horizontal = 6.dp, vertical = 5.dp)
.verticalScroll(state = rememberScrollState())
.weight(1f),
)
// 送信ボタン
Button(
colors = ButtonDefaults.buttonColors(
backgroundColor = colorResource(id = android.R.color.transparent),
),
contentPadding = PaddingValues(0.dp),
elevation = ButtonDefaults.elevation(defaultElevation = 0.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(width = 43.dp, height = 30.dp),
onClick = onSendClick,
) {
Image(
contentDescription = "Send button",
painter = painterResource(id = R.drawable.send),
contentScale = ContentScale.Fit,
modifier = Modifier.rotate(45f),
)
}
}
}
- 次に、チャットメッセージを表示するためのアイテムやヘッダーのビューを作成します。ヘッダーから作ります。日付をフォーマットして、背景をつけるだけです。ここで、
Row
を使って、画面の中央に表示するようにModifier
などを設定します。日付表示用のビューも一応作っておいて、後に作成するメッセージアイテムのビューにも流用できるようにします。
ヘッダーと日付要素
@Composable
fun TimestampText(
formattedTimestamp: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
) {
Text(
text = formattedTimestamp,
modifier = modifier,
color = color,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
@Composable
fun ChatMessageDateHeader(formattedTimestamp: String) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xff919191),
modifier = Modifier.padding(10.dp),
) {
TimestampText(
formattedTimestamp = formattedTimestamp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
color = Color.White,
)
}
}
}
- さらに、メッセージアイテムを作成します。その前に、アイテムのパーツを整理します。
ユーザーのプロファイル画像を表示したいので、画像表示と、チャットの本文表示が必要です。また、チャット本文もテキスト以外に画像もあり得るので、これも考慮します。それ以外、送受信の日付も表示したいです。そうすると、以下の構造で再現できます。
なお、プロファイル画像表示用のビューを通常のXMLカスタムビューを利用します。この通り、従来のビューもCompose
内で利用できます。また、送受信を区別するため、受信は左揃え、送信は右揃えになるようにModifier
等を設定もします。日付表示は以前作成したパーツを流用します。
メッセージアイテム要素
@Composable
fun ChatItemMessage(message: Message) {
// メッセージの送受信によって、横のalignmentを決める
val horizontalArrangement = if (message.isInbound) Arrangement.Start else Arrangement.End
Row(
horizontalArrangement = horizontalArrangement,
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(bottom = 6.dp)
.fillMaxWidth()
) {
// プロファイル画像は受信側にのみ表示
if (message.isInbound) {
AndroidView(
modifier = Modifier
.padding(start = 2.dp)
.size(26.dp),
factory = ::AvatarView,
update = {
it.userProfile = message.fromUser
})
}
Column(
modifier = Modifier.fillMaxWidth()
) {
// ユーザー名は受信側にのみ表示
if (message.isInbound) {
Text(
text = message.fromUser.name,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp),
color = Color(0xff717171),
)
}
Row(
horizontalArrangement = horizontalArrangement,
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth(),
) {
// 送信側の日付は左側に
if (message.isOutbound) {
TimestampText(
formattedTimestamp = message.timestampFormatted,
modifier = Modifier.padding(horizontal = 6.dp),
color = Color(0xff717171),
)
}
if (message.isPictureMessage) {
AndroidView(
factory = ::ImageView,
update = {
// 画像読み込みをGlideで利用する
Glide.with(it)
.load(message.chatPicsUrl)
.fitCenter()
.override(640)
.into(it)
})
} else {
// テキストの背景を送受信で分ける
val bubbleColor = if (message.isInbound) {
colorResource(id = R.color.inboundBackground)
} else {
colorResource(id = R.color.outboundBackground)
}
// テキストの背景は丸角にする
Surface(
shape = RoundedCornerShape(5.dp),
color = bubbleColor,
) {
Text(
text = message.text,
fontSize = 16.sp,
color = Color.Black,
modifier = Modifier
.padding(
horizontal = 12.dp,
vertical = 10.dp,
)
)
}
}
// 受信側の日付は左側に
if (message.isInbound) {
TimestampText(
formattedTimestamp = message.timestampFormatted,
modifier = Modifier.padding(horizontal = 6.dp),
color = Color(0xff717171),
)
}
}
}
}
}
Message
のクラスは、メッセージ内容を保持するほか、送信するユーザー情報、送受信の種類、メッセージの種類などのメタデータも保持します。大まかに以下のように定義できます。
メッセージアイテムのモーデルクラス
data class Message(
val itemType: Message.ViewType,
val direction: Message.Direction,
val timestamp: Timestamp,
val textMessage: String? = null,
val picMessage: Uri? = null,
) {
val isInbound get() = direction == Message.Direction.Inbound
val isOutbound get() = direction == Message.Direction.Outbound
val isPictureMessage get() = picMessage != null
val timestampFormatted get() = DateFormat.getInstance(DateFormat.SHORT).format(timestamp)
enum class Direction { Inbound, Outbound }
enum class ViewType { Header, Content }
}
- これらの定義をもとに、画面の構造を作ります。Activityの
onCreate
に、setContent
のラムダメソッドを使ってCompose
による画面構築ができます。
ActivityのonCreate
でビューを構築
override onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ViewModelに val messages: StateFlow<List<Message>> があります。
val messages = viewModel.messages.collectAsState()
// ViewModelに val textBody: LiveData<String> があります。
val textBody = viewModel.textBody.observeAsState(initial = "")
setContent {
val listState = rememberLazyListState()
// アプリのテーマを利用する
// そのほか、MaterialDesignのテーマもある
AppCompatTheme(context = this) {
// 読み込み時に、メッセージを一番最後までスクロールする
LaunchedEffect(key1 = messages.value) {
if (messages.value.isNotEmpty()) {
listState.animateScrollToItem(messages.value.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
) {
// RecyclerView相当のリスト表示用コラム。縦にアイテムを入れ続ける。
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
messages.value.forEach { message ->
when (message.itemType) {
Message.ViewType.Header -> {
// stickyHeaderを利用する。普通のヘッダーだけであればitemで十分
stickyHeader {
ChatMessageDateHeader(formattedTimestamp = message.timestampFormatted)
}
}
Message.ViewType.Content -> {
item {
ChatItemMessage(message = message)
}
}
}
}
}
// Toolbar追加
Toolbar(
onCameraClick = {
openCamera()
},
onPictureClick = {
openPictureSelection()
},
onSendClick = {
viewModel.sendMessage()
},
textState = textBody,
onTextChanged = { text ->
viewModel.textBody.postValue(text)
},
)
}
}
}
}
これでJetpack Composeによる画面の見た目(UI)と機能(ロジック)連携が出来上がりです!XML版では、DataBinding
などを駆使して作りましたが、Jetpack Composeを使うとロジックとUI部分が一緒にあってみやすいと思いますね。デバッガーも利用できるので、ビュー描画や処理のデバッグがしやすく便利です。
Jetpack Composeの考え方
XML形式との最も根本的な違いは、やはり考え方にあります。 UI要素を考えて組み込む時に、まず覚えておきたいことは、各要素の役割から考えることです。 なるべく再利用できるために、各要素の必要最低限の情報を絞り、組み込みます。
また、UI制御の考え方も重要です。
Compose
で作成されるビューは不変となっているため、基本的には表示されたら終わりです。
変化を起こすには、Composable関数
に渡す状態・パラメータの変化を感知して再構築を行うのみです。
一方、ビューの操作結果をイベントとしてコード層に伝達されます。
このような、データの流れが一方向のみになる概念が、Unidirectional Data Flow (UDF)
といい、Compose
のUI制御の基本的な考え方の一つです。
公式サイトから拝借したグラフでUIと状態の関係性をわかりやすく表しています。

Unidirectional Data Flowの概要
XMLビューとJetpack Composeのビューの対応
Jetpack Compose
が正式リリースされるだけあって、ほとんどのビューは対応済みです。
よく使用されるビューの互換表を記載します。
XML View | Jetpack Compose UI |
---|---|
Button | Button |
TextView | Text |
EditText | TextField |
ImageView | Image |
Toolbar | TopAppBar |
ButtomNavigationBar | ButtomNavigation |
RecyclerView | LazyRow / LazyColumn |
LinearLayout | Row / Column |
ConstraintLayout | ConstraintLayout |
ビュー並びを制御するRowかColumnを使用しないと、XMLでFrameLayoutを使用するように、ビューが重なっているようになります。 リソースはそのまま読み込めますが、注意として、Composeでは、shapeのdrawableを利用できません。
Jetpack Composeで解決できた問題
Jetpack Compose
では、画面単位ではなく、要素単位の役割に着目するため、シンプルなアプリでは恩恵が少ないが画面が増えるほど、再利用可能な場所が増え、ビューの管理がしやすくなると考えられます。
また、ビューのテストとデバッグがしやすくなり、今までビュー関連のデバッグが大変だったのが解決できたかと思います。
不変性によって想定可能なビューの作成もでき、カスタムビューの作成もしやすい上に、Kotlinベースなので、様々なKotlinの機能を満遍なく利用できます。
Jetpack Composeへの移行時にぶつかる壁・難点
既存アプリにJetpack Composeを取り入れる際、最も難しい問題は、どうすれば既存の画面の要素をそれぞれの用途ごとに分離するかです。 根付いた今までの考え方や先入観が邪魔して、なかなか効率的に進められないことも多々あります。
特に難しいのはRecyclerViewの移行です。 今までのXML形式では、RecyclerViewを用意して、それに使用されるアイテムのレイアウトを用意して、それらを紐付けるRecyclerView.Adapterを実装して、データ更新や表示ロジックをAdapterで実装しますが、Jetpack ComposeのLazyColumn / LazyRowでは、アイテムのビューを用意する必要があるものの、データ表示や更新ロジックがビュー内に内蔵でき、専用のAdapter実装が不要になります。
状態管理とイベントハンドリングについては、UDFの考え方に慣れれば難しくないが、状態を保持する場所を選ぶには慣れが必要だと思います。 基本的には、その要素にのみ使われる状態なら内蔵してもいいですが、よっぽどない限りはその要素のパラメータとして受け取った方が無難ですね。
Jetpack Composeに移行した後に残された課題
Jetpack Composeを取り入れることができたとしても、いくつか課題がまだ残っています。
まず、ライブラリのビューを利用する場合、必ずしもJetpack Composeを対応したと限りません。
そのため、手放しでJetpack Composeのみにして、XMLを投げ出すなどができません。
互換性を保つために、Jetpack ComposeをXMLに用いるためのComposeView
、またはXML形式のビューをComposable関数に用いるためのAndroidView
がそれぞれ用意されます。
また、XMLの方がネット上のガイドやチュートリアルが多くあって、初心者には親しみやいと考えられます。 Jetpack Composeは最近になって勢いがついてきましたが、まだまだ使用率が低いのも事実で、参考情報がまだ少ない。 そのため、初回の移行を行う際には、情報収集が最も時間を有することになりました。
そのほか、独自のShapes関連の実装があるため、shapeDrawableのリソースを利用できない点もあります。 そのため、shapeDrawableを利用するビューを移行する際、Jetpack ComposeのShape機能を使って作り直すか、shapeをvectorDrawableに変換するか、どちらかにしないと難しいですね。 場合によって、全く同じものを再現できないかもしれませんので、多少困惑はあります。 これも慣れればなんとかできると思いますが、如何せん情報が少なすぎるため確固たる結論は出せません。
最後に
ここまで色々なことを書いてきましたが、難しいことが多いですね。前述通り、Jetpack Composeの使用率が未だ少ないため情報不足気味ではありますが、この記事で一つ助けになれればと思い執筆しました。 古く良き友であるXMLを完全に見捨てず、将来性のあるJetpack Composeの手をとって、より良い開発者人生を送りましょう!