所感箱

所感を書きためていきます

マルチプラットフォーム構成で、SwiftUIとJetpack Composeを試す

モチベーション

  • DroidKaigi2023で、JetpackCompoeに触れたことで、SwiftUIにも触れたくなった
  • マルチプラットフォーム構成で、それぞれの特徴を比較しながら試せると理解が深まりそう

何を試すか

ステップ

詳しくはこちらを参照:https://kotlinlang.org/docs/multiplatform-mobile-getting-started.html

  1. Android Studioで新規プロジェクト -> Kotlin Multiplatformを選択
  2. プロジェクト構成の設定画面。 iOS用にCocoaPodsの導入可能なようだ。
  3. 添付画像のような構成でプロジェクトが作成される
  4. iOSAndroidで、各種コンポーネントのカタログページを作る

ちなみに今回の記載の内容はAndroidで、androidx.compose.material3:material3を利用しているので、純粋なJetpackComposeとは少し差分がある。

成果物

できあがったのは、このようなページ

以降は、この成果物を作る上で理解した、それぞれの特徴を書いておく。

OS毎の比較 

スタイル設定の方法

JetpackComposeはModifierと呼ばれる修飾用のクラスを渡して設定し、iOSはComponentに対してメソッドチェーンで指定する。

JetpackCompose

Image(
    painter = image,
    contentDescription = "Turtle Rock",
    modifier = Modifier // 画像を円型に切り抜き、サイズを指定
        .clip(CircleShape)
        .size(64.dp)
)

SwiftUI

Image("turtlerock")
    .resizable()
    .frame(width: 64, height: 64)
    .clipShape(Circle())

いずれもModifierと呼ばれていて、思想的には似通っていた。

画像 + Shape表示

画像に対して Shapeを指定するには、AndroidModifier.clipを使用し、iOSではView.clipShapeを利用する。

デフォルトで、いくつかのクラスが用意されていて抜粋すると以下の通り。

JetpackCompose

  • RoundedCornerShape
    • 円形のCircleShapeの実態はRoundedCornerShape(50)
  • RectangleShape
  • CutCornerShape

SwiftUI

  • Circle
  • RoundedRectangle
  • Rectangle
  • Capsule

テキスト

テキストに文字スタイルを設定するのは、AndroidTextViewのstyle属性に指定し、iOSではText.fontを利用する。

JetpackCompose

Text(
    text = it.name,
    modifier = Modifier.padding(4.dp),
    style = MaterialTheme.typography.bodyMedium // 文字スタイル指定
)

SwiftUI

Text("Texts")
    .font(.largeTitle) // 文字スタイル指定
    .padding(8)

各OSに用途毎にプリセットの文字スタイルが用意されおり、AndroidではTypographyクラスに、iOSではFontの拡張として、実装されている。

ボタン

ボタンは、Androidにはマテリアルデザインに準拠したコンポーネントクラスが用意されているが、iOSはButtonに対して装飾をしていくことになる。

JetpackCompose

Button(
    modifier = modifier,
    enabled = enabled,
    onClick = {},
) {
    Text("Primary")
}

OutlinedButton(
    modifier = modifier,
    onClick = {},
    enabled = enabled,
) {
    Text("Secondary")
}

SwiftUI

Button(action: {}) {
   Text("Primary")
       .padding(10)
}
.foregroundColor(Color.white)
.background(Color("Primary"))
.cornerRadius(3)
.font(Font.system(size: 12, weight: .bold))


Button(action: { }) {
    Text("Secondary")
        .padding(10)
        .foregroundColor(Color("Primary"))
}
.overlay(
    RoundedRectangle(cornerRadius: 3)
        .stroke(lineWidth: 1)
        .foregroundColor(Color.black)
)

縦横配置

データ量が多いリストを作る時は、JetpackComposeではLazyColumn、SwiftUIではLazyVStackListを使う設計になっているようだった。 今回は、簡素なページなので、JetpackComposeではColumnRow、SwiftUIではVStackHStackを利用した。

JetpackCompose

