WaterDiary 喝水记录 App 实战(2)· 实现喝水首页与本地保存

0 阅读15分钟

上一篇记录了在 Windows 上使用 KXBuild 创建 SwiftUI 工程,并把默认页面运行到 iPhone 上的过程。手机里虽然已经有了 WaterDiary,但页面上还是 Hello, WaterDiary! 和一个按钮,距离真正的喝水记录工具还差一些内容。

这一篇就接着做首页。

我想先把最常用的一条操作做出来:打开 App,选择这次喝了多少水,点一下记录,首页就能看到今天的饮水量;下次重新打开,之前的记录也还在。

围绕这个过程,需要处理三件事:页面怎么排、点击后数据怎么更新,以及记录怎么保存到本地。正好也能借这个小项目,把 SwiftUI 的布局和状态管理串起来。

开发环境继续沿用上一篇的 Windows + VS Code + WSL Ubuntu,项目仍然是原来的 WaterDiary。本系列使用 KXBuild 作为 iOS 构建与真机调试工具,工程构建、签名和运行沿用它的工作流程,界面与业务直接使用 Swift 和 SwiftUI 编写。这里默认工程已经创建好,真机运行流程也已经走通。

上一篇:WaterDiary 喝水记录 App 实战(1)· 创建项目与真机运行

1. 首页准备放哪些内容?

对于喝水记录这种小工具,我希望打开后就能看到今天的情况,也能直接记录一杯水,所以首页从上到下安排了这几个区域:

区域显示内容
顶部当天日期和一句简单的提示语
今日饮水已喝水量、目标、进度圆环、还差多少和记录次数
喝一杯水几个常用杯量,以及记录按钮
最近 7 天最近几天的目标完成情况
今日记录今天每一笔饮水的时间和水量

底部则分为“首页”和“我的”两个入口。

WaterDiary 首页:当天尚未记录饮水,已喝水量和次数均为 0

首页上方显示当天日期和饮水进度。当天还没有记录时,已喝水量和次数都是 0,圆环显示浅色背景。

当前项目里的自定义记录、设置和历史页面也已经实现了,后面两篇再分别展开。这一篇先沿着“选择杯量 → 点击记录 → 更新首页 → 本地保存”这条操作往下看。

下面的代码是从当前项目中按功能摘出的片段,文件位置会一起标明。像弹窗、历史图表这样的关联部分会保留必要说明,完整文件中的其他逻辑不在这里逐行展开。

先把页面和数据分开放

功能增加后,我把原来模板中的内容拆成了几个部分:

WaterDiary/
├─ WaterDiaryApp.swift             应用入口
├─ ContentView.swift               底部导航和共享数据对象
├─ Models/
│  └─ WaterModels.swift            记录、归档和每日统计模型
├─ Stores/
│  └─ WaterStore.swift             记录操作、统计和本地读写
├─ Theme/
│  └─ WaterStyle.swift             共用颜色
└─ Views/
   ├─ Home/WaterHomeView.swift     首页
   └─ Components/
      ├─ WaterProgressCard.swift  进度卡与圆环
      ├─ WaterEntryRow.swift      单条记录
      └─ WaterWeekChart.swift     最近 7 天图表

这里列的是本文会涉及的主要文件。

对我来说,这样拆开最直接的好处是:调整圆环样式时去看进度卡,处理保存问题时去看 Store,不用每次都在一个很长的页面文件里找代码。

2. 先把首页的结构搭起来

WaterDiaryApp.swift 继续负责显示 ContentView,底部导航放在 ContentView.swift 中。它的核心结构是:

@StateObject private var store = WaterStore()

var body: some View {
    TabView {
        WaterHomeView(store: store)
            .tabItem {
                Label("首页", systemImage: "drop.fill")
            }

        WaterProfileView(store: store)
            .tabItem {
                Label("我的", systemImage: "person.crop.circle")
            }
    }
    .tint(WaterStyle.blue)
    // 日期刷新和错误提示放在完整文件中,后文及后续文章再说明。
}

TabView 对应底部的两个页面,Label 同时提供文字和 SF Symbols 图标,.tint 设置主要强调色。“我的”页面这里先接入入口,具体内容放到下一篇。

