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)ハンドラが self をキャプチャして構いません。マウントしたコントローラが content とビューツリーの両方を所有し、content はどちらも所有しないので、循環しないからです(メモリ管理を参照)。
マウントは FineContentController を通します。表示状態に応じた suspend / resume と navigationItem の更新を繋ぐのがこのクラスの仕事です。
既に自前のコントローラがある場合は、子コントローラとして足してください。addChild(_:) は親子関係を結ぶだけでビューは足さないので、UIKit の手順どおり 4 段階が要ります。
let child = FineContentController(content)
addChild(child)
containerView.addSubview(child.view)
child.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
child.view.topAnchor.constraint(equalTo: containerView.topAnchor),
child.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
child.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
child.didMove(toParent: self)この形なら appearance の転送も効くので、画面が隠れている間のレンダリング停止もそのまま働きます。
navigation() を実装したいときだけ FineContent ではなく FineNavigating に適合します。navigationItem は画面レベルの関心事なので、区画として使う content には生えません。
body 内で読んだ @Observable プロパティが変化すると自動で再レンダリングされます。ビューは作り直されず、互換なビューは in-place 更新されます。
FineList(sections: [
FineListSection(id: "active", header: "Active", items: activeItems),
FineListSection(id: "done", header: "Completed", items: completedItems),
]) { item in
FineLabel(text: item.title)
}
.onRefresh { await viewModel.reload() }| コンポーネント | ベース | 特記事項 |
|---|---|---|
FineLabel |
UILabel |
型付きモディファイア: .font / .textColor / .textAlignment / .numberOfLines |
FineButton |
UIButton |
action クロージャ。.image / .configuration(UIButton.Configuration) / .enabled |
FineImage |
UIImageView |
|
FineStack |
UIStackView |
vertical / horizontal、spacing / alignment / distribution。子は keyed + 位置ベースで差分適用 |
FineList |
UITableView |
diffable data source(Identifiable)。セクション / ヘッダー・フッター / .onRefresh / .reconfiguringOnlyChangedRows() / .onSelect / .onDelete / .keyboardDismissMode。行の高さは観測起因の変化に自動追従 |
FineGrid |
UICollectionView |
compositional layout。columns: .count(n) / .adaptive(minimum:)、セクション / ヘッダー・フッター / .onRefresh / .reconfiguringOnlyChangedItems() / .onSelect / .keyboardDismissMode |
FineTextField |
UITextField |
FineBinding<String> で双方向。.keyboardType / .returnKeyType / .secureTextEntry / .onSubmit / .enabled / .focused |
FineTextView |
UITextView |
複数行入力。FineBinding<String> + placeholder(UIKit にないので独自描画)。既定でスクロール無効=内容に合わせて伸びる。.font / .textColor / .textAlignment / .editable / .scrollEnabled / .keyboardType / .focused |
FineToggle |
UISwitch |
FineBinding<Bool>。.enabled |
FineSlider |
UISlider |
FineBinding<Float> + in: レンジ。.enabled |
FineStepper |
UIStepper |
FineBinding<Double> + in: レンジ / step:。.enabled |
FineSegmentedControl |
UISegmentedControl |
FineBinding<Int> で選択。タイトル / 画像セグメント。セグメントの増減・差し替えは in-place 差分適用。.enabled |
FineDatePicker |
UIDatePicker |
FineBinding<Date> + in: レンジ。.datePickerMode / .preferredDatePickerStyle / .minuteInterval / .enabled。.countDownTimer は非対応(Date では duration を表現できないため。debug ビルドでは assert) |
FinePageControl |
UIPageControl |
FineBinding<Int> + numberOfPages(範囲外はクランプ)。.hidesForSinglePage / .pageIndicatorTintColor / .currentPageIndicatorTintColor |
FineProgressView |
UIProgressView |
value: / total:(0...1 にクランプ)。.progressViewStyle / .progressTintColor / .trackTintColor |
FineActivityIndicator |
UIActivityIndicatorView |
isAnimating: で開始・停止。.style / .color / .hidesWhenStopped |
FineSpacer |
— | スタック内の余白吸収(minLength:) |
FineDivider |
— | 区切り線。既定は1物理ピクセルのヘアライン(display scale 追従)。FineDivider() = 横線 / FineDivider.vertical() = 縦線、.thickness / .color |
FineScrollView |
UIScrollView |
縦横対応。.keyboardDismissMode。FineList / FineGrid は自身がスクロールするので入れないこと |
組み込みにないビューは FineViewRepresentable で任意の UIView をラップできます(後述)。
FineProgressView(value:) / FineActivityIndicator(isAnimating:) は表示値を @autoclosure で受け取ります(FineLabel(text:) と同じ)。読み取りはそのノードの _update 内で起きるため、進捗やローディングの変化ではそのビューだけが更新され、body は再評価されません。total: は素の値なので、これが変わったときは body から再評価されます。
FineStack.vertical(spacing: 12) {
FineProgressView(value: viewModel.downloaded, total: viewModel.totalBytes)
FineDivider()
FineStack.horizontal(spacing: 8) {
FineActivityIndicator(isAnimating: viewModel.isLoading)
FineLabel(text: viewModel.status)
}
}FineNavigating に適合して navigation() を実装すると、body() と同じ observation / hot reload の流れで navigationItem を宣言できます。nil を返せば navigationItem には触れないので、手動管理もそのまま使えます。
ナビゲーションは body() とは別の observation スコープで追跡されます。navigation() だけが読んだ値(タイトル、ボタンの .enabled など)が変わったときは navigationItem だけが更新され、ツリーの再評価・再差分は起きません。下の例で draft が1文字変わるたびに全画面が再差分されることはありません。
func navigation() -> FineNavigation? {
FineNavigation(title: "ToDo (\(items.count))")
.trailing(
FineBarButton(systemItem: .add) { self.add() }
.enabled(!draft.isEmpty)
)
}これは遷移ではなく chrome の記述です。画面遷移そのものは FineUIKit の対象外で、content は「何が起きたか」を外へ伝えるだけにします。
protocol ToDoListDelegate: AnyObject {
func toDoList(_ list: ToDoList, didSelect item: ToDo)
}
@Observable
final class ToDoList: FineContent {
@ObservationIgnored weak var delegate: (any ToDoListDelegate)?
func body() -> any Renderable {
FineList(self.items) { ... }
.onSelect { self.delegate?.toDoList(self, didSelect: $0) }
}
}遷移を行うのは content を組み立てた側です。Example/Counter の SettingsForm と CounterTabs.Coordinator がこの形の実例です。
FineTextField(text: .init(viewModel, \.draft)) // ReferenceWritableKeyPath から生成
FineToggle(isOn: .init(item, \.completed))
FineSlider(value: .init(settings, \.volume), in: 0...10)
FineStepper(value: .init(settings, \.servings), in: 1...20, step: 1)
FineSegmentedControl(titles: ["All", "Active", "Done"], selection: .init(viewModel, \.filter))
FineDatePicker(selection: .init(item, \.dueDate), in: .now ... .distantFuture)
.datePickerMode(.date)
FinePageControl(numberOfPages: viewModel.pages.count, currentPage: .init(viewModel, \.page))
FineTextField(text: .init(viewModel, \.draft), placeholder: "New task")
.returnKeyType(.done)
.onSubmit { viewModel.add() }
FineTextView(text: .init(item, \.memo), placeholder: "Memo") // 複数行。内容に合わせて伸びるFineBinding は get / set のペアです。get はレンダリング中(observation スコープ内)に評価されるため、バインド先の変更で自動的に再レンダリングされます。UI 側の変更は set を通じて状態へ書き戻され、「現在値と異なるときだけビューに書く」ガードにより入力中のカーソルは保持されます。
クランプ・丸めの書き戻し: FineSlider / FineStepper / FineDatePicker / FinePageControl は、UIKit 側で値がクランプ(レンジ外)・丸め(minuteInterval など)されたとき、適用後の値をバインディングへ書き戻します。状態は常に「画面に出ている値」と一致するため、範囲外の値が状態にだけ残り続けることはありません(例: pages が減ったあとの page が末尾を超えたままにならない)。書き戻しは1回で収束し、以降の再レンダリングは発生しません。
FineSegmentedControl だけは例外で、selection がセグメント範囲外のときは「未選択」を表示するだけで状態は書き換えません(セグメントを後から流し込む間、選択の意図を消さないため)。
FineTextField / FineTextView の .focused(_:) に FineBinding<Bool> を渡すと、first responder を状態から駆動できます。true を書くとフォーカス(キーボード表示)、false を書くと解除。ユーザー操作によるフォーカスの出入りもバインディングへ書き戻されます。
@Observable
final class FormModel {
var name = ""
var isNameFocused = false
}
FineTextField(text: .init(model, \.name), placeholder: "Name")
.focused(.init(model, \.isNameFocused))
FineButton(title: "Edit") { model.isNameFocused = true }ビューが window に載る前の描画では、載った直後にフォーカスが適用されます。
外部の @Observable に持たせるまでもない一過性の UI 状態(開閉トグル、ローカルな下書きなど)は FineState でコンポーネント内に閉じ込められます。SwiftUI の @State / React の useState に相当します。
FineState(false) { isExpanded in
FineStack.vertical(spacing: 8) {
FineButton(title: isExpanded.value ? "Collapse" : "Expand") {
isExpanded.value.toggle()
}
if isExpanded.value {
FineLabel(text: "Details")
}
}
}状態は FineBinding として渡されます。get は読んだノードの observation スコープで追跡されるため、value を書き換えるとそのノードだけが再レンダリングされ、body 全体は再評価されません。
状態はツリー(ビューを所有する FineNode 要素)に生き、親の再レンダリングをまたいで保持されます。.key(_:) / FineForEach で安定した identity を与えれば、並び替え・挿入・削除をまたいでも同じ論理項目の状態が追従します。ビューが作り直される(ビュー型・モディファイア署名・key のいずれかが変わる)ときは初期値から作り直されます。
テーマ・ロケール・依存オブジェクトのようなアンビエントな値は、body の引数で配り歩かずに .environment(_:_:) でサブツリーへ暗黙に伝播できます。SwiftUI の @Environment / React Context に相当します。
まず値のキーを定義し、FineEnvironmentValues に読み書き用のプロパティを生やします。
private struct ThemeKey: FineEnvironmentKey {
static let defaultValue = Theme.light
}
extension FineEnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}.environment(\.theme, value) で注入し、FineEnvironmentReader で読みます。
FineEnvironmentReader { environment in
FineLabel(text: environment.theme.title)
}
.environment(\.theme, currentTheme).environment は透過ラッパーでビューを増やさず、内側の記述の描画コンテキストへ値を差し込むだけです。ネストすると内側の注入が優先されます。注入元が @Observable プロパティなら、値の変化で FineEnvironmentReader が再レンダリングされます。
FineLabel は Dynamic Type に追従します。.font(.preferredFont(forTextStyle:)) を渡しておけば、文字サイズ設定の変更でラベルが拡大縮小します。
さらにランタイムは、記述が分岐しうる trait の変化でツリーを再評価します。UIFont.preferredFont(forTextStyle:) は「記述を作った時点」のカテゴリで解決されるため、再評価しないと古いサイズの記述が残るからです。観測している trait は次の7つです。
preferredContentSizeCategory / userInterfaceStyle / horizontalSizeClass / verticalSizeClass / layoutDirection / accessibilityContrast / legibilityWeight
trait は environment から読めるので、記述の中で分岐できます。
FineEnvironmentReader { environment in
environment.traitCollection.horizontalSizeClass == .compact
? FineStack.vertical { … }
: FineStack.horizontal { … }
}traitCollection は environment 値なので、リスト / グリッドの可視セルにも既存の伝播経路でそのまま届きます(要素が変化していない行も更新されます)。画面が隠れている間の trait 変化は、他の変更と同じく再表示時の catch-up にまとまります。
上記7つ以外の trait も environment.traitCollection から読めますが、その変化では自動で再レンダリングされません。
.onAppear / .onDisappear はビューが window に載った / 外れたタイミングで発火します。.task は表示時に async 処理を起動し、非表示になると自動でキャンセルします。
FineLabel(text: viewModel.status)
.task { await viewModel.load() } // 表示で開始、非表示でキャンセル
FineLabel(text: detail.title)
.task(id: viewModel.selectedID) { await viewModel.loadDetail() } // id が変わると再起動再レンダリングで実行中の task が再起動されることはありません(再起動は id の変化時のみ)。.onAppear は window への着脱のたびに発火します。
FineContentController は、画面が隠れている間(push で覆われた、タブが切り替わった)は再レンダリングを止めます。その間に届いた状態変更は記録され、再表示時(viewIsAppearing)に1回の catch-up レンダリングでまとめて反映されます。共有ストアを持つ画面スタックで、見えていない画面が変更ごとに再差分されることはありません。
ナビゲーションは止まりません。覆われた画面のタイトルは上の画面の戻るボタンとして見えているためです。
override var suspendsWhenDisappeared: Bool { false } // 隠れている間も更新し続けるFineUI を直接使う場合は suspend() / resume() を呼びます。build(to:) の初回レンダリングは止まりません。また catch-up レンダリングはアニメーションしません(画面外で起きた変化をアニメーションする意味がないため)。
判定は viewDidDisappear / viewIsAppearing に基づくため、次の2つは自動では止まりません。必要なら suspendRendering() / resumeRendering() を手動で呼んでください(FineUI を直接使う場合は suspend() / resume())。
.overFullScreen/.overCurrentContextでモーダルを被せた場合 — UIKit は下の画面にviewDidDisappearを送りません(部分的に見えている可能性があるため)。通常の.fullScreenpresentation なら送られるので自動で止まります- ロードしたが一度も表示していない画面 — 表示前は動き続けます。
loadViewIfNeeded()してから状態を流し込み、表示せずにビュー階層を検証する使い方(テストなど)を壊さないための意図的な選択です
viewIsAppearing(_:) / viewDidDisappear(_:) を override する場合は super の呼び出しが必須です。呼ばないと初回表示のあと再開されず、画面が黙って更新されなくなります。
ルートビューの下端は既定で keyboardLayoutGuide に追従するため、キーボード表示中はコンテンツがその上に詰まり、隠れません(キーボード非表示時は safe area 下端と一致し、レイアウトは従来どおり)。無効にする場合は FineContentController(_:avoidsKeyboard:) に false を渡します。
FineContentController(ToDoList(), avoidsKeyboard: false)スクロールでキーボードを閉じるには .keyboardDismissMode を使います(FineList / FineGrid / FineScrollView)。
FineList(viewModel.items) { item in
FineLabel(text: item.title)
}
.keyboardDismissMode(.onDrag)withFineAnimation で状態変更を包むと、その変更で発生する次の再レンダリングが UIView.animate 内で差分適用されます。引数省略時は .easeInOut(duration: 0.3) です。
withFineAnimation {
viewModel.isExpanded.toggle()
}
withFineAnimation(.spring(duration: 0.5, bounce: 0.2)) {
viewModel.padding = 32
}
withFineAnimation(nil) {
viewModel.resetAll()
}対象は同じ UIView への in-place なプロパティ変更と、制約 constant の変更です。opacity / backgroundColor / tintColor / cornerRadius などは UIKit の通常のアニメーションとして動き、padding / width / height などのレイアウト変更は layoutIfNeeded() による frame アニメーションになります。
ビューの作り直し、スタックへの挿入・削除、テキスト差し替えのクロスフェードは行いません。動かしたい変化は、同じビューに対する値変更として表現してください。
FineList / FineGrid の diff は従来どおり window 上では自動アニメーションします。withFineAnimation(nil) の中で行った変更では、diff 適用のアニメーションも抑止されます。
FineLabel(text: title)
.font(.preferredFont(forTextStyle: .headline)) // コンポーネント固有(型付き)
.padding(16) // レイアウト(ラッパー)
.backgroundColor(.systemGray6) // 外観(同一ビューへ適用)
.cornerRadius(8)
FineButton(title: "Add") { viewModel.add() }
.configuration(.filled())- 外観系:
.backgroundColor/.cornerRadius/.border/.opacity/.tintColor - レイアウト系:
.padding/.frame(width:height:alignment:) - インタラクション系:
.onTap(任意のビューにタップハンドラを付ける。ラベルや画像でもisUserInteractionEnabledを自動で有効化。タッチはビューにも届くため、コントロール自身のアクションと共存する。チェーンした.onTapは全て順に実行。nilを渡すとビューの identity を保ったままハンドラだけ外せる — 条件付きタップは.onTap(cond ? handler : nil)と書く) - アクセシビリティ系:
.accessibilityLabel/.accessibilityValue/.accessibilityHint/.accessibilityTraits/.accessibilityIdentifier/.accessibilityHidden - ライフサイクル系:
.onAppear/.onDisappear/.task/.task(id:) - 順序に意味があります(
.backgroundColor().padding()は背景の外に余白、逆は余白ごと背景) - コンポーネント固有モディファイアは具体型を返すため、汎用モディファイアより先に書きます(型消去後は呼べません。これは意図的な設計で、不正な組み合わせをコンパイルエラーにします)
残留しない仕組み: 各記述はモディファイア構成の「署名」を持ち、レンダラーは署名が一致するビューだけを in-place 更新します。値の変更(色・inset 等)は高速に反映され、モディファイアの有無・順序が変わったときはビューを作り直すため、古いスタイルが残りません。
SwiftUI 風の近似ではなく、NSLayoutConstraint の概念をそのまま宣言します。
FineImage(image: icon)
.width(.equal, 44) // ビュー自身への実制約。constant 変更は in-place
.aspectRatio(1)
FineLabel(text: title)
.compressionResistance(.required, axis: .horizontal)
FineTextField(text: binding)
.hugging(.defaultLow, axis: .horizontal)
FineLabel(text: "badge")
.frame(width: 80, height: 44, alignment: .center) // 枠内配置が必要なときだけラッパー
FineImage(image: photo).constraints(id: "photo") { view in // エスケープハッチ
[view.heightAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75)]
}寸法制約のデフォルト priority は 999 で、コンテナ(fill 揃えのスタック等)が課す required 制約と矛盾しないようになっています。必要なら .required を明示できます。
FineStack の子は既定では位置ベースで照合されますが、FineForEach / .key(_:) で安定した identity を与えると、挿入・並び替え・削除で同じ論理項目のビューが同一インスタンスのまま移動します(フォーカス・スクロール位置などビューローカル状態の保持)。
FineStack.vertical(spacing: 8) {
FineLabel(text: "Header")
FineForEach(items) { item in
FineTextField(text: .init(item, \.title))
}
}if/else と for-in は位置ベースで照合されます。安定した identity が必要な子には FineForEach か .key(_:) を使ってください。
従来の配列リテラル構文({ [a, b] } や配列連結)もそのまま動きます。
FineList / FineGrid は Identifiable の ID で常に keyed です。
生き残った行の再構築: リスト / グリッドが再レンダリングされたとき、ID が生き残った行の content は要素が変化した行だけ再実行されます。
この最適化が成立する条件は「比較の両辺が独立した値のスナップショットであること」です。要素が Equatable でない場合と、要素が参照型(class)の場合は、安全側に倒して生き残った全行を再実行します。参照型で比較をスキップさせたい場合だけ .reconfiguringOnlyChangedRows() を明示してください。
既存コードからの移行: 以前は生き残った行を毎回すべて再実行していました。値型で
Equatableな要素を使い、かつ行 content が「要素にも@Observableにも含まれない値」(bodyで読んだ素のletをキャプチャしている等)を表示している場合、コンパイルエラーなしで表示が古いままになります。該当する場合は.reconfiguringAllRows()/.reconfiguringAllItems()を付けてください。
struct Row { let model: SomeClass } のような要素は、比較の両辺が同じインスタンスを指すため、どんな == でも「等しい」と答えます。この場合は次のどちらかにしてください。
- モデルを
@Observableにする(セル単位の observation が変化を拾います。FineUIKit ではこちらが素直です) .reconfiguringAllRows()/.reconfiguringAllItems()で毎回再実行させる.reconfiguringOnlyChangedRows()/.reconfiguringOnlyChangedItems()は値型では既定と同じ動作の明示形で、参照型では「同一インスタンスの比較でもスキップする」というオプトインになります(「表示に使う全プロパティを==が反映する」ことが前提)。
要素にも @Observable にも含まれない値(row content がキャプチャしただけの素の Bool など)を表示している場合は、変化を知らせる経路がないため .reconfiguringAllRows() / .reconfiguringAllItems() で毎回再実行させてください。
行 / item content が読んだ @Observable プロパティは、リスト / グリッド全体の再 render なしにセル単位で自動更新されます。ヘッダー・フッターも同様にセル単位の observation で更新されます。観測起因の更新で高さが変わった場合は、リスト / グリッド単位で1回に合流(coalesce)された高さ再計算が自動で走ります(ヘッダー・フッターも対象)。
.environment(_:_:) で注入した値はセル・ヘッダー・フッターの content にも伝播します。環境値の変更は observation 経由で可視セルにも自動反映されるため、.reconfiguringOnlyChangedRows() 使用時も取り残されません。環境値には Equatable な型を推奨します(非 Equatable の値は毎レンダー「変更あり」とみなされ、可視セルの再描画が増えます)。
組み込みコンポーネントにないビュー(WKWebView、MKMapView、自作ビューなど)は FineViewRepresentable で宣言的ツリーに組み込めます。SwiftUI の UIViewRepresentable に相当します。
struct BlurBackground: FineViewRepresentable {
let style: UIBlurEffect.Style
func makeView() -> UIVisualEffectView {
UIVisualEffectView(effect: nil)
}
func updateView(_ view: UIVisualEffectView, environment: FineEnvironmentValues) {
let effect = UIBlurEffect(style: style)
if view.effect != effect {
view.effect = effect
}
}
}
// 通常のコンポーネントと同じように合成・修飾できる
BlurBackground(style: .systemMaterial)
.padding(16)makeView()はビューの identity が新しくなるときに1回だけ呼ばれ、以降の再レンダリングでは同じインスタンスにupdateView(_:environment:)が呼ばれますupdateViewは記述が管理する全プロパティを毎回書き戻してください(別の状態のあとに再利用されるため)。setter が重いプロパティは「現在値と異なるときだけ書く」ガードを推奨します- 再利用の判定は組み込みと同じ「型 + モディファイア署名 + key」です。
ViewTypeが同じでも representable の型が異なればビューは共有されません
FineButton の action や FineStack の builder といったクロージャは、node 単位の再レンダリングのためにビュー側(FineNode)に保持されます。FineList / FineGrid の coordinator も cell content や onSelect を保持します。つまり記述が抱えたクロージャは、ビューが生きている間ずっと生き続けます。
ビューは hosting controller のものです。ここから 2 つのことが導かれます。
- content をキャプチャするのは安全。controller が content とツリーの両方を所有し、content はどちらも所有しないので、グラフは循環しません
- controller をキャプチャすると循環します。
controller → view → node → クロージャ → controllerを切るものがありません
func body() -> any Renderable {
FineStack.vertical {
// ✅ self は content。capture list は要りません
FineButton(title: "Add") { self.add() }
FineLabel(text: "\(self.items.count)")
}
}[weak self] を書く必要はありません。書く場所がないからです。body() は escaping なクロージャの中で self. を明示するよう Swift が要求するので、何をキャプチャしているかは常に目に見えます。
content は自分の controller を強参照で保持してはいけない。
外へ何かを伝えるときは、クロージャプロパティではなく weak var delegate を使ってください。weak が宣言側に 1 回書かれるだけで、利用側にキャプチャのルールが残りません。
// ✅ 推奨: weak が宣言に 1 回だけ
@Observable
final class ToDoList: FineContent {
@ObservationIgnored weak var delegate: (any ToDoListDelegate)?
}
// ⚠️ クロージャでも書けますが、合成する側が毎回 [weak] を守る必要があります
screen.onSelect = { [weak controller] item in controller?.push(...) }
// これを忘れると controller → content → クロージャ → controller で循環します| 寿命 | 用途 | |
|---|---|---|
| store / model | 画面より長い。外から注入、共有可 | ドメイン状態 |
| content | マウントされている間 | その区画固有の UI 状態 |
FineState |
ビューの identity と同じ | 局所的な UI 状態(行の展開など) |
content が store を持つかどうかは、ただのプロパティの持ち方です。FineUIKit は「model」という概念を持ちません。
content は入れ子にできます。子は FineContent に適合する必要すらありません — ランタイムは子オブジェクトの存在を知らず、child.body() はただのメソッド呼び出しだからです。親が子を所有し、自分の記述に差し込みます。細粒度の再レンダリングは階層を貫通します(子の状態変更で親の body() は再評価されません)。
ランタイムが管理するのはビューとノードの identity です。そのため条件付きで隠したサブツリーの FineState は捨てられますが、子オブジェクト自身の状態は親が持っているので残ります。リセットしたければ親が子を差し替えてください。
controller を所有する第三者のオブジェクト(coordinator、router)をクロージャがキャプチャすれば、同じ循環は作れます。Swift はクロージャのキャプチャを制限できないため、ここは原理的な限界です。
.task は content をキャプチャしたまま実行されるので、キャンセルを尊重しない task は content の解放を遅らせます(循環ではありません)。
各パターンが実際に解放されるかは FineLeakTests が検証しています。「content が controller を持つとリークする」という境界も、テストとして固定してあります。
差分適用は「型 + モディファイア署名 + key」が一致したときだけ in-place 更新し、それ以外はビューを作り直します。作り直し自体は正しい動作ですが、意図しない作り直しはフォーカス・スクロール位置・FineState を失わせます。原因を知りたいときは診断を有効にしてください。
FineDiagnostics.logsViewReuse = trueスキームの環境変数 FINEUIKIT_LOG_REUSE=1 でも有効になります。出力例:
FineUIKit rebuilt UILabel for FineLabel: modifier composition changed ("|backgroundColor" → "|backgroundColor|cornerRadius")
FineUIKit rebuilt UITextField for FineTextField: key changed (a → b)
FineUIKit rebuilt UILabel for FineImage: view type is incompatible
既定では OSLog に出力します。FineDiagnostics.handler を差し替えれば、テストや自前のコンソールへ流せます。
各ビューには「その位置で何回レンダリングされたか(renders)」「そのうち何回ビューを作り直したか(rebuilds)」が常に記録されます。フラグ不要・常時有効で、コストは整数のインクリメント2回です。作り直しの際はカウンタが新しいビューへ引き継がれるため、数字はビュー個体ではなくツリー上の位置を表します。
作り直しに至らない再レンダリングも含めて全件ログに出したいときは:
FineDiagnostics.logsRenders = true // または FINEUIKIT_LOG_RENDERS=1FineUIKit created UILabel for FineLabel (render #1, 0 rebuilt)
FineUIKit rebuilt UILabel for FineLabel (render #2, 1 rebuilt)
名乗るのはビューを作ったコンポーネントです。.backgroundColor() や .key() は content のビューにそのまま描画する(自前のビューを作らない)ため、FineStyled / FineKeyed ではなく FineLabel と表示されます。適用されたモディファイア自体は署名の方に出ます。
再レンダリングされたビューの輪郭が一瞬光ります。緑 = in-place 更新、赤 = 作り直しで、数字はそのビューの累計レンダリング回数です。
FineDiagnostics.highlightsRenders = true // または FINEUIKIT_HIGHLIGHT_RENDERS=1ビュー自身の layer.border ではなく専用のサブレイヤーを重ねるため、枠線を持つコンポーネントの見た目を壊さず、タップも吸いません。DEBUG ビルド限定です。フレームレートを計測するときはオフにしてください(オーバーレイの描画コストが計測対象を上回ります)。
Xcode の View Debugger は UILabel は見せますが、それを作った FineLabel・key・モディファイア署名は見せません。デバッガから:
(lldb) po view.fineDumpTree()
FineStack → UIStackView renders 1
FinePadded → FinePaddingView renders 1 modifiers "padding"
FineLabel → UILabel renders 3
FineStack → UIStackView renders 1
FineTextField → FineTextFieldView renders 1 key draft
UITextFieldLabel (unmanaged)
FineButton → UIButton renders 1 modifiers "|backgroundColor"
UIButtonLabel (unmanaged)
(lldb) po someLabel.fineDebugDescription
FineLabel → UILabel renders 3
この例では、ラベルだけが renders 3、親は renders 1 です。テキストがノード単位で3回更新され、ツリーの再 diff は1回も起きていないことがそのまま読み取れます。作り直しがあれば rebuilds N が付き、FineState を持つノードには state が付きます。
FineUIKit が管理していないビュー(UIKit が内部で作るラベルなど)は unmanaged と表示されます。どちらも observable な状態を読まないので、ブレークポイントから呼んでもレンダリングループを乱しません。
3つのレンダリングループが Points of Interest に signpost 区間を出します。Time Profiler や Animation Hitches のテンプレートでそのまま見えるため、専用テンプレートは不要です。
| 区間 | 意味 |
|---|---|
render |
ルートの再レンダリング(body() の再評価とツリーの再 diff) |
node |
ノード単位の更新(観測起因のノードローカル再レンダリングを含む。記述の型名付き) |
cell |
リスト / グリッドのセルが抱えるサブツリー |
計測ツールが記録していなければ何も出力されないため、release ビルドにもそのまま残ります。
Renderable— UI 記述の公開プロトコル。アプリ側はbodyで組み込みコンポーネントを合成する- 内部プリミティブ — 組み込みコンポーネントが持つ
_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 の設計判断 にまとめてあります。
DEBUG ビルドでは、コード注入(InjectionLite / InjectionIII / InjectionNext)の完了通知を FineUI が受け取り、自動で再レンダリングします。
FineContent.body() はメソッドなので注入が名前で辿れます(final なら symbol の再バインド、非 final なら vtable スロットの差し替え)。実装が差し替わると、次の再レンダリングから新しいコードが使われます。アプリ側にホットリロード用のコードは一切不要です。 状態は content(@Observable なクラス)に住んでいるため、リロードをまたいで保持されます。
これが body をクロージャではなくメソッドにしている理由です。ストアドクロージャは生成時に記述が確定してしまい、注入では差し替えられません。公開 API に記述をクロージャで受け取る入口が無いのはこのためです。
差し替えの経路は content クラスが final かどうかで変わります。
| content の宣言 | 呼び出し | 注入の経路 |
|---|---|---|
final class(推奨・例もこちら) |
protocol witness が直接呼び出し | symbol interposition → -Xlinker -interposable が必要 |
非 final な class |
witness thunk が vtable 経由 | vtable スロットの差し替え → フラグ不要 |
つまり final class で書く限り -Xlinker -interposable は必須です(下のセットアップ手順 2 がこれにあたります)。final を外せばフラグ無しでも差し替わりますが、Swift の慣習に反するので推奨しません。
Example アプリでは InjectionLite(GUI アプリ不要)を利用しています。セットアップ:
- InjectionLite を SPM で追加(またはビルドマシンで InjectionIII.app を起動)
- Debug 構成の Other Linker Flags に
-Xlinker -interposableを追加 - シミュレータでアプリを起動し、ソースを編集・保存すると数秒で画面に反映される
注入が届いて再レンダリングが走ると、画面上部に「FineUIKit reloaded」のトーストが出ます(複数のツリーが再レンダリングされた場合は ×3 のように件数付き)。「注入が届いていない」のか「届いたが記述が変わらなかった」のかは画面上は同じに見えるため、前者だけが無音になるこの区別が切り分けの起点になります。不要なら FineDiagnostics.showsInjectionToast = false、または環境変数 FINEUIKIT_INJECTION_TOAST=0 で消せます。
Xcode の新リンカ(chained fixups)環境では、private メソッドへの直接呼び出しなど静的ディスパッチされるコードは注入で差し替わりません。確実に差し替わるのは、メソッドとして宣言されたコード(FineContent.body() の実装)か ObjC ディスパッチ(@objc dynamic)のコードです。ホットリロードで書き換えたいロジックはできるだけ body から辿れる位置に置いてください。
- Xcode 27 の SLF ログ形式との非互換 — InjectionLite がビルドログから抽出するコンパイルコマンドの行頭にゴミ(不均衡な引用符)が混入し、
sh: unexpected EOF while looking for matching '"'で再コンパイルに失敗する。GUI ビルドでも発生する(InjectionLite 側の対応待ち) - 注入 dylib の rpath に
/usr/lib/swiftが含まれない —libswift_Concurrency.dylibが見つからず dlopen に失敗することがある - CLI ビルドのみ:
xcodebuildにはEMIT_FRONTEND_COMMAND_LINES=YESを付けないとログにswift-frontendの行が残らない。対象ファイルが実際に再コンパイルされたビルドのログにしか行は残らない。加えて-destination "generic/platform=iOS Simulator"は arm64 と x86_64 の両方をビルドするため、x86_64 のコマンドがログに混ざり、注入時にunable to load standard library for target 'x86_64-...'で失敗します。具体的なシミュレータを-destination "platform=iOS Simulator,id=<UDID>"で指定してください
1 と 2 は Scripts/injectionlite-xcode27-fix.sh で回避できます。クリーンなコマンドだけを含むログを DerivedData に生成し、PackageFrameworks/ に dylib の symlink を張ります:
Scripts/injectionlite-xcode27-fix.sh ToDo # ビルドのたびに実行(スキームの post-action 推奨)新しいビルドを行うと壊れたログが最新になってしまうため、ビルド後に毎回実行が必要です。Xcode の Edit Scheme → Build → Post-actions に Run Script として登録しておくと自動化できます。
複数の Example を行き来する場合も注意が必要です。InjectionLite は最も新しい DerivedData のビルドログを走査するため、Counter をビルドした直後に ToDo で注入しようとすると、Counter のログを見て「コマンドが見つからない」と言われます。対象アプリを最後にビルドしてください。
iOS 27 のシミュレータランタイムは usr/lib/swift/libswift_Concurrency.dylib をファイルとして持ちません(dyld shared cache に取り込まれました)。スクリプトが張ろうとする symlink のリンク元が存在しないため、注入 dylib の dlopen は次のように失敗します。
⚠️ dlopen failed ... Library not loaded: @rpath/libswift_Concurrency.dylib
| ランタイム | usr/lib/swift/libswift_Concurrency.dylib |
|---|---|
| iOS 26.x | あり → スクリプトが機能する |
| iOS 27.0 | 無し → 回避策なし |
現状の実用的な対処は iOS 26 のシミュレータで動かすことです。 Example のデプロイメントターゲットは $(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)(現在 17.0、パッケージの下限と一致)なので、iOS 26 機を選べます。iOS 27 での注入は InjectionLite 側の対応待ちです。
FineUITests.injectionRerenderDoesNotLeaveStaleObservationActive は「注入完了通知を受け取ったら再レンダリングする」という FineUI 側の配線だけを検証しています(NotificationCenter で通知を手動 post するテストで、実際の dylib 差し替えは行いません)。InjectionLite/InjectionIII/InjectionNext が実際にコードを注入できるかどうかはビルド環境・ツールのバージョンに依存し、自動テストではカバーされていません(上記「既知の問題」参照)。ホットリロードが実際に機能するかどうかは、都度手元の環境で確認してください。
- 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 構成でないと意味を持ちにくく、シミュレータ結果は傾向把握用です。