Column { // 縦へのコンポーネント配置
    // 画像コンポーネント
    Text(text = "Image", modifier = Modifier.padding(4.dp), style = MaterialTheme.typography.titleMedium)
    Row {   // 横へのコンポーネント配置
        Image(painter = image, contentDescription = "Turtle Rock", modifier = Modifier.size(64.dp))
        Image(
            painter = image,
            contentDescription = "Turtle Rock",
            modifier = Modifier.clip(RectangleShape).size(64.dp)
        )
    }

    // ボタンコンポーネント
    Text(text = "Buttons", modifier = Modifier.padding(4.dp), style = MaterialTheme.typography.titleMedium)
    Components.ButtonComponents.values().forEach {
        Row {
            Text(text = it.name, modifier = Modifier.padding(4.dp), style = MaterialTheme.typography.bodyMedium)
            PrimaryButton(text = "Primary", modifier = Modifier.padding(4.dp))
            PrimaryButton(text = "Primary", modifier = Modifier.padding(4.dp), enabled = false)
        }

       ・・・
    }
}

SwiftUI

VStack(alignment: .leading) { // 縦へのコンポーネント配置
    // 画像コンポーネント
    Text("Image").font(.largeTitle).padding(8)
    HStack { // 横へのコンポーネント配置
        Image("turtlerock")
            .resizable()
            .frame(width: 64, height: 64)
            .clipShape(Rectangle())
                        case .circle:
        Image("turtlerock")
            .resizable()
            .frame(width: 64, height: 64)
            .clipShape(Circle())
                        default:
                            Text("")
                        }
                    }
                }
            }
    }
    // ボタンコンポーネント
    Text("Buttons").font(.largeTitle).padding(8)
    HStack {            
       Button(action: {
       }) {
           Text("Primary").padding(10)
       }
       .foregroundColor(Color.white)
       .background(Color("Primary"))
       .cornerRadius(3)
        .font(Font.system(size: 12, weight: .bold))

       ・・・
    }
}

所感

  • GoogleAppleも丁寧なチュートリアルを用意しているので、ライトに試してみることができた
  • どちらも思想や構成は似ており、AndroidViewとUIKitの時代より、両OSの開発を兼任する際のハードルは下がっていると感じた
  • マテリアルコンポーネントの開発が別軸で進んでいるAndroidの方が、用意されたコンポーネントの種類は多そうに思った。
    • これは単なる思想な違いでもある
  • KMMで両方を試せると、比較しながら疑問が浮かぶので理解が進むので良き

Appendix

developer.apple.com

developer.android.com

マルチモジュールもスタンダードになりつつあるので、バージョンカタログを整理してみた

モチベーション

筆者はAndroid開発を主戦場で、日々Gradleを使って依存関係の管理をします。 昨今はマルチモジュールもスタンダードになってきており、依存関係の管理に工夫が必要になっています。そんな依存関係の管理の方法として、Gradleからバージョンカタログという機能が提供されており、利用する機会が増えてきそうなので、今後のためにも整理してみます。

簡単な解説

Gradle 7.0からfeature previewとして導入され、Grade 7.6にて正式導入された機能で、端的に書くと、マルチモジュールで依存関係とプラグインを簡単に一括管理する方法です。これだけでも便利な機能だと思えますが、もう少し踏み込んでメリットを整理してみます。

バージョンカタログのメリット

docs.gradle.org

gradleのサイトにあるメリットを直訳すると以下の通り。

  • Gradle はカタログごとにタイプセーフなアクセサーを生成するため、IDE でオートコンプリートを使用して依存関係を簡単に追加できます。
  • 各カタログは、ビルドのすべてのプロジェクトに表示されます。これは、依存関係のバージョンを宣言し、そのバージョンへの変更がすべてのサブプロジェクトに適用されることを確認するための中心的な場所です。
  • カタログでは、一般的に一緒に使用される「依存関係のグループ」である依存関係バンドルを宣言できます。
  • カタログでは、依存関係のグループと名前を実際のバージョンから分離し、代わりにバージョン参照を使用できるため、複数の依存関係間でバージョン宣言を共有できます。

それぞれ簡単に整理してみる。

IDEでのオートコンプリート

