Slide 1

Slide 1 text

三者三様 宣言的UI React / Compose / Flutter を見比べて Keita Kagurazaka @ ANDPAD Inc.

Slide 2

Slide 2 text

今日のテーマ 状態管理と再レンダリング 3つの宣言的UIフレームワークの共通点や相違点を概説 React (Web): 2013年初版公開 Jetpack Compose (Android): 2019年preview版公開 Flutter (iOS/Android): 2017年alpha版公開 2

Slide 3

Slide 3 text

アプリの状態管理

Slide 4

Slide 4 text

アプリの2つの状態について アプリの状態とは UIを(再)構築する際に必要となる、あらゆるデータ 宣言的UIは アプリの状態 を引数に UI を出力する関数とみなせる 大きく分けて2種類ある Ephemeral State Ephemeralは 一時的な という意味 1つのUI要素に閉じた状態 他のUIツリーからは依存されず、独立している App State アプリ内の様々な箇所から参照される状態 画面単位だったり、アプリが立ち上がってる間ずっと保持する状態 https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app 4

Slide 5

Slide 5 text

Ephemeral Stateの扱い方

Slide 6

Slide 6 text

Ephemeral Stateの扱い方 各フレームワークにおける代表的な方法 React: useState など Compose: remember x mutableStateOf Flutter: StatefulWidget 6

Slide 7

Slide 7 text

Reactの場合 - Hooks React Hooksでシンプルな状態をget/setするパターン function Counter() { const [count, setCount] = useState(0); return ( setCount(count + 1)}> Count: {count} ); } 7

Slide 8

Slide 8 text

Composeの例 (1) - remember recompositionを超えて MutableState を維持させるため remember を利用 Kotlinのdelegation記法 by を使って、mutableな変数を書き換える形で書ける @Composable fun Counter() { var count by remember { mutableStateOf(0) } Button(onClick = { count++ }) { Text("Count: $count") } } 8

Slide 9

Slide 9 text

Composeの例 (2) - remember Kotlinの分解宣言を用いて書くこともできる こうするとReactのuseStateとそっくりなことがわかる setterを関数としてそのまま引数に渡すみたいなケースで便利 @Composable fun Counter() { val (count, setCount) = remember { mutableStateOf(0) } Button(onClick = { setCount(count + 1) }) { Text("Count: $count") } } 9

Slide 10

Slide 10 text

Flutterの例 (1) - StatefulWidget class Counter extends StatefulWidget { @override _CounterState createState() => _CounterState(); } class _CounterState extends State { int count = 0; @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () => setState(() { count++; }), child: Text('Count: $count'), ); } } 10

Slide 11

Slide 11 text

Flutterの例 (2) - flutter_hooks React Hooksライクな書き方ができる外部パッケージ 中身のコードは本質的にStatufulWidgetと等価なので、個人的にオススメ class Counter extends HookWidget { @override Widget build(BuildContext context) { final count = useState(0); return ElevatedButton( onPressed: () => count.value++, child: Text('Count: ${count.value}'), ); } } 11

Slide 12

Slide 12 text

Ephemeral Stateの扱い方 - まとめ いずれもReact Hooksスタイルで書ける React: useState など Compose: remember x mutableStateOf Flutter: useState など by 3rd-party lib 12

Slide 13

Slide 13 text

暗黙的な状態伝達

Slide 14

Slide 14 text

暗黙的な状態伝達 UIツリーの部分木で状態を共有する仕組み React: Context API Compose: CompositionLocal Flutter: InheritedWidget 14

Slide 15

Slide 15 text

Reactの例 - Context API Providerで提供した値を、任意のComponent内で useContext を使って取得 Providerをネストすると直近の祖先の値を優先して使う const ThemeContext = React.createContext('light'); function App() { return ( ); } function Screen() { const theme = useContext(ThemeContext); return
Theme: {theme}
; } 15

Slide 16

Slide 16 text

Composeの例 - CompositionLocal Providerで提供した値を、任意のComposable内で .current を使って取得 Providerをネストすると直近の祖先の値を優先して使う val LocalTheme = compositionLocalOf { LightTheme } @Composable fun App() { CompositionLocalProvider(LocalTheme provides DarkTheme) { Screen() } } @Composable fun Screen() { val theme = LocalTheme.current Text("Theme: $theme") } 16

Slide 17

Slide 17 text

