Skip to content
academia.sh

Lesson 15 / 15

Choosing a Framework

Measuring two screens, one declarative and one imperative, built from the same model and checked with the same rule, showing that agreement is not a criterion for choosing a framework; the closing table of the course's fifteen lessons.

Published Updated

In this lesson

  • Checks the declarative and imperative screens of the same model with the same rule
  • Shows that a field forgotten on the imperative side is measured missing and a field not updated is measured stale
  • Confirms with a screen that breaks in both directions that agreement is not a criterion for choosing a framework
  • Explains the reasons for choosing through team, legacy code, platform version and custom drawing, without turning them into numbers
Contents

In the previous lesson we moved an old screen to the new framework screen by screen and saw that the rule works per field. The last question of this topic is unavoidably this: if a new screen is to be written, which framework should be chosen? The course’s measurement does not answer this question, and the job of this lesson is to show exactly that. The measuring axis has not changed: agreement. The core file is the same as in the previous lessons.

// Sync.swift — compares every field shown on screen with the model's current value
final class Screen {
    let name: String
    private(set) var shown: [String: String] = [:]

    init(_ name: String) { self.name = name }

    func set(_ key: String, _ value: String) { shown[key] = value }
    func clear(_ key: String) { shown[key] = nil }
}

func verdict(model: String?, shown: String?) -> String {
    switch (model, shown) {
    case (nil, nil): return "out-of-scope"
    case (nil, _?): return "leftover"
    case (_?, nil): return "missing"
    case let (wanted?, displayed?): return wanted == displayed ? "matching" : "stale"
    }
}

func check(_ label: String, model: [String: String], screen: Screen, keys: [String]) -> [String] {
    keys.map { key in
        let result = verdict(model: model[key], shown: screen.shown[key])
        print("\(label) | \(screen.name).\(key): model=\(model[key] ?? "-") screen=\(screen.shown[key] ?? "-") -> \(result)")
        return result
    }
}

func countVerdicts(_ verdicts: [String]) {
    for name in ["matching", "stale", "missing", "leftover"] {
        print("\(name): \(verdicts.filter { $0 == name }.count)")
    }
}

The Same Model, Two Screens

A profile screen shows three values: a name, an unread count and a status. The model is a single value type. In the declarative framework the screen is a function of this model.

import SwiftUI

struct ProfileView: View {
    let model: ProfileModel

    var body: some View {
        VStack {
            Text(model.title)
            Text(String(model.unread))
            Text(model.status)
        }
    }
}

In the imperative framework the code writes the same three values to three labels one by one.

import UIKit

final class ProfileController: UIViewController {
    @IBOutlet private var titleLabel: UILabel!
    @IBOutlet private var unreadLabel: UILabel!
    @IBOutlet private var statusLabel: UILabel!

    func render(_ model: ProfileModel) {
        titleLabel.text = model.title
        unreadLabel.text = String(model.unread)
        statusLabel.text = model.status
    }
}

The model builds what the code writes to the screen; it is not the framework itself. The values the two screens write stay in the helper file and the names are the same as in the SOURCE snippets.

// Profile.swift — two screens built from the same model
struct ProfileModel {
    var title: String
    var unread: Int
    var status: String

    var values: [String: String] {
        ["title": title, "unread": String(unread), "status": status]
    }
}

struct ProfileView {
    let model: ProfileModel

    func body(on screen: Screen) {
        screen.set("title", model.title)
        screen.set("unread", String(model.unread))
        screen.set("status", model.status)
    }
}

final class ProfileController {
    let screen = Screen("ProfileController")

    func render(_ model: ProfileModel) {
        screen.set("title", model.title)
        screen.set("unread", String(model.unread))
        screen.set("status", model.status)
    }
}

First event: the same model is given to both screens.

// main.swift — the same model is written to two screens
let model = ProfileModel(title: "Alice", unread: 3, status: "online")
let keys = ["title", "unread", "status"]

let declarative = Screen("ProfileView")
ProfileView(model: model).body(on: declarative)
let controller = ProfileController()
controller.render(model)

let viaDeclarative = check("same-model-declarative", model: model.values, screen: declarative, keys: keys)
let viaImperative = check("same-model-imperative", model: model.values, screen: controller.screen, keys: keys)