特定のスクリプトファイルの拡張プロパティで、バージョン数値を管理し参照する方法もあります、オートコンプリートには対応しておらず、別ファイルを確認する必要があります。それを、以下の画像のように、IDEがフォローしてくれるようになる機能です。ちなみに宣言に移動などもサポートしています。

なお、筆者は最初オートコンプリートが機能しませんでしたが、IDEのアップデートをしたところ無事に機能しました。

依存関係のバージョン宣言とサブプロジェクトへの適用

依存関係の宣言を集約する機能です。例えば、以下のように、settings.gradleに依存関係の定義を書いておくと他の全てのモジュールから参照できるようになります。

// [settings.gradle]
dependencyResolutionManagement {
    versionCatalogs {
        libs {
            library('room-runtime', 'androidx.room:room-runtime:2.5.2')
            library('room-ktx', 'androidx.room:room-ktx:2.5.2')
            library('room-rxjava3', 'androidx.room:room-rxjava3:2.5.2')
        }
    }
}

// [app/build.gradle]
dependencies {
    implementation libs.room.runtime
    implementation libs.room.ktx
    implementation libs.room.rxjava3
}

// [hoge/build.gradle]
dependencies {
    implementation libs.room.runtime
    implementation libs.room.ktx
    implementation libs.room.rxjava3
}

versionCatalogsの定義により、エイリアスとしてアクセサーが提供され、参照側ではバージョンの指定は不要となります。エイリアス- or _ or . を区切り文字として、マッピングが行われ、例えば、room-runtimeという定義であれば、room.runtimeとして展開されます。

なお、settings.gradleに記述する以外に、tomlファイルに定義することが可能で、 gradle/libs.versions.tomlのパスにファイルを配置すると、デフォルトで参照してくれます。

# ./gradle/libs.versions.toml

[versions]

[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version = "2.5.2" }
room-ktx = { group = "androidx.room", name = "room-ktx", version = "2.5.2" }
room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version = "2.5.2" }

[bundles]

複数の依存関係間でバージョン宣言の共有

複数のモジュールで、共通のバージョンニングルールが適用されているライブラリを、一つの宣言で管理できるようになる機能です。

[versions]
# room用のバージョンを宣言
room = "2.5.2"

[libraries]
# それぞれ、version.refにversion.roomを指定
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "room" }

[bundles]

Android開発では、共通バージョンニングのライブラリも多いので、宣言的にルールをつけられると管理の手間を下げられると思います。なお、Renovateもバージョンカタログに対応しており、tomlファイルに対してのPRも生成してくれるようです。

renovate/docs/usage/java.md at main · renovatebot/renovate · GitHub

依存関係バンドルの宣言

最後は、いくつかのライブラリをまとめて利用するケースに対応する機能です。 例えば、room-runtime, room-ktx, room-rxjava3を一緒に利用している場合は以下のようになります

# ./gradle/libs.versions.toml

[versions]
room = "2.5.2"

[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "room" }

[bundles]
# room-runtime, room-ktx, room-rxjava3をバンドル宣言
room = ["room-runtime", "room-ktx", "room-rxjava3"]

// [app/build.gradle]
dependencies {
    # room-runtime, room-ktx, room-rxjava3が全て参照される
    implementation libs.bundle.room
}

必ず一緒に利用するものを宣言的に纏まることで、MECEに管理していくことができそうです。

所感

Gradleの公式の機能だけあって、使わない手はないと感じました。 Androidデベロッパーサイトでも移行ガイドが提供されているので、安心して利用できます。 移行過程で、依存ライブラリの要不要も整理していくと、お掃除も目指せそうですね。

ビルドをバージョン カタログに移行する  |  Android デベロッパー  |  Android Developers

スクラムからみるXPとカンバン

モチベーション

日常的にスクラムをベースに開発を進めているが、スクラムガイドでは技術的な手法や開発プロセスの改善方法について具体的な実践方法は明記されていないため、日々起きる課題について引き出しが足らないと感じていた。 より実践方法の引き出しを増やすために、以下のXP本とカンバン本を読んでみることにした。

エクストリームプログラミング 第2版

https://www.amazon.co.jp/dp/B012UWOLOQ

リーン開発の現場 カンバンによる大規模プロジェクトの運営

https://www.amazon.co.jp/dp/427406932X