Flutterの例 (1) - InheritedWidget class ThemeData extends InheritedWidget { final Color primaryColor; // ThemeDataは、この状態を保持するUIなしWidget final Widget child; const ThemeData({ required this.primaryColor, required this.child, }) : super(child: child); @override bool updateShouldNotify(ThemeData old) => primaryColor != old.primaryColor; static ThemeData of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType()!; } } 17

Slide 18

Slide 18 text

Flutterの例 (2) - InheritedWidget class App extends StatelessWidget { @override Widget build(BuildContext context) { return ThemeData( // 提供したい値を引数にInheritedWidgetでwrap primaryColor: Colors.blue, child: Screen(), ); } } class Screen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ThemeData.of(context); // .ofで提供された値を取得 return Text('Theme: ${theme.primaryColor}'); } } 18

Slide 19

Slide 19 text

暗黙的な状態伝達 - まとめ 書き方にはそれぞれ個性があるが、同じ機能は提供されている ThemeのようにUIツリーのありとあらゆる箇所から参照しつつ、動的に変わりうる ものだけに使うのが一般的 非常にシンプルなアプリの場合、App State管理としても使える UIツリー全体をroot nodeを頂点とする部分木としてみる 19

Slide 20

Slide 20 text

App Stateの扱い方

Slide 21

Slide 21 text

App Stateの扱い方 アプリ全体で共有する状態管理 React: Zustand など Compose: ViewModel + StateFlow Flutter: Riverpod など 21

Slide 22

Slide 22 text

Reactの例 - Zustand selectorで状態を部分watchできる import create from 'zustand'; const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) })); function Counter() { const increment = useStore((state) => state.increment); return ; } function CountDisplay() { const count = useStore((state) => state.count); // 状態を部分的にwatch return Count: {count}; } 22

Slide 23

Slide 23 text

Composeの例 (1) - ViewModel + StateFlow 状態とその操作を定義 data class CounterUiState(val count: Int = 0) class CounterViewModel : ViewModel() { private val _uiState = MutableStateFlow(CounterUiState()) val uiState: StateFlow = _uiState.asStateFlow() fun increment() { _uiState.update { it.copy(count = it.count + 1) } } } 23

Slide 24

Slide 24 text

Composeの例 (2) - ViewModel + StateFlow Jetpack Composeにはselectorはなし @Composable fun Counter(viewModel: CounterViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsState() Button(onClick = { viewModel.increment() }) { CountDisplay(count = uiState.count) } } @Composable fun CountDisplay(count: Int) { Text("Count: $count") } 24

Slide 25

Slide 25 text

Flutterの例 (1) - Riverpod 状態とその操作を定義 class CounterState { final int count; CounterState(this.count); } final counterProvider = StateNotifierProvider( (ref) => CounterNotifier() ); class CounterNotifier extends StateNotifier { CounterNotifier() : super(CounterState(0)); void increment() => state = CounterState(state.count + 1); } 25

Slide 26

Slide 26 text

Flutterの例 (2) - Riverpod selectorで状態を部分watchできる class Counter extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).increment(), child: CountDisplay(), ); } } class CountDisplay extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider.select((state) => state.count)); return Text('Count: $count'); } } 26

Slide 27

Slide 27 text

App Stateの扱い方 - まとめ 採用するライブラリによって千差万別 実現したいこと 状態の定義や操作をUIとは別のクラス・コンポーネントに切り出す パフォーマンスを落とさない 暗黙的な状態伝達も同じライブラリで実現可能なことも ZustandやRiverpodはUIツリーの特定の部分木で状態を上書きできる 大は小を兼ねる そもそも全ての状態をApp Stateとして管理することは理屈としては可能 適宜使い分けると、より保守しやすいコードに 27

Slide 28

Slide 28 text

再レンダリングの思想

Slide 29

Slide 29 text

Reactの場合 再レンダリングは制御しよう派 コンポーネント設計を工夫する 状態をなるべくツリー末端に移動 propsでchildren / componentを受け取る useMemo , useCallback , React.memo などのメモ化を駆使する デフォルトでは、あるコンポーネントを再レンダリングすると、その子孫すべて が再レンダリングされる つい先日、React Compiler v1.0がリリースされたので、今後は不要になりそう 状態管理ライブラリにもパフォーマンスを維持する機能が搭載されがち selectorによる部分watchなど 29

Slide 30

Slide 30 text

Composeの場合 State Hoistingすればコンパイラがなんとかする派 Composableはなるべくstatelessにし、必要なimmutable stateを引数として受け取る いわゆるバケツリレー推奨の設計 原則として Composableが引数を比較して必要な部分だけ再composition Stable うんぬん話は今回は省略 strong skippingでラムダも自動でメモ化 後発なUIフレームワークなだけあって開発者フレンドリー https://developer.android.com/develop/ui/compose/performance/stability/strongskipping 30

Slide 31

Slide 31 text

Flutterの場合 再buildが軽量ならば何も考えなくていいよね派 Reactと同様、あるWidgetの再buildは子孫のWidget全てを再buildする const で生成されたWidgetを除く Flutterの開発陣いわく、再buildは十分に高速なので、むやみに避ける必要はない Widgetツリーの再構築とRenderObjectの差分更新が分離しているため 現実問題どうなのかは周りの人に聞いてみよう RiverpodやInheritedWidgetを活用しているチームが多いと思われる 31

Slide 32

Slide 32 text

まとめ

Slide 33

Slide 33 text

まとめ 宣言的UIの本質は共通 パラダイムが実現したいことは一緒なので、目的ベースで機能を理解しよう 書き方の差はAIが埋めてくれる時代に感謝 それぞれに独自の思想がある 「なぜこういう書き方になるのか?」を掘っていくと理解が深まる これらを踏まえたうえで、1つ理解すれば他も学びやすい 33

Slide 34

Slide 34 text

ありがとうございました