countVerdicts(viaDeclarative + viaImperative)
same-model-declarative | ProfileView.title: model=Alice screen=Alice -> matching
same-model-declarative | ProfileView.unread: model=3 screen=3 -> matching
same-model-declarative | ProfileView.status: model=online screen=online -> matching
same-model-imperative | ProfileController.title: model=Alice screen=Alice -> matching
same-model-imperative | ProfileController.unread: model=3 screen=3 -> matching
same-model-imperative | ProfileController.status: model=online screen=online -> matching
matching: 6
stale: 0
missing: 0
leftover: 0

All six fields are matching. On the imperative side the reason is that the code writes all three fields; on the declarative side it is that the framework recomputes what it reads.

BR21. The model assumes that the values the declarative view writes in body have been written to the screen, and it builds recomputation in the model by calling body again. When the framework does the computation is not measured; this is an assumption.

A Field Forgotten on the Imperative Side

While writing the same screen in the imperative framework, it is very easy to drop a line. We set up two events. In the first, the controller never writes the status label. In the second, the screen is set up correctly, then the model changes and the code that sees the change does not rewrite the screen.

// main.swift — the imperative controller forgets the status, then the model changes
final class ForgetfulProfileController {
    let screen = Screen("ForgetfulProfileController")

    func render(_ model: ProfileModel) {
        screen.set("title", model.title)
        screen.set("unread", String(model.unread))
    }
}

let model = ProfileModel(title: "Alice", unread: 3, status: "online")
let keys = ["title", "unread", "status"]

let forgetful = ForgetfulProfileController()
forgetful.render(model)
let declarative = Screen("ProfileView")
ProfileView(model: model).body(on: declarative)

let missingDeclarative = check("imperative-forgot-declarative", model: model.values, screen: declarative, keys: keys)
let missingImperative = check("imperative-forgot-imperative", model: model.values, screen: forgetful.screen, keys: keys)

let latest = ProfileModel(title: "Alice", unread: 5, status: "online")
let refreshed = Screen("ProfileView")
ProfileView(model: latest).body(on: refreshed)
let careful = ProfileController()
careful.render(model)

let changedDeclarative = check("model-changed-declarative", model: latest.values, screen: refreshed, keys: keys)
let changedImperative = check("model-changed-imperative", model: latest.values, screen: careful.screen, keys: keys)

countVerdicts(missingDeclarative + missingImperative + changedDeclarative + changedImperative)
imperative-forgot-declarative | ProfileView.title: model=Alice screen=Alice -> matching
imperative-forgot-declarative | ProfileView.unread: model=3 screen=3 -> matching
imperative-forgot-declarative | ProfileView.status: model=online screen=online -> matching
imperative-forgot-imperative | ForgetfulProfileController.title: model=Alice screen=Alice -> matching
imperative-forgot-imperative | ForgetfulProfileController.unread: model=3 screen=3 -> matching
imperative-forgot-imperative | ForgetfulProfileController.status: model=online screen=- -> missing
model-changed-declarative | ProfileView.title: model=Alice screen=Alice -> matching
model-changed-declarative | ProfileView.unread: model=5 screen=5 -> matching
model-changed-declarative | ProfileView.status: model=online screen=online -> matching
model-changed-imperative | ProfileController.title: model=Alice screen=Alice -> matching
model-changed-imperative | ProfileController.unread: model=5 screen=3 -> stale
model-changed-imperative | ProfileController.status: model=online screen=online -> matching
matching: 10
stale: 1
missing: 1
leftover: 0

The forgotten status field came out missing on the imperative screen, the field that was not updated, unread, came out stale; all six fields of the declarative screen are matching. That is, the imperative framework increases the number of places where the programmer can forget to write. This is not a defect but the price of the framework handing the work back to the programmer: the control over the screen is that much greater too.

The Bounding Measurement

This price does not make agreement a criterion for choosing. The rule does not know which framework built the screen; it looks at what the screen shows, and breakage is possible in both directions. A carefully written imperative controller keeps all fields matching. A declarative view, on the other hand, can show a constant text that is not in the model; the constant text is written as is on every recomputation.

// main.swift — breakage happens in both frameworks
struct BannerProfileView {
    let model: ProfileModel

    func body(on screen: Screen) {
        screen.set("title", model.title)
        screen.set("unread", String(model.unread))
        screen.set("status", model.status)
        screen.set("banner", "new")
    }
}

let model = ProfileModel(title: "Alice", unread: 3, status: "online")
let keys = ["title", "unread", "status", "banner"]

let careful = ProfileController()
careful.render(model)
let banner = Screen("BannerProfileView")
BannerProfileView(model: model).body(on: banner)