結果的に言えば、どちらの本も参考にできる事例が多くあり、読んで良かったと思う。
どちらの本もスループットとプログラムの欠陥にの対処方法ついて触れられていて、うまく活用することで、より良い開発を実践できる可能性を感じた。

試しに日常的に実践しているスクラムをベースにそれぞれの事例を捉え直してみたので、残しておこうと思う。

WIPを制限してボトルネックに向き合う

カンバン本では、開発フロー全体に着目して、ボトルネックを特定しながら、チームのマルチタスク制御をして開発を進める手法が中心に書かれている。

例えば「11章:WIPをマネジメントする 」では、WIP の制限の目的は、 過剰なマルチタスクを避けるためと、 後続のプロセスに 負荷をかけすぎないためだ。と書かれており、もし、 テスターたちが大量のやるべき作業を抱えて いたなら、 開発者が新しい機能を開発し続けて、 テスターの仕事をさらに増やす のは避けたいはずだ。 代わりに、 機能開発チームはテストチームの支援に集中すると続く。

書籍は大規模開発の事例がベースになっているため、チーム間の支援の事例になっているが、これはスモールチームでも転用できる考え方のはずだ。例えば、3人作業者がいて、それぞれABCのタスクを進めていたとする。

誰か一人が作業を完了し、コードレビューに出したあと、新たにタスクDに着手すると仕掛かり中の作業は4つになる。

この開発の流れに対して、何も制約をつかずにいた場合に極論すると、以下のように合計6つ(あるいはそれ以上)の仕掛かり作業をチームが持つことになる。

仕掛かり中の作業に上限がある場合は、これを避けられる。 例えば、コーディングとレビューの数に3つまでの上限を設けることで、他の人の作業の支援に回ったりレビューを終わらせることに力をかけることが可能になるからだ。

これをスクラムガイドから捉えなおして、以下のような整理をした。

  • 持続可能なペースでインクリメントを作成できるするために
  • 開発フローを可視化し、ボトルネックを検査し、マルチタスクを制御して適応する

共同作業を通じて、スループットと欠陥に向き合う

XPの価値・原則・プラクティスを中心にソフトウェア開発の進め方が紹介されている。特に具体的な技術的なプラクティスが書かれていることが、特徴的だ。

先の例に主要プラクティスの一つであるペアプログラミングを当てはめた場合、そもそもの仕掛かり中の数をペアプログラミングを実施して減らすこともできるし、仕掛かり上限を越えるタイミングでペアプログラミングやペアレビューを実施し、他のメンバーの支援をすることもアプローチを取りやすくなるように思う。

最初からペアプロ ペアレビュー

いずれにせよ、チームの仕掛かり中の作業は2つか3つになり、チームのキャパシティを超えて増えていくことには対処ができそうだ。

そしてペアプログラミングは別の効用もある。「第8章:始めてみよう」には以前、あるチームをコーチしたときに、開発後の欠陥を確認してもらったことがある。その結果、すべての欠陥が、単独でプログラミングしたときに生み出されたものであることが判明した。という記述がある。

これは共同作業をすることで、ケアレスミスを減らせるということや、より良い設計についてその場で議論することができることが効果を生んでいるように思う。

これをスクラムガイドから捉えなおして、以下のような整理をした。

  • 品質基準を満たすインクリメントを素早く作るために
  • 共同作業を通じてタスクに集中しながら、システムの改良について意見を出し合う。

最後に

自分の思考整理のために、スクラムをもとにそれぞれの本から学んだことを整理してみた。
XP本にもカンバン本にも、今回紹介した以外にも書ききれないほどの、実践事例が載っているので是非読んでみてほしい。

Androidのローカルデータベースの選択肢の整理

この記事はコネヒトアドベントカレンダー2022の13日目の記事です。

Androidでローカルデータベースの選択肢を整理してみた。

最初に結論

  • Realmは更新の速度は優秀だが、アプリサイズや参照メソッド数の考慮が必要
  • Androidだけの環境ならRoomを選択する方が無難に思う
  • iOSAndroidマルチプラットフォームの構想があるならRealmもあり

※他にもいい選択肢があれば教えてください!

