The first version of a "save this as a template" feature is always easy. Make the struct Codable, encode an array to JSON, write it to disk. Ship it.
The trouble arrives in the second version, when you add a field, rename one, or change what a number means. Now there are files on people's phones written by the old code, and the new code has to read them. If decoding throws, the friendliest outcome is that someone opens the app and their saved templates are gone. Nobody files a bug report for that. They just stop trusting the feature.
I build BatchMark, a batch photo app for iPhone, where people save a watermark setup once and reapply it later (the iPhone watermarking guide shows the user-facing side). This post is about the unglamorous part underneath: saving user templates in Swift so that next year's app still opens this year's files. The model below is a simplified teaching version, not the app's actual schema.
Start with a version number you don't need yet
import Foundation
struct WatermarkTemplate: Identifiable, Equatable {
static let currentSchemaVersion = 2
var id: UUID
var name: String
var text: String
var fontName: String
/// Text height as a fraction of the photo's shorter side.
var relativeSize: Double
var opacity: Double
var anchor: Anchor
/// Inset from the edges as a fraction of the photo's shorter side. Added in v2.
var margin: Double
enum Anchor: String, CaseIterable {
case topLeading, top, topTrailing
case center
case bottomLeading, bottom, bottomTrailing
}
}
The struct isn't Codable yet on purpose. Synthesized conformance is great until the day it silently encodes the new shape and refuses to decode the old one. For anything a user saved by hand, I prefer to write the coding by hand once and keep control.
schemaVersion isn't stored on the struct. It is written into every encoded template and read back to decide how to interpret the rest. Version 1 was the first shipped format; version 2 is the one in this post.
Write the decoder for every version you shipped
Imagine version 1 stored the text size as pointSize, meaning points on a 1080-pixel-wide reference canvas, and had no margin at all. Version 2 stores relativeSize and margin. The decoder handles both:
extension WatermarkTemplate: Codable {
private enum Keys: String, CodingKey {
case schemaVersion
case id, name, text, fontName, relativeSize, opacity, anchor, margin
case legacyPointSize = "pointSize"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: Keys.self)
let version = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
name = try c.decode(String.self, forKey: .name)
text = try c.decode(String.self, forKey: .text)
fontName = try c.decodeIfPresent(String.self, forKey: .fontName) ?? "System"
opacity = try c.decodeIfPresent(Double.self, forKey: .opacity) ?? 0.8
if version >= 2 {
relativeSize = try c.decode(Double.self, forKey: .relativeSize)
} else {
let points = try c.decodeIfPresent(Double.self, forKey: .legacyPointSize) ?? 48
relativeSize = Self.relativeSize(fromLegacyPoints: points)
}
let rawAnchor = try c.decodeIfPresent(String.self, forKey: .anchor)
anchor = rawAnchor.flatMap(Anchor.init(rawValue:)) ?? .bottomTrailing
margin = try c.decodeIfPresent(Double.self, forKey: .margin) ?? 0.04
// Clamp anything a hand-edited or buggy file could push out of range.
opacity = min(max(opacity, 0.05), 1)
relativeSize = min(max(relativeSize, 0.005), 0.5)
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: Keys.self)
try c.encode(Self.currentSchemaVersion, forKey: .schemaVersion)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(text, forKey: .text)
try c.encode(fontName, forKey: .fontName)
try c.encode(relativeSize, forKey: .relativeSize)
try c.encode(opacity, forKey: .opacity)
try c.encode(anchor.rawValue, forKey: .anchor)
try c.encode(margin, forKey: .margin)
}
static func relativeSize(fromLegacyPoints points: Double) -> Double {
points / 1080
}
}
Putting the Codable conformance in an extension keeps the memberwise initializer, which is handy for built-in presets and tests.
The rules this decoder follows:
- Missing version means version 1. The first release didn't write one. Every later release does.
- New fields use
decodeIfPresentwith a sensible default. Older files simply don't havemargin. That isn't an error. - Changed meanings are converted, not reinterpreted. A v1
pointSizeof 48 is not a v2relativeSizeof 48. The conversion lives in one named function so it's testable. - Enums decode from a raw string with a fallback. If a future version adds
.diagonaland someone downgrades, an unknown anchor becomes the default instead of throwing away the template. - Values are clamped. A template with opacity
0is a template that makes invisible watermarks. Clamp at the boundary rather than trusting the file. - The encoder always writes the current version. Once a v1 template is loaded and saved again, it is migrated for good.
One bad template shouldn't take the rest down
The decoder above is tolerant, but some files will still be broken: truncated writes, a bug in a TestFlight build, a field that is simply the wrong type. If the whole file is decoded as [WatermarkTemplate], one bad element throws and you lose all of them.
Decode the array element by element instead, skipping what fails:
struct TemplateFile: Codable {
var templates: [WatermarkTemplate]
init(templates: [WatermarkTemplate]) {
self.templates = templates
}
private enum Keys: String, CodingKey { case templates }
/// Decodes anything, so a failed element can be stepped over.
private struct Skip: Decodable {
init(from decoder: Decoder) throws {}
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: Keys.self)
var list = try c.nestedUnkeyedContainer(forKey: .templates)
var decoded: [WatermarkTemplate] = []
while !list.isAtEnd {
if let template = try? list.decode(WatermarkTemplate.self) {
decoded.append(template)
} else {
_ = try? list.decode(Skip.self)
}
}
templates = decoded
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: Keys.self)
try c.encode(templates, forKey: .templates)
}
}
The Skip type matters. An unkeyed container only moves to the next element after a successful decode. Without something that always succeeds, a single bad element would leave you looping on it forever.
Save atomically, and never overwrite what you couldn't read
struct TemplateStore {
let fileURL: URL
static func standard() throws -> TemplateStore {
let support = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
return TemplateStore(fileURL: support.appendingPathComponent("templates.json"))
}
func load() -> [WatermarkTemplate] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
do {
return try JSONDecoder().decode(TemplateFile.self, from: data).templates
} catch {
keepUnreadableCopy()
return []
}
}
func save(_ templates: [WatermarkTemplate]) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(TemplateFile(templates: templates))
try data.write(to: fileURL, options: .atomic)
}
private func keepUnreadableCopy() {
let stamp = Int(Date().timeIntervalSince1970)
let backup = fileURL
.deletingPathExtension()
.appendingPathExtension("unreadable-\(stamp).json")
try? FileManager.default.copyItem(at: fileURL, to: backup)
}
}
Two habits here:
.atomicwrites. The data goes to a temporary file and is then swapped in. If the app is killed mid-write, the old file survives intact instead of being half a JSON document.- Back up what you couldn't read before anything can overwrite it. If the top-level decode fails and you return an empty list, the next save would replace the user's file with
[]. Copying it aside first means a later version (or you, with a support email) can still recover it.
Application Support is the right home: it is backed up, it isn't visible in the Files app, and it isn't purged like caches.
Keep built-in presets out of the user's file
Built-in presets are tempting to store alongside user templates. Don't. If presets live in the same file, you can never improve a preset without either migrating every copy or leaving people stuck with the old one.
Keep presets in code with stable IDs, and only write user-made templates to disk:
extension WatermarkTemplate {
static let builtIn: [WatermarkTemplate] = [
WatermarkTemplate(
id: UUID(uuidString: "6F1C2B8E-7A55-4C1E-9E2B-1D0B6E3A0001")!,
name: "Minimal corner",
text: "Your Name",
fontName: "System",
relativeSize: 0.025,
opacity: 0.8,
anchor: .bottomTrailing,
margin: 0.04
)
]
}
When someone edits a preset, save the edit as a new user template with a fresh ID. The preset stays a preset; their version is theirs.
Test old files with fixtures, not hopes
The only way to know version 3 can read version 1 is to keep a version 1 file in your test bundle and decode it on every run.
import XCTest
final class TemplateMigrationTests: XCTestCase {
func testDecodesVersion1File() throws {
let json = """
{
"templates": [
{ "name": "Shop", "text": "@myshop", "pointSize": 54, "anchor": "bottomTrailing" },
{ "name": "Broken", "text": 42 },
{ "name": "Future", "text": "hi", "schemaVersion": 2,
"relativeSize": 0.03, "anchor": "diagonal" }
]
}
"""
let file = try JSONDecoder().decode(TemplateFile.self, from: Data(json.utf8))
XCTAssertEqual(file.templates.count, 2)
XCTAssertEqual(file.templates[0].relativeSize, 54.0 / 1080.0, accuracy: 0.0001)
XCTAssertEqual(file.templates[0].margin, 0.04)
XCTAssertEqual(file.templates[1].anchor, .bottomTrailing)
}
}
That one test covers a missing version, a renamed field, a missing field, a malformed element that must be skipped, and an unknown enum value. When you ship version 3, copy a real version 2 file into the fixtures before you change anything.
The checklist
- Write a schema version into every saved object, starting now.
- Decode by hand for anything users created; default new fields; convert changed ones.
- Decode arrays leniently so one bad item doesn't erase the rest.
- Write atomically; back up unreadable files before you can overwrite them.
- Keep built-in presets in code, not in the user's file.
- Keep a fixture for every version you've shipped.
I'm Maxwell, and I learned most of this list the slow way. Saved templates look like a small feature, but they're the one place where people trust your app to remember something for them, and that's worth a little extra code.
Comments
Join the discussionNo comments yet. Be the first to comment.