- AI Agent
- 人工智能
- 大模型
- AI 应用
- 工具调用
- 本地部署
- MCP Clients
- Agent 记忆
【免费下载链接】Operit
The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent
导读
本文深入解析 Operit(Android 端 AI Agent / AI 聊天软件)中 Token 统计页的显示单位切换功能:在不新增设置项、不破坏统计页整体布局的前提下,让页面上的两个大型 Token 数值作为隐藏的 M/B(百万/十亿)切换入口,实现全局统一显示单位,并通过 DataStore 持久化用户选择。读完本文,你将掌握TokenStatsDisplayUnit枚举、formatTokenCount纯格式化器的完整规则、单位切换的 ViewModel 状态流转与 UI 接入方式,以及如何用纯单元测试锁定格式化行为。
一、功能背景与设计意图
1.1 背景:单一紧凑格式化的局限
Operit 的 Token 统计模块此前对 Token 数值使用单一自动紧凑格式化器(K/M 自适应)。当数值达到百万级时,阅读与对比尚可;但主总览(周期总览、生命周期总览)与峰值等大数值,在百万(M)口径下位数仍然偏多,跨数量级对比不够直观——例如917.4M与1.2B之间的大小关系不如统一到十亿(B)口径时一目了然。
1.2 意图:两个大数值即隐藏切换入口
设计文档(docs/TODO/token_stats_display_unit_20260821/index.md)明确给出三条核心设计约束:
- 让两个大型 Token 数值充当不可见的 M/B 切换入口(即隐藏点击入口,不显示按钮、不新增可见行);
- 全统计页保持同一显示单位(一次切换,处处生效);
- 不在统计设置(Statistics Settings)中新增一行可见设置项。
这一设计刻意避免了"设置页 + 切换按钮"的常规做法,将交互收敛到用户最常注视的大数字本身,符合"界面越少可见控件越好"的移动端设计取向。
二、核心实现:枚举与纯格式化函数
2.1 显示单位枚举
显示单位模型定义于 TokenStatsDisplayUnit.kt:
enum class TokenStatsDisplayUnit { MILLIONS, BILLIONS; fun toggled(): TokenStatsDisplayUnit = when (this) { MILLIONS -> BILLIONS BILLIONS -> MILLIONS } }toggled()是纯状态切换函数:当前为MILLIONS则返回BILLIONS,反之亦然。该函数被 ViewModel 的切换入口与 UI 的无障碍描述文本共同复用,保证"切换目标"在逻辑与文案上永远一致。
2.2 纯格式化器 formatTokenCount
同一文件中的顶层函数formatTokenCount(value: Long, unit: TokenStatsDisplayUnit)承担全部格式化职责,规则分两段:
fun formatTokenCount(value: Long, unit: TokenStatsDisplayUnit): String { if (value < 1_000_000L) { return when { value >= 1_000L -> String.format(Locale.US, "%.1fK", value / 1_000.0) else -> value.toString() } } return when (unit) { TokenStatsDisplayUnit.MILLIONS -> String.format(Locale.US, "%.1fM", value / 1_000_000.0) TokenStatsDisplayUnit.BILLIONS -> String.format(Locale.US, "%.3fB", value / 1_000_000_000.0) } }格式化规则要点:
| 数值区间 | 行为 | 示例 |
|---|---|---|
< 1_000 | 原样输出(raw) | 880→"880" |
1_000 ~ 999_999 | 保留一位小数的 K 紧凑格式 | 8_800→"8.8K";999_900→"999.9K" |
≥ 1_000_000,单位 MILLIONS | 保留一位小数的 M 格式 | 1_000_000→"1.0M";917_400_000→"917.4M" |
≥ 1_000_000,单位 BILLIONS | 保留三位小数的 B 格式 | 917_400_000→"0.917B";1_000_000_000→"1.000B" |
两点值得注意的工程细节:
- 单位切换只影响百万级及以上的数值。低于百万(K/raw)的展示不受
unit参数影响,这正是设计文档中"保留百万量级以下的紧凑 K/raw 格式"(Preserve compact K/raw formatting for values below the million scale)的直接实现——切换单位不会让小数值在 K/B 之间跳变产生噪声。 - 小数位数随量级收紧:M 用 1 位小数、B 用 3 位小数,保证十亿口径下仍保有两位有效对比精度(
0.917B比0.9B更能体现真实用量)。
格式化统一使用Locale.US,规避了不同区域设置下小数点符号差异导致的展示不一致问题。
三、持久化:复用现有统计 DataStore
设计文档要求"将所选显示单位持久化到现有统计 DataStore"。实现位于 TokenStatsPreferences.kt:
private val Context.tokenStatsDataStore: DataStore<Preferences> by preferencesDataStore(name = "token_stats_preferences") private val TOKEN_DISPLAY_UNIT = stringPreferencesKey("token_display_unit") suspend fun loadTokenDisplayUnit(): TokenStatsDisplayUnit { val stored = dataStore.data.first()[TOKEN_DISPLAY_UNIT] return stored?.let { TokenStatsDisplayUnit.valueOf(it) } ?: TokenStatsDisplayUnit.MILLIONS } suspend fun saveTokenDisplayUnit(unit: TokenStatsDisplayUnit) { dataStore.edit { preferences -> preferences[TOKEN_DISPLAY_UNIT] = unit.name } }要点:
- 复用既有 DataStore:
token_stats_preferences与币种(target_currency)、汇率(usd_to_cny_rate)、时间范围(time_range_start/end)、活跃视图模式(activity_view_mode)等标量统计设置存放在同一个 DataStore 中,不新建存储文件(文件内注释亦指明:结构化用量、分组与定价数据留在 Room,标量设置走 DataStore); - 按枚举名持久化:以
stringPreferencesKey("token_display_unit")存unit.name(即"MILLIONS"/"BILLIONS"),读取时valueOf反序列化; - 默认值为 MILLIONS:首次使用或键缺失时回退到百万口径,与旧行为保持一致,实现平滑升级;
- 该类标注为
internal,仅对统计模块内部暴露,存储细节对外不可见。
四、状态流转:ViewModel 中的切换与加载
4.1 UI 状态持有当前单位
TokenUsageStatisticsViewModel.kt 中的TokenStatsUiState以默认值MILLIONS持有tokenDisplayUnit字段;loadInternal()在每次加载时调用settings.loadTokenDisplayUnit(),并在_state.update中写入状态——因此页面每次重建、每次数据刷新都会从 DataStore 恢复用户上次选择的单位。
4.2 切换入口:乐观更新 + 异步持久化
fun toggleTokenDisplayUnit() { val unit = _state.value.tokenDisplayUnit.toggled() _state.update { it.copy(tokenDisplayUnit = unit) } viewModelScope.launch(dispatcher) { try { settings.saveTokenDisplayUnit(unit) } catch (e: CancellationException) { throw e } catch (e: Exception) { AppLogger.e(tag, "Failed to save token display unit", e) } } }切换采用乐观更新策略:先同步更新内存中的 StateFlow 使 UI 立即生效,再异步落盘到 DataStore;落盘失败仅记录日志,不阻断用户操作。CancellationException被显式重新抛出以保持协程取消语义。
五、UI 接入:两个隐藏入口与全局统一单位
5.1 入口一:周期总览卡的大数值
TokenStatsComponents.kt 中的TokenStatsOverviewCard将 "总 Token" 大数值(38.sp 字体)包在clickable(role = Role.Button)内:
Row( modifier = Modifier .clickable(role = Role.Button, onClick = onToggleTokenDisplayUnit) .semantics { contentDescription = tokenUnitToggleDescription }, verticalAlignment = Alignment.Bottom, ) { /* 大号 Token 数值 + " Token" 后缀 */ }可见样式没有任何按钮痕迹——它只是"看起来普通的大数字",但点击即触发单位切换。
5.2 入口二:2×2 核心指标网格的峰值
TokenStatsMetricGrid的峰值 Token(peakTokens)数值同样接入切换入口,复用同一个onToggleTokenDisplayUnit回调(对应屏幕代码 TokenUsageStatisticsScreen.kt 中TokenStatsMetricGrid的tokenUnitToggleDescription与onToggleTokenDisplayUnit参数)。两个入口共享同一toggleTokenDisplayUnit回调,点击后全页单位同步翻转。
5.3 无障碍与多语言描述
切换入口虽不可见,但对读屏用户完全可感知。屏幕层通过资源文案构造 contentDescription:
val tokenUnitToggleDescription = stringResource( R.string.token_stats_unit_toggle_description, stringResource(state.tokenDisplayUnit.labelResource()), stringResource(state.tokenDisplayUnit.toggled().labelResource()), )其中labelResource()将枚举映射为M/B文案,toggled()提供切换目标。多语言字符串已在values、values-en、values-es、values-ko、values-pt-rBR、values-ms、values-id、values-ro等多套资源中就位,例如中文(values/strings.xml):
<string name="token_stats_unit_millions">M</string> <string name="token_stats_unit_billions">B</string> <string name="token_stats_unit_toggle_description">当前 Token 单位为 %1$s,点击切换为 %2$s</string>5.4 全局统一单位的应用范围
tokenDisplayUnit从状态一路下钻到页面所有 Token 数值展示,实现"一次切换、处处生效":
- 周期总览卡:总 Token 大数值与面积趋势图(
TokenStatsAreaChart的formatValue = { formatTokenCount(it.toLong(), tokenDisplayUnit) }); - 2×2 核心指标:峰值 Token、输出 Token;
- 活跃记录三视图:日热力图、周图、累计图(TokenActivitySection.kt 中按
viewMode分发tokenDisplayUnit,各图对单日、单点数值统一格式化); - Token 构成卡:缓存读取 / 未缓存输入 / 输出三条构成的数值;
- 模型排名与模型详情:各模型用量、构成行、配置详情;
- 趋势卡片与详情弹窗:同口径格式化。
设计文档中列举的 "Token summaries, cards, charts, activity details, rankings, composition rows, configuration details, and detail dialogs" 均已覆盖。
六、边界约束:什么保持不变
设计文档对功能边界有严格约束,实现也严格遵守:
- 币种、请求次数、百分比不变:费用展示继续使用
formatLifetimeMoney+targetCurrency口径,请求数用formatCount,缓存率仍为%.1f%%百分比格式; - 数据库记录与 Room 迁移不变:本功能不触碰任何表结构,Token 统计的原始事件与聚合数据仍走 Room(
TokenStatsQueryService/TokenUsageDao),显示单位只是"展示层"的映射; - 百万以下格式不变:K/raw 紧凑格式与单位无关(见 2.2 节)。
这种"只改显示口径、不动数据模型"的边界设计,把功能风险压缩到纯展示层。
七、测试验证:纯函数覆盖
设计文档要求"为 M/B 值与单位切换添加纯格式化器覆盖"。测试实现于 TokenStatsDisplayUnitTest.kt,四个用例精确锁定了 2.2 节的全部规则:
@Test fun `million display keeps compact values below one million`() { assertEquals("8.8K", formatTokenCount(8_800L, TokenStatsDisplayUnit.MILLIONS)) assertEquals("999.9K", formatTokenCount(999_900L, TokenStatsDisplayUnit.MILLIONS)) } @Test fun `million display formats large values in millions`() { assertEquals("1.0M", formatTokenCount(1_000_000L, TokenStatsDisplayUnit.MILLIONS)) assertEquals("917.4M", formatTokenCount(917_400_000L, TokenStatsDisplayUnit.MILLIONS)) } @Test fun `billion display formats large values in billions`() { assertEquals("0.917B", formatTokenCount(917_400_000L, TokenStatsDisplayUnit.BILLIONS)) assertEquals("1.000B", formatTokenCount(1_000_000_000L, TokenStatsDisplayUnit.BILLIONS)) } @Test fun `display unit toggles between millions and billions`() { assertEquals(TokenStatsDisplayUnit.BILLIONS, TokenStatsDisplayUnit.MILLIONS.toggled()) assertEquals(TokenStatsDisplayUnit.MILLIONS, TokenStatsDisplayUnit.BILLIONS.toggled()) }测试特意覆盖了917_400_000这一边界样例在两种单位下的不同输出(917.4Mvs0.917B),以及999_900→999.9K的"即将破百万"阈值,确保格式化规则在量级边界处不被破坏。由于formatTokenCount是纯函数(无 IO、无状态),测试无需任何 Android 环境即可在 JVM 上运行。
八、落地流程与交付
从设计文档的 Steps 可以看出该功能的完整落地路径:
- 模型与存储:新增
TokenStatsDisplayUnit枚举、DataStore 偏好读写、ViewModel 状态字段与toggleTokenDisplayUnit()(已完成); - UI 接入:接通两个隐藏点击入口(周期总览大数值、峰值指标),并让全页所有 Token 展示统一使用当前单位(已完成);
- 测试与评审:新增纯格式化测试,审查最终 diff(已完成);
- 构建交付:推送变更并通过构建器 API 构建 Release APK(已完成)。
该功能已处于完成状态(Steps 全部标记 DONE),可以作为 Android 端统计模块显示层的一个完整可参考的实现范例。
九、小结
Operit 的 Token 统计显示单位切换是一个典型的"小而精"展示层功能:用枚举 + 纯函数收敛格式化逻辑,用 DataStore 复用持久化,用两个隐藏点击入口替代可见设置项,用纯单元测试锁定边界行为。它不触碰任何数据模型与 Room 迁移,却让百万级与十亿级 Token 用量的阅读与对比体验得到整体提升——这一实现思路同样适用于其他需要"全局单位口径切换"的统计类页面。
关键源码索引:
- 格式化器与单位模型:app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsDisplayUnit.kt
- DataStore 持久化:app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt
- 切换与加载逻辑:app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt
- 页面组装:app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt
- 卡片与图表接入:app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt
- 活跃记录三视图:app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt
- 纯函数测试:app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDisplayUnitTest.kt
- 多语言文案:app/src/main/res/values/strings.xml
- AI Agent
- 人工智能
- 大模型
- AI 应用
- 工具调用
- 本地部署
- MCP Clients
- Agent 记忆
【免费下载链接】Operit
The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent
相关推荐
Operit 构建系统重构:外部制品清单(External Artifact Manifest)设计与落地指南
Operit 构建系统重构:外部制品清单(External Artifact Manifest)设计与落地指南 本文档对应 Operit 仓库构建系统重构计划的
AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化Open Design Modern 设计系统实战指南:从 DESIGN.md 到语义 Token 的落地实现
Open Design Modern 设计系统实战指南:从 DESIGN.md 到语义 Token 的落地实现 导读 本文以 Open Design 仓库中 d
AI 应用人工智能AI 技能设计系统媒体生成Megatron-DeepSpeed vs 传统训练框架:为什么它是大规模语言模型的首选?
Megatron DeepSpeed vs 传统训练框架:为什么它是大规模语言模型的首选? 在人工智能快速发展的今天,大规模语言模型(LLM)的训练面临着计算资
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考