- §6.2 睡眠时段图:动因(用户原话「只有最高时间最低时间,我不知道一天内具体什么时候睡的觉」)、 SleepSession 为何不复用 MetricPoint、横轴锚在 20:00 的理由(含 % 86400000 是 UTC 余数这个坑)、 轴范围由数据算、只用一个颜色不假装知道分期位置、刻度文字为何画在 Canvas 里、 以及睡眠页撤掉柱状图的理由 - §6.3 功能开关联动:现象与根因(两个互不引用的字段 + 「开关 id 与枚举长得像」这个巧合)、 修法(MetricType.switchId、仓库订阅开关流、todaySummary 刻意不给默认值)、 顺带纠正的两处(未开启不画图、血压卡恢复可点击),以及三项已知未修的结构性重复 - §5.1 接口补 functionSettings / observeSleepSessions / SleepSession / MetricType.switchId - §6.1 睡眠页的「趋势图」改为「睡眠时段图」;§6 的 M1 差异注记标注同日已关闭 - §7.3 组件清单补 SleepWindowChart 及其与 MetricBarChart 的边界
36 KiB
智能戒指健康 App 设计文档(阶段一)
- 日期:2026-09-10
- 状态:待评审
- 产品代号(占位):
ring-health - 相关仓库:
ring-health-app(Android App)、ring-health-api(后端 API)
1. 背景与目标
我们要为自有的智能戒指/手环做一款手机 App,用于后续与设备联调、并最终面向用户。 交互参照小米运动健康(Mi Fitness),但按我们戒指的真实能力裁剪。
阶段一目标(本文档范围):用原生 Android + Jetpack Compose 搭出完整可运行的 静态页面骨架,页面齐全、导航顺畅、真机可跑;所有数据来自本地 Mock。
不在阶段一范围:真实蓝牙连接、协议解析、本地数据库、账号登录、云端同步。 这些在 M2 及以后的里程碑中,通过预留的接口边界接入。
2. 现状:厂商 SDK 分析(已探明)
手上的 SmartRingF01_SDK 不是 .aar 库,而是厂商「鑫域隆 SmartRingF01/F02」的完整 Demo App。
| 项 | 现状 |
|---|---|
| 工程名 / 包名 | SmartRingDCLT_SDK / com.sr.smartring |
| 可复用核心 | BluetoothLeClass(BLE 扫描/连接/GATT 收发,仅依赖 Android 框架,零第三方依赖) |
| 协议收发 | 写在厂商 MainActivity 的静态方法:sendBleData / recvBleData / recvBleWriteData,帧格式 DC 0001 <cmd> <data> LT(下行)、DC 0002 …(上报) |
| 辅助类 | HEROUtils(OTA 下载 + CRC32)、Utils、BluetoothApplication、LeDeviceListAdapter |
| 技术栈 | Java + Android Support Library(非 AndroidX) + EventBus 3 + Gradle 6.7.1 / AGP 4.2.2(很旧) |
| 协议文档 | 鑫域隆智能戒指协议.txt(GBK 编码,含全部指令码) |
戒指能力(来自协议文档,决定我们能做哪些页面): 计步、心率、血氧、睡眠、血压、体温、压力、运动检测、查找戒指、电量、佩戴/充电状态、OTA 升级、自动监测开关配置、时间同步。
复用策略:SDK 内容不修改。M2 接 BLE 时,把 BluetoothLeClass 等纯通讯类原样拷贝进
App 的 vendor/ 包(保持 com.sr.smartring 包名与代码原样),我们只新写一层适配器去调用它。
UI 永远不直接依赖厂商代码。
3. 仓库与目录结构
遵循团队 docs/ + repos/ 惯例;仓库名 kebab-case,移动端 -app、后端 -api。
d:\cherry
├── SmartRingF01_SDK/ # 厂商原始工程(只读参考,不纳入任何 git 仓库)
├── docs/ # 项目文档根(独立 git 仓库)
│ └── superpowers/specs/ # 设计/技术文档
└── repos/
├── ring-health-app/ # Android App(Kotlin + Compose) ← 阶段一主战场
└── ring-health-api/ # 后端 API ← 阶段一仅脚手架 + 接口契约
ring-health-app:阶段一全部实现在此。ring-health-api:阶段一只放空骨架(README + gitignore)与docs/api-contract.md契约草稿, 不写业务接口。后端技术栈(Spring Boot / .NET)待 M4 真正做云同步时再定。
4. App 技术选型
| 项 | 选择 | 理由 |
|---|---|---|
| 语言 | Kotlin | Compose 唯一舒适选择 |
| UI | Jetpack Compose + Material 3 | 写卡片/图表类仿小米页面效率最高 |
| 导航 | Navigation-Compose,单 Activity | 多 Tab + 深层子页标准做法 |
| 架构 | MVVM + StateFlow(UiState) |
官方推荐,易测试 |
| 异步 | Coroutines + Flow | — |
| 图表 | Compose Canvas 手绘(今日大环、柱状/折线占位) | 阶段一不引第三方图表库(YAGNI) |
| DI | 手动组合根(Application 中构造仓库) |
阶段一无需 Hilt,后续可平滑替换 |
| 构建 | Gradle 8.14 + AGP 8.13.1 + JDK 17 + libs.versions.toml |
本机已缓存 Gradle 8.14,免联网下载。 |
| SDK | minSdk 26 / targetSdk 36 / compileSdk 36,build-tools 36.1.0 |
对齐厂商 minSdk(26); |
注记(2026-09-10,M1 实施后回填): 上表两行原写 AGP 8.11.x / compileSdk 35 / build-tools 35.0.0, 与最终交付的
app/build.gradle.kts不符 —— 代码是对的,是本文档当时没跟着更新。 实际工具链:AGP 8.13.1、compileSdk/targetSdk 36、buildToolsVersion 36.1.0、minSdk 26、 Kotlin 2.2.20。下调理由见 Ruling R5(工具链以本机已装版本为准)与 R6(越界依赖下调至工具链可接受的最新版本), 完整证据链写在gradle/libs.versions.toml顶部的注释里。 另外compose-bom用的是 2026.06.01(compose 1.11.4),不是 2026.09.00 —— 那组版本要 compileSdk 37 + AGP 9.1.0,与本工具链互斥。
阶段一依赖清单(刻意精简):androidx.core:core-ktx、androidx.activity:activity-compose、
androidx.lifecycle:lifecycle-runtime-compose / lifecycle-viewmodel-compose、
androidx.navigation:navigation-compose、androidx.compose:compose-bom(ui / material3 /
material-icons-extended / ui-tooling-preview)、kotlinx-coroutines。
不引 Hilt、Room、Retrofit、第三方图表库。
5. App 架构分层(单 :app 模块 + 严格分包)
com.placeholder.ringhealth
├── RingHealthApplication.kt # 组合根:手动构造 Repository,注入 ViewModel
├── MainActivity.kt # 唯一 Activity,承载 Compose NavHost
├── core/
│ ├── designsystem/ # Theme/Color/Type/Shape + 通用组件
│ │ └── component/ # HealthCard、RingProgress、MetricTile、SectionHeader…
│ ├── common/ # 格式化与扩展(步数、时长、心率区间)
│ └── navigation/ # Route 常量与 NavGraph
├── domain/
│ ├── model/ # 纯 Kotlin 数据模型(无 Android 依赖)
│ └── repository/ # 仓库接口(UI 只依赖这里)
├── data/
│ └── mock/ # Mock 实现 + 造数器(阶段一)
│ # M2 将新增 data/sdk/ 作为厂商 SDK 适配层
├── feature/ # 每个 Tab / 子页一个包:Screen + ViewModel + UiState
│ ├── home/ workout/ device/ profile/ detail/
└── vendor/ # 预留:M2 原样存放厂商 com.sr.smartring 通讯类(不改一行)
核心边界原则:UI(feature/*)只依赖 domain/repository 的接口与 domain/model。
阶段一注入 Mock 实现;M2 新增 SDK 适配实现,接口不变则 UI 零改动。
5.1 关键接口(阶段一定稿)
// domain/repository
interface RingDeviceRepository {
val deviceState: StateFlow<RingDeviceState> // 连接态/电量/型号/固件版本
val functionSettings: StateFlow<List<DeviceFunctionSetting>> // 功能开关(见 §6.3)
suspend fun refreshDeviceState()
fun setFunctionEnabled(id: String, enabled: Boolean)
}
interface HealthDataRepository {
fun observeTodaySummary(): Flow<HealthSummary>
fun observeMetricHistory(metric: MetricType, range: DateRange): Flow<List<MetricPoint>>
fun observeSleepSessions(range: DateRange): Flow<List<SleepSession>> // 见 §6.2
fun observeWorkouts(): Flow<List<WorkoutRecord>>
}
// domain/model(节选)
// switchId 为 null 表示「常开」(计步、体温),见 §6.3
enum class MetricType(val switchId: String?) {
STEP(null), HEART_RATE("heart_rate"), SLEEP("sleep"), BLOOD_OXYGEN("blood_oxygen"),
STRESS("stress"), TEMPERATURE(null), BLOOD_PRESSURE("blood_pressure"),
}
data class RingDeviceState(
val connected: Boolean, val name: String, val batteryPercent: Int,
val firmwareVersion: String, val charging: Boolean,
)
data class HealthSummary( // 今日概览
val steps: Int, val stepGoal: Int, val calories: Int, val distanceKm: Double,
val heartRate: Int?, val sleepMinutes: Int?, val bloodOxygen: Int?,
val stress: Int?, val temperature: Double?, val bloodPressure: String?, // 血压未开启时为 null
)
data class MetricPoint(val timestamp: Long, val value: Double)
/** 一晚睡眠。睡眠占的是一段区间,MetricPoint 装不下,所以单独建模(§6.2)。 */
data class SleepSession(
val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long,
val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int,
)
约定:ViewModel 用 stateIn 把 Flow 转成 StateFlow<XxxUiState>;null 表示"该指标未开启/无数据",
UI 统一走"未开启占位态",不显示 0。
6. 页面地图与路由
底部四 Tab(仿小米运动健康),并按戒指能力裁剪:
| Tab | 页面内容 | 路由 |
|---|---|---|
| ① 健康·首页 | 顶部问候 + 设备连接状态条;今日大环(步数/卡路里/距离);指标卡片网格:心率 / 睡眠 / 血氧 / 压力 / 体温(血压显示"未开启/需校准"占位) | home |
| ② 运动 | "开始运动"入口(跑步/骑行/自由训练占位)+ 运动历史记录列表(静态) | workout |
| ③ 设备 | 戒指卡片(名称/电量/连接态/固件)+ 设置列表:健康自动监测开关(心率/血氧/血压/压力/睡眠,对应协议 0502 开关位) |
device |
| ④ 我的 | 头像/昵称(登录占位)、App 设置(单位/通知)、通知权限引导、关于/检查更新、帮助与反馈 | profile |
详情子页(全部静态 + Canvas 图表占位):
| 路由 | 内容 |
|---|---|
detail/step |
今日步数详情:大环 + 7 天/月切换柱状图 + 说明文案 |
detail/heart_rate |
心率详情:当日折线/区间统计(静息/运动)+ 历史区间 |
detail/sleep |
睡眠详情:深睡/浅睡/清醒分段条 + 入睡/起床时间 + 睡眠时段图(§6.2) |
detail/blood_oxygen |
血氧详情:趋势折线 + 正常范围说明 |
device/settings |
戒指功能开关详情(静态开关,M2 后写向设备) |
workout/history |
运动历史列表 |
M1 实际交付与上表的差异(2026-09-10 回填,全分支终审发现): 上面
detail/*的四条路由实际合并成了一条:detail/{metric}(Routes.DETAIL_PATTERN, 参数为MetricType.name),七个指标共用同一个模板 —— 柱状趋势图 + 周/月切换 + 平均/最高/最低。 仅睡眠保留了专属区块(SleepStagesCard:深睡/浅睡/清醒分段条 + 入睡/起床时间)。 未实现的是: 步数的大环、心率的折线/静息-运动区间、血氧的正常范围说明文案。 同日晚已关闭这条差异:七个页面各自独立成文件并各自专属化,见 §6.1;睡眠另加时段图,见 §6.2。 收敛本身是有意的(避免七套几乎相同的页面代码),但当时没留下裁定记录 —— 这不妥, 此处补记,供后续决定是否回归为专属设计。这是 M1 已知的、有意的范围收窄,不是漏做。该悬案已关闭(2026-09-10 晚,见下方「详情页专属化」,§6.1)。 结论是回归专属设计, 但拆法与上表原来的设想不同:不是「一页一条路由」,而是共用一条路由、七个页面各自实现内容。
另一处当时没被记录的差异:首页顶部的「问候 + 日期」在 M1 根本没做。 上表白纸黑字写了「顶部问候」,交付的首页却是从设备状态条直接开始的 —— 而且这条差异在终审时也没被发现,是后来做视觉改版时才补上的(
greetingResFor/weekdayResFor,按 5/12/18/23 点四档给「早上好 / 下午好 / 晚上好 / 夜深了」)。 记在这里是因为它暴露了验收方式的问题:M1 的验收逐条核对了「页面在不在、能不能点」, 却没核对「页面上该有的东西在不在」—— 纯静态的文案块最容易这样漏掉。
明确不做(小米有但我们戒指不支持):跑步课程、商城、社区、多设备接入、体重秤/体脂秤。
6.1 详情页专属化(2026-09-10 晚,关闭上面的悬案)
动因。 M1 收敛成一套模板后,验收时的原话是「每个模块点开都差不多,只有颜色不一样, 能不能有特点一点」。这不是观感偏好 —— 七个页面共用「一张柱状图 + 平均/最高/最低」, 等于七个页面都只回答了同一个问题(「这几天怎么变的」),而每个指标真正要回答的问题并不相同。
方案:一页一个文件,共用路由。
feature/detail/
├── MetricDetailScreen.kt # 骨架:TopAppBar + 周/月切换 + 空态 + 按 metric 分派
├── MetricDetailShared.kt # 只放「每页都用且不因指标而变」的东西(成色、数值口径、轴刻度)
├── MetricStats.kt # 统计口径纯函数(+ 单测)
└── pages/ # 七个指标各一个文件,每个是一页的全部内容
├── StepDetail.kt HeartRateDetail.kt SleepDetail.kt BloodOxygenDetail.kt
├── StressDetail.kt TemperatureDetail.kt BloodPressureDetail.kt
每个 pages/*.kt 导出 internal fun LazyListScope.xxxDetail(state),自己决定往列表里放几张卡。
骨架里的 when (metric) 是唯一知道「有哪些页面」的地方。改一页不必碰其他六页。
七个页面各自的「主张」(这是本次改版的实质,不是换皮):
| 页面 | 这个指标真正要回答的问题 | 头图 | 趋势图 |
|---|---|---|---|
| 步数 | 今天走够了没有 | 进度环 + 完成度 % | 柱状图 + 目标线(不是均值线) |
| 心率 | 这个数算不算正常 | 大字 + 区间条(40–120 轴,正常带 60–100 高亮) | 柱状图 + 正常区间带 |
| 睡眠 | 昨晚这一觉睡得怎么样 / 我每天都几点睡 | 时长 + 分期条 + 各期时长 + 入睡/起床时刻 | 睡眠时段图(每晚一行,横轴是钟点) |
| 血氧 | 有没有掉到线下 | 大字 + 达标/偏低判定 | 阈值带(点阵 + 95% 阈值虚线,线下标红) |
| 压力 | 这几天是紧还是松 | 大字 + 等级名 | 柱状图按等级着色 + 四色图例 |
| 体温 | 比自己的基线高了还是低了 | 大字 + 较基线 ±x.x°C | 偏离条(基线居中,上下偏离的棒棒糖) |
| 血压 | ——(阶段一该功能未开启) | 空态说明 | 不画图 |
为此新增三个 Canvas 图元(core/designsystem/component/):MetricRangeBar(区间条)、
MetricThresholdStrip(阈值带)、MetricDeviationBar(偏离条);MetricBarChart 增两个参数
valueRange(取景窗口)与 bandRange(正常区间带),average 泛化成 referenceLine。
顺带修掉的两个真实缺陷(都是截图时看出来的,不是预先想到的):
- 窄区间指标的柱子全一样高。
MetricBarChart原来按value / maxValue定高, 血氧(95–99 → 比值 0.96–1.0)与体温(36.1–36.9 → 0.978–1.0)的七根柱子因此齐平, 整张图没有任何信息量 —— 这不是「不好看」,是数据被抹平了。加valueRange取景窗口解决。 (代码里原本有一条预览注释已经指出过同类问题,但修复只应用到了预览数据,没到真实范围。) - 头图的「当前值」与趋势图最后一根柱子对不上。 概览的
todaySummary()是固定演示值、 历史点由另一套随机序列生成,于是出现「头图写心率 72、图上今天却是 96」这种自相矛盾, 而且两个数字看起来都很确定。改为metricHistory的末点值取自todaySummary()(HealthSummary.valueOf(metric)),并加了单测钉住。
血压页为什么不画图。 血压是演示数据里唯一 bloodPressure = null(未开启)的指标,
首页那张卡据此渲染成「未开启」。若详情页画出一张完整趋势图,两页自相矛盾。
注意 metricHistory(BLOOD_PRESSURE) 仍会返回 110–130 的演示点 —— 本页刻意不读它。
M3 接入真实血压数据后,本页整体改为双值(收缩压/舒张压)详情页,
HealthSummary.bloodPressure 也从字符串改为结构化的两个数值。
这张卡必须能点开(同日晚修正)。 第一版为了堵上面那个矛盾,顺手让详情页按
「有没有数值」决定挂不挂 clickable —— 血压没有数值,于是不可点击。这堵过头了:
「未开启 + 怎么开启」的说明页因此一个入口都没有,成了死代码,用户也无从知道
该怎么把功能打开。矛盾的根源是详情页画了图,不是卡片可点击,所以改为由本页不画图来解决,
卡片恢复可点击;enabled = false 仍保留(数值位显示的是「未开启」三个字,不该用大号粗体,
看着比真实数值还重)。同理,本页不显示周/月切换 —— 那两个 chip 点了不会有任何东西跟着变。
6.2 睡眠时段图(2026-09-10 晚,追加)
动因。 睡眠页当时只有「最高/最低时长」两个数,用户的原话是 「只有最高时间最低时间,但我不知道一天内具体什么时候睡的觉」。 柱状图只画「睡了多久」,七根柱子一样高的时候看不出作息是不是一天天往后拖 —— 而「几点睡」恰恰是睡眠这个指标最直接的问题。
方案:SleepSession 单独建模,不复用 MetricPoint。
data class SleepSession(
val wakeDate: Long, val sleepStart: Long, val sleepEnd: Long,
val deepMinutes: Int, val lightMinutes: Int, val awakeMinutes: Int,
)
MetricPoint 只装得下一个 Double,而一晚睡眠占的是一段区间。硬塞进去的话
「入睡时刻」无处可放,只能像改版前那样拿「起床时刻往前推 [时长]」凑一个 ——
那份凑出来的时刻与时长永远自洽,因此永远看不出是凑的,但它不对。
HealthDataRepository 增加 observeSleepSessions(range),与 observeMetricHistory 同口径
(WEEK 七晚 / MONTH 三十晚)。
只有一个数据源。 todaySummary().sleepMinutes 取自最后一个会话,
metricHistory(SLEEP) 也由各会话折算成点(value = sleepMinutes)。
此前睡眠页上「头图 7 小时 32 分」和「图上另一根长度」是可以并存的 ——
同一屏里最显眼的两块内容各说各的。分期条的权重同样取自会话
(深睡 96 / 浅睡 356 / 清醒 22,合计即卧床时长),不再是写死的 25% / 65% / 10%。
SleepWindowChart(core/designsystem/component/):每晚一行,
一根圆角条从入睡横到醒来,横轴是钟点,左侧行标签(近 7 天用星期、近 30 天用日期)。
三个决定值得记下来:
- 横轴锚在 20:00。 一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到 1423 → 457
这样一对反过来的数,画出来入睡点跑到起床点右边。
minutesSinceSleepAnchor以 20:00 为界重新起算,两个时刻都落在 0–960 内。不能用timestamp % 86_400_000—— 那是 UTC 的余数,在东八区会把整张图平移八小时,而平移后每根条看着都还挺合理。 - 轴范围由数据算,不写死。 写死「20:00–12:00」的话,某晚熬到中午才睡, 那根条会被右端裁掉一截,看起来像睡得短。取整到最近的刻度线(2/3/4/6 小时, 取第一个能让刻度不超过 8 个的),刻度因此均匀铺满整条轴。
- 只用一个颜色,不画分期。 手头只有「深睡共 96 分钟」这样的累计时长, 没有「第 47 分钟进入深睡」这种分期序列。按比例铺成色块画的是一段并不存在的分期位置, 比不画更容易让人当真。M3 接入真实分期序列后再谈分期着色。
刻度文字画在 Canvas 里(TextMeasurer),没有另起一行等宽 Text。
等宽文本框的中心落在 (i + 0.5) / n,刻度线落在 i / (n - 1),中间几档会差出小半个格。
量一次宽度再居中,文字和网格线用的是同一个 xOf。首尾两档贴边 —— 居中的话各有一半落在画布外。
睡眠页不再画柱状图。 柱子高度是时长,时段图的条长同样是时长,两张图讲同一件事。 「目标 8 小时」原本是柱状图那条参考线的附注,改挂到头图右侧 —— 目标本身仍有用, 不该跟着那张图一起消失。
遗留: 分期时长(深睡/浅睡/清醒)本身仍是 Mock 按比例生成的演示值,
但生成的位置从渲染层挪到了数据层(MockDataProvider.sleepSessions),
M3 接入真实分期时只需换掉数据源,UI 与统计口径一行不用改。
HealthSummary.valueOf 对 BLOOD_PRESSURE 返回 null,同样等结构化的血压字段。
6.3 功能开关与健康数据的联动(2026-09-10 晚,修 bug)
现象。 用户在「设备 - 功能设置」里打开「血压监测」,首页那张卡纹丝不动。
根因不是漏了一个刷新,是两套系统。 DefaultAppContainer 里
ringDeviceRepository 与 healthDataRepository 是两个互不引用的字段,
健康数据由写死的常量生成,压根不认识开关。「开关 id 是 blood_pressure、
枚举是 BLOOD_PRESSURE」这个对应关系只存在于字符串长得像这个巧合里,
没有一行代码声明过它们是同一件事。同类症状还有一处:压力开关默认关着,
而 todaySummary() 一直给出 stress = 34。
修法:让开关成为首页数据的唯一真相来源。
MetricType显式带上switchId: String?,null 表示常开(计步与体温由戒指持续采集, 没有「关掉」这个选项)。enabledMetrics()由它算出「当前开着哪些指标」, 常开指标在这里兜底 —— 若按「找不到对应开关就算关」处理,一个空开关列表会让步数和体温 凭空变成「未开启」,而戒指一直在数着步。MockHealthDataRepository订阅functionSettings这个StateFlow,todaySummary/metricHistory/sleepSessions全部改为接受已开启指标集。todaySummary(enabledMetrics)刻意不给默认值 —— 默认「全都开着」会让漏传参数的调用方 看起来一切正常,而那正是这次要修的病。- 关掉的指标在概览里是
null,首页那张卡显示「未开启」。
顺带纠正的两处。
- 详情页对未开启的指标不画图。 此前未开启的指标只是首页显示「未开启」,
点进详情页照样画出七天的随机数据 —— 一段并不存在的监测记录,比空图更有误导性。
现在整页走共用的
MetricDisabledCard(图标与颜色取自MetricVisuals, 与首页那张卡完全一致,用户才知道自己没点错),周/月切换一并隐藏 —— 两个点了不会有任何东西跟着变的 chip 比不显示更让人困惑。 - 血压卡片恢复可点击。 第一版为了堵「首页说未开启、详情页画图」这个矛盾,
顺手让详情页按「有没有数值」决定挂不挂
clickable,于是血压页一个入口都没有,成了死代码。 矛盾的根源是详情页画了图,不是卡片可点击,所以改由「本页不画图」来解决。
未修的(已知,本次不动): 「设备」页与「更多设置」页渲染的是同一份 functionSettings
(同一批开关出现在两个地方,两个页面各自维护一套列表 UI);运动页的「最近运动」与
「运动历史」内容完全相同;「我的」页四个入口均为死链接。三项都已报告,未在本轮处理。
7. 设计系统与视觉
方向变更(2026-09-10,M1 真机验收后)。 本节原始表述为「仿小米运动健康:浅色为主, 白底卡片 + 大圆角 + 轻阴影」。该方向在验收时被否,原话是「我不想像小米我想更好看, 我觉得现在的页面不好看」。「像小米」从此不再是目标,也不再是验收标准; 目标改为「用同一套视觉语言,做得比小米干净」。下面 §7.1–§7.4 是改版后的实际口径, 其中每一条都对应当时截图上肉眼可见的一个问题 —— 原表述没能防住它们。
7.1 视觉口径(改版后)
- 两层底色,不是一层。 页面底
#EEF1F6(浅灰蓝),卡片#FFFFFF,分隔线#EFEFF4。 原表述只说了「白底卡片」,没说页面底是什么 —— 实现时页面底取了近白的#F5F6F8, 与卡片几乎同色,卡片边界只剩一层几乎看不见的阴影在撑,整屏糊成一片白。 底色必须与卡片拉开可见差距,卡片才立得起来。 - 卡片用真阴影,不是 M3
Card默认的 tonal elevation。 后者的 elevation 走的是 「给表面叠一层主色调」而不是投影,在浅灰底上表现成一团脏灰。现改为Surface+Modifier.shadow(5dp, 20dp 圆角, ambient/spot 双色)。 彩色阴影只在 API 28+ 生效,minSdk 26 会退化成黑色 —— 可接受,不为此提高 minSdk。 - 颜色是有含义的,不能当装饰用。 每个健康指标一个专属色(§7.2); 非指标的次要读数(距离、千卡)一律深色数值 + 灰单位,不上色 —— 否则用户会以为「蓝色」代表某种指标身份,点上去却什么也没有。
- 大数字用等宽数字(
fontFeatureSettings = "tnum"),否则数值刷新时字宽跳动、整行抖动。
7.2 指标色板
步数=绿、心率=红、睡眠=蓝、血氧=青、压力=紫、体温=橙、血压=黄(core/designsystem/Color.kt 统一定义)。
小字号绿字单列一色 BrandGreenText #12894A:品牌绿 #34C759 在 12sp 文字上对白底
只有约 2.0:1 对比度,远低于 WCAG AA 要求的 4.5:1 —— 小字用它读不清,故另立一色专供文字。
踩过的坑(M1 验收时被当成正常现象记录了下来): Material 3 的
secondaryContainer同时是「底部导航选中态药丸底」和「FilterChip 选中底」两处的取色来源。 主题里不显式覆盖它,就会落到 M3 默认的淡紫#E8DEF8,于是出现 「淡紫底 + 绿色文字」的撞色。现已在Theme.kt中显式指向浅绿NavIndicator。
7.3 组件清单(core/designsystem/component/)
HealthCard(白卡 + 真阴影)、RingProgress(Canvas 环形进度)、
MetricTile(2 列网格用的紧凑指标卡:色底图标徽章 + 数值 + 单位)、
IconBadge(圆形色底图标,首页指标卡与「我的」页共用)、SectionHeader、
StatBar(睡眠分段条)、MetricBarChart(Canvas 柱状图)、SubPageTopAppBar(子页顶栏)。
详情页专属化(§6.1)新增四个,都是「某个指标要回答的问题」缺一个图形语言时补的:
| 组件 | 图形语言 | 用在 |
|---|---|---|
MetricRangeBar |
横向轴 + 高亮的正常区间 + 一个带白环的当前值圆点 | 心率 |
MetricThresholdStrip |
点阵 + 阈值虚线,越线的点加警示色光晕 | 血氧 |
MetricDeviationBar |
基线居中的棒棒糖图,向上/向下分别着色 | 体温 |
ChartAxisLabels |
不是图元,是三个图共用的 X 轴标签行 | 全部 |
ChartAxisLabels的存在理由:X 轴标签要和柱子逐列对齐,所以「标签数 == 柱数时按weight等分、否则SpaceBetween」这段判断每个图都得写一遍。抽出来后三个图只调一次, 也保证了三张图的日期对位规则不会各走各的。
睡眠时段图(§6.2)另加一个:
| 组件 | 图形语言 | 用在 |
|---|---|---|
SleepWindowChart |
每晚一行,圆角条从入睡横到醒来,横轴是钟点(锚在 20:00,跨零点) | 睡眠 |
它不复用
MetricBarChart:后者的横轴是「第几天」,槽位等分、每根柱居中, 而时段图的横轴是连续的钟点,一根条的位置本身就是数据。两者只有配色可以共用。 唯一的例外是刻度文字 —— 时段图的刻度画在 Canvas 内(TextMeasurer量宽后居中), 因为它必须和竖向网格线共用同一个坐标换算,见 §6.2。
四条组件级约定,都是改版时踩出来的:
- 子页顶栏底色取
background,不取surface。 M3TopAppBar的默认容器色是surface(本应用里是纯白),而页面底是浅灰 —— 两者不同色时,顶栏会在浅灰页面上 糊出一条白色横带,同时状态栏那一条仍是页底色,顶部于是出现「灰、白、灰」三层。 三个子页统一改用SubPageTopAppBar。 MetricBarChart必须画基线、均值参考线与 X 轴刻度。 改版前它只有一个SectionHeader加一排浮空的胶囊柱:没有基线(柱子悬空)、没有刻度 (看不出哪根是哪天)、没有参照(读不出「今天比平时高还是低」)。三者补齐后这张图 才开始承载信息。参考线用中性灰而非柱色 —— 跟着柱色走会被当成「第 8 根柱子」。- 窄区间指标必须给
valueRange,否则柱子会被抹平。 柱子按value / max定高, 血氧(95–99)与体温(36.1–36.9)的七根柱子因此几乎齐平,整张图没有信息量。 这不是审美问题,是数据被压没了。valueRange把取景窗口收窄到数据的实际活动范围; 但睡眠与步数不能收——「睡了 3 小时」相对「睡了 8 小时」确实只有 3/8 高, 这个比例本身有含义,所以它们的下界仍是 0。 - 统计数值的小数位按指标分别定。 此前一律走
"%.1f",心率显示成「78.0」、 步数显示成「8426.0」—— 尾随的.0是假精度,看着像仪器精度比实际高一个数量级。 现在只有体温保留一位(36.5 与 36 的差别有实际意义),其余取整并补上单位。
7.4 其他
- 深色模式:阶段一仅保证浅色可用,深色留到 M5。
- 所有中文字符串进
strings.xml;禁止硬编码文案。多语言排在 M5(见下方注记 3)。 - 网格布局:首页六张指标卡用
LazyVerticalGrid两列 —— 原先单列时每张卡被拉到整屏宽 而高只有 180px(宽高比约 6:1),横向大片留白、纵向被撑开,六个数字要滚三屏才看得完。
M1 实施状态注记(2026-09-10,全分支终审后回填)—— 与上文不符的实际交付:
- 设备页删减了四项: 查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测, M1 未实现(见 Ruling R27,并已同步删除对应的 8 条字符串资源)。理由是阶段一没有真实协议可对接, 摆一排点不动的入口不如不摆。这些能力仍属于产品范围,将随后续里程碑回归;本文档上表已用删除线标注。
- 详情页由「四个专属设计」收敛为一个模板 —— 详见上方「详情子页」表下的注记。
- 多语言:
strings.xml目前只有中文,没有values-en/。本文档 「附英文」与 M5 把多语言排期两处自相矛盾;以 M5 为准,M1 不做。- 详情页仍缺三项已规划的内容(§6 注记已列):步数的大环、心率的折线/静息-运动区间、 血氧的正常范围说明。本次改版给柱状图补了刻度与参考线,但「这个值算不算正常」 仍需按指标给出参考区间才能回答 —— 那属于领域数据(应来自
domain/,不该写死在 UI 层),留到 M3 与真实数据一起做。
8. Mock 数据策略
data/mock/MockDataProvider.kt提供确定性演示数据(固定随机种子),保证每次运行截图一致、便于展示。- Mock 仓库模拟轻微异步延迟(~200ms),让加载态/占位骨架在阶段一就能跑通。
- 开关类设置的状态用内存态(
StateFlow)保存,重启重置;阶段一不引入持久化。
9. API 仓库(阶段一)
ring-health-api 阶段一只包含:
ring-health-api/
├── README.md # 说明:后端栈待定,M4 落地
├── .gitignore
└── docs/api-contract.md # 接口契约草稿(登录、设备绑定、健康数据同步的字段雏形)
契约草稿仅为对齐未来前后端边界,不实现。后端技术栈(Spring Boot / .NET)在 M4 前单独定。
10. 里程碑与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| M0 基建 | 建 docs/、repos/ 目录;初始化两个 git 仓库;gitignore/README |
两仓库结构就绪,符合命名规范 |
| M1 静态 App(阶段一) | Compose 工程骨架、主题、四 Tab 导航、本文档第 6 节全部页面、Mock 仓库 | 真机/模拟器可跑;四 Tab 与全部子页可切换;无崩溃;assembleDebug 通过 |
| M2 SDK 接入 | vendor/ 原样放厂商通讯类;新增 data/sdk/ 适配层实现既有接口;BLE 权限、扫描、连接 |
真机扫描到戒指、连接成功、读到电量/固件 |
| M3 数据打通 | 协议解析(帧 DC…LT)、Mock→真数据、ROOM 本地存储、真实图表 |
首页与详情显示真机数据 |
| M4 云端/账号 | 定后端栈、实现登录与同步接口、App 接入 | 接口联调通过 |
| M5 发布 | 签名、图标、深色模式、多语言、内测包 | 可分发 APK |
本次实施范围 = M0 + M1。
11. 风险与决策记录
| 风险/决策 | 说明与对策 |
|---|---|
| 厂商 SDK 老旧(Support Library / EventBus)与 Compose 工程共存 | BluetoothLeClass 零第三方依赖,风险低;M2 隔离在 vendor/ 包内,仅当必需才考虑 jetifier |
| 占位命名需全局替换 | 替换清单见 §12;集中一处记录,替换时按清单执行 |
| BLE 权限(Android 12+) | M2 统一处理 BLUETOOTH_SCAN/CONNECT + 位置权限与引导 UI,阶段一不涉及 |
| 手绘 Canvas 图表工作量 | 阶段一只做环形 + 简单柱/折线,避免过度实现;复杂图表留 M3 |
| 后端栈未定 | 有意延迟到 M4,避免两仓库空转 |
| 本机构建环境版本 | 已探明:JDK 17、Android SDK(platform 34/35/36)、build-tools 仅 35.0.0 / 36.1.0、Gradle 缓存 8.12/8.14、无全局 gradle。故工具链钉死在已安装版本,避免构建时联网拉取缺失组件 |
| 视觉方向定为「不仿小米」 | 2026-09-10 M1 真机验收后由需求方否决原「仿小米运动健康」方向。「像小米」不再是目标,也不是验收标准。改版后的实际口径见 §7,视觉审查一律以 §7 为准 |
已确认决策:① 前后端分离,两个仓库;② Jetpack Compose;③ 阶段一单 :app 分层模块;
④ 四个 Tab 与全部详情子页都做;⑤ 后端阶段一只留契约;⑥ 占位命名后续统一替换;
⑦ 视觉方向不仿小米(见上表,改版口径见 §7)。
12. 占位命名与替换清单
待正式产品名/域名确定后,需统一替换:
| 位置 | 占位值 |
|---|---|
| 仓库名 | ring-health-app、ring-health-api(含 git 服务器上名称) |
applicationId / namespace |
com.placeholder.ringhealth |
| 包路径 | com/placeholder/ringhealth 目录树 |
app_name(strings.xml) |
RingHealth(占位) |
| 文档与 README 中的产品代号 | ring-health |
替换为一次性全局操作,不涉及逻辑改动;执行时按上表逐项核对。