UIKit の上に宣言的 UI とホットリロードをもたらす実験的ライブラリ。
Rust の Dioxus と同じ設計方針 — 「UI を記述(データ)として扱い、ランタイムが差分適用する」 — を UIKit に適用したものです。UI 記述と UIView を分離しているため、状態変化・コード注入のどちらでも「記述を作り直して差分適用する」だけで画面が更新されます。
FineContent に適合した @Observable なクラスを書きます。そのオブジェクトが状態を持ち、状態から自分のビューツリーを記述します。
import FineUIKit
import Observation
@Observable
final class ToDoList: FineContent {
var draft: String = ""
var items: [ToDo] = []
func add() {
items.append(.init(title: draft))
draft = ""
}
func body() -> any Renderable {
FineStack.vertical(spacing: 8) {
FineLabel(text: "\(self.items.count) items")
.font(.preferredFont(forTextStyle: .headline))
.padding(.init(top: 8, leading: 16, bottom: 0, trailing: 16))
FineStack.horizontal(spacing: 8) {
FineTextField(text: .init(self, \.draft), placeholder: "New task")
FineButton(title: "Add") { self.add() }
.hugging(.defaultHigh, axis: .horizontal)
}
.padding(.init(top: 8, leading: 16, bottom: 0, trailing: 16))
FineList(self.items) { item in
FineLabel(text: item.title)
}
.onDelete { item in self.items.removeAll { $0.id == item.id } }
}
}
}
// 画面として使う
navigationController.pushViewController(FineContentController(ToDoList()), animated: true)body 内で読んだ @Observable プロパティが変化すると自動で再レンダリングされます。ビューは作り直されず、互換なビューは in-place 更新されます。ハンドラが self(content)をキャプチャして構いません — 循環しないからです(メモリ管理)。
続きは はじめかた へ。
FineLabel / FineButton / FineImage / FineStack / FineScrollView / FineSpacer / FineDivider、コレクション系の FineList / FineGrid、入力系の FineTextField / FineTextView / FineToggle / FineSlider / FineStepper / FineSegmentedControl / FineDatePicker、表示系の FinePageControl / FineProgressView / FineActivityIndicator があります。
対応する UIKit クラスと使えるモディファイアの一覧は コンポーネント にまとめてあります。組み込みにないビューは FineViewRepresentable で任意の UIView をラップできます。body が長くなったら Renderable に適合した struct へ切り出せます — ホットリロードは効いたままです。
| ドキュメント | 内容 |
|---|---|
| はじめかた | FineContent の書き方、FineContentController でのマウント、ナビゲーション |
| コンポーネント | 組み込みコンポーネント一覧、Renderable での記述の分割、FineViewRepresentable、キーボード |
| 状態とバインディング | FineBinding、フォーカス、FineState、Environment |
| モディファイアとレイアウト | 外観・レイアウト・インタラクションのモディファイア、Auto Layout ネイティブな制約 API |
| レンダリングの挙動 | keyed diff、Dynamic Type と trait、ライフサイクル、画面が隠れている間の停止、アニメーション |
| メモリ管理 | キャプチャの安全性、守るべきルール、状態の置き場所、入れ子 |
| 診断 | ビューが作り直された理由のログ、レンダリング回数、ハイライト、ツリーダンプ、signpost |
| ホットリロード | コード注入のセットアップ、-Xlinker -interposable、既知の問題 |
| 内部アーキテクチャ | 記述層 / FineNode / UIView の三層、差分適用、observation の粒度 |
| 公開 API の設計判断 | この API 形状に至った判断とその根拠 |
Renderable— UI 記述の公開プロトコル。アプリ側はbodyで組み込みコンポーネントを合成する。長い記述を名前の付いた部品へ切り出す単位でもあり、struct で書ける(ホットリロードは効く。通った型はビューの identity に入る)- 内部プリミティブ — 組み込みコンポーネントが持つ
_makeView()/_canUpdate(_:)/_update(_:context:)契約。署名や全プロパティ書き戻しの規則は公開 API ではない FineRenderer— 差分適用層。bodyを内部プリミティブへ解決し、「ビュー型互換 + モディファイア署名一致 + key 一致」のときだけ in-place 更新、それ以外は作り直すFineNode— 各ビューに紐づく永続「要素」(Flutter の Element 相当)。モディファイア署名・key・ノード局所の観測状態(scheduler の generation / context)に加え、FineStateのローカル状態を所有する。ビューと同寿命なので、状態は再レンダリングをまたいで保持されるFineUI(internal) —withObservationTrackingで差分適用を駆動するランタイム。body()は構造、コンテナの builder はそのノード、FineLabel.textはラベルノード単位で再評価される。画面が隠れている間はsuspend()で観測起因のレンダリングを止め、resume()で1回だけ catch-up する。マウントはFineContentControllerが行うので公開していないFineContent— 状態を持ちbody()でビューツリーを記述するオブジェクト。@Observableなクラスとして書く。画面とは限らず、任意のビューにマウントできるFineNavigating—FineContentにnavigation()を足したもの。画面として使うときだけ適合するFineContentController— 画面をマウントする view controller。body()とnavigation()を別の observation スコープで追跡し、表示状態に応じてFineUIを suspend / resume する。手動で止めたいときの公開 API はsuspendRendering()/resumeRendering()。openなので継承してよい
内部構造は 内部アーキテクチャ、この API 形状に至った判断とその根拠は 公開 API の設計判断 にまとめてあります。
- iOS 17+(Observation フレームワーク前提)
- Swift 6 / ホットリロードはシミュレータ + DEBUG ビルド限定
xcodebuild -scheme FineUIKit -destination 'platform=iOS Simulator,name=iPhone 17' test性能比較テストだけを実行する場合:
xcodebuild -scheme FineUIKit -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:FineUIKitTests/RenderingPerformanceTests test性能値の絶対値は実機 + Release 構成でないと意味を持ちにくく、シミュレータ結果は傾向把握用です。