注意两个页面接收到的是同一个 store。首页新增记录后,“我的”页面需要读取的统计也来自这里,不用在两个页面之间再复制一份记录。

首页 WaterHomeView.swift 则用 ScrollView 包住纵向排列的内容。下面摘出布局部分:

NavigationView {
    ScrollView {
        VStack(alignment: .leading, spacing: 28) {
            header
            WaterProgressCard(today: today) {
                showGoal = true
            }
            quickAdd
            WaterWeekChart(days: store.recentDays, date: store.now)
            records
        }
        .padding(.horizontal, 22)
        .padding(.top, 18)
        .padding(.bottom, 28)
        .frame(maxWidth: 620)
        .frame(maxWidth: .infinity)
    }
    .background(WaterStyle.background.ignoresSafeArea())
    .navigationBarHidden(true)
    // 完整页面还在这里接入了记录成功后的底部提示。
}
.navigationViewStyle(.stack)

header、quickAdd 和 records 是首页里的计算属性,分别返回对应区域的视图;进度卡和周图表则拆成了单独的组件。

用 ScrollView 是考虑到屏幕高度。上一篇使用的是 iPhone SE 这类小屏设备,首页内容多起来后,让页面自然滚动会更合适。

两个 .frame 也各有用途:先把内容宽度限制在 620 以内,再让外层占满可用宽度并居中。这样在更宽的屏幕上,卡片不会一直横向拉伸。

配色统一放在 Theme/WaterStyle.swift:

import SwiftUI
import UIKit

enum WaterStyle {
    static let blue = Color(red: 0.08, green: 0.40, blue: 0.86)
    static let aqua = Color(red: 0.20, green: 0.74, blue: 0.89)
    static let background = Color(UIColor.systemGroupedBackground)
    static let surface = Color(UIColor.secondarySystemGroupedBackground)
}

蓝色用于按钮和主要进度,页面背景和卡片背景使用系统分组颜色。后面想调整整体颜色时,从这个文件改会方便一些。

3. 一条喝水记录需要保存什么?

页面的结构有了,接下来先把数据确定下来。

一笔饮水记录暂时只需要三项:标识、水量和时间。在 Models/WaterModels.swift 中定义:

struct WaterEntry: Codable, Identifiable, Equatable {
    let id: UUID
    var amount: Int
    var date: Date
}

amount 的单位统一使用 毫升;date 保存记录时间;id 用来区分每一条记录,即使两次喝的水量一样,也仍然是两笔不同的记录。

这里的几个协议也都有实际用途:

协议在项目中的用途
Codable将记录编码保存,并在下次打开时解码读取
Identifiable为 SwiftUI 列表提供稳定标识
Equatable比较记录内容,后面的记录提示状态也会用到

除了每一笔记录,还要保存每日目标和常用杯量,所以再定义一个统一的归档结构:

struct WaterArchive: Codable {
    var dailyGoal = 2000
    var cupAmount = 250
    var entries: [WaterEntry] = []
    var dailyGoals: [String: Int] = [:]
}

这里的 2000 毫升 和 250 毫升 是程序初始值,用户后面可以修改。dailyGoals 保存按日期区分的目标,目的是在以后修改目标时,保留已有记录日期对应的目标,下一篇再细讲。

首页需要展示“今天总共喝了多少、喝了几次、是否达标”,这些内容由 WaterDay 表示:

struct WaterDay: Identifiable {
    let date: Date
    let amount: Int
    let goal: Int
    let count: Int

    var id: Date { date }
    var progress: Double { min(Double(amount) / Double(goal), 1) }
    var isComplete: Bool { amount >= goal }
}

比如当天喝了 500 毫升,目标是 2000 毫升,进度就是 0.25。

progress 最多取到 1,是为了让圆环画到一整圈就停下。实际饮水量仍然保留原来的数值,不会因为超过目标就被改成目标水量。这里的除法建立在目标大于零的前提上,Store 会限制目标的有效范围。

4. 今日饮水的圆环怎么画?

有了 WaterDay,进度卡就能从中读取水量和进度。

圆环放在 WaterProgressCard.swift 里的 WaterGauge 中。它的主体其实不复杂:两个圆叠在一起,中间再放文字。下面摘出绘制部分:

ZStack {
    Circle()
        .stroke(WaterStyle.blue.opacity(0.08), lineWidth: 12)

    Circle()
        .trim(from: 0, to: progress)
        .stroke(
            WaterStyle.blue,
            style: StrokeStyle(lineWidth: 12, lineCap: .round)
        )
        .rotationEffect(.degrees(-90))

    VStack(spacing: 6) {
        Image(systemName: complete ? "checkmark.seal.fill" : "drop.fill")
            .font(.system(size: 40))
            .foregroundStyle(WaterStyle.blue)

        Text("\(amount)")
            .font(.system(size: 47, weight: .bold, design: .rounded))
            .monospacedDigit()
            .minimumScaleFactor(0.5)
            .lineLimit(1)

        Text(complete ? "今日已达标 / 毫升" : "已喝 / 毫升")
            .font(.subheadline)
            .foregroundStyle(.secondary)
    }
    .padding(28)
}
.padding(10)
.frame(maxWidth: 245)

底下的浅色圆是一整圈背景,上面的蓝色圆通过 .trim(from: 0, to: progress) 只显示对应比例的路径。比如进度是 0.25,蓝色部分就是四分之一圈。

再转动 −90 度,让进度从顶部开始;lineCap: .round 则让进度条两端变成圆头。

圆环中间根据是否达标切换图标和文字。数字加上 .monospacedDigit(),是为了让数字使用等宽字形,水量变化时看起来更稳定。

进度卡里调用圆环时,使用 SwiftUI 的系统默认动画,让圆环跟随进度变化:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

// 以下放在进度卡的 body 中。
WaterGauge(
    progress: today.progress,
    amount: today.amount,
    complete: today.isComplete
)
.frame(height: 245)
.animation(
    reduceMotion ? nil : .default,
    value: today.progress
)

这里监听的是 today.progress,所以记录水量或修改目标后,只要完成比例改变,圆环就会跟着过渡。动画采用 .default,由系统决定节奏;不同系统版本的默认表现可能有所区别。开启系统“减弱动态效果”时,这段状态动画关闭。

相关 API 可以参考 Apple:Animation.default。

圆环下面的三个数字也都直接来自 today:进度取 Int(today.progress * 100),剩余水量取 max(today.goal - today.amount, 0),饮水次数取 today.count。

所以超过目标后,剩余水量显示为 0,不会变成负数。

5. 选择杯量后,点击按钮发生了什么?

回到首页,先看杯量选择。

WaterHomeView 中有一个局部状态:

@State private var selectedAmount = 250

它表示当前选中了哪个杯量,还没有生成饮水记录。点击 150 毫升 或 350 毫升 时,只更新这个值,等用户点“记录喝水”才真正保存。

杯量选项来自:

private var cupOptions: [Int] {
    Array(Set([150, 250, 350, store.archive.cupAmount])).sorted()
}

除了几个固定选项,还把用户设置的常用杯量加进来。先用 Set 去重,再排序,比如常用杯量设为 500 毫升,首页就会多出 500 毫升;如果仍是 250 毫升,也不会出现两个一样的选项。

选项通过 LazyVGrid 排列,选中时改变文字、背景和边框颜色。网格使用 .adaptive(minimum: 68),让列数随可用宽度调整。

杯量切换也使用系统默认动画。首页先读取系统的“减弱动态效果”设置,再把动画绑定到网格的选中值:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

// 以下片段位于首页的 quickAdd 中。
LazyVGrid(columns: [GridItem(.adaptive(minimum: 68))], spacing: 10) {
    // 原有的杯量按钮与选中样式。
}
.animation(reduceMotion ? nil : .default, value: selectedAmount)

杯量按钮和记录按钮使用 .buttonStyle(.automatic),按压反馈交给系统默认按钮样式处理。杯量切换仍然只改变选择,记录数据继续等点击提交后再保存。

真正提交记录的是下面这个按钮:

Button {
    if store.add(amount: selectedAmount) {
        lastAdded = store.archive.entries.last
    }
} label: {
    Label("记录喝水 · \(selectedAmount) 毫升", systemImage: "plus")
        .font(.headline)
        .frame(maxWidth: .infinity)
        .padding(.vertical, 18)
        .foregroundStyle(.white)
        .background(WaterStyle.blue)
        .clipShape(RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.automatic)

这里把操作交给 store.add。只有它返回成功,首页才设置 lastAdded,用于显示“已记录”的底部提示。提示中的撤销操作留到下一篇一起讲。

选择 250 毫升 并记录后,今日水量为 250 毫升,进度为 12%,还差 1750 毫升,饮水次数为 1

选择 250 毫升 并点击「记录喝水」后,已喝水量、进度和饮水次数一起更新。杯量选项本身只负责选择,点击记录按钮才会新增一笔记录。

为什么把记录交给 WaterStore?

Stores/WaterStore.swift 中,主要的数据状态是:

final class WaterStore: ObservableObject {
    static let amountRange = 1...2000
    static let goalRange = 500...10000
    private static let storageKey = "waterDiary.archive.v1"

    @Published private(set) var archive: WaterArchive
    @Published private(set) var now: Date
    @Published var errorMessage: String?

    private let defaults: UserDefaults
    private var canSave = true
    private var calendar: Calendar { .autoupdatingCurrent }

    // 初始化、记录操作和读写方法见后面的代码片段。
}

配合前面的页面,这几个状态相关写法可以先这样理解:

写法当前项目中的职责
@StateObjectContentView 创建并持有 Store,生命周期跟随对应的视图身份
@ObservedObject首页等子页面接收并观察已有 Store
@Published数据变化时发布通知,让观察它的页面更新显示
@State保存当前页面的局部状态,比如选中的杯量

首页接收 Store 的写法就是:

@ObservedObject var store: WaterStore

archive 加上 private(set),让页面可以读取,但修改要经过 Store 的方法。这样水量范围、日期检查和保存过程都可以集中处理。

add 方法的实际代码如下:

@discardableResult
func add(amount: Int, date: Date = Date()) -> Bool {
    guard Self.amountRange.contains(amount), date <= Date() else {
        return false
    }

    var updated = archive
    preserveGoal(for: date, in: &updated)
    updated.entries.append(
        WaterEntry(id: UUID(), amount: amount, date: date)
    )
    return save(updated)
}

它先检查单次水量是否在 1–2000 毫升 范围内,以及记录时间是否超过当前时间;通过后复制一份归档,追加带有新 UUID 的记录,再交给 save。

preserveGoal 会在这个日期尚未保存目标时记下当前目标。它与日期键的实现是:

private func dayKey(_ date: Date) -> String {
    let parts = calendar.dateComponents([.era, .year, .month, .day], from: date)
    return "\(parts.era ?? 0)-\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
}

private func preserveGoal(for date: Date, in updated: inout WaterArchive) {
    let key = dayKey(date)
    if updated.dailyGoals[key] == nil {
        updated.dailyGoals[key] = updated.dailyGoal
    }
}

这里先知道它随记录一起保存即可,下一篇再结合修改目标的操作解释。

6. 总量、次数和列表怎样一起更新?

添加记录后,首页有几处内容会一起变化:已喝水量增加、圆环向前走、次数增加,列表也多一条。

这里不需要在按钮中分别修改这几个数字。Store 直接从记录中计算当天的数据:

var today: WaterDay { day(for: now) }
var todayEntries: [WaterEntry] { entries(on: now) }

func entries(on date: Date) -> [WaterEntry] {
    archive.entries
        .filter { calendar.isDate($0.date, inSameDayAs: date) }
        .sorted { $0.date > $1.date }
}

func day(for date: Date) -> WaterDay {
    let records = entries(on: date)
    return WaterDay(
        date: calendar.startOfDay(for: date),
        amount: records.reduce(0) { $0 + $1.amount },
        goal: archive.dailyGoals[dayKey(date)] ?? archive.dailyGoal,
        count: records.count
    )
}

entries(on:) 按当前日历筛选同一天的记录,再按时间倒序排列;day(for:) 将这些记录的水量相加,并统计条数。

这样,圆环和列表读取的都是同一批记录。以后修改或删除一笔记录时,相关显示也能继续由这份数据计算出来,省去手动同步几个数字的麻烦。

首页显示记录的关键分支是:

if store.todayEntries.isEmpty {
    VStack(spacing: 10) {
        Image(systemName: "drop")
            .font(.title)
            .foregroundStyle(WaterStyle.aqua)
        Text("今天的第一杯,从现在开始")
            .font(.subheadline)
            .foregroundStyle(.secondary)
        Text("选择杯量,点击「记录喝水」")
            .font(.caption)
            .foregroundStyle(.secondary)
    }
    .frame(maxWidth: .infinity)
    .padding(.vertical, 30)
} else {
    // 完整代码还包含卡片背景和记录间的分隔线。
    ForEach(store.todayEntries) { entry in
        Button {
            editingEntry = entry
        } label: {
            WaterEntryRow(entry: entry)
        }
        .buttonStyle(.automatic)
    }
}

没有记录时,显示提示,引导用户选择杯量并点击记录;有记录时,通过 ForEach 生成记录行。

记录区域的外层 VStack 也绑定了系统默认动画:

.animation(reduceMotion ? nil : .default, value: store.todayEntries)

这行放在整个 records 区域末尾。当天记录新增、删除或调整时间时,SwiftUI 会处理对应视图的默认过渡和位置变化;从空状态变成第一条记录时,也使用同一套状态动画。WaterEntry 的 UUID 保持稳定,让系统能够区分一条记录被修改,还是新增了另一条。

WaterEntryRow 接收一条 WaterEntry,负责显示水滴图标、时间和 +250 毫升 这样的水量文字。点击后的编辑弹窗由首页处理,记录行本身不负责保存数据。

今日记录列表:14:23 记录喝水 250 毫升,当天共 1 次

往下滑到“今日记录”,可以看到刚才新增的 250 毫升,以及对应的记录时间。上方的最近 7 天图表也从同一份归档中计算,具体统计方式留到第四篇。

比如目标是 2000 毫升,第一次记录 250 毫升 后,计算得到:

项目对应结果
已喝水量250 毫升
饮水次数1 次
圆环比例0.125,也就是 12.5%
页面进度文字12%
还差1750 毫升

这里进度文字显示 12%,是因为当前代码使用 Int(today.progress * 100),会截掉小数部分,圆环本身仍然使用原来的比例。

7. 重新打开后,记录从哪里来?

到这里还差本地保存。如果只把记录放在内存数组里,结束 App 后重新启动,这些数据就没有了。

当前版本使用 Codable + UserDefaults:先把 WaterArchive 编码成 JSON 数据,再存入一个固定的键中;下次创建 Store 时,按同一个键读取并解码。

对于当前这个记录规模较小、没有复杂查询的小项目,先用这种方式实现本地保存,代码比较容易看懂。随着记录增多,它每次编码和写入的仍然是整份归档;以后如果扩展到大量数据或更复杂的查询,可以再评估数据库方案。

保存时,先处理归档,再更新页面状态

WaterStore 中的保存方法是:

private func save(_ updated: WaterArchive) -> Bool {
    guard canSave else {
        errorMessage = "本地记录尚未恢复,暂时不能保存。请重新打开应用后重试。"
        return false
    }

    do {
        let data = try JSONEncoder().encode(updated)
        defaults.set(data, forKey: Self.storageKey)
        archive = updated
        now = Date()
        return true
    } catch {
        errorMessage = "这次记录没有保存成功,请重试。"
        return false
    }
}

这里的顺序是:编码归档、提交给 UserDefaults,然后替换 archive 并更新当前时间。archive 的变化会通知观察它的页面,首页再重新读取今日统计和记录列表。

前面的 add 先操作一份 updated 副本,也正是为了在编码成功前,不先把页面展示的数据改掉。

有个地方容易混淆:@Published 负责状态变化通知,本地保存仍然靠这里显式调用的编码和写入代码。 只给属性加上 @Published,并不会自动保存记录。

另外,UserDefaults.set 提交后由系统管理后续落盘,方法没有返回磁盘写入成功状态。因此这里的 true 表示当前校验、编码和设置流程完成,不等于另外做过同步磁盘写入确认。

启动时,读取同一份归档

初始化 Store 时,对应代码是:

init(defaults: UserDefaults = .standard, now: Date = Date()) {
    self.defaults = defaults
    self.now = now

    if let data = defaults.data(forKey: Self.storageKey) {
        do {
            let saved = try JSONDecoder().decode(WaterArchive.self, from: data)
            guard Self.goalRange.contains(saved.dailyGoal),
                  Self.amountRange.contains(saved.cupAmount),
                  saved.entries.allSatisfy({ Self.amountRange.contains($0.amount) }),
                  Set(saved.entries.map(\.id)).count == saved.entries.count,
                  saved.dailyGoals.values.allSatisfy({ Self.goalRange.contains($0) }) else {
                throw CocoaError(.coderReadCorrupt)
            }
            archive = saved
        } catch {
            archive = WaterArchive()
            canSave = false
            errorMessage = "暂时无法读取本地记录。原始数据已保留,请重新打开应用后重试。"
        }
    } else {
        archive = WaterArchive()
    }
}

第一次打开,没有保存过数据,就使用默认归档;已经有数据时,先解码,再检查目标、水量范围和记录标识是否重复。

读取失败则进入保护状态:页面暂时使用默认值,保留原始保存数据,并禁止继续写入。这样可以避免一次读取异常后,又用空归档覆盖原来的记录。

这里不是自动修复数据。重新打开后如果仍然读取失败,保护状态也仍然会存在,异常处理的细节放到最后一篇再展开。

8. 这一部分可以怎样检查?

把这些代码串起来,一次快捷记录的过程就是:

选择杯量
   ↓
点击“记录喝水”
   ↓
WaterStore.add 校验并追加记录
   ↓
save 编码归档并设置到 UserDefaults
   ↓
更新 @Published archive 和当前时间
   ↓
首页重新读取今日总量、进度和记录列表

跟着实现后,可以在设备上按下面这个顺序检查。这里列的是检查步骤和预期结果,具体运行情况以自己的设备为准:

操作预期结果
没有本地归档时首次打开今日水量和次数为 0,显示空状态提示
只切换杯量,不点击记录选中样式和按钮文字变化,今日水量不变
目标 2000 毫升 时记录 250 毫升总量 250 毫升、1 次、进度文字 12%,列表新增一条记录
再记录 350 毫升总量 600 毫升、2 次、进度 30%
完全结束 App,再在同一天重新打开重新读取归档,仍能显示之前两笔记录
继续记录到超过目标实际水量继续增加,圆环封顶,剩余水量显示为 0
切换杯量、添加第一条记录选中样式与记录区域使用系统默认过渡,按钮提供系统交互反馈
开启系统“减弱动态效果”本文显式添加的状态动画关闭,记录操作仍正常完成

“重新打开”这一步要和切到后台区分开。单纯回到桌面再进入时,原来的 Store 可能还在内存中;结束应用后重新启动,才更能检查初始化时的读取过程。检查期间也不要卸载 App,卸载后的数据保留不属于这里的验证范围。

如果显示结果不符合预期,我会先沿着数据经过的几个位置往回查:

现象先检查什么
选择杯量就增加了饮水量杯量按钮是否只更新 selectedAmount,记录是否只在提交按钮中执行
新增成功,但首页没有变化页面是否观察了共享 Store,保存后是否更新了 archive
总量和列表对不上是否都从当天记录计算,有没有另外维护一份总量
同一天重新启动后记录丢失保存与读取是否使用同一个键,读取时是否进入错误分支
超过目标后圆环显示异常progress 是否限制在最大 1,目标值是否经过校验

做到这里,WaterDiary 就从上一篇的默认页面,变成了可以记录饮水并保存到本地的小工具。页面上的圆环、次数和记录列表,也都开始围绕同一份数据工作。

这一部分里,我觉得最值得理清的是按钮背后的过程:用户在首页操作,Store 处理记录和读写,页面再根据数据变化更新。后面继续做编辑、删除和历史页面时,就能沿着这条路径往下扩展。

下一篇接着完善日常使用中的操作:自定义水量、补记、修改和删除记录,以及“我的”页面中的每日目标、常用杯量设置。


参考资料: