8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

KotlinのDSLを活用して似たような仕様の画面を効率よく作成する【前編】

Last updated at Posted at 2022-07-08

説明する予定の内容

  • :white_check_mark: Webフォームのようなデータ入力画面をDSLで効率よく複数作成
    (後編に続く)
  • NavHostで画面遷移
  • Jetpack Composeから切り離すことで UnitTest だけで入力条件などのテストを実行できるようにする
    • 実際の操作をシミュレートしたシナリオテストも UnitTest で書く
  • Preview 表示にも対応する

初めに

今個人的に取り組んでいるアプリでちょっと特殊な仕様があるので、そこから話をさせてください。
このアプリはユーザーが色々情報を送れる機能の要望があって次のような画面を作ることになりました:

Untitled 5.001.png

これが2〜3くらいであれば普通にComposeで実装すれば良いのですが、バリエーションがそれなり多いのと画面遷移もやや複雑なこともあり(入力内容に応じて画面をスキップしたりする必要がある)、DSLを定義して極力シンプルに実装できる仕組みを考えてみました。DSLはKotlinならではの特徴ではありますが、iOSやWebでも似たようなことは実現できると思います。ここ3日ぐらいの思いつき&勢いで記事を書いていますので今後もっと良いアイデアが生まれたり半年後、無駄にコストをかけすぎていたなと反省することがあるかもしれません。。

まずはシンプルに1画面分実装する

画像の1枚目を実装するにあたりこのようなDSLを考案してみました。ここから便宜上1画面のことをページと呼びます。

イメージ
text("説明文")
textInput(title = "お名前", minLength = 3, maxLength = 50)
textInput(title = "希望内容", minLength = 3, maxLength = 200)
button("次へ", enabled = /* ページの全ての要素がエラーになっていない条件 */) {
    /* 画面遷移処理 */
}

一部コメントでぼかしてますが、このように書けると仕様書のように単純明快ですね!早速考えていきたいと思います。

DSLはクラスのインスタンスがthisにバインドされたラムダ式

上記のコードだけであればトップレベルの関数を定義するだけで実現できそうですが、次の画面のラジオボタンを実現する場合、このような要件を実現したくなります:

イメージ
text("説明文が入ります。...")
radioGroup(title = "好みの料理ジャンル") {
    radio(title = "和食") // radioGroupの中だけで使えるようにしたい!
    radio(title = "洋食")
    ... 
}

こういったケースを叶えてくれるのがDSLな訳です。DSLのためにはラムダ式とバインドするインスタンスが必要です。そのためのPageScopeクラスを定義します:

実装
class PageScope {
    fun text(text: String) {}

    fun textInput(title: String, minLength: Int, maxLength: Int) {}

    fun button(title: String, onClick: () -> Unit) {}
}

これをこのようなラムダ式にすればOKです、簡単!

イメージ
fun defineUi(block: PageScope.() -> Unit) {}

defineUi {
    text("") // このラムダ式の中だけ書ける!
    this.button("次へ") {} // this の正体は PageScope のインスタンス
}

text("") // ここは書けない

Composeでレンダリングされる方法を考える

インターフェースの部分は定義できました。次に実際に表示されるような実装を考えていきます。DSLは何か特別な言語仕様があるわけではなく単純に関数呼び出しが行われているだけです。つまり、上から順番に実行されていきます。私は mutableList を用意して text() といった関数が呼び出されたらリストに要素を追加、その後 @Composable な関数でまとめて表示できる仕組みがあれば良さそうというアイデアが思いついたので、ここではそのやり方を紹介します。

配列に格納できる要素を定義する

Element インターフェースというものを定義してみました:

実装
interface Element {
    /* これが呼ばれると Compose に描画する */
    @Composable
    fun render()
}

// 各種エレメントはDSL外に晒す必要はなさそうなので private にしておく
private class TextElement(val value: String) : Element {
    @Composable
    override fun render() {
         Text(text = value)
    }
}

private class TextInputElement(
    val title: String,
    val minLength: Int,
    val maxLength: Int,
) : Element {

    // Elementは要素の状態を保持する役目も担う
    var currentText by mutableStateOf("")

    @Composable
    override fun render() {
        Text(text = title)
        // シンプルに使えるコンポーネントを用意しておく
        TextInput(value = currentText, onValueChange = { currentText = it }, minLength = minLength, maxLength = maxLength)
    }
}

private class ButtonElement(
    val title: String,
    val onClick: () -> Unit,
) : Element {
    @Composable
    override fun render() {
        Button(onClick = onClick) {
            Text(text = title)
        }
    }
}

これらを先ほど用意した PageScope の中で定義した関数を呼び出すと追加されていくようにします:

実装
 class PageScope {
+     val elements = mutableListOf<Element>()
    
     fun text(text: String) {
+        elements += TextElement(text)
     }

     fun textInput(title: String, minLength: Int, maxLength: Int) {
+        elements += TextInputElement(title, minLength, maxLength)
     }

     fun button(title: String, onClick: () -> Unit) {
+        elements += ButtonElement(title, onClick)
     }
 }

あとはスコープを何か本来の保持するクラスに移譲してそちらでレンダリングできるようにするだけです。 Page クラスを作って、ビルダーの代わりにこの PageScope を使えるようにします。また render() できるようにしておきましょう:

実装
data class Page(val elements: List<Element>) {
    
    @Composable
    fun render() {
        Column {
            elements.forEach { it.render() }
        }
    }
    
    companion object {
        fun build(block: PageScope.() -> Unit): Page {
            val scope = PageScope()
            block(scope) // DSLのラムダ式を呼ぶ場合は最初の引数にインスタンスを渡す
            return Page(scope.elements)
        }
    }
}

これで基本部分は完成です!色々試しつつ表示を確認してみてください:

お試しコード
// Composeでプロジェクト作るとできる関数の中に書いてみました
@Composable
fun Greeting(name: String) {
    val context = LocalContext.current
    val page = remember {
        Page.build {
            text("こんにちは世界")
            button("挨拶する") {
                Toast.makeText(context, "Hi!", Toast.LENGTH_SHORT).show()
            }
            textInput("お名前?", 0, 50)
        }
    }
    page.render()
}

// 足りない部分はここで補っておきます
@Composable
fun TextInput(value: String, onValueChange: (String) -> Unit, minLength: Int, maxLength: Int) {
    OutlinedTextField(value = value, onValueChange = onValueChange)
}

image.png

render() の部分に Composable functions that return Unit should start with an uppercase letter という警告が出ると思います。 Render() にすれば解決できますが違和感あるので、 interface Dsl {} などの「名前空間」を用意してそこに @SuppressLint("ComposableNaming") をつけてあげます。 Page クラス以外をこの中へ入れる方法が個人的に良いかなと。

今回のDSLでやっていることは昔ながらのBuilderパターンでも実現できると思います。が、最初の方で示した radioGroup のような入れ子は考えたくないのとそもそも時代遅れ感が強いですね :sweat_smile:

イメージ
final Page page = new PageBuilder()
    .text("こんにちは世界")
    .button("挨拶する", { ... })
    .textInput("お名前?", 0, 50)
    .build()

入力チェックと活性化の変化

表示が実現できたところで次はボタンの enabled の変化を考えてみます。最初の画面では二つの文字入力が条件を満たしていたらボタンが押せるようになると解釈ができます。そこで Element に不備がないか取得できる hasError というプロパティを増やします:

実装
     interface Element {
         /* これが呼ばれると Compose に描画する */
         @Composable
         fun render()
         
+        val hasError: Boolean
     }

自動的に TextElement などに対して実装を求められますのでそれぞれ追加していきます:

実装
     private class TextElement(val value: String) : Element {
         @Composable
         override fun render() {
             Text(text = value)
         }
+
+        override val hasError = false // テキストはエラーにならない
     }

     private class TextInputElement(
         val title: String,
         val minLength: Int,
         val maxLength: Int,
     ) : Element {
         
         var currentText by mutableStateOf("")

         ...
+
+        override val hasError
+            get() = !(minLength..maxLength).contains(currentText.length) // UTF-16 の簡易的な文字数カウント
     }

TextInputElement の hasError は必ず get() の形にしてください、 get = で書いてしまうと初期化時しか評価されません

ユーザーが入力できるのは今のところ TextInputElement だけなので、 Element を abstract class に変え open val hasError = false とし TextElementButtonElement の実装を省略しても良いでしょう。

次に PageScope でスコープ内の全要素の不備がないことを確認できるメソッドを増やします:

実装
     class PageScope {
         ...
+
+        fun hasError(): Boolean {
+            return elements.any { it.hasError }
+        }
     }

これでDSLの中で呼び出せるようになりました。ボタンの enabled 引数も実装します:

イメージ
    fun button(
        title: String, 
        enabled: Boolean, // 追加
        onClick: () -> Unit
    ) {
        elements += ButtonElement(title, enabled, onClick) // 引数追加
    }

    private class ButtonElement(
        val title: String,
        val enabled: Boolean, // 注目
        val onClick: () -> Unit,

ここですでに違和感に気づいた方は頭の回転が速いか経験を積まれてる方ですね。Button の enabled は動的に切り替わるはずなのに val で定義してしまっています。
では var にすればうまくいくかというとそれでもうまくいきません。

今回のDSLは静的に実行される

もう一度Pageを生成するコードをよくみてください。記事の例だとComposeでベタ書きしてしまっていますが、 remember した中で build を呼び出しており、Page インスタンスを生成後はElement 内部で描画されるような仕組みになっています。つまり今の書き方だと build した時の評価がずっと残る構造であるため、どうにかしてDSLを何度も実行するように変更する必要があります :expressionless:

イメージ
button("挨拶する", true /* 固定値なら当然OK */) {
    hasError() // ここには書けるけど意味がない
    Toast.makeText(context, "Hi!", Toast.LENGTH_SHORT).show()
}

val enabled = hasError()
button("挨拶する", enabled) {} // こうしても build 時に値が確定するだけなので、何度も build させる?!

動的な部分を作ってあげる

そこで私の場合はこうすることで回避しました:

実装
    fun button(
        title: String, 
        enabled: () -> Boolean = { true }, // 追加
        onClick: () -> Unit
    ) {
        elements += ButtonElement(title, enabled, onClick) // 引数追加
    }

    private class ButtonElement(
        val title: String,
        val enabled: () -> Boolean, // 追加
        val onClick: () -> Unit,
    ) : Element {
        @Composable
        override fun render() {
            Button(enabled = enabled(), onClick = onClick) { // 引数追加
                Text(text = title)
            }
        }

DSL の部分はこのように書いてあげてください(あとminLengthを変えるのもお忘れなく):

実装
button("挨拶する", { !hasError() }) {

これで動かしてみてください。うまく動きましたか?ラムダ式にしたことで Compose から適宜呼ばれるようになり無事要件が満たされたわけです。このように静的なDSLの設計でも動的な部分を用意することができればダイナミックなコンテンツも表現可能なことがわかります。また、この静的メインだからこそ多くのことを考える必要がなくなったり、テストが容易だったりします。

test.gif

思ったより長くなってしまったので前編はこの辺りで一旦終わろうと思います。後編では画面遷移やプレビュー対応、本格的なユニットテストについても解説していきます。お楽しみに。

8
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?