デベロッパーガイドはRoomの利用を強く推奨している

Androidでローカルデータベースを選択する場合、Room or Realmが選択肢に上がることが多いと思う。

もちろんSQLiteを生で実装することも、可能ではあるが学習コストの高さを軽減したり、安定的な開発をするために、デベロッパーガイドではRoomを利用することが推奨されている。

数年前では、Realmを利用することが多かったイメージだが、Roomが実装されたことにより、トレンドが大きく動いたと感じている。この2つについて改めて整理してみた。

RealmとRoomの比較は下の記事を参考にさせてもらった。

itnext.io

参照メソッド数とアプリサイズ

RoomとRealmの参照されるメソッドは、Roomがはるかに少なかった。Realmの場合はMultiDexやアプリサイズへの影響を考慮する必要がありそうだった。

Room

Realm

アプリサイズ

加えて、Realmはネイティブライブラリが含まれるため、アプリサイズへの影響もありそうだった

複数スレッドでの操作

RoomはRoomDatabase.Builder.setTransactionExecutor(Executor)を指定することで、複数スレッドからのトランザクションを抑制できるとのことで、それ以降のバージョンを使うほうが余計な考慮はせず済みそうだった。

developer.android.com

対してRealmはKotlinのSDKのみスレッドセーフな実装になっている様子。

www.mongodb.com

速度面

触ってみるついでに、1万件のクラスを作りそれぞれ全件取得するコードを書いて速度を比較してみた。

Room

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>
}

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
            ).build()
val userDao = db.userDao()

userDao.getAll()

Realm

class Person : RealmObject {
    var name: String = "Foo"
}

val configuration = RealmConfiguration.create(schema = setOf(Person::class))
val realm = Realm.open(configuration)

realm.query<Person>().find().forEach {
    // nop
}

結果

挿入と参照の速度。単位はミリ秒。

このような使い方をすることはないので、あくまでも参考情報だが、10万件の全件取得だとRoomの方が速度が速かった。RoomはgetAllで全件を一度に取得する設計に対して、RealmはfindではRealmResultで参照を持ち、アクセス時に実体を取得する設計のようだった。

試しに1件だけ更新したところ以下のようにRomの方が高速で処理がなされていた。

更新の速度。単位はナノ秒

じゃあ、どっちを使う?

性能面の比較をしてみたが、Realmではもう一つ重要な観点があった。

RealmはKMMでのSDKが提供されているため、iOSAndroidでのマルチプラットフォームを狙う場合は、選択肢としては強い動機になりうると思った。

www.mongodb.com

KotlinSDKはマルチプラットフォーム対応

結論

Flutter開発とAndroid開発

Flutter界隈まだまだいい意味で枯れていないことが多く、コミュニティ全体でより良いアーキテクチャや開発手法が試行錯誤がされている状況だ。毎回、調べては忘れてしまうので、今回は、現状のFlutter開発のアーキテクチャやライブラリについて、Androidと比較しながらまとめておく。

アーキテクチャ

AndroidではMVVMが主流で、GoogleJetpackを提供して、開発者がスムーズにアプリ開発を進める環境を整備している。後述するJetpack Composeが登場したことで、変わる部分もありそうだが、基本的な方向性は変わらないと想定している。

developer.android.com

対してFlutterでは、まだまだベストプラクティスを作るために、試行錯誤を繰り返しており、デファクトスタンダードが決まりきっていない印象を持っている。 MVVMの形に落ち着くと、Android畑に主軸を置く身としては楽だなーと感じているが、 Android開発と比較して、MVVMの実現のために複数のサードパーティのパッケージを組み合わせて実現する必要がまだまだあるので、どれを選択するかは悩ましい部分がありそうだ。

wasabeef.medium.com

DI

Android開発では、DaggerやKoinを採用しているところが多い印象を持っている。最近は公式のガイドラインにDagger Hiltを用いたDIの解説も提供されていて、よりDaggerを採用する傾向が強まる印象を持っている。

developer.android.com

対して、FlutterではDIのみを提供するライブラリは、あまり使われていないようで、ProviderパッケージやRiverpodパッケージの機能を利用して、実現していることが多い状況に思う。

