计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
swiftui-view-refactor【效率与协作】Restructures an existing SwiftUI view without changing what the user sees: splits an oversized body into small view types with narrow inputs, moves actions and business logic out of the view, replaces type erasure and duplicated branches, fixes state ownership, and migrates ObservableObject models to the Observable macro. Works in small steps that ea…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
A SwiftUI view grows by accretion: one more section in body, one more flag, one more closure with a network call in it. The result still works, but nobody can change it safely, previews stop being useful, and every state change recomputes the whole screen. Refactoring it means changing the structure while the pixels and the behaviour stay the same. In SwiftUI that takes care, because structure is behaviour: a view's position in the tree is its identity, and moving a view, a @State property or a .task can reset state or change when work runs. This skill gives a fixed order of small moves, the checks that keep behaviour intact, and the form of the final report.
@Observable is available) and whether the project already has conventions for file layout, naming and state. Match them.#Preview for each state it can show (loaded, empty, error, busy). If there are none, add them before changing anything: they are the only quick way to see that nothing moved. Keep accessibility identifiers as they are, because UI tests depend on them.Write a short inventory. It decides where the cuts go.
CheckoutView.swift: 212 lines, body 148 lines
Stored: @ObservedObject cart: CartStore; @State promo, isPlacing, showError, errorText
Sections: lines list (reads cart.lines) | promo row (reads/writes promo, writes cart.discount)
| total (reads cart.lines, cart.discount) | place-order button (reads isPlacing)
Effects: Task in button action (network, mutates cart); alert bound to showError
Smells: price maths in body; promo rule in a closure; AnyView in the button label
Each step is one kind of change. Build and look at the previews after each one.
Step A: name the actions. Replace multi-line closures in body (button actions, .task, .onChange, .refreshable) with calls to private methods on the same view. Nothing else moves, so this step cannot change behaviour, and body becomes readable.
Step B: move logic that is not about display out of the view. Calculations, validation rules, formatting decisions and network calls go to the model or a service type, where they can be unit-tested without SwiftUI. The view keeps only the thin method that calls them and updates view state.
Step C: extract sections into view types. For each section in the inventory create a struct conforming to View:
let), bindings (@Binding) for what it edits, closures for what it triggers. Not the parent's whole model, unless it really uses most of it.private in the same file until something else needs it; move it to its own file when it is reused or the file is still too long.@ViewBuilder function is enough for a fragment of a few lines with no state of its own. Prefer a type when the section has its own state or async work, deserves a preview, or reads data the rest of the screen does not: a separate view type is updated independently, so narrowing its inputs also narrows what SwiftUI recomputes.Step D: repair the structure.
| Found | Change to | Why |
|---|---|---|
AnyView used to return different views | @ViewBuilder, if/switch, or a generic parameter | Type erasure hides the structure SwiftUI uses for identity and diffing |
The same view in both branches of an if, differing in modifiers | One view with conditional modifier values (.opacity(isLocked ? 0.6 : 1), .disabled(isLocked)) | Each branch is a different identity; switching destroys the view's state and animates as a removal and insertion |
| Several Booleans driving mutually exclusive sheets or alerts | One optional enum with sheet(item:) | Removes impossible combinations |
A child copying a parent's value into its own @State | @Binding or a plain let | Two sources of truth drift apart |
Model built in init with State(initialValue:) or set in onAppear behind an optional | @State private var model = Model() when it needs no inputs; otherwise receive the model from the parent, or create it in .task | State storage is created once per view identity; later init arguments are ignored, which surprises readers |
Values derived in body several times | A computed property on the model, or one let at the top of body | One definition, testable |
Step E: migrate observation (only if asked or agreed, iOS 17+).
| Before | After |
|---|---|
class Store: ObservableObject | @Observable class Store |
@Published var items | var items (use @ObservationIgnored to exclude a property) |
@StateObject private var store = Store() | @State private var store = Store() |
@ObservedObject var store: Store | let store: Store, or @Bindable var store: Store when the view needs $store.property |
@EnvironmentObject var store: Store | @Environment(Store.self) private var store |
.environmentObject(store) | .environment(store) |
Migrate one model type at a time; the old and new systems can coexist in one app. Behaviour differs in one important way: with ObservableObject a view updates when any published property changes, with @Observable only when a property its body reads changes. That is usually the goal, but check views that depended on the broader updates.
After the last step, and after any step that moved state or effects, go through this list:
@State or @FocusState property moved into an extracted view now belongs to that view's identity. If the view sits inside an if, the state resets whenever the branch flips..task or .onAppear moved from the screen to a section runs when that section appears, and a .task is cancelled when it disappears. Inside lazy containers that is on scroll, not on screen load..environment, .disabled, .animation, .navigationDestination, .toolbar and .alert apply to the subtree they are attached to; an extracted view may now be outside it.ForEach, and ForEach ids are the same.Refactor of CheckoutView.swift (212 → 96 lines; body 148 → 14)
Moved
OrderLinesSection, PromoCodeField, TotalRow, PlaceOrderButton new private views, same file
applyPromo(), placeOrder() private methods on CheckoutView
subtotal, total, applyPromo(_:), placeOrder() CartStore (unit-testable)
Structure fixes
AnyView in button label → if/else in a ViewBuilder
Left alone, with reasons
showError + errorText pair (works; an optional error enum would be cleaner)
Noticed, not changed
Place-order button stays enabled while a request is running: a double tap sends two orders
Verification: build clean; previews "Filled", "Empty", "Placing" identical before/after; 31 tests pass
bodyBefore (shortened), the button and total as found:
let subtotal = cart.lines.reduce(Decimal(0)) { $0 + $1.unitPrice * Decimal($1.quantity) }
Text("Total \((subtotal * (1 - cart.discount)).formatted(.currency(code: "EUR")))")
Button {
isPlacing = true
Task {
do { try await cart.api.placeOrder(cart.lines); cart.lines = [] }
catch { errorText = error.localizedDescription; showError = true }
isPlacing = false
}
} label: { isPlacing ? AnyView(ProgressView()) : AnyView(Text("Place order")) }
After steps A to D:
struct CheckoutView: View {
@ObservedObject var cart: CartStore
@State private var promo = ""
@State private var isPlacing = false
@State private var showError = false
@State private var errorText = ""
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
OrderLinesSection(lines: cart.lines)
PromoCodeField(code: $promo, onApply: applyPromo)
TotalRow(total: cart.total)
PlaceOrderButton(isPlacing: isPlacing, action: placeOrder)
}
}
.alert(errorText, isPresented: $showError) { Button("OK") {} }
}
private func applyPromo() {
do { try cart.applyPromo(promo) } catch { show(error) }
}
private func placeOrder() {
isPlacing = true
Task {
do { try await cart.placeOrder() } catch { show(error) }
isPlacing = false
}
}
private func show(_ error: Error) {
errorText = error.localizedDescription
showError = true
}
}
private struct PlaceOrderButton: View {
let isPlacing: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
if isPlacing { ProgressView() } else { Text("Place order") }
}
}
}
extension CartStore {
var subtotal: Decimal { lines.reduce(0) { $0 + $1.unitPrice * Decimal($1.quantity) } }
var total: Decimal { subtotal * (1 - discount) }
}
The price rule now has one definition and a unit test can cover it. TotalRow takes a Decimal, so it is recomputed only when the total changes. The enabled-while-busy button was kept as it was and reported.
// Before
final class ReaderSettings: ObservableObject {
@Published var fontSize = 17.0
@Published var usesSerif = false
@Published var lastSyncedAt: Date?
}
struct FontControls: View {
@ObservedObject var settings: ReaderSettings
var body: some View {
Slider(value: $settings.fontSize, in: 12...28)
Toggle("Serif font", isOn: $settings.usesSerif)
}
}
// After
@Observable final class ReaderSettings {
var fontSize = 17.0
var usesSerif = false
var lastSyncedAt: Date?
}
struct FontControls: View {
@Bindable var settings: ReaderSettings
var body: some View {
Slider(value: $settings.fontSize, in: 12...28)
Toggle("Serif font", isOn: $settings.usesSerif)
}
}
The owner changes from @StateObject private var settings = ReaderSettings() to @State private var settings = ReaderSettings(). Reported difference: FontControls no longer updates when lastSyncedAt changes, because its body does not read it.
// Before: flipping isLocked swaps one editor for another; cursor and scroll position are lost
if note.isLocked {
NoteEditor(note: note).disabled(true).opacity(0.6)
} else {
NoteEditor(note: note)
}
// After: the same editor in both modes
NoteEditor(note: note)
.disabled(note.isLocked)
.opacity(note.isLocked ? 0.6 : 1)
This one does change behaviour, for the better: the editor's state survives locking. Say so in the report and get agreement before including it in a structure-only refactor.
isExpanded, focus, scroll position) in the view.body on paper while every fragment still shares the screen's state and is recomputed with it.@State initial values: built with Xcode 26 or earlier, the initial-value expression of @State runs every time the view is initialised and only the first result is kept, so do not put expensive or side-effecting construction there. Built with Xcode 27 or later, a class-typed initial value is created once.task restarts) is the likeliest thing to change unnoticed.