我花了两天时间,把一款钟爱的开源 App 的设计理念,用 Rust + Tauri 从零实现了一遍。这不是换皮,也不是套壳------代码是全新的,界面是我自己画的,只有数据格式做成了兼容。 这篇文章聊聊为什么想做它,以及背后的原理和实现思路。
好习惯,改变一生。好习惯,陪伴一生。习惯树是一款完全本地运行的习惯养成应用。坚持不必是枯燥的对勾与数字------它把每一次坚持可视化成一棵基于分形算法生成的「习惯树」,让坚持本身成为一种值得期待的小仪式。
🌱 开发缘由:好习惯,值得一棵树
我们都明白「习惯成自然」的道理,但真正把一件小事,日复一日地做下去,并不容易啊。有多少人能够克服阻力,坚持下去?但如果你每次的努力,都能有反馈和惊喜呢?
用过的习惯 App 不少,可大多数是「打卡按钮」------每天机械地按一下,久了之后,打卡本身反而成了一种负担。也试过Habitica 那种游戏化的养成,系统很完整,但对只想安静坚持的人来说,稍显复杂。
于是我想:我需要的不是又一个待办清单,而是一点仪式感------一个让坚持本身变得值得期待的东西。
坚持有了形状,也就有了仪式感。
习惯树把每一次坚持,具象成一棵基于分形几何算法、不断生长的「习惯树」:
- 打卡模式:养成好习惯(每日健身、早睡早起)。每完成一次任务, 小树就长高一点、多分几个叉,最终长成一棵茂盛的大树------树上的每一片枝叶,都是你努力的证明。
- 戒断模式:戒掉坏习惯(戒酒、戒烟)。你只需要坚持「不做」,小树就会自己生长;一旦忍不住破了戒,就去记录「复发」的时间和原因,然后亲眼看着自己培育的树苗枯萎回一颗种子------这种眼睁睁看着亲手培育的植物凋零的挫败感,比任何单纯的警告都更有效。
开源地址 :https://gitcode.com/qq8864/habit-tree
截图


缘起:从「打卡焦虑」到「想看一眼那棵树」
我是习惯养成类 App 的重度用户,但一直卡在一个尴尬的位置。
传统打卡 App 的问题在于,打卡这个动作慢慢变成了一种负担。每天早上点一下「完成」,久了之后你分不清自己是在培养习惯,还是在给日历上色。Habitica 那套把习惯变成 RPG 养成很有意思,但对我这种只想安静坚持的人来说太庞大了------装备、任务、副本,我只是想记住「今天该跑步了」。
直到我发现了 Grove。它把坚持具象成一棵分形算法生成的树:
- 打卡模式:每完成一次,小树就长高一点、多分几个叉
- 戒断模式:你只要什么都不做,树就自己长大;一旦破戒,你得亲手记录复发的原因,然后眼睁睁看着树枯萎回一颗种子
这个设计击中了我:它把「坚持」变成了「想看看今天它长什么样」的期待,而不是又一个待办。
它还很精致------Material You 动态取色、四种卡片布局,尤其是那个 3D 滚筒布局,上下滑动时卡片像唱片一样转过去,配合线性马达的震动,每次操作都很解压。
Grove 是 GPL-3.0 开源的,数据格式、成长机制、树形算法都是公开的。于是我想:与其天天夸它,不如自己动手,用 Rust + Tauri 造一个属于我的版本。不是要替代谁,是想借这个项目,把「习惯追踪」这个领域里最好的设计拆开看明白。
🏮 彩蛋:东风夜放花千树
东风夜放花千树,更吹落、星如雨。
玉壶光转,一夜鱼龙舞。------《青玉案·元夕》辛弃疾
生活需要仪式感,为了让生活更有仪式感,为了让这个小而美的APP更加有趣有新引力,当然为了让你更能养成好习惯,想到了一个好创意,连续打卡达成生长阶段(7 天 / 30 天 / 90 天...)时,里程碑动画自动绽放:
想象这么一首东风夜放花千树的景象:
夜色与星光中,你的习惯树自下而上逐层亮起彩灯 (花千树);
烟花升空爆散,火花如星雨坠落 ;满月升起(玉壶光转);
发光灯龙蜿蜒游过(一夜鱼龙舞);最后以词句落款成一张可一键保存的卡片。
可以将这个"纪念勋章"一样的东西,保存起来发个朋友圈,作为自己努力的见证,形成一种潜在的正向激励,不断鼓励自己坚持下去,养成一个好习惯,无论是看书也好,跑步也罢,对自己终身受益。