pub.dev

riverpod.dev

個人的には、DIライブラリは別の機能と混在しないほうが、責務が明確になりパッケージの改善も進むと思うので、injectorを採用するのもありなのかなーと思っている。

pub.dev

ネットワーク通信

Android開発では、retrofit + okhttpを主流で、公式のガイドでは、retrofitをネットワーク通信の方法がが紹介されている。

developer.android.com

ただ最近は、ktorを採用するところも増えてきている印象があり、新規開発であれば採用するのも選択肢としてはありな印象だ。

ktor.io

対してFlutter開発では、dartのhttpパッケージもしくは、chopperパッケージを使用している多いようで、そのどちらかを採用すると良さそうだ。

pub.dev pub.dev

View

Android開発はxmlでのレイアウトベースの開発が主流であったが、jetpack composeの登場で大きな変化が訪れている。例えば初めてFlutterを触ってリストビューの実装の楽さに驚いたが、jetpack composeでは、Flutterのリストビューウィジェットと同じような実装が可能になった。 例えば以下のコードでは、LiveDataの値が更新されると、必要な分だけリストビューが更新されるといった具合だ。

@OptIn(ExperimentalCoilApi::class)
@Composable
fun Contents(names: LiveData<List<ListViewItem>>, onClickContentButton: () -> Unit) {
    val namesState = names.observeAsState(emptyList()) // ここで値が購読され、namesの内容が変わるとリストビューにも反映される

    LazyColumn {
        items(namesState.value) { item ->
            when(item) {
                is ListViewItem.Header -> { HeaderView(item).buildView() }
                is ListViewItem.Content -> { ContentView(item, onClickContentButton).buildView() }
            }
        }
    }
}

developer.android.com

とはいえFlutterは、UIウィジェットの種類が豊富でドキュメントの充実度がすごいので、サクッとアプリを作る上では、まだまだFlutterの方が進めやすそうだなという印象。

flutter.dev

もちろんクロスプラットフォームのメリットが一番大きいので、iOSも一緒に作りたい場合は依然としてFlutterのするのが良いように思う。

まとめ

Flutter開発は、試行錯誤が大変な分、色々な選択の余地があって楽しいです!

Flutterで開発で利用しているGetXパッケージの備忘録

今回はGetXパッケージについて簡単な説明と内部実装を追った内容を残しておく。

pub.dev

GetXは何か?

GetXは状態管理、依存性注入、ルーティングの機能を備えたパッケージで、GitHubのスター数も4000を超えている。特にルーティング機能を利用して、画面遷移を非常に楽に書けるようになる印象を持ったので、簡単なサンプルを元に紹介したいと思う。

通常、Flutterで画面遷移を実装する場合下記のようになる。

class _MyList extends State<List> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ・・・
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
            // BuildContextが必要なため、呼び出し元が限られる
            Navigator.push(
                context,
                MaterialPageRoute(
                  settings: const RouteSettings(name: "/new"),
                  builder: (context) => InputForm(null)
                )
            );
        },
      ),
    );
  }

GetXパッケージを使って書いてみると以下のようになる。

class _MyList extends State<List> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ・・・
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
            // BuildContextが不要になった
            Get.to(InputForm(null))
        },
      ),
    );
  }

ちなみにルートはMaterialAppではなくGetMaterialAppを利用する必要がある

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'Flutter Demo',
      ・・・
  }
}

このようにGetXパッケージを使用することで、シンプルかつBuildContextから分離された書き方が可能になる。GetXパッケージのドキュメントでは、以下のように説明されている。

抜粋

You don't need context to access your controllers/blocs through an inheritedWidget, so you completely decouple your presentation logic and business logic from your visualization layer.

和訳

継承されたウィジェットを介してコントローラー/ブロックにアクセスするためにコンテキストは必要ないため、プレゼンテーションロジックとビジネスロジックを視覚化レイヤーから完全に分離します。

この説明通りにFlutterの制約から剥がして、設計の自由さが増すことはメリットが大きそうだと思うが、 BuildContextがない場合、副作用はないのか気になったので、内部実装を調べてみた。

どうやって動いているか?

先にそもそも標準の画面遷移はどのように行われているかを少し追ってみる。

