画面と状態の分け方
画面と処理が混ざると、直すたびに壊れる
Section titled “画面と処理が混ざると、直すたびに壊れる”ファイルが長くなったら、画面の組み立てと、状態や処理を分けます。変更する場所を追いやすくなります。分けないままだと、画面を直したつもりが通信の処理を壊す、といったことが起きます。
たとえば、次のような 1 つの State があるとします。
public class PanelState : State<PanelWidget>{ private int _count; private readonly HttpClient _http = new(); private Timer? _timer;
public override void InitState() { _timer = new Timer(async _ => { var json = await _http.GetStringAsync("http://localhost:8080/count"); File.WriteAllText("count.json", json); SetState(() => _count = int.Parse(json)); }, null, 0, 5000); }
// …ここに、100 行の画面の組み立て…}この例は責務の混在を示す反例で、通信やタイマーの実装例ではありません。通信、保存、タイマー、画面が、1 つのクラスに入っています。AI に「ボタンの色を変えて」と頼んだとき、AI は通信の行も同じ場所で読むことになり、関係のない箇所を巻き込んで書き換える余地が生まれます。画面を確かめるにも、通信やタイマーが動く状態で確かめることになり、表示する値を固定して画面だけを確かめにくくなります。
FloatSoda は UI のすべてを C# のコードで書くので、分けたコードは diff で読めて、AI が直しやすくなります。分け方の考え方を、次の節から順に説明します。
画面は「値を受け取って画面を返す」部分に絞る
Section titled “画面は「値を受け取って画面を返す」部分に絞る”画面のウィジェットは、受け取った値をどう描くかだけを持つ部品にします。この例のウィジェットは、init プロパティで値を受け取る record で、値を受け取って画面を返す設計図です。持つものは、表示する値と、押されたときに呼ぶ処理(Action)だけです。
public record CounterView : StatelessWidget{ public required int Count { get; init; } public required Action OnIncrement { get; init; }
public override Widget Build(IBuildContext context) => new GestureDetector { Behaviour = HitTestBehaviour.Opaque, OnTap = OnIncrement, Child = new Text($"カウント: {Count}"), };}この CounterView は、通信も保存も知りません。何を表示し、押されたら何を呼ぶかを、外から渡されます。画面に置くものを決めるときは、「この行は、渡された値から画面を作るために要るか」を問います。要らない行は、次の節で外に出します。
状態と処理は、画面の外に置く
Section titled “状態と処理は、画面の外に置く”状態と処理は、画面とは別のクラスに置きます。外に出すものの例は、通信、保存、タイマーです。
public sealed class CounterModel : INotifyPropertyChanged{ public event PropertyChangedEventHandler? PropertyChanged;
public int Count { get; private set; }
public void Increment() { Count++; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); }}CounterModel は、ウィジェットを 1 つも知りません。値が変わったら PropertyChanged で知らせるだけです。通信や保存を足すときも、このクラスの中に足します。画面へ渡す値と Action が変わらなければ、画面のファイルは変わりません。
画面の外の状態を、画面から取り出す
Section titled “画面の外の状態を、画面から取り出す”外に置いた状態を画面から取り出す手段は、2 つあります。違いは、取り出すものの性質です。
| 手段 | 向いているもの | 値が変わったときの動き |
|---|---|---|
ServiceProvider |
アプリの間ずっと使うオブジェクト(通信、保存、状態を持つクラス) | 取り出しただけでは画面は変わらない。ListenableBuilder などで変更通知を受ける |
InheritedWidget |
画面の上から下へ渡す値(設定、見た目の指定など) | 値が変わると、その値に依存する子孫が作り直される |
ServiceProvider は、画面のルートを包み、IServiceProvider を子孫へ渡します。子孫は context.GetRequiredService<T>() で取り出します。取り出した状態の変更は、ListenableBuilder で受け取ります。ListenableBuilder は、INotifyPropertyChanged の通知のたびに、自分自身を作り直し、ChildBuilder で新しいウィジェットを作ります。ChildBuilder が返したウィジェットから下は、通常の作り直しと同じ規則で更新されます。
app.CreateWindow(new DashboardWindow{ Title = "Counter", Child = new FloatSoda.Widgets.ServiceProvider { Services = host.Services, Child = new CounterScreen(), },});
public record CounterScreen : StatelessWidget{ public override Widget Build(IBuildContext context) { var model = context.GetRequiredService<CounterModel>();
return new ListenableBuilder { Listenable = model, ChildBuilder = _ => new CounterView { Count = model.Count, OnIncrement = model.Increment, }, }; }}CounterModel は、起動のコードで builder.Services.AddSingleton<CounterModel>() として登録します。登録の手順は Generic Host の仕組みを使う にあります。
ListenableBuilder は、変更通知を、ウィジェットがマウントされたスレッド(通常は FloatSoda のメインループ)から出すことを前提にしています。別のスレッドから PropertyChanged を起こすと、InvalidOperationException になります。バックグラウンドのスレッドで状態を更新するときは、メインループに処理を戻してから PropertyChanged を起こします。
InheritedWidget は、自作の record で作ります。子孫は context.DependOnInheritedWidgetOfExactType<T>() で取り出し、その値に依存します。InheritedWidget の中の値を書き換えても、通知は起きません。親が作り直されて、新しい InheritedWidget が渡され、UpdateShouldNotify が true を返したときだけ、依存した子孫が作り直されます。
分けると、HMD を被らずに確かめられる
Section titled “分けると、HMD を被らずに確かめられる”画面が「値を受け取って画面を返す」部品に絞られていると、状態を作らなくても、決めた値で画面を作れます。たとえば new CounterView { Count = 42, OnIncrement = () => { } } は、通信が動いていなくても組み立てられます。
組み立てた画面は、WidgetBitmapRenderer で画像にして確かめられます。HMD を被る必要はありません。手順は HMD を被らずに確かめる にあります。値を変えた画像を何枚か書き出しておくと、AI に画面を直してもらったあとの見た目の変化を、画像で比べられます。
分けすぎない
Section titled “分けすぎない”ツールが小さいうちは、分けなくてかまいません。次の 3 つがすべて当てはまるなら、1 つの State に置いたままで足ります。
- 通信、保存、タイマーのどれも使わない
- 画面のファイルが 1 つで、読み返して全体を追える
- 同じ値を、複数の画面で使わない
1 つでも外れたら、状態を外に出す時期です。
分ける対象は、ツール全体の構造です。1 つの部品の中の状態、つまり SetState() の仕組みは 状態と作り直し にあります。
FloatSoda は、状態管理の特定のライブラリを強制しません。上の例の INotifyPropertyChanged のほかに、自作のクラスや、使い慣れたライブラリを使えます。FloatSoda.Hooks は、今は使えません(追跡: #72)。