この記事は 株式会社ビットキー Advent Calendar 2023 4日目の記事です。
個人的に、今年1年は色々な宣言的UIフレームワークを触った年でした。 React/SwiftUI/Jetpack Compose/Flutter をそれぞれ触った中で、似ている概念やアイディアをまとめてみたいと思います。移植を行うときの参考にしていただければ幸いです。
非同期処理
PCやスマホ上で計算や条件分岐・ループといったCPUとメモリが動くペースに対して、ファイルを保存したり通信を行ったりするペースは遥かに遅く、様々な周辺機器に司令を出して動いているうちに他の処理を進めることができれば、より効率的に目的を達成することができます。
代表的な例として、 Node.js の fs モジュール があります。結果をコールバックで受け取る fs.open
(Callback API) と、同期的に返される fs.openSync
(Synchronous API) が存在します。
周辺機器の処理の完了を戻り値の代わりにコールバックで受け取るようにすることで、全体を効率的に動かすようにできるようにはなったのですが、コールバックのネストが大量に発生するようになります。可読性の悪さから、2010 年代前半あたりでは「コールバック地獄」と呼ばれていました。1
その後、このコールバック地獄問題は Promise
や async
/await
によって解決されます。また、JavaScript以外の多くの言語も、これらの課題に対処するアイディアが存在します。
- JavaScript
- Kotlin
- Swift
- Dart
非同期イテレータ
連続的に発生するイベントに対してListenerを登録して購読するパターンは、購読の中止まで含めて一つの概念として構築されるようになりました。また、文法に組み込まれて for
文で記述したりすることもあります。
- JavaScript
- Android, Kotlin
- Swift
- Dart
加えて、 RxJS をはじめとする ReactiveX や Swift Combine も関連するアイディアとして紹介しておくべきでしょう。 Subject や Kotlin Flowのhotとcoldの考え方は、連続的に発生するイベントを扱う上でのヒントになるはずです。
Cooperative Cancellation
直訳すると「協調的なキャンセル」で、これは非同期処理を途中で止まれるようにするときに出てくるアイディアです。
一連の非同期処理を含む処理の起動で、 Job
(Kotlin) や Task
(Swift) オブジェクトを作成します。このオブジェクトに対してキャンセルを要求すると、例外をスローしたりゼロ値や途中結果を返して終了するようになります。逆にキャンセルをサポートする側は、そのように実装しなければなりません。
- Kotlin
- Swift
JavaScriptやDartでは、非同期処理のキャンセルが文法や言語ではなくFetch APIなどの個々のAPIで定められています。
- JavaScript
- Dart
コンポーネントと非同期処理をつなぐ
非同期処理をUIと組み合わせる実装で気をつけるべきポイントの一つに、登録したまま終わらなかったり中止せずに溜まってしまうリークの懸念があります。購読と中止はなるべく自分で直接扱わずに、フレームワークやライブラリに任せてしまいたいところです。
スコープを作成する
コンポーネントを表示している期間で非同期処理のスコープ (CoroutineScope, Task) を構成し、非表示になったらキャンセルするという機能をUIライブラリとして標準で持っているパターンがあります。
-
Kotlin, Jetpack Compose
LaunchedEffect
rememberCoroutineScope
- Compose における副作用 - Jetpack
- SwiftUI
Reactの場合は、例えば AbortController
と useEffect
の組み合わせで実現できます。
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetch(url, { signal })
.then((response) => {
console.log("Complete", response);
})
.catch((err) => {
console.error(`Error: ${err.message}`);
});
return () => controller.abort();
}, []);
同様の組み合わせはDartなら State.dispose
とDioの CancelToken
などで実現することになるでしょう。
スナップショット化する
主にデータ取得処理で見られるパターンとして、表示する瞬間の状態をスナップショットとして取得できるようにする方法もあります。
- JavaScript, React
- Dart, Flutter
連続的なイベントの発生を表す非同期イテレータも、スナップショット化の方法が用意されていることがあります。
- Kotlin
- Dart, Flutter
View Modelの見直し
宣言的UIとView Modelという組み合わせは、個人的には警戒すべきという意味でややネガティブに考えています。PageやScreenといった大きなViewの状態をView Modelで管理してしまうと、コンポーネントの再利用性を著しく損なわれてしまいます。Viewが再利用性の低いView Modelに依存すると、そのコンポーネントは別のPageやScreenで再利用することが難しくなります。とはいえ、AndroidのXMLレイアウトやiOSのUIKit時代の資産や考え方の流用を考えると、今すぐ全部撤廃するのもそれはそれで難しいという感覚でいます。
Dependency Injection
状態管理を変化させる部分にRepositoryなどの依存するオブジェクトをどのように差し込むのか?という問題は、宣言的UIになっても依然として存在します。
Riverpodは、宣言的UIのいくつかの要素をまとめてカバーできる点でユニークな解決策を持ったライブラリだと感じています。元々が改善されたProviderというコンセプトなので、Provider/Contextの代替として利用できます。また、 ref.read
/ref.watch
で他の Provider
を注入できることから、Dependency Injectionとしての側面も持ち合わせています。 FutureProvider
や StreamProvider
を各コンポーネントで watch
して Snapshot を得るという動作は、 useSWR
や useReactQuery
と似ている部分だと思います。
Context/Provider
どのフレームワークにも引数のバケツリレーを避けて子孫全体に値を受け渡す方法があります。見た目を整えるThemeや言語を切り替えるLocalizationオブジェクトを配る際に登場します。
- JavaScript, React
- Kotlin, Jetpack Compose
- Swift, SwiftUI
- Dart, Flutter
Actions/Intent
Flutterの Actions API は、イベントハンドラによる子孫から祖先方向のバケツリレーをContextのような受け渡し方法で解決してしまうものです。祖先から子孫に情報を配るのとは逆に、子孫側で発生させたIntentを祖先側でハンドルすることができます。
乱用は危険そうですが、使い方によっては途中のグルーコードを省略できるため、うまく機能する状況もあるのではないかと考えています。
最後に
宣言的UIフレームワークで開発する際に出てくる要素をいくつかまとめてみました。「Reactの〇〇ってSwiftUIだと何て言うの?」みたいなときに参考にしていただければと思います。
5日目の 株式会社ビットキー Advent Calendar 2023 は @uminoooon18 が担当します!