Navigator.pushの内部は以下のようにNavigator.of(context)で必要なNavigationStateが取得し、pushで画面遷移の指示を行っている。

  @optionalTypeArgs
  static Future<T?> push<T extends Object?>(BuildContext context, Route<T> route) {
    return Navigator.of(context).push(route);
  }

これはpop、replaceなど画面遷移に関わる関数も同様にNavigator.of(context)からNavigationStateを取得し、全ての画面遷移状態を管理しているようだった。

そしてこの関数では以下のように、NavigatorStateを探索して見つかったものを返却している

  static NavigatorState of(
    BuildContext context, {
    bool rootNavigator = false,
  }) {
    NavigatorState? navigator;
    // 自分自身がNavigatorStateを持っていたら返却
    if (context is StatefulElement && context.state is NavigatorState) {
        navigator = context.state as NavigatorState;
    }
    // NavigatorStateを探索的に探している
    if (rootNavigator) {
      navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
    } else {
      navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
    }

    ・・・

    return navigator!;
  }

BuildContextは各ウィジェットに紐づくクラスなので、渡されたBuildContetを起点に、ウィジェットツリーから、NavigatorStateを探索して、ルーティングを実行する仕組みになっているようだった。

GetXのNavigatorはどのように動いているか

ここまで見てみると、標準のルーティングがウィジェットツリーに依存しているため、GetXがBuildContextなしに画面遷移を実現する方法が気になってくる。Get.toの内部実装を見てみたところ以下のようになっていた。

  // Get.to() 関数
  Future<T?>? to<T>(
    dynamic page, {
    bool? opaque,
    Transition? transition,
    Curve? curve,
    Duration? duration,
    int? id,
    String? routeName,
    bool fullscreenDialog = false,
    dynamic arguments,
    Bindings? binding,
    bool preventDuplicates = true,
    bool? popGesture,
    double Function(BuildContext context)? gestureWidth,
  }) {
    ・・・
    // idはデフォルトはnull
    return global(id).currentState?.push<T>(
        ・・・
    );
  }

Get.to内部ではglobal関数からGlobalKeyが返却されていて、どうやらGetパッケージでは独自にNavigatorStateを管理しBuildContextの依存なしでルーティングを実現しているようだった。 もう少し詳しく実装を見ていくと以下の通り。

extension GetNavigation on GetInterface {
    // 指定した引数に応じてたをGlobalKey<NavigatorState>返却する*
    GlobalKey<NavigatorState> global(int? k) {
      GlobalKey<NavigatorState> _key;
      if (k == null) {
        _key = key;
      } else {
        if (!keys.containsKey(k)) {
          throw 'Route id ($k) not found';
        }
        _key = keys[k]!;
      }

      ・・・

      return _key;
    }

    ・・・
    // このGlobalKey<NavigatorState>経由で画面遷移時にNavigatorStateにアクセスする
    GlobalKey<NavigatorState> get key => _getxController.key;
    static GetMaterialController _getxController = GetMaterialController();
}

このGlobalKeyはget_main.dartに定義された変数でアクセスでき、GetMaterialAppのbuild時にnavigatorKeyに対して指定がされていた。

// get_main.dart
class _GetImpl extends GetInterface {}
final Get = _GetImpl();
// get_material_app.dart
MaterialApp(
    ・・・
    navigatorKey:
         (navigatorKey == null ? Get.key : Get.addKey(navigatorKey!))
    ・・・
)

GetXではこのようにBuildContextの依存せず、画面遷移を実現しているようだった。 これに対して、標準のMaterialAppでは、widgetsAppのnavigatorKeyを利用しているようだった。

    return WidgetsApp(
    ・・・
      navigatorKey: widget.navigatorKey,
    ・・・
   )

簡単にまとめると以下のような違いとなりそう

  • GetXではGlobalKeyを直接参照して、NavigatorStateを管理/参照する
  • 標準実装ではBuildContextを経由し、ウイジェットツリーから、NavigatorStateを管理/参照する

まとめ

他の機能をあまり利用しておらず、紹介できなかったが、画面遷移がシンプルに書けるようになるのが良い感じなので、ルーティングだけに閉じて利用するのもありのように思う。