🎁 仪式感,不止于此
生活需要仪式感。这也是习惯树的底色------我们想打造一款小而美的精品习惯应用:不追求大而全的功能堆砌,专注把「坚持」这件小事做得有温度、有惊喜。
规划中的仪式感彩蛋(敬请期待):
- 🎏 节日应景 :根据节日变换氛围------春节的万家灯火、中秋的玉壶月影、
元宵的花千树灯海,都会在应景的时节悄悄登场 - 🎁 开盲盒 :达成特殊条件时,开启仪式感盲盒------专属树形皮肤、限量徽章、
词句笺等小礼物,为坚持添一份不确定的期待 - 🕵️ 隐藏彩蛋:不定期埋入的小惊喜,藏在界面的某个角落,等你发现
功能特性
- 🌳 分形习惯树 :每棵树由确定性种子生成,同一棵树在任何平台渲染结果一致;
从种子到参天大树经历 5 个生长阶段(1/7/30/90 天),阶段内渐进生长 - 🏮 里程碑彩蛋「东风夜放花千树」 :达成生长阶段(7/30/90 天...)时播放节日庆典动画------
分形树逐层亮起彩灯、烟火如星雨坠落、玉壶满月升起、灯龙游过,
词句落款卡片可一键截图分享(每阶段仅一次,可跳过、可关闭) - 🌱 双追踪模式 :打卡模式(每日任务)与戒断模式(戒掉习惯),
破戒触发枯萎动画,把「失去」做成视觉反馈 - 🎠 四种卡片布局 :3D 滚筒(核心卖点,滚动带旋转景深与聚焦光环)、
横向轮播、双列网格、紧凑列表,一键切换 - 📅 完整打卡管理 :月历五态视图(打卡/请假/复发/今天/未来)、补记历史日期、
修改打卡时间、请假不中断连续、冻结连续、每日备注 - 🎨 Material You 动态主题:种子色一键换肤,浅色/深色模式,默认森林绿
- 🔒 隐私优先 :零权限、零联网、零账号;数据仅存本机
(%APPDATA%\com.grove.habittrees\grove.redb) - 🔄 备份兼容:导出/导入 Grove 兼容 JSON 备份,原版数据可直接迁移
- 🌐 本地化:界面为简体中文,适应中文用户习惯
技术栈
| 层 | 选型 |
|---|---|
| 桌面框架 | Tauri 2(Rust + WebView2) |
| 后端 | Rust(serde / chrono) |
| 存储 | redb(纯 Rust 嵌入式 KV,零 C 编译) |
| 前端 | TypeScript + Vite(无框架),Canvas 分形树渲染 |
| 测试 | Rust 单元测试 13 个(派生统计 + 存储) |
架构
┌─ 前端(Tauri WebView)────────────────────────┐
│ TypeScript + Canvas 渲染 │
│ 四种布局 / 日历 / 弹层 / 主题 │
└───────────────┬── invoke(JSON 契约)─────────┘
┌───────────────▼───────────────────────────────┐
│ Rust 后端 │
│ 派生统计(纯函数 + 单测) │
│ redb 存储(id → 整棵树 JSON) │
│ 5 条命令:list / upsert / delete / export / import │
└───────────────────────────────────────────────┘
领域逻辑(连续天数、生长阶段、请假/冻结规则)全部在 Rust 端以纯函数实现,前端只负责渲染;JSON 契约与 Grove 备份格式一致(camelCase),保证备份文件双向兼容。
第一阶段:先画原型,别急着写代码
动手之前,我先从 Grove 的源码里拆出了两样「宝贝」。
第一样是数据模型。 整个 App 的核心就一个结构体:名称、ARGB 颜色、起始日期、复发列表、打卡日期集合、请假日、打卡时间戳与备注,外加一个决定树形态的 geneticSeed。所有派生数据------连续天数、最佳纪录、生长阶段------都是从这个结构算出来的,而且全部是纯函数:输入一棵树和今天的日期,输出全部统计,没有副作用,没有内部状态。
第二样是分形树渲染器。 一个 31KB 的绘制文件,藏着整棵树的秘密:树的 DNA 由种子确定性生成,从 0 到 5 层递归分支,叶簇、树皮刻痕、树冠飘动的孢子,还有打卡时的生长爆发粒子。同一个种子,在任何平台上画出来都是同一棵树。
我照着这两样,先做了一个单文件 HTML 交互原型------四个布局、种树、打卡、请假、破戒枯萎动画、Material You 主题,全部能点能玩。原型阶段验证了一件事:这套交互和算法用 Web 技术完全能还原,值得投入正式实现。
原理一:连续天数,到底是怎么算的
习惯追踪的核心是「连续」(streak)。听起来简单,细想全是边界条件:
- 今天没打卡,昨天的连续还算不算?(规则:最新记录距今天超过 1 天,连续清零)
- 出差请了假,请假那天算不算连续?(有个开关:请假「计入连续」或「只保不增」两种策略)
- 断更了但之前冻结过,还要不要清零?(冻结 = 断更不清零,但也不再增长)
- 戒断模式和打卡模式完全相反:戒断是「距离上次破戒的天数」,而打卡是「连续打卡的天数」
我把原版算法逐行翻译成 Rust,每个分支都配了单测:阶段边界(1/7/30/90 天)、连续中断、请假两种策略、冻结、破戒重置、导入数据的日期格式容错------13 个测试把行为钉死,以后改代码不怕改出偏差。
这个「数据 + 纯函数」的架构是整件事的地基。前端不自己算统计,它只负责渲染:每次数据变更后,调 Rust 拿最新的派生结果。统计逻辑只有一份、在 Rust 里、有测试护着,永远不会出现「界面显示的连续天数和数据库对不上」这种烂账。
原理二:一棵树是怎么「长」起来的
这是整个项目最漂亮的部分,值得展开讲讲。
确定性种子。 每棵树创建时生成一个随机种子,之后一切形态都由它决定。种子经哈希映射出 8 个 DNA 参数------主干倾斜、分叉展开角、枝长衰减率、分叉高度、叶密度、叶形......还从 4 种「树型原型」里挑一种(有的树喜欢往上蹿,有的树爱横向铺开)。同一颗种子永远长出同一棵树,所以树的形态可以放心存、放心恢复,不用序列化任何几何数据。
递归分支。 渲染从根部开始:主干按角度和长度画一笔,然后在顶端分叉成两个子枝,子枝长度按比例衰减、角度加随机扰动,继续递归。生长阶段决定递归深度------种子 0 层、嫩芽 2 层、树苗 3 层、小树 4 层、参天大树 5 层。层数越多,树冠越丰满。
渐进生长。 光有阶段跳变是不够的------一棵树从「种子」直接变成「嫩芽」会显得很生硬。所以阶段内部还有一个 0 到 1 的进度值:主干的长度、展开角、粗细都随进度线性插值。连续天数从 1 天涨到 90 天,树是肉眼可见地一点点长起来的,而不是某天早上突然换了一棵。
颜色与细节。 树皮、树叶、高光都不是单独的调色板,而是从用户选的主色派生出来的------树干压暗、树叶提亮、叶尖再提亮一点,整个树自然就有体积感。叶簇由多层贝塞尔叶片叠加,大树还有飘浮的孢子和苔藓团,加上风的相位参数,整个树冠在缓慢呼吸。
两个情绪时刻。 打卡成功时,枝端会喷出一圈粒子叶片加光晕,是「成长」的正反馈;破戒时,树叶飘落、树干倾倒枯萎,再重置回一颗种子------把「失去」做成一种视觉惩罚。这两个动画是 Grove 设计的灵魂,我原样保留了自己的实现。
原理三:架构与数据流动
┌─ 前端(Tauri WebView)────────────────────────┐
│ TypeScript + Canvas 渲染 │
│ 四种布局 / 日历 / 弹层 / 主题 │
└───────────────┬── invoke(JSON 契约)─────────┘
┌───────────────▼───────────────────────────────┐
│ Rust 后端 │
│ 派生统计(纯函数 + 单测) │
│ redb 存储(id → 整棵树 JSON) │
│ 5 条命令:list / upsert / delete / export / import │
└───────────────────────────────────────────────┘
几个关键设计决策:
前后端各管各的。 领域逻辑(统计、校验)全在 Rust,前端只管画。Tauri 的 invoke 是天然的契约边界,JSON 字段统一成驼峰命名,与 Grove 备份格式保持一致------这意味着原版 App 导出的备份,我能直接导入,反之亦然。
存储选型踩过坑。 一开始用 SQLite,结果撞上这台机器 MSVC 工具链选择的坑:vswhere 失效导致 Rust 误选了一个古董 VS2015 链接器,release 构建时 SQLite 的 C 代码报出 CRT 导入符号未解析。关键是 tauri build 的子进程环境我完全控制不了,修了多轮都不稳定。最后换成 redb------纯 Rust 的嵌入式数据库,零 C 编译。对「单用户、几十棵树的 JSON 存储」这个场景它完全够用,还顺带把一整类「C 代码 × MSVC 工具链」的脆弱性从根上删掉了。换成之后,构建一次通过,安静得像什么都没发生过。
双后端适配。 前端的存储层做了两层实现:浏览器里用 localStorage mock(方便自动化回归),Tauri 里走真实 invoke。开发时在普通浏览器里就能跑完整流程,调试 UI 不用每次起桌面窗口。
高光时刻:原版备份导入成功
全部跑通之后,我做了个实验:把 Grove 官方仓库里的备份文件导进来。
当「Smoking · 已坚持 247 天 · 参天大树」和「Alcohol · 80 天 · 小树」出现在我自己的应用里时,那种感觉挺奇妙的------真实用户的数据,跨过 Flutter 和 Rust 的鸿沟,在一棵由我写的代码渲染出来的分形树上继续生长。这大概是「兼容性」这个词最有成就感的样子。
顺带一提,下载原版备份时还发现一个彩蛋:Grove 仓库里那个 demo 备份文件本身是截断的,JSON 在 9934 字节处断在字符串中间。我只好从 GitHub API 拉 blob 解码,再手工抢救出前两棵完整的树。
途中踩过的三个坑
字段名的大小写之争。 原型阶段图省事用了下划线命名,移植到 TypeScript 时保留了这个习惯,而 Rust 模型为了兼容备份格式用了驼峰。两边各说各话,invoke 反序列化静默失败,界面上只弹一个笼统的「操作失败」。修法不复杂------统一成驼峰,让类型系统把所有的 check_in_days 改成 checkInDays------但这类「两个语言约定不一致」的坑,跨端项目里简直是家常便饭。现在我把 JSON 契约写死在类型定义里,谁都不许私自改名。
一个负数颜色的诡异事故。 报错信息很有意思:invalid value: integer -8622120, expected u32。我明明存的是 #7C6FD8,怎么成了负数?凶手是 JavaScript 的位运算:0xff000000 | 0x7c6fd8 会把结果强制转成有符号 32 位整数,于是 42 亿多的正数变成了负数。改一行算术加法就好,但教训值得记住:跨语言传颜色,别跟 JS 的位运算较劲。
「同样的命令,为什么它不行」。 就是上面提到的 MSVC 工具链问题。最崩溃的是:同样的 cargo 命令,我手动敲能过,tauri build 就是不行------子进程的环境我完全拦截不到,试了包装 cargo、注入环境变量、改链接器路径,全被无声地绕开。最后的选择是换掉数据库,从问题域上删除它,而不是继续和工具链搏斗。这也算一种工程智慧:当你在某个层面反复受挫,退一步问「我是不是可以不经过这个层面」。
现状与下一步
现在的状态:
- 后端:Rust + redb,13 个单测守护全部业务逻辑,构建一次通过
- 前端:TypeScript + Vite,无框架,分形树渲染器 + 四种布局 + 日历 + 枯萎动画
- 数据:与 Grove 备份格式双向兼容,导出即原版格式
- 交付:NSIS 安装包和 MSI 都已产出,双击即装
M2 的清单还排着:系统通知和里程碑提醒、中英文国际化、树快照分享。不过说真的,最想做的其实是那个滚筒布局的桌面版------在宽屏上把几棵参天大树排成一排转起来,应该比手机端还好看。
生活需要仪式感。这也是习惯树的底色------我们想打造一款小而美的精品习惯应用:不追求大而全的功能堆砌,专注把「坚持」这件小事做得有温度、有惊喜。