UI構築がつらいあなたへ: Jetpack Composeの問題点と解決策
第一章
Jetpack ComposeでUIを書くのがつらい理由(前回の記事)
第二章
Composeを楽にするためのフレームワークの提案(本記事)
目次
-
前回のおさらい
1.1. ボイラープレートコードとは -
本題
2.1. 概念的な説明
2.2. 最初の発想
2.3. 実際の例
2.4. 入力要素はまとめられる
2.5. ScreenProviderのその他の責務 - まとめ
前回のおさらい
前回の記事では主にjetpack composeでの最小限のソースコードを提示しました。
そこでは、入力要素が増えるたびにStateを増やし、同じような処理(ボイラープレート)が増える点を指摘しました。
本記事に関する注意事項に関しても前回の記事に記載があるため必要あれば参照のほどよろしくお願いいたします。
ボイラープレートコードとは
ボイラープレートコードとはソースコード中に何度も登場する本質的な価値がないコードです。
冗長的なコードは、ソースコードを汚染するほか記述さえ嫌で面倒になりがちなため、今回提案する方法でその問題が回避できれば良いと思います。
本題
概念的な説明
Jetpack Compose では、従来の XML や HTML のような UI 構築とは異なり、自身で定義するコンポーザブル関数でState・Action・Style を直接管理するというスタイルが採用されています。
このアプローチはUIの表示が決まり切っていて再現性が高い反面、柔軟性を関数内に閉じてしまうため、「引数が少し違うだけの関数を都度定義する」といった冗長なコードが発生しがちです。
私はこの様々なコンポーザブル関数を定義していくことをパーツでのUI構築だと考えています。
パーツを増やすにはコンポーザブル関数を増やすしかなく、うまく細分化できない限りコンポーザブル関数はほとんど同じような機能を持つのではないでしょうか。
私が提案する方法論は、こうしたボイラープレートや状態管理の煩雑さをよりシンプルに、かつ XML に慣れた方でも直感的に扱える形で解決しようというものです。
そのコアとなる考え方は、「すべての UI 状態をオブジェクトとして管理する」というものです。
たとえば、1つの TextField に対して1つのオブジェクトを割り当て、状態・アクション・スタイルをまとめて保持します。
これによってオブジェクト指向の継承による冗長性の回避や多態性によるビジネスロジックの簡素化も副次的に行えるようになりました。
XMLで見えないうちにやっていた裏側の部分であるUI のレンダリング処理はコンポーザブル関数を通し、UI 部品自体はオブジェクトとして構成されることで、部品の再利用性が飛躍的に向上します。
その結果、似たような UI を何度も書く必要がなくなり、UI を「部品」ではなく「構成情報」として考えられるようになります。
最初の発想
私がフレームワークと呼んでいる部分はかなりシンプルです。
以下の例ではコンポーザブル関数の引数をオブジェクトにまとめました。
状態を表すオブジェクト
open class TextFieldKit() {
var state: TextFieldState by mutableStateOf (TextFieldState(inputValue = ""))
protected set
var action: TextFieldAction by mutableStateOf(TextFieldAction(onValueChange = ::updateInputValue))
protected set
var style: TextFieldStyle by mutableStateOf(TextFieldStyle())
protected set
fun updateInputValue(value: String) {
state = state.copy(inputValue = value)
}
}
オブジェクトを受け取りUIを表示するコンポーザブル関数。
@Composable
fun AppTextFiled(
textFieldKit: TextFieldKit
) {
TextField(
value = textFieldKit.state.inputValue,
onValueChange = {textFieldKit.action.onValueChange(it)},
modifier = textFieldKit.style.modifier,
enabled = textFieldKit.style.enabled,
readOnly = textFieldKit.style.readOnly,
textStyle = textFieldKit.style.textStyle(),
label = textFieldKit.style.label,
placeholder = textFieldKit.style.placeholder,
leadingIcon = textFieldKit.style.leadingIcon,
trailingIcon = textFieldKit.style.trailingIcon,
isError = textFieldKit.style.isError,
visualTransformation = textFieldKit.style.visualTransformation,
keyboardOptions = textFieldKit.style.keyboardOptions,
keyboardActions = textFieldKit.style.keyboardActions,
singleLine = textFieldKit.style.singleLine,
maxLines = textFieldKit.style.maxLines,
minLines = textFieldKit.style.minLines,
interactionSource = textFieldKit.style.interactionSource,
shape = textFieldKit.style.shape(),
colors = textFieldKit.style.colors()
)
}
コンポーザブル関数の方に着目してください。
TextFieldの引数をすべて列挙しオブジェクトのほうにすべての状態を管理できるようにしています。
実際のコードはかなりシンプルで、わかりやすさのためにstate, action, styleは分離しています。
これらはすべてデータクラスとして定義しています。
data class TextFieldStyle(
val modifier: Modifier = Modifier,
val enabled: Boolean = true,
val readOnly: Boolean = false,
val textStyle: @Composable () -> TextStyle = { LocalTextStyle.current },
val label: @Composable (() -> Unit)? = null,
val placeholder: @Composable (() -> Unit)? = null,
val leadingIcon: @Composable (() -> Unit)? = null,
val trailingIcon: @Composable (() -> Unit)? = null,
val isError: Boolean = false,
val visualTransformation: VisualTransformation = VisualTransformation.None,
val keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
val keyboardActions: KeyboardActions = KeyboardActions(),
val singleLine: Boolean = false,
val maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
val minLines: Int = 1,
val interactionSource: MutableInteractionSource? = null,
val shape: @Composable () -> Shape = { TextFieldDefaults.TextFieldShape },
val colors: @Composable () -> TextFieldColors = { TextFieldDefaults.textFieldColors() }
)
data class TextFieldState(
val inputValue: String ,
)
data class TextFieldAction(
val onValueChange: (String) -> Unit ,
)
TextFieldの引数にはコンポーザブルなgetterをとる部分があります。そのためその部分に関しては関数でラップし、必要なタイミングで呼び出しています。
私は、コンポーザブル関数に渡すこのオブジェクトをクラス名からわかる通りkitと呼んでいます。
入力要素はkitを引数に受け取り、ViewModelでkitをインスタンス化します。
具体的に実装したければ継承し必要な部分は変更できますので、コンポーザブル関数を都度定義するというパーツ的な考えはしなくてよくなりました。
また、stateとactionはkitの中に閉じているためviewModelの中で何度も同じ処理を書く必要がなくなりました。
StateやActionをdata classで管理することが冗長に感じるかもしれませんが、一貫性のためにdata classに管理しています。また、入力要素がドメイン的にたった一つではなく、いくつかの要素をまとめた状態である際に、そのようなkitを一貫性を保ちつつ定義できることもメリットとしてあげられます。
ちなみに、data classをmutablestateofでインスタンス化した際に、再コンポーズされる部分はそのデータクラスのすべてのプロパティを参照している部分ではありません。
実際には変更された部分を参照しているコンポーザブル関数のみが再コンポーズされ、効率的です。
実際の例
考え方は上記の通りですが、初期化の処理などが必要ということが分かったため、現在は以下のように拡張しています。
open class TextFieldKit(): StateClearable, Inputable {
var initialState: TextFieldState = TextFieldState(inputValue = "")
set(value) {
field = value
state = value
}
var initialAction: TextFieldAction = TextFieldAction(onValueChange = ::updateInputValue)
set(value) {
field = value
action = value
}
var initialStyle: TextFieldStyle = TextFieldStyle()
set(value) {
field = value
style = value
}
override val inputValue: String
get() = state.inputValue
var state: TextFieldState by mutableStateOf (initialState)
protected set
var action: TextFieldAction by mutableStateOf(initialAction)
protected set
var style: TextFieldStyle by mutableStateOf(initialStyle)
protected set
fun updateInputValue(value: String) {
state = state.copy(inputValue = value)
}
override fun clearValue(){
state = initialState
}
fun clearAction(){
action = initialAction
}
fun clearStyle(){
style = initialStyle
}
}
初期値(initialXXX)はいつでも変更できるようにしてあり、変更時に実際の値も変更されるようになっています。
また、いくつかのインターフェースを実装していますが、後述のScreenProviderでの使用を想定しています。
このような構成を実際に継承したLoginIdとLoginPasswordの例を示します。
class LoginIdKit : TextFieldKit() {
init {
initialStyle = TextFieldStyle(
label = { Text("Login ID") }
)
}
}
class LoginPasswordKit : TextFieldKit() {
init {
initialStyle = TextFieldStyle(
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(), // パスワードを隠す
)
}
}
LoginPasswordKitについては、パスワードを隠すためにstyleを変更しなければなりませんので、LoginIdの例より少し面白いかもしれません。
継承したkitは現在は非常にシンプルですが、validatableインタフェースを実装することですべてのkitをイテレートしての一括バリデーションを任意のタイミングで行えるなどの拡張も考えられます。
継承関係としても非常に合理的で、単にLoginIdKitを継承した拡張クラスを作成してもよいでしょう。
ドメイン的な観点から内容が同じでも、画面によって処理が異なるケースでも管理することができます。
各コンポーザブル関数を定義するよりもコード汚染がなくなり、わかりやすくなったと私は考えています。
入力要素はまとめられる
入力要素は実際に画面単位でバラバラというわけではなく、htmlでもformの中に閉じられています。
私はこのためのクラスをScreenProviderと呼んでいます。
ScreenProviderは
どのような入力機能があるのか、
その入力機能はビジネスロジック上でどのように変更されるのか、
を知っていますが、ビジネスロジックでいつ変更されるのかを知りません。
いつされるかという部分はViewModelに管理させます。
class LoginScreenProvider(
val onLoginButtonClick: () -> Unit
): ScreenProvider() {
val loginIdKit: LoginIdKit = LoginIdKit()
val loginPasswordKit: LoginPasswordKit = LoginPasswordKit()
override val inputableKits: MutableList<Any> = mutableListOf(
loginIdKit,
loginPasswordKit,
)
val loginButtonKit = LoginButtonKit(::submitLogin)
private fun submitLogin(){
onLoginButtonClick()
}
}
この構成要素をViewModel上でインスタンス化します。
class LoginViewModel () : ViewModel() {
val loginScreenProvider = LoginScreenProvider(
onLoginButtonClick = ::login
)
private fun login(){
loginScreenProvider.clearInputAll()
}
}
実際にこのviewModelをコンポーザブル関数で使用します。
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel<LoginViewModel>()
) {
val loginFormModel = viewModel.loginScreenProvider
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.wrapContentSize(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
){
AppTextFiled(loginFormModel.loginIdKit)
AppTextFiled(loginFormModel.loginPasswordKit)
AppButton(loginFormModel.loginButtonKit){
Text("ログイン")
}
}
}
AppTextFiledの部分が非常に簡素になり、UIの構成レイアウトが一目でわかりやすくなりました。
またViewModelが非常にシンプルになり、複雑なビジネスロジックを書くための懸け橋になりました。
実際には依存注入を利用して別のクラスに処理を委譲しますが、どのタイミングで値を変更するのか、どのライフサイクルでスレッド処理を行うのかという部分にViewModelは完全に専念することができ、何がどう変わるかといった部分で汚染されることがなくなりました。
現在、ScreenProviderのBaseには初期化処理が実装されています。
ログの一括書きこみや、バリデーションの一括処理などを機能として追加することも検討してよいかもしれません。
実際には使わない機能もあると思います。
abstract class ScreenProvider {
open val inputableKits :MutableList<Any> = mutableListOf()
fun clearInputAll(){
for (kit in inputableKits) {
if (kit is StateClearable){
kit.clearValue()
}
}
}
}
ScreenProviderのその他の責務
ScreenProviderはStateがどのように更新されるのかを管理しており、ActionはViewModelに投げています。
ReactにはFluxという概念があり、こちらでは定義されていたActionをもとにStateを返す純粋関数を定義するプロセスが存在します。
あくまで私の考えですが、これはScreenProviderの中でメソッドとして定義することで実現できますし、実際にStateが複数の状態によって変更されるなどの複雑な状態ではFluxでの方法はコードの変更が難しくなるのではないかと考えています。
しかし、Actionを完全に分離するかは設計の好みのため、そのような拡張もできるかと思っています。
まとめ
提案は以上になります。
これらは完全にライブラリーとして提供するつもりはありませんが、ソースコードを書く際の実際の考えとして組み込んでみてはいかがでしょうか。
フォルダ構成など、その他の補足情報が必要であれば記事にするかもしれませんが、三部構成にする必要性を感じなかったので二部構成にとどまりました。
以上、ありがとうございました。
現在、発展途上なため様々なアーキテクチャやライブラリ、フレームワークが登場することに期待しており、この発想は古い、もしくは古くなるかもしれませんし、もっといいアイデアを持った閲覧者様もおられることと思います。
様々な考えを共有することは大変好ましく思っているため、考え方もコードもブラッシュアップしていければよいと思います。