let carefulResult = check("bound-imperative", model: model.values, screen: careful.screen, keys: keys)
countVerdicts(carefulResult)
let bannerResult = check("bound-declarative", model: model.values, screen: banner, keys: keys)
countVerdicts(bannerResult)
bound-imperative | ProfileController.title: model=Alice screen=Alice -> matching
bound-imperative | ProfileController.unread: model=3 screen=3 -> matching
bound-imperative | ProfileController.status: model=online screen=online -> matching
bound-imperative | ProfileController.banner: model=- screen=- -> out-of-scope
matching: 3
stale: 0
missing: 0
leftover: 0
bound-declarative | BannerProfileView.title: model=Alice screen=Alice -> matching
bound-declarative | BannerProfileView.unread: model=3 screen=3 -> matching
bound-declarative | BannerProfileView.status: model=online screen=online -> matching
bound-declarative | BannerProfileView.banner: model=- screen=new -> leftover
matching: 3
stale: 0
missing: 0
leftover: 1

The imperative screen gave three matching and banner stayed out of scope. The declarative screen gave the same three matching and one leftover. At first glance one of the two screens looks “more in agreement”, but the counts of the two frameworks do not rank them: the same rule, the same model, different mistakes written in code.

BR22. The model does not know the framework that built the screen; it only compares the fields the screen shows. The reasons for choosing a framework are not measured, and are not turned into numbers in this lesson.

The real reasons lie elsewhere and all of them are weighed in prose.

  • Team. A team knowing the declarative framework means fewer lines written per screen; if the team knows the imperative framework, the learning cost can exceed the gain the new framework brings.
  • Legacy code. Rewriting a working screen carries risk. The incremental path in the previous lesson shrinks this risk per screen.
  • Platform version. The declarative framework is not available on every target version; the lowest version to be supported is decided before choosing the framework.
  • Custom drawing. A piece drawn by hand or animated with layers may need the control the imperative framework offers; the declarative screen takes it in by wrapping.

None of these four reasons is a number, and our measurement is not enough to make this decision.

Summary

  • When the same model is built with both frameworks, both screens are matching in six fields (6 matching).
  • On the imperative side a field not written is measured missing and a field that does not follow the model is measured stale; the declarative side is matching in the same events (10 matching, 1 stale, 1 missing).
  • Breakage happens in both frameworks: the declarative screen’s constant text that is not in the model gives leftover (3 matching, 1 leftover).
  • Agreement is not a criterion for choosing a framework; the choice is weighed in prose through team, legacy code, platform version and custom drawing.
  • This lesson’s agreement: matching.

Course Wrap-Up

The course measured fifteen lessons with a single question: does this screen agree with the model? Each lesson’s event, agreement and bounding measurement are below. The agreement cells are taken from the source lesson’s summary.

Lesson Event Agreement Bounding measurement
The View Controller Concept loading, read mark, folder change, draft arrival, cell reuse all four (3/2/2/2) formatted text is not counted equal; a field absent on both sides is not counted
Lifecycle Methods value written only at load, model changes underneath stale the model builds the call order, not the framework
Navigation Controllers push and pop, highlight of a removed selection leftover the popped screen is out of scope
Presentation Styles modal presented note, field the presenting screen does not write missing only the visible screen is looked at
The Visual Editor and Code-Built Interfaces broken outlet, placeholder text stale placeholder stays on a label the code does not write
Outlets and Actions action that writes the model but not the label stale formatted text comes out stale against the raw value
Constraint-Based Layout constraint constant not updated stale priority and ambiguity are not in the model
Stack Views subview not hidden leftover hiding and removing write the same value
Table Views accessory left on a reused cell leftover queue size is not measured
Collection Views item inserted without notifying missing position calculation is not measured
The Delegate Pattern unconnected delegate stale the person who forgot is not read
Drawing and Layer Animation unwanted redraw request stale animation duration is not measured
Declarative and Imperative Interfaces Together root view not reassigned stale declarative recomputation is an assumption
Incremental Migration of Legacy Code field not carried over, removed badge missing no single answer at screen level
Choosing a Framework same model, forgotten field matching agreement is not a criterion for choosing

The distribution of the fourteen lessons other than the opening lesson: 1 matching, 7 stale, 3 missing, 3 leftover. The core’s 3/2/2/2 distribution is a separate count: the core counts the nine fields in the opening lesson, while the distribution counts the agreement of fourteen lessons; the two are not the same thing.

The next course (M30/K05) covers data persistence, network requests and concurrency. The measuring axis of this course does not continue there; K05 asks its own question.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close