Skip to content
PressboardKit
PerformanceBrowse docs

Guides

Performance

The extension memory budget, keeping the key grid off the per-keystroke render path, sizing the input view, and measuring a process you cannot attach to.

A keyboard extension is the least forgiving process on the phone. It runs on a small fraction of an app’s memory, it is killed and relaunched constantly, and every frame it drops is a frame the user feels under their thumb. Two things follow: keep the render path short, and keep things out of the process entirely.

The memory budget

The package is split into five products for this reason. Link PressboardKitAutocomplete and PressboardKitEmoji only if you use them — see Installation.

Beyond that:

  • Do not pull a heavy framework into the extension. A JavaScript runtime, a large ML model or an analytics SDK will not fit next to a keyboard.
  • Keep large data out of the process. The bundled frequency lists are loaded lazily per locale; a custom words: list you pass is held in memory for as long as the provider is.
  • Do not keep a controller alive across appearances. iOS builds a fresh input view controller for every appearance, and it is easy to retain the previous one by accident — the leak then compounds every time the keyboard is dismissed and shown again, until iOS kills the process mid-sentence. PressboardInputViewController releases its SwiftUI host explicitly in deinit for this reason. If you host the SwiftUI view yourself, do the same, and verify it by showing and dismissing the keyboard repeatedly rather than by typing — typing is not what grows.

The render path

Rendering the whole key grid costs far more than a keystroke can afford, so it must not happen on every keystroke. Two things keep it cheap: one in the engine, one in your host.

The engine half

The key grid is an Equatable view. SwiftUI skips re-rendering it unless layout, appearance or behavior actually changes. A plain letter keystroke changes none of those — only your suggestion bar changes — so the grid is skipped entirely: measured on a device, typing mid-word produces zero grid re-renders.

One caveat: that equality compares layout, appearance and behavior, and ignores closures and the style. If you pass a keyOverlay that depends on external value state, or swap the style dynamically, change something in behavior or layout too — otherwise the fast path will show stale keys until the next real change.

Your half

Keep the state that changes on every keystroke — the suggestion bar’s contents — in its own ObservableObject, observed only by the toolbar. A keystroke then publishes only that object and re-renders only the bar, without republishing the model that drives the keys.

@MainActor final class SuggestionsModel: ObservableObject {
    @Published var suggestions: [Suggestion] = []
}

@MainActor final class KeyboardModel: ObservableObject {
    let suggestionsModel = SuggestionsModel()   // NOT @Published — its changes don't republish us
    @Published private(set) var layout: KeyboardLayoutDefinition
    // update suggestionsModel.suggestions per keystroke; update `layout` only on case/page change
}

private struct SuggestionsToolbar: View {
    @ObservedObject var model: SuggestionsModel
    let onPick: (Suggestion) -> Void
    var body: some View { SuggestionBar(suggestions: model.suggestions, onPick: onPick) }
}

The two are complementary: the equatable grid protects you even if your model is coarse, and the split-state pattern brings the per-keystroke re-render down to just the bar. PressboardController already does this — its suggestionsModel is a separate object for exactly this reason.

Whatever provider you use, keep suggestions(for:) cheap; it runs on every keystroke. The bundled DictionarySuggestionProvider indexes its list rather than scanning it, so the call stays far inside a frame.

Do not do the work twice

You will typically recompute suggestions and case both after your own edit and from the system’s textDidChange, which fires for the same edit. Cache the last document context and return early when it has not changed:

private var lastSyncedContext: String?

private func syncAfterInput() {
    let before = textDocumentProxy.documentContextBeforeInput ?? ""
    guard before != lastSyncedContext else { return }   // same edit — already handled
    lastSyncedContext = before
    model.updateSuggestions(for: before)
    model.updateCase(forTextBeforeCursor: before)
}

PressboardController exposes this as syncAfterInput(), with invalidateSyncCache() for the cases where the document changed underneath you.

Sizing the input view

The extension controls its own height. Compute it from the style metrics and update it when the page changes — the emoji page is taller, and a bar adds its own height:

func desiredHeight() -> CGFloat {
    let keyArea = NativeKeyboardStyle().metrics.keyAreaHeight(rowCount: 4)
    if model.layout.keyboardType == .emojis {
        return (behavior.emojiSearch ? EmojiSearchHeader.height : 0) + keyArea + 44
    }
    return (behavior.predictiveText ? SuggestionBar.height : 0) + keyArea
}

Activate a height constraint on the input view at priority 999 and update its constant when the mode changes. Add the key-pop headroom from Theming on top.

Measure in Release

The engine’s Swift-level work — suggestion lookup, string transforms, reference counting — is many times faster optimised than in a Debug build. Judge responsiveness on a Release build or you will be tuning the wrong thing. For reference, the engine’s own touch-to-insert latency measures at the floor of what the SwiftUI gesture path allows, so if typing feels slow in your integration, the cause is above the engine.

Measuring memory from inside

Measuring a keyboard extension’s memory from outside is unreliable. Instruments’ Allocations instrument cannot attach to a process it did not launch, and an extension cannot be launched by it. So the keyboard reports its own footprint instead.

behavior.memoryDiagnosticsInterval = 50   // log every 50 keystrokes; 0 is off

It costs one system call per interval and nothing else. Read the numbers back from the unified log:

log collect --device
log show --predicate 'subsystem == "PressboardKit"'

This is a diagnostic, not something to ship on. Perf.logFootprint(keystrokes:state:) and Perf.physFootprint() are public if you want to call them yourself.

Input pipelines

multiTouchInputLayer routes key presses through a single shared multi-touch gesture rather than each key’s own SwiftUI DragGesture. It exists because fast multi-touch typing could buffer, reorder or drop overlapping touches on the per-key path — confirmed on a device. The multi-touch layer processes raw touches ordered by their own timestamps, so overlapping presses commit in real time order. It has full parity with the per-key path: key-down commit, backspace, drag-off cancel, feedback, long-press callouts, the spacebar cursor drag and slide-to-type, and the two are visually identical.

It is on in .pressboardDefault after a full round of on-device verification, and off in .standard, so existing integrators keep the path they are already running. Set it explicitly either way to pin the behaviour.

Next: the behaviour reference.

Edit this page on GitHub