Flutter開発で利用しているfreezedの備忘録

普段はkotlinでのAndroid開発に慣れているため、kotlinに言語仕様を使いたくなることがあり、擬似的にenumやsealed class的な扱いができるのが気に入っている。
ライブラリ自体は、immutable なオブジェクト用のコード生成を主題としているので、そういうお悩みも解決できるのが素敵だ。

今回は、sealed class的な扱い方の備忘録も兼ねてブログに残しておく。

pub.dev

freezedでの記述方法

例えば、ひとつのListViewにViewTypeに応じて、ウィジェットを切りたい変えたくなった場合、freezedを使うと下記のように書くことができる。

クラス定義

@freezed
class HomePageListItem with _$HomePageListItem {
  const factory HomePageListItem.header(String header) = Header;
  const factory HomePageListItem.task(String header, String description) = Task;
}

利用時

final items = List<HomePageListItem>.generate(
  1000,
      (i) => i % 6 == 0
      ? HomePageListItem.header('Heading $i')
      : HomePageListItem.task('Sender $i', 'Message body $i'),
);

// whenで各クラスtypeに応じた分岐処理をかける
final item = items[index];
return item.when(
    header: (title) { return ListTile(title: Text(title), subtitle: const SizedBox.shrink()); },
    task: (title, description) { return ListTile(title: Text(title), subtitle: Text(description)); }
);

whenというfunctionの名前付き引数がViewの種別を表していて、どのViewTypeでどのウィジットが生成されるかがわかりやすくなると感じている。

どうやって実現されているか。

では、sealed classやwhen構文のないdartでどのように実現しているのだろうか。
結論を先に書くと、freezedはcode generatorの機能でコードを生成している。

コード生成のコマンド

flutter pub run build_runner build --delete-conflicting-outputs

このコマンドを実行すると、hoge.freezed.dartというファイルに、コードが生成される。 コードの中身をseald に関係するのみ抜粋する下記のようになる。

生成されるコード

// 各クラスに機能を提供するためのmixin
mixin _$HomePageListItem {
  String get header => throw _privateConstructorUsedError;

  @optionalTypeArgs
  TResult when<TResult extends Object?>({
    required TResult Function(String header) header,
    required TResult Function(String header, String description) task,
  }) =>
      throw _privateConstructorUsedError;
}

// _$Headerと_$Taskのクラスでwhenをoverrideして実装。
// 実行時はクラスタイプに応じたfunctionが呼び出される
class _$Header with DiagnosticableTreeMixin implements Header {
  const _$Header(this.header);

  @override
  @optionalTypeArgs
  TResult when<TResult extends Object?>({
    required TResult Function(String header) header,
    required TResult Function(String header, String description) task,
  }) {
    //  headrに指定したfunctionのみ実行
    return header(this.header);
  }
}

class _$Task with DiagnosticableTreeMixin implements Task {
  const _$Task(this.header, this.description);

@override
  @optionalTypeArgs
  TResult when<TResult extends Object?>({
    required TResult Function(String header) header,
    required TResult Function(String header, String description) task,
  }) {
    // taskに指定したfunctionのみ実行
    return task(this.header, description);
  }
}

つまりwhenのcall時にクラスタイプに応じて引数で渡したfunctionのいずれをcallするかを分岐して、sealed class的な whenを実現しているようだった。

// itemがHeaderの場合: header引数のfunction
// temがTaskの場合: task引数のfunction
final item = items[index];
return item.when(
    header: (title) { return ListTile(title: Text(title), subtitle: const SizedBox.shrink()); },
    task: (title, description) { return ListTile(title: Text(title), subtitle: Text(description)); }
);

所感

使ってみて以下のメリデメがあると感じている。

メリット

  • dartを使いながらimmutableなコードを扱える
  • sealed classやenumのように分岐をかける

デメリット

  • code generatorでコード生成する使用上、dartのバージョンアップに依存する

対象言語のバージョンに依存するのは、code generatorの辛みでもあるが、サービス開発で使う場合は、慎重検討をした方が良さそうに思った。dartが公式で同等の機能を実装してくれると良いのだけれど。