UI構築がつらいあなたへ: Jetpack Composeの問題点と解決策
第一章
Jetpack ComposeでUIを書くのがつらい理由(本記事)
目次
-
前提など
1.1. 目的と背景
1.2. Jetpack Composeを使う必然性と本記事の注意事項
1.3. 筆者のレベルとシリーズの思想 -
本題
2.1. 関数型UIライブラリとは
2.2. 最小構成の関数型UIの問題点
2.3. UI入力とViewModelを扱う -
まとめ
3.1 最後に
前提など
目的と背景
Android アプリ開発における Jetpack Compose は、宣言的 UI を用いることで、従来の手続き的な UI プログラミングにありがちなロジックの破綻やエラーを減らし、効率的な開発を実現できるとされています。
しかし、その一方で State 管理が煩雑になりやすく、初めて Compose を導入する開発者にとっては混乱のもとにもなりがちです。
本記事では、そのような混乱と起きうる問題点を明文化し、次回の記事で解決策の一例を提示していきたいと考えています。
なお、本記事ではJetpack Composeの基本的な概念、概要を解説いたしますのでこの記事をお読みいただくだけでJetpack Composeを理解する手助けにもなるかと思います。
Jetpack Composeを使う必然性と本記事の注意事項
筆者の環境ではKotlin Multplatform(以下:KMP)を使用し、Windowsアプリ開発を行っています。
そのため、従来のXMLベースのUI構築は使用できず、Jetpack Composeによる宣言的なUI構築が推奨されています。
(本来、XMLでのUI構築がしたくはありました。。。)
こちらのシリーズで触れる内容はAndroidアプリ開発の際に起きた問題であるものの、次回の記事で掲載するコードはKMPを使用していますが、筆者はコードの動作よりもその背景にある考え方を重視しております。
Stateの考え方、ライフサイクル、Composeの実行タイミングが厳密にAndroidと同じではないかもしれませんので、適宜置き換えて拝読ください。
また、ViewModelなどが登場しますが、依存性注入などは本質的ではない上今回必要でもないため省略させていただきます。そのまま製品環境での使用を想定していない点に予めご了承ください。
筆者のレベルとシリーズの思想
以下の内容は第二章での注意事項になり、本記事に間接的な関係しかありません。
関数型UIは関数型言語やReactのような環境で現在使用されていますが、筆者はそれらを通っておらず従来のオブジェクトを組み合わせたUI、あるいは HMLT,XMLでのUI作成に慣れています。
最終的にはこれらと同じ感覚でUIの作成ができる環境を提供したいと思っています。
前提として私が提供するものはフレームワークライブラリです。
学習容易性を意識しており、一度導入後はプログラミング知識の浅いメンバーでも簡単に使用できるということを前提目的と掲げています。
本題
関数型UIライブラリとは
関数型UIライブラリ Jetpack Composeとは
既にJetpack Composeでコードを書き始めた方なら、その独特な感触に戸惑いを覚えたかもしれません。従来のXMLベースのUI構築では、ネストしたレイアウトや要素の表示・非表示を行うためには、Viewのinflateや条件分岐の処理が必要でした。この際に起きうるシステムエラーを防ぐのが関数型UIの考え方です。
Jetpack Composeは関数型UIの考え方に基づいており、UIは状態(State)によって動的に構成が切り替わるようになっています。このため、表示の分岐や入れ子構造は、条件に応じて関数の中で自然に記述できるようになっています。
この「状態によってUIを構築する」というパラダイムは、手続き型であったXML時代のUI構築とは本質的に異なるため、ここに違和感や混乱が生まれる要因があると考えられます。
@Composable
fun ToggleContentExample() {
var showContent by remember { mutableStateOf(false) }
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
// 何らかの操作によりshowContentの値が変われば、再コンポーズされる
// showContentがTrueならcontentScreenが表示される
if (showContent) {
contentScreen()
}
}
XMLではページの遷移や画面の表示はすべて手続き的でした。
関数型UIの概念としてはページの遷移やモーダルの表示、リストの表示(リストの項目追加における表示)はStateが変更されることでUIが切り替わることに着目してください。
関数型UIでは、Steteの状態により表示や動作が決まることから、これらを再現することでUIの再現可能性が上がり、テストやデバッグが簡便になるというメリットも付随しています。
最小構成の関数型UIの問題点
アプリケーションにおいて切っても切り離せないのがユーザーの入力です。
jetpack composeではユーザーの入力もStateとして管理し、Stateの入力があれば再コンポーズされます。
再コンポーズされなければ、以前のUIと同じです。
入力の動作を最小限で示した例がこちらです。
@Composable
fun SimpleFilledTextFieldSample() {
// textという変数を宣言しています。mutableStateOfはStateを示します。
// StateはJetpack Composeにおける特別な変数であり、Composable関数が適切にコンポーズされるためのものです。
var text by remember { mutableStateOf("Hello") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Label") }
)
}
rememberなど、プロパティ委譲という特別な文法が登場してはいますが、注目するべきは
TextFieldという関数です。TextFieldはユーザー入力ができる変数で表示される値(つまりvalue)はtextです。
textはStateですから、変更されればTextFieldが再コンポーズされます。
再コンポーズされたTextFieldはUIを描画します。つまり新しいtextが入った状態のTextFieldになるわけです。
onValueChangeにはtextをitで更新する処理が書かれていますitはコールバック関数の引数の値を省略することができるkotlinの特別な文法ですが、とりあえず今回はただの入力であると解釈してください
textFieldに「あ」が入るとtextが「あ」で更新され、再コンポーズされることで「あ」が入った状態のtextFieldがユーザーに届けられます。
記事を書いていると若干複雑にも思えますが実際に手を動かしていろいろ触った後であれば動作として合点がいく部分は多いのではないでしょうか。
この最小の構成ではユーザーの入力を扱うことができることが分かりました。ユーザーの入力値はこの例では常にtextです。ただし、textはこの関数内のスコープに完全に閉じ込められているため、入力を示すことしかできません。
本来のアプリケーションではこの入力を扱いビジネスロジックに渡し例えばDBに格納するなどの処理が必要ですが、この例では書けません。
また、TextFieldに任意のIDやname属性を振り分け、どこか特殊な空間で取得するという方法も取れません。
xmlやhtmlでUIを構築してきた人にとっては受け入れがたい現実でしょう。
そのためにtextは外部のスコープで受け取ることにします。
UI入力とViewModelを扱う
上記の問題を解決するためには単純に以下のような構成にすることができます。
@Composable
fun SimpleFilledTextFieldSample(
text: String,
onTextChange: (String) -> Unit
) {
TextField(
value = text,
onValueChange = onTextChange,
label = { Text("Label") }
)
}
@Composable
fun TextFieldContainer() {
var text by remember { mutableStateOf("Hello") }
SimpleFilledTextFieldSample(
text = text,
onTextChange = { text = it }
)
}
SimpleFilledTextFieldSampleは引数を受け取ることでTextFieldを描画でき、変数のスコープはTextFieldContainerまで伸びました。本質的には何も変わっていませんが、ほかのテキストフィールドがあった際には、TextFieldContainerを拡張すればよく、見やすくなったと思う人もいるかもしれません。
このように変数のスコープを伸ばす方法は、こちらの記事でも紹介されています。
しかし、今回したいのはビジネスロジックの記述です。
ビジネスロジックに渡すためにはViewModelを使用します。必ずそうしなければならないというわけではありませんが、ViewMoelはandroidのライフサイクルの管理を自動でしてくれるため、画面回転時にstateが消えるなどのいくつかの問題を回避することができます。viewModelを利用することで、rememberの表記は削除できviewModelにライフサイクルを託すことができました。mutableStateOfは健在です。同じようにStateを使用しUIは再コンポーズされる仕組みを提供しています。
class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
var username by mutableStateOf("")
private set
fun updateUsername(input: String) {
username = input
}
}
// SignUpScreen.kt
@Composable
fun SignUpScreen(/*...*/) {
OutlinedTextField(
value = viewModel.username,
onValueChange = { username -> viewModel.updateUsername(username) }
/*...*/
)
}
上記の例は公式サイトからの引用ですが、SignUpScreenはおそらくviewModelという引数を受け取り、これがインスタンス化された、SignUpViewModelであるという例です。
これによりSignUpViewModelがStateを持てるようになり、例えばButton clickで何か動作をしたいときはここを見ればよくなりました。
話が若干脱線しますが、
「ViewModel」というのはViewに対するModelという意味であるように思えます。単にModelはDBなどの普遍的なデータでしたが、Viewの動的な状態に対してもModelが必要でしょうということで提唱された概念なのかもしれません(要出典)。
つまり、設計的な観点から見てもStateを管理することに何の矛盾も生じていません。
まとめ
上記の方法を組み合わせることで
jetpack composeを使いユーザーの入力を処理することができるようになりました。
ただし、よく考えてみましょう。これらを組み合わせると入力要素がひどく増えないでしょうか?
SignUpViewModelにlogin user, login password,くらいしか入力要素が想定できないのは不幸中の幸いですが、ほかのviewModelでたったの5要素になるだけで、updateUsernameのようなロジックがどんどん増えていきますし、同じ処理を何度も書かなければならず冗長的です。
updateUsernameがgetterではなく関数なのは入力時バリデーション(例えば8文字しか入れられないので、それ以上の入力は破棄する)などの要素が入る可能性があるためです。
またUIのStateとActionを分離する考えを尊重しているためでもあります。
当然、
var username by mutableStateOf("")
private set
というような部分もどんどん増えていきます。viewModelのロジックが増えることでボタン押下時の処理フローが追いづらくなりますし、開発時に同じコードを名前違いで書いていくというのもストレスや妨げとなりそうです。
当然ながら、コンポーズ関数であるSignUpScreenのほうも増大します。
アプリケーションでは単に入力だけでなくチェックボックスやtime pickerなどのUI要素も考えられ、入力が増えると同時にこれらを考慮していかなければならいのは非常に億劫ですしコンポーズ関数のレイアウト自体が人間の手に負えないくらい要素が肥大化していきそうです。考えるだけで嫌になります。
これらの問題はXMLやHTMLでのデザイン、オブジェクト指向でのUI設計ではなかった問題でした。
最後に
次回の記事でこれらの問題を解決するための案を提示していきたいと考えています。
最後まで読んでいただきありがとうございました。