要解决的问题
饮食记录死在搜索框上。没有人愿意在小贩中心打出菜名再从九个结果里挑一个,习惯自然活不过第一周。
我做了什么
把记录设计成一个动作——对准相机,得到一张附带营养素的贴纸——并从这张贴纸推演出整套视觉识别。
结果如何
2026 年持续开发中:识别系统已完成,从第一个提交起就是双语,本页展示可交互复刻。
灵感来源
Strava 让一次跑步变成值得发布的内容,Instagram 让照片本身成为主角。Carb Kun Carb 想为你盘中的食物做同样的事:拍下这道菜,它会被从背景中抠出来,「噗」地一声化成贴纸,落进你当天的记录里,营养素早已附在上面——记录本身成了乐趣,而不是为了乐趣付出的代价。它专为亚洲的食物而做,也刻意做得可爱:一份小贩中心的饭,应该和一杯蛋白粉一样好记录。
项目概述
一套 Expo / React Native 代码库同时驱动 iOS 与 Android,TypeScript 严格模式,从第一次提交起就同时支持英文与简体中文。相机、抠图与动画运行在客户端;一个轻量 Node 服务负责分割、菜品识别与回顾视频渲染;Supabase Postgres 与 Storage 则由行级安全策略守护。这虽是个人项目,但我完全按生产级产品的方式来做——设计令牌自动生成、模型有评测集、访问规则有测试来证明。
我的角色
全部:品牌与视觉识别、交互设计、React Native 应用、后端服务、数据库结构与安全策略,以及从唯一数据源重新生成整套设计系统的工具链。
背后的设计决策
贴纸是这个点子本身,但它真正成立的原因,是一系列关于「摩擦」的决策——哪里该拿掉,哪里留一点反而值得。
一个动作完成记录
把相机对准盘子。没有搜索框,没有份量选择器,也没有秤。记录流程里每少一步,这个习惯就多活一天——而搜索正是杀死它的那一步:没有人愿意在小贩中心里打出「炒粿条」,再从九个结果里挑一个。
营养素要分主次,而不是并列
先是展示级字号的热量,然后是作为「这一餐的形状」的分段条,最后才是各个标签。四个数字平铺出来是一张表格;分了主次,才是一眼就懂。分段条只是辅助,绝不单独承载信息——每个标签都仍然带着自己的名称和数值。
从第一个提交起就是双语
英文与简体中文从一开始就是一等公民,而不是最后再补的本地化,因为这个应用里有一半的菜名是中日韩文字。这约束了字体栈、行长与标签宽度——其中任何一项事后再改,都意味着整个版式要重画。
一路设计到最小的那个尺寸
这个图标是对着 29 像素的应用图标调的,而不是对着主视觉标准组合;每一个导航图标都活在 44dp 的点击区域里,并带着一个作为该控件唯一名称的无障碍标签。如果一个设计只在你画它的尺寸上成立,那它就还没做完。
标志:一台头顶午饭的相机
相机说明这是什么产品,饭碗说明它关注什么,而那张脸决定了整体调性——年轻人不觉得幼稚,长辈也不觉得疏远。
像帽子一样戴着的一碗饭
它是主角,不是点缀。碗的宽度必须远大于饭堆——这个比例是它在 29px 下不被看成一朵蘑菇的唯一保障。
会回望你的镜头
脸长在镜头里,于是相机和角色是同一件东西,而不是一个吉祥物站在产品旁边。
那颗闪光就是闪光灯
它同时代表贴纸弹出的那一刻——应用里唯一的「小确幸」,被图标借用了过来。
先有描边,再谈填充
奶油色镜头压在柿橙机身上只有 2.57:1。真正拉开层次的是可可色轮廓,所以它永远不细于 1.5dp。
文字标志在暗色下要翻转
可可色压在浓缩咖啡色上只有 1.47:1——字会直接消失。暗色版组合把文字换成奶白色,而标志本身不变,因为它的可可色描边压在自己的亮色填充上依然清晰。这也是为什么暗色组合是一个独立文件,而不是一层 CSS 滤镜。
缩小之后依然成立
下面每一个尺寸都来自同一个文件。小于 40px 之后,脸只剩一个暗示,真正起作用的是轮廓——碗、机身、闪光。
iOS 会施加自己的圆角遮罩,因此交付的图标是不含透明通道的满幅方图。Android 拿到的是分层的自适应图标(标志与底色各一层),外加一个单色主题图标。
目前已完成的部分
贴纸制造机
照片 → 主体从背景中抠出 → 像素尘埃消散动画 → 命名并保存。三级抠图方案层层降级而非直接失败:服务端返回裁好的贴纸与配准的遮罩;端上通过 ML Kit 与 Apple Vision 离线完成;两者都不可用时则保留原图。所有原生模块都在带缓存的 try/catch 后惰性加载,因此同一份 JS 包也能跑在未链接原生模块的二进制上。
藏在接缝之后的菜品识别
由视觉大模型识别菜品并附上营养素,它被封装在一个带兜底实现的接口之后,因此供应商随时可换——而这确实必要。我按实测可用率而非版本号来选模型:一个更新更快的模型在八次探测中只答了两次。推理型模型还会先花光 token 预算再作答,并在结束思考标签前后把答案输出两遍;这两点都在解析层处理掉,而不是假装不存在。
用数据说话,而不是靠争论
我用自己拍摄并标注的食物照片建了一套评测集,因为不同的错误代价并不相同:名字错了只是表面问题,用户重打一遍即可;而菜品 ID 错了,会把另一道菜的营养素悄悄记到别人的食物上。置信度低于 0.72 时,界面会询问而不是断言——绝不把对他人饮食的猜测当成事实呈现。
本地优先,再同步
Zustand 与 AsyncStorage 是设备上的唯一真相来源;Supabase Postgres 与 Storage 在后台备份。行级安全策略把每一行数据锁定到它的归属者,并由一套 SQL 测试来证明可见性规则,而不是默认它成立。此外还有 Google 登录、换新设备后的下拉恢复,以及服务端的账号导出与删除——删除的执行顺序至关重要。
可以分享的一天
服务端用 sharp 把当天的贴纸拼成画面,再用 ffmpeg 渲染成视频,最后经签名令牌从对象存储分发——一段关于你今天吃了什么的短回顾,天生就是用来分享出去的。
菜品「噗」地变成贴纸
应用里唯一的「小确幸」,也是代码库中约束最多的部分。与页面同色的实心圆点在照片上依次弹出,把背景一点点吃掉,而抠好的菜品静静待在上面一层,纹丝不动。下面是它的网页版复刻——滚动到此处会自动播放,也可以点击重播。
只用纯色视图
总共两张图片,加约一百个小圆点。不用 Skia,不用图片切片——几百个由图片构成的视图会拖垮中端 GPU,动画在几帧之后就直接跳到终态。
每个圆点都有自己的时钟
没有共享的进度值。Reanimated 基于时间戳,一个中央驱动会让整个网格在一次卡顿后一起跳变。一百个独立计时则会优雅降级——掉一帧只让某个圆点晚几毫秒,别的毫无影响。
圆点本身就是背景
它们的颜色必须与组件背后的页面完全一致。哪怕差一个色阶都会露馅,因为这些圆点不是尘埃——它们是提前抵达的背景。
严格只做一次分割
揭晓过程中的第二次推理会把渲染管线饿死。这个问题让动画重写了三次,最后才发现真正的元凶在上游的拍摄处理里。
时间线:每个圆点在 800ms 的散布窗口内随机起跑,各自用 350ms 弹出,因此最后一个在 1150ms 落定。随后照片在圆点之下淡出,作为覆盖率的安全网——圆点不必自己做到完美覆盖——最后再停留片刻,让贴纸独自待一会儿,画面才继续。
视觉识别,运行起来的样子
由应用自己的设计令牌与图标几何构建,因此不会与真正交付的界面脱节。
⚠ 复刻界面,并非真机截图
导航按钮为 44dp,并且独占一行。四个按钮加上间距约 200dp,在 360dp 的手机上无法与一个完整日期共处一行而不被截断——而日期是更有用的信息。
Google 按钮刻意游离于整套配色之外。其品牌规范要求标志出现在白色或近黑色表面上,柿橙并非选项。正是这个例外,让它一眼就被读作「那个 Google 按钮」——屏幕上其余的按钮都是我们自己的。
设计系统是生成的,而非手工维护
一个令牌文件是色彩、字体与贴纸几何的唯一真相来源。构建脚本据此生成应用的主题模块、SVG 标志与图标集,以及两大应用商店所需的每一张 PNG——因此视觉识别与真正运行的应用不会各走各的。主题文件与品牌 SVG 一律不手工修改。
奶白打底,只留一个高声调
奶白 Milk
#FAF4EA
主要底色
燕麦 Oat
#EFE1CB
下沉表面、贴纸台纸
可可 Cocoa
#4A3B32
文字与贴纸描边 · 奶白上 9.78:1
柿橙 Persimmon
#FF6B4A
唯一的高声调 · 填充与主要操作
抹茶 Matcha
#7FB069
膳食纤维与蔬菜
紫薯 Ube
#9B7EDE
蛋白质
黄油 Butter
#FFC94D
脂肪,以及那颗闪光
樱花 Sakura
#FF9EB5
仅作装饰 · 绝不用于文字
米色负责承托,柿橙负责发声。柿橙在奶白上只有 2.57:1——作为填充按钮恰到好处,作为正文则无法辨读——所以每个亮色都配有一个 -ink 变体,在两种浅色底上都达到 WCAG AA。
每一道菜都会变成一张贴纸
保存下来的菜品沿用与标志相同的构造——可可色描边、奶油色模切边、偏移投影——因此贴纸与应用图标看上去属于同一类物件。
- 碳水 79g
- 蛋白质 17g
- 脂肪 28g
- 纤维 4g
- 碳水 76g
- 蛋白质 23g
- 脂肪 38g
- 纤维 3g
- 碳水 21g
- 蛋白质 3g
- 脂肪 4g
此处数值仅作示意,并非真实营养数据。
四个图标,手工绘制而非采购
一套图标字体为了用其中四个字形要付出几百 KB,而且没有任何现成图标集里的「贴纸乐园」能一眼被读懂。它们用 react-native-svg 在 24 的网格上绘制,遵循与标志相同的规则。
- 日历 — 月视图网格。刻意填满三天,因为那个页面想说的正是:有些日子有贴纸,有些没有。
- 贴纸乐园 — 翻滚中的散落贴纸——是那个陀螺仪玩具,而不是一颗泛泛的星光。
- 回顾 — 圆角框里的播放三角:是当天的一段视频,而不是又一台相机。
- 账号 — 四个之中,唯一一个现成图标集也能做对的。
只用描边,绝不填充。它们要放进 44dp 的圆形按钮里,而填充式图标在 22dp 下会丢掉内部留白,糊成一团。描边宽度随图标缩放,但下限锁定在 1.5dp——与贴纸描边同一个下限,理由也相同:撑起形状的是轮廓,不是填充。
只有图标意味着无障碍标签就是这个控件唯一的名字。因此它是必填属性而非可选项——少了它,读屏软件会连续念四次「按钮」,导航彻底不可用。
圆润到可以当贴纸
标题
ui-rounded 800
Char Kway Teow 炒粿条
正文
system-ui
把相机对准你的盘子。Carb Kun Carb 会把这道菜抠出来、叫出它的名字,并把营养素归档——不用搜索,也不用称重。
数据
ui-monospace, 等宽数字
640 千卡 · 79 碳 · 17 蛋 · 28 脂
ui-rounded 是真实存在的 CSS 通用字体族,在 Apple 平台上直接映射到 SF Pro Rounded,零下载成本。Android 则必须自带字体:首选 M PLUS Rounded 1c——一款圆润无衬线体,且完整覆盖假名与汉字,而这个应用里有一半菜名是中日韩文字。Baloo 2 则把同一套圆润骨架延伸到天城文、孟加拉文与泰米尔文。
从设计过程中沉淀下来的原则
颜色只是强化,绝不是唯一信号
碳水、蛋白质与纤维三者的相对亮度相差不到 0.09,在红绿色盲下无法区分,因此每一枚营养素标签都同时带着名称与数值。
置信度低于 0.72 时,询问而不是断言
绝不把对他人饮食的猜测当成事实呈现。这个阈值首先是产品决策,其次才是模型问题。
文案只存在于一个地方
所有面向用户的字符串都放在多语言文件中,页面代码里不含任何文案。繁体中文语言环境刻意回退到英文——把简体字形呈现给繁体读者,看起来只会显得敷衍,而非贴心。
当前进度
Carb Kun Carb 仍在开发中,尚未上架。目前以开发版运行:拍摄、抠图、消散动画、菜品识别、当日视图、日历、回顾视频与同步都已完整跑通。剩下的,大多是不那么光鲜的另一半。
刻意列出的已知缺口
- 还没有信息流。数据结构与安全策略已经支持「公开」与「仅好友可见」,但让它真正社交起来的浏览界面还不存在。
- 营养数值是占位数据。真实数字需要有授权的区域性数据库——新加坡 HPB 的成分表、马来西亚的 MyFCD,或商业平台——而不是一张写死的列表。
- 还没有内容审核工具,而在除我之外的任何人往里放照片之前,它必须先存在。
- Android 上仍找不到既圆润、又能完整覆盖简体中文的展示字体,而这套视觉识别恰恰依赖它。