☰
Operit Token 统计显示单位切换(M/B)的落地实现解析
2026/9/29 6:32:11 网站建设 项目流程
  • AI Agent
  • 人工智能
  • 大模型
  • AI 应用
  • 工具调用
  • 本地部署
  • MCP Clients
  • Agent 记忆

【免费下载链接】Operit

The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载

导读

本文深入解析 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"

两点值得注意的工程细节:

  1. 单位切换只影响百万级及以上的数值。低于百万(K/raw)的展示不受unit参数影响,这正是设计文档中"保留百万量级以下的紧凑 K/raw 格式"(Preserve compact K/raw formatting for values below the million scale)的直接实现——切换单位不会让小数值在 K/B 之间跳变产生噪声。
  2. 小数位数随量级收紧: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 可以看出该功能的完整落地路径:

  1. 模型与存储:新增TokenStatsDisplayUnit枚举、DataStore 偏好读写、ViewModel 状态字段与toggleTokenDisplayUnit()(已完成);
  2. UI 接入:接通两个隐藏点击入口(周期总览大数值、峰值指标),并让全页所有 Token 展示统一使用当前单位(已完成);
  3. 测试与评审:新增纯格式化测试,审查最终 diff(已完成);
  4. 构建交付:推送变更并通过构建器 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

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载

相关推荐

上一篇:Alibi:将你的手机变身终极行车记录仪,自动保存关键时刻的最后30分钟!
下一篇:如何在5分钟内快速部署PP-FormulaNet_plus-L_safetensors?完整安装与配置教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询