起因:每天最难的决策不是写代码,是吃什么
我在公司的日常大概是这样的:上午写代码挺顺畅,11点半左右开始走神,脑子里只有一个问题——中午吃什么。
三四个同事围在一起,“随便”“都行”"你定吧"的对话能持续十分钟。情侣之间更夸张,我和女朋友为这事吵过不止一次,说出来有点丢人。
后来我想,这不就是个决策问题吗?写个工具把它解决掉不就行了。
刚好那段时间在研究 HarmonyOS 开发,ArkTS 写起来和 TypeScript 很像,上手没什么门槛,就直接在鸿蒙上做了。App 叫「决定今天吃什么」,目前版本 1.2.0,已经上架华为应用市场。
三种模式,覆盖不同场景
同类工具我翻了一圈,大多只有一个随机功能,点一下出个结果,完事。但吃饭这事的场景差异挺大的:
- 一个人的时候,随机就够了
- 两个人的时候,重点是减少分歧
- 一群人的时候,要先排除大家不想吃的
所以我做了三种模式:随机选择、转盘选择、投票选择。
- 一群人的时候,要先排除大家不想吃的
随机模式没什么好说的。转盘模式加了点仪式感,转的过程本身就挺解压。投票模式是我觉得最有意思的——每个人划掉自己不想吃的选项,剩下的再随机决定。
说白了就是"排除法 + 随机",但实际用起来真的减少了很多扯皮。
投票模式的实现
投票这块我折腾了一阵子。核心逻辑不复杂,但要处理平票的情况。
说实话最后的处理方式很朴素:平票就是等概率随机。我一开始想搞复杂的——比如用历史偏好加权、用投票轮次做衰减什么的,试了两个方案都觉得过度设计。几个人投票投出平局,说明大家对这几家的接受度差不多,随机挑一个就完事了,没必要硬凹差异。
export function finishVote(session: VoteSession): string { const entries = Object.entries(session.votes) let max = -1 let winners: string[] = [] for (const [id, count] of entries) { if (count > max) { max = count; winners = [id] } else if (count === max) { winners.push(id) } } if (winners.length === 1) return winners[0] // 平票等概率随机,没必要搞复杂 return winners[Math.floor(Math.random() * winners.length)] } ``` 投票模式真正有意思的地方不在最后怎么决胜,而在"划掉"这个动作本身——每个人都参与了排除过程,最后不管选到哪家,大家的接受度都比较高。这比"一个人拍板"的体验好太多了。 ## 历史排重:避免连续吃同一家 这个功能是我自己用了两周之后加的。因为随机嘛,真的会连续三天选到同一家。概率上完全正常,但体验上很烦。 解决方案很直接:在 `pickRandom` 的时候,把最近 N 次选过的餐厅权重设为 0。N 是可配置的,默认我设的 3。 有个边界情况值得说一下:如果用户收藏的餐厅本来就少,比如只有 4 家,`avoidRecentN` 设成 3,那过滤完可能所有候选权重都是 0。这种情况下我的处理是忽略排重约束,回退到全量随机。总不能因为排重逻辑选不出来,给用户弹个"无候选"吧,那也太蠢了。 `avoidRecentN` 这个默认值我纠结了挺久。设太大,候选池容易被排空;设太小,排重效果约等于没有。最后决定让用户自己调,默认 3,餐厅少的人可以改成 1 或 0。 ## 筛选候选:距离、预算、口味标签 餐厅数据模型里我加了 `distanceKm`、`price`(min/max)、`cuisines`、`tags` 这些字段。用户可以按条件过滤候选列表,比如"3公里以内、人均50以下、不要辣的"。 过滤逻辑我写成了管道式的链式调用,每个条件都是"有值才过滤,没值就跳过": ```arkts export function getCandidates(all: Restaurant[], query: CandidateQuery): Restaurant[] { const s = (query.search || '').trim().toLowerCase() const f = query.filters || {} return all .filter(r => !r.archived) .filter(r => !(f.tagsAll?.length) || f.tagsAll.every(t => r.tags.includes(t))) .filter(r => !f.budgetMax || r.price.max <= f.budgetMax) .filter(r => !f.distanceMaxKm || r.distanceKm <= f.distanceMaxKm) .filter(r => !s || `${r.name} ${r.cuisines.join(' ')} ${r.tags.join(' ')}` .toLowerCase().includes(s)) } ``` 这种写法的好处是每个 `.filter()` 职责单一,后面加新条件直接追加一行就行。比如我后来加了"只看收藏"的功能,就是多挂了一个 `.filter(r => !onlyFav || r.favorite)`,改动量很小。 ArkUI 上的交互实现主要是筛选面板。说实话鸿蒙的 `@State` 和 `@Link` 装饰器用起来和 React 的 useState 思路差不多,响应式更新做得还行。踩坑的地方是列表刷新的时机——我一开始把筛选逻辑放在 UI 层做,列表一长就卡,后来挪到 service 层先算好再传给组件,流畅多了。 ## 数据全部本地存储 这个 App 没有后端,所有数据都存在本地。用的是 HarmonyOS 的轻量级存储方案,餐厅列表、历史记录、用户设置分开存。 为什么不做云端?两个原因:一是吃饭这种数据没必要上云,二是我一个人做,维护服务器的精力不够。保持简单,能跑就行。 数据模型上有个小设计我觉得还不错:餐厅的 `archived` 字段。删除操作我没做真删除,而是标记归档。这样历史记录里引用的餐厅 ID 不会变成空指针,回头翻历史还能看到当时选的是哪家。这个坑是我第一版直接 delete 之后发现的——历史列表全是"已删除的餐厅",挺难受的。 ## 转盘的段数限制 转盘模式有个 `MAX_WHEEL_SEGMENTS` 的限制,我设的 12。候选餐厅太多的话,转盘上塞不下那么多格子,12 个以上文字就开始重叠,视觉上根本看不清。 超出 12 个时,我按权重排序取前 12 个放到转盘上。 这个处理方式有个小问题:权重低的餐厅永远上不了转盘。我想过随机抽取的方案,但那样转盘内容每次都不一样,用户会困惑"我明明收藏了那家怎么不在上面"。权衡之后还是选了按权重截断——至少结果是确定的、可预期的。反正用户想让某家店上转盘,把它权重调高就行,操作上说得通。 ## 目前的状态 App 今年上架的华为应用市场,当前版本 1.2.0。说实话下载量还很少,刚起步阶段。 我自己每天在用,身边几个同事也装了,午饭决策确实快了不少。上周有个同事说"投票模式挺好的,但能不能加个匿名投票",这个我在考虑下个版本加进去。 对了,餐厅数据目前是手动录入的,这个体验确实有点重。我在想要不要接入地图 API,搜附近餐厅直接导入。但 HarmonyOS 的地图能力和 API 调用还在摸索中,不确定什么时候能做。 ## 一些鸿蒙开发的体感 ArkTS 写业务逻辑没啥问题,类型系统和 TypeScript 基本一致,数据模型定义、工具函数这些迁移成本很低。 ArkUI 的声明式写法上手也快,但组件库的丰富度跟 Flutter 或 SwiftUI 比还是有差距。转盘动画那块我花了不少时间自己画,没有现成的轮盘组件可以用。 调试工具目前够用,DevEco Studio 的预览功能帮了不少忙。真机调试偶尔会断连,重启一下就好,不算大问题。 整体感受是:鸿蒙的工具链已经能支撑中小型 App 的开发了,但社区资源还比较少,遇到问题能搜到的解决方案不多,很多时候得自己翻文档试。 对了想问一下同样在做 HarmonyOS 开发的各位:转盘段数超限时,你们会怎么处理?按权重截断、随机抽取、还是有别的方案?我一直觉得这两种都不够好,评论区聊聊。