Files
ring-health-docs/superpowers/specs/2026-09-10-ring-health-app-design.md
T
cuiluzheng 32b05acd6f docs: 补记睡眠时段图与功能开关联动修复(spec §6.2 / §6.3)
- §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 的边界
2026-09-10 16:49:28 +08:00

36 KiB
Raw Blame History

智能戒指健康 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,免联网下载。AGP 8.11.x 已按 R5/R6 改为 8.13.1(见下方注记)
SDK minSdk 26 / targetSdk 36 / compileSdk 36,build-tools 36.1.0 对齐厂商 minSdk(26);35 已按 R5 改为 36,与本机已装 platform/build-tools 一致

注记(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 开关位)、查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测 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。

顺带修掉的两个真实缺陷(都是截图时看出来的,不是预先想到的):

  1. 窄区间指标的柱子全一样高。 MetricBarChart 原来按 value / maxValue 定高, 血氧(95–99 → 比值 0.96–1.0)与体温(36.1–36.9 → 0.978–1.0)的七根柱子因此齐平, 整张图没有任何信息量 —— 这不是「不好看」,是数据被抹平了。加 valueRange 取景窗口解决。 (代码里原本有一条预览注释已经指出过同类问题,但修复只应用到了预览数据,没到真实范围。)
  2. 头图的「当前值」与趋势图最后一根柱子对不上。 概览的 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 天用日期)。 三个决定值得记下来:

  1. 横轴锚在 20:00。 一晚从 23:43 到 07:37,用「当天第几分钟」表示会得到 1423 → 457 这样一对反过来的数,画出来入睡点跑到起床点右边。minutesSinceSleepAnchor 以 20:00 为界重新起算,两个时刻都落在 0–960 内。不能用 timestamp % 86_400_000 —— 那是 UTC 的余数,在东八区会把整张图平移八小时,而平移后每根条看着都还挺合理。
  2. 轴范围由数据算,不写死。 写死「20:00–12:00」的话,某晚熬到中午才睡, 那根条会被右端裁掉一截,看起来像睡得短。取整到最近的刻度线(2/3/4/6 小时, 取第一个能让刻度不超过 8 个的),刻度因此均匀铺满整条轴。
  3. 只用一个颜色,不画分期。 手头只有「深睡共 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。 M3 TopAppBar 的默认容器色是 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,全分支终审后回填)—— 与上文不符的实际交付:

  1. 设备页删减了四项: 查找戒指(050B)、智能提醒/闹钟占位、固件升级占位(OTA 17xx)、佩戴检测, M1 未实现(见 Ruling R27,并已同步删除对应的 8 条字符串资源)。理由是阶段一没有真实协议可对接, 摆一排点不动的入口不如不摆。这些能力仍属于产品范围,将随后续里程碑回归;本文档上表已用删除线标注。
  2. 详情页由「四个专属设计」收敛为一个模板 —— 详见上方「详情子页」表下的注记。
  3. 多语言:strings.xml 目前只有中文,没有 values-en/。本文档 「附英文」与 M5 把多语言排期两处自相矛盾;以 M5 为准,M1 不做。
  4. 详情页仍缺三项已规划的内容(§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

替换为一次性全局操作,不涉及逻辑改动;执行时按上表逐项核对。