工 工具小站 bang0.com
首页 作品 笔记 关于
bang0.com · 个人站

工具小站

我一个人做 iOS 工具。这里放着做出来的两个 App —— TongWidget 和 帮您记账, 以及做它们的时候踩到的坑。

看看作品 → 读开发笔记
2个自研 App
5篇开发笔记
iOS + Node客户端与后端自己写
零框架后端不用 Web 框架
Works · 作品

两个自己在用的工具

做的标准很朴素:我自己每天会打开,且没有广告、没有第三方 SDK。

TongWidget 应用图标

TongWidget

桌面小组件 · 主题 · 壁纸

说一句「做一个显示天气和新年倒数的组件」,它就画出来给你上桌面。 AI 生成组件、主题市场、壁纸、灵动岛,收敛在一个 App 里。 桌面只有一种小组件,但能存下无数个实例。

SwiftUI WidgetKit Live Activity Node.js MySQL
查看作品详情 →
TongWidget 组件库 TongWidget 主题市场 TongWidget 壁纸
帮您记账 应用图标

帮您记账

家庭记账 + 物品资产管理

一家人一本账,谁花了多少、家里有什么值钱东西,打开就看见。 Apple ID 一键登录,账单截图本机识别金额,微信支付宝导出的账单能直接搬进来。

SwiftUI SwiftData Vision Node.js APNs
查看作品详情 →
帮您记账 记账页 帮您记账 统计页 帮您记账 物品页
Journal · 笔记

做这些东西时想明白的事

不写教程,只写「为什么会踩」和「最后怎么处置」。多数坑的代价是:编译过了、界面正常、功能其实没接上。

2026-10-19
TongWidget

AI 生成组件:为什么模型只许吐 JSON

iOS 不允许运行时加载新代码,AI 的产出空间就被限死成一份配置描述。省掉的是审核风险,换来的是要自己写一个渲染引擎,还得让 App 预览和桌面组件用的是同一份。

→
2026-10-19
TongWidget

点一下桌面组件,把定位授权框叫出来

小组件是一块静态渲染面,没有 present UI 的能力,弹不出任何对话框。能做的只有一件事:用深链把 App 拉起来。而 iOS 又不允许二次弹框,于是还得有「被拒之后」的分支。

→
2026-10-18
TongWidget

SwiftUI 的透明命中层,报给你的框是假的

代理画出来 284pt 宽,accessibility 也报回 284pt,逐像素量过;可真正接得住点击的只有绘制文字墨迹那 40 多 pt。而且只有排在最后的元素会犯,只测第一个永远发现不了。

→
2026-10-17
帮您记账

家庭账本的增量同步:墓碑、幂等和 30 秒轮询

两个人同时记一笔,谁也不该覆盖谁。删除要能被别人同步到,所以得留墓碑;重放不能占容量,所以幂等键要先于容量预检。判「是不是新建」用 affectedRows 是错的。

→
2026-10-16
帮您记账

把账单搬进 App:零依赖解析微信、支付宝导出件

XLSX 本质是个 zip,不装库也能读;真正麻烦的是 GB18030 编码的 CSV,和「同一笔交易在两边都出现」的去重。识别金额用 Apple Vision 在本机跑完,图片不出设备。

→
← 返回作品列表
TongWidget 应用图标

TongWidget

桌面小组件 · 主题 · 壁纸 · iOS 17+

不用在几十个模板里翻。说一句「做一个显示天气和新年倒数的组件」, 它当场画出来给你看,改到满意再上桌面。 桌面其实只有一个小组件,但它能存下无数个实例。

SwiftUI WidgetKit ActivityKit App Group Node.js MySQL Sign in with Apple
形态
4 Tab:组件 / 主题 / 壁纸 / 我的
组件尺寸
桌面 3 档 + 锁屏 3 档
数据槽位
9 种,天气跟随定位
后端
自建 Node 零框架 + MySQL
状态
开发中,未上架
TongWidget 组件库首页

组件库:内置模板与云端市场作品混排,卡片由渲染引擎真实出图,不是设计稿。

截图来自 iPhone 模拟器实机运行画面,非设计稿合成。

Features · 功能

能做什么

✦

AI 生成组件

聊天描述需求,也能传一张图。后端把大模型的输出翻译成组件配置,边生成边预览,支持「改成红色」这类多轮增量修改。

▦

组件库

应用市场形态的双列列表,内置模板和云端已过审作品混排,卡片带作者和版本号。分类、搜索、收藏都有。

◱

添加到桌面

长按组件 → 编辑小组件 → 在「我的组件」里挑一个实例绑定。App 内新建的实例,立刻出现在选择面板里。

☼

天气跟随定位

槽位里城市留空就跟随你的定位。没开定位时组件会写「未开启定位 · 轻点开启」,点它把授权框叫出来。

◐

灵动岛

三个真实 Live Activity:专注计时、实时网速(真的在测)、音乐律动(真的在播)。不是静态贴图。

❑

主题与图标

一键生成整套 App 图标存进相册;主题安装是三步向导:存壁纸、组件批量入库、换预置 App 图标,完成页逐项打勾。

◈

创作市场

做好组件能发布,管理员过审后上架。别人装了你的作品,你改出新版本要重新过审才覆盖,老用户自己决定要不要更新。

✎

微调布局

不满意 AI 的第一版,可以选中元素直接改字号、颜色、文案,动态文本槽位实时显示改完的效果。

Design · 取舍

桌面只留一个小组件

最早做过「万能组件 + 5 种可绑定内容」,每种内容一套 hardcoded 渲染,加一个玩法就要发一次版。 后来整体删掉,桌面组件形态收敛成 AI 组件一条链路 —— 代价是必须自己写渲染引擎, 收益是新玩法变成改配置,而不是改代码。

  1. AI 只能吐 JSON。iOS 不允许运行时加载新代码,所以模型产出的是一份配置描述,由渲染层按配置绘制。要支持新玩法,就是扩元素类型 —— 那是开发行为。
  2. 渲染层只有一份。App 里的 1:1 预览和桌面组件的渲染共用同一个 AIWidgetRenderer,两个 target 同源。否则「预览长这样、桌面长那样」永远修不完。
  3. 定位放在主 App。组件扩展不申请定位权限:WidgetKit 时间线受系统预算限制,还要额外声明隐私清单,偶发拿不到坐标且原因不可见。改成主 App 定位,坐标和城市名写进 App Group 给组件读。
  4. 失败要能被看见。取不到天气时不再只画一个 --°,而是分「未开启定位 / 找不到城市 / 天气获取失败」三种文案,点组件还能把 App 拉起来处理。
Gallery · 更多界面

各界面截图

组件库首页
组件库
组件详情
组件详情 · 尺寸切换
主题市场
主题市场
壁纸页
壁纸
Journal · 相关笔记

这个作品上写的笔记

2026-10-19
TongWidget

AI 生成组件:为什么模型只许吐 JSON

→
2026-10-19
TongWidget

点一下桌面组件,把定位授权框叫出来

→
2026-10-18
TongWidget

SwiftUI 的透明命中层,报给你的框是假的

→
← 返回作品列表
帮您记账 应用图标

帮您记账

家庭记账 + 物品资产管理 · iOS

一家人一本账,谁花了多少、家里有什么值钱东西,打开就看见。 不用注册、不用记密码,Apple ID 直接登录;换手机还是同一个账号。

SwiftUI SwiftData Vision App Intents Node.js MySQL APNs
Tab
资产 / 记账 / 统计 / 我的
账号
Apple ID + 邮箱密码
家庭
多家庭,邀请码加入
后端
自建 Node 零框架 + MySQL
状态
1.0.7 上架流程中
帮您记账 记账页

记账:本月支出、笔数、日均一眼看全,流水按日分组带小计;截图记账、识图记账、账单导入都在这一屏。

截图为 iPhone 16 Pro Max 尺寸实机界面,用于 App Store 上架素材。

Features · 功能

能做什么

◉

Apple ID 一键登录

不注册、不记密码。服务端校验 Apple 的 JWKS 签名,换手机还是同一个账号。

✦

截图自动记账

装好「截图记账」快捷指令并绑定截屏自动化,每截一张账单图就自动识别金额与日期,生成待确认草稿。

▤

账单导入

微信、支付宝导出的账单能直接搬进来:XLSX 与 GB18030 的 CSV 都吃,按时间加金额去重,再映射到自家分类。

⛂

一家人共用

邀请码把家人拉进同一本账,各自记、一起看。家人新记一笔,你的手机会收到提醒,也可以关掉。

◫

物品与资产

给大件小件建档:分类、图标、购入价、购入日期、数量。自动算持有天数和日均成本,一眼看出哪些东西真在用。

◲

趋势与统计

本月支出、日均、分类占比、近几月走势、日均成本 Top,图表直接看。

◍

帮助与在线客服

内置 21 条 FAQ;找不到答案就转人工,文字和图片都行,前台走长连接、后台走推送。

⛨

没有广告和埋点

无广告、无第三方 SDK、无埋点、无崩溃上报。截图识别用 Apple Vision 在本机跑完,图片不出设备。

Data · 数据

账本什么时候会更新

记账 App 最怕「家人记了一笔我这边半天不出现」。同步策略是明确写出来的, 而不是让用户猜:

停在 App 里不动
每 30 秒自动拉一次(下限 5 秒)
收到家人记账推送
立刻同步,不等下一个轮询周期
切回前台 / 冷启动 / 切家庭 / 恢复网络
立刻同步
自己改动数据
1.2 秒防抖后同步,先推后拉
完全切到后台
不更新 —— iOS 不允许后台常驻轮询。点通知进来会立刻同步
  1. 没网也能记。数据先落本机,联网后自动补传;云端同步走 HTTPS,用于多设备与家人共享。
  2. 重复提交不会变两条。每条记录带客户端唯一号,(family_id, client_uid) 建唯一键,重放多少次都只有一条。
  3. 删除也要能同步。真删之前先留墓碑,别人拉增量时才知道这条要消失,90 天后清理。
  4. 照片存在库里。物品配图直接进 MySQL 的 MEDIUMBLOB,/files/<key> 直出,不依赖对象存储。
Gallery · 更多界面

各界面截图

记账页
记账
物品资产页
资产
统计页
统计
记一笔
记一笔
Journal · 相关笔记

这个作品上写的笔记

2026-10-17
帮您记账

家庭账本的增量同步:墓碑、幂等和 30 秒轮询

→
2026-10-16
帮您记账

把账单搬进 App:零依赖解析微信、支付宝导出件

→

关于这个站

bang0.com · 更新于 2026-10-19

我叫工具小站,域名 bang0.com。这里放我一个人做的 iOS 工具,和做它们的时候想明白的事。

为什么只做工具

因为只有工具是自己会天天打开的。内容、社交那些方向做不动,也不想做 —— 一个 App 如果我自己每天要打开十次,它的每一个别扭我都躲不掉,改的动力很足。

所以判断标准很朴素:我自己每天用,且没有广告、没有第三方 SDK、没有埋点、没有崩溃上报。 两个作品都是这个标准下长出来的 —— TongWidget 解决「桌面想放点什么」, 帮您记账 解决「家里的钱和东西到底有多少」。

笔记为什么不写教程

这里写的都是「为什么会踩」和「最后怎么处置」。踩过的坑大多有一个共同特征: 编译过了、界面正常、功能其实没接上。这类问题在文档和搜索结果里通常查不到, 因为报错信息不会指向真正的原因。

比如判「这条记录是不是新建」用了 affectedRows,结果改一笔旧账就给全家发一次推送; 比如 SwiftUI 摆出来的透明命中层,画出来 284pt、accessibility 也报 284pt, 真正接得住点击的只有 40 多 pt。这种记录写下来,下次自己少踩一遍,别人碰上了也能对上号。

怎么联系我

最直接的是 App 内:帮您记账里有帮助中心(21 条常见问题),找不到答案可以转人工, 文字和图片都能发,前台走长连接、后台走推送。TongWidget 的反馈入口在「我的 → 设置 → 反馈与建议」。

邮箱地址暂未公开 —— 需要的话把上面注释里的模板替换成真实地址即可。

关于这个站点

纯静态:手写 HTML + CSS + JavaScript,没有框架、没有构建步骤、没有第三方依赖。 截图来自 iPhone 模拟器与真机运行画面,不是设计稿合成。

  • 不加载任何外部字体、脚本或样式,页面资源全部在本域名下。
  • 没有访问统计、没有 Cookie、没有埋点,因此也不需要同意横幅。
  • 深色主题跟随系统偏好之外不做任何设备指纹。

站内出现的软件名称、图标与截图,均为本人作品的原始素材。

← 全部笔记

AI 生成组件:为什么模型只许吐 JSON

2026-10-19 · TongWidget · 约 6 分钟

TongWidget 里最常被问的一句是「AI 生成组件,是不是让模型写一段 SwiftUI 代码」。不是,而且它不可能是: iOS 不允许 App 在运行时加载新代码。热更新代码这条路从平台层面就是死的,不是审核松紧的问题。

所以模型能产出的只有一样东西 —— 一份配置描述:背景是什么色、有哪几个元素、每个元素画什么、数据从哪个槽位来。 画它的是我自己写死的渲染层。约束听起来很憋屈,但它同时把风险面收窄了:模型再怎么发挥, 能表达的东西都被白名单框住,不存在「生成出一段把用户数据发走」的可能。

槽位是白名单,一共 9 个

配置里能引用的数据来源是枚举好的:文字、时钟、电量、天气、图片、倒数日、收藏计数、每日一言、我的创作。 后端把大模型的输出对着这份白名单校验,越界的字段直接丢掉。

要支持新玩法,就是加一种元素类型 —— 那是开发行为,要发版。这个代价换来的是确定性: 渲染层能处理什么,取决于代码里写了什么,而不是取决于模型当天想到什么。

渲染层只能有一份,而且两个 target 共用

App 里的 1:1 预览和桌面小组件上的实际渲染,必须是同一份代码。 这条一开始没守住,预览用一套视图、Widget 用另一套,结果就是「预览长这样,桌面上长那样」, 而且只在特定尺寸下复现,修不完。

现在 AIWidgetRenderer.swift 是唯一渲染层,作为 source 同时编进主 App 和 Widget 扩展两个 target。 同一个配置文件进去,出来的像素一定一样。

解码必须防御式,因为对面是语言模型

这是最疼的一条。Swift 的合成 Codable 不认属性默认值:结构体里写了 var w: Double = 1, 只要 JSON 里没有 w,合成的 init(from:) 就直接抛 DecodingError, 界面上一句「未能读取数据,因为数据丢失」。

而模型少给一个字段是常态,不是异常。所以配置结构体的解码全部手写 init(from:), 每个字段走 decodeIfPresent 再兜默认值。让模型「必须给全」是不现实的契约。

和不可靠的上游打交道时,把容错写在解析层,而不是写在提示词里。提示词是愿望,解析层是保证。

兜底值不能写死

没有 API key 的时候,后端会退化成关键词模板兜底 —— 用固定模板拼一份配置出来,保证链路不断。 但模板里的默认值出过两次事:

  • 倒数日的兜底日期写成了某一天。过了那天之后,组件永远显示「已过」。改成「距今 30 天」这种相对值。
  • 天气的城市被塞了默认「北京」。表现是定位功能形同不存在 —— 所有人看到同一个城市的天气。 现在的规矩是:后端只在用户点名城市时才写 city,留空就等于客户端回落到定位。
  • 模板里的 hex 不带 #。校验函数要求 #RRGGBB, 整段配色被打回兜底色,看起来像「AI 画的不好看」,其实是格式没过。

还有一条很小的:天气图标按关键词匹配,「雷阵雨」同时含「雷」和「雨」,先判「雨」就永远拿不到闪电图标。 判断顺序得是雷 → 雪 → 雨。

生成过程要让人看见

一次生成要等几秒到几十秒。如果这期间界面只是转圈,用户会以为卡死了。 现在做的是:思考气泡 + 阶段提示轮播(正在理解需求 / 设计布局 / 挑选配色 / 生成配置 / 校验), 已经有预览的时候叠一层半透明进度,而不是把画布清空 —— 清空会让人以为刚才的东西丢了。

后端还会把模型吐出的 JSON 原文翻译成人话回复,包含槽位清单和一句「天气跟随你的定位」。 用户看到的不是「生成成功」,而是「我给你放了这几样东西」。

多轮修改不能堆实例

「改成红色」这类增量调整,实现上是一次新的生成。最早每点一次「应用」就新建一个组件实例, 改五轮桌面上多出五个组件。现在的规则是:首轮建实例,后续更新同一个。

发布到创作市场之后还有一套版本化:作者提交的新版本先作为待审草稿,过审才覆盖已发布配置并把 version 加一。这期间市场和已装用户手里仍是旧版,用户要在「我的组件」主动点 「更新到 vN+1」才会换新版 —— 不点就继续用旧的。审核页预览的必须是草稿而不是线上配置, 否则等于没看内容就批了。


回头看,「AI 只能吐 JSON」这个约束是整个功能能落地的前提而不是障碍。 它把不可预测的模型输出压进一个可校验的形状里,剩下的工程问题 —— 同源渲染、防御解码、相对兜底值、 过程可见 —— 都是普通的、能测的、能修的。

相关作品TongWidget 下一篇点一下桌面组件,把定位授权框叫出来
← 全部笔记

点一下桌面组件,把定位授权框叫出来

2026-10-19 · TongWidget · 约 6 分钟

天气组件的城市来自定位。用户没开定位的时候,组件上应该显示什么? 最初的答案是 --°。这个答案的坏处是:它既不说明原因,也不给出路, 用户只能猜是 App 坏了、网断了,还是这个组件本来就没内容。

现在没定位时组件文案是「未开启定位 · 轻点开启」,点它真的能把授权框叫出来。 这条链路看着简单,做起来撞了三层平台限制。

第一层:小组件弹不出任何对话框

Widget 是一块静态渲染面,没有 present UI 的能力。它连一个 alert 都弹不出来, 更别说系统授权框。所以「点组件请求定位」在组件侧是不可能完成的任务。

能做的只有一件事:把 widgetURL 换成一个深链, 比如 bang0widgets://enable-location,让点击动作把主 App 拉起来, 由 App 去调 requestWhenInUseAuthorization()。组件负责喊人,人到了 App 来办事。

第二层:iOS 不允许二次弹框

用户拒过一次之后,再调 requestWhenInUseAuthorization() 是静默无效的 —— 系统不会再弹第二次。这时点组件如果什么都不做,用户视角就是「我点了,没反应」。

所以 denied 必须有独立分支:不弹系统框,改弹自己的引导框,说明「定位权限已关闭」, 给一个「去设置」按钮跳 UIApplication.openSettingsURLString。 这不是产品设计的偏好,是平台保证 —— 我们只能这么做。

第三层:刚立起来的引导框被自己关掉

这条是真正的 bug,而且现象非常迷惑:点组件 → 引导框出现 → 立刻消失, 看起来像「点了什么也没发生」。

原因是两条 relocate 调用相隔 1.2 秒。第一条来自用户点击,带 promptSettingsOnDenied: true,把引导框立起来了; 紧接着 App 回前台触发的自动重试也跑了一次 relocate, 这次参数是 false。而那一行代码写的是无条件赋值:

// 错:自动路径把用户主动触发的引导取消了
needsSettingsGuide = promptSettingsOnDenied

// 对:denied 分支只立不清
if promptSettingsOnDenied { needsSettingsGuide = true }

教训是:自动路径没有资格取消用户主动触发的 UI。同理,用户点「稍后再说」之后,也不该被下一轮自动重试重新弹出来。

为什么定位放在主 App,而不是组件里

组件扩展自己申请定位看起来更直接,实际不行:WidgetKit 的时间线刷新受系统预算限制, 还要额外声明 WidgetPrivacyInfo,偶发拿不到坐标而且原因不可见。

现在的分工是:主 App 用 CoreLocation 定位,CLGeocoder 反查城市名, 城市名和坐标一起写进 App Group,组件只读不申请权限。

坐标也一起传,是因为组件里如果有坐标就直接走经纬度查天气, 省掉「坐标 → 城市名 → 再地理编码成坐标」这一趟有损往返。

顺手记两个同源问题

初始化阶段的赋值不触发 didSet

load() 在 init 里调用,里面给 authToken 赋值, 本该由 didSet 把值同步到 App Group 镜像。但 Swift 规定初始化期间的赋值不跑属性观察器, 于是镜像静默跳过,扩展侧读到 nil,桌面勾待办时回一句「登录后才能…」。

编译过、界面正常、功能完全没接上。修法是在 load() 结尾显式调一次同步,别指望 didSet 兜住 init 路径。

异步换回来的状态要立刻落盘

syncWithBackend 拿到 token 之后只赋值没 persist(), 而登录流程里那次 persist 跑在它之前。结果每次冷启动 token 都是 nil, 待办拉回空列表 —— 一串看起来各自独立的毛病,同一个根因。

测试上的一个坑:模拟器造不出「定位已拒绝」

要验 denied 分支,得先有 denied 状态。simctl privacy revoke location 退出码 0, locationd 日志里也确实记了 auth:2,但 App 里 CLLocationManager.authorizationStatus 照旧返回 authorizedWhenInUse: 没有横幅、天气照样定位成功。设备目录里那份 TCC.db 连 location 行都没有 —— simctl privacy 写的不是 App 读的那份。

最后不再跟环境较劲,加了个启动参数 -twForceLocationDenied 把授权态钉死成 denied。 要验的本来就是「我们拿到 denied 之后怎么处置」,「iOS 不会二次弹框」是平台保证,不需要在模拟器上复现。

TongWidget 组件详情页,天气组件显示城市来源
组件详情里会写清天气城市的来源,而不是让用户对着空值猜。

回头看,这个功能的难点从来不是代码量,而是三层平台约束叠在一起:组件不能弹框、App 不能二次弹、 引导框还会被自动重试抹掉。约束只能绕,绕法要能被用户看懂 —— 所以文案得写「未开启定位 · 轻点开启」,而不是一个 --°。

上一篇AI 生成组件:为什么模型只许吐 JSON 下一篇SwiftUI 的透明命中层,报给你的框是假的
← 全部笔记

SwiftUI 的透明命中层,报给你的框是假的

2026-10-18 · TongWidget · 约 5 分钟

AI 生成的组件第一版往往不太满意,所以有个「微调布局」页:在预览画布上点中某个元素, 下面面板改字号、改颜色、改文案。需求很朴素 —— 点哪儿选哪儿。 实现它花的时间超出预期好几倍。

第一版:给每个元素摆一块透明代理

最直觉的做法是让每个元素上面盖一层透明视图,frame 定大小、position 定位置, 各自挂一个 onTapGesture。画面上看是对的,测第一个元素也是对的。

然后发现只有排在最后的元素点不中。前几个全好。

量了一下,结果很反直觉:那块代理画出来是 284pt 宽,用 XCUITest 的 accessibility 去问它,报回来的 frame 也是 284pt 宽 —— 截图逐像素对过,两处一致。 可真正接得住点击的,只有绘制出文字墨迹的那 40 多 pt。点在其余位置上, 事件整个漏到下面的背景层,表现就是「取消选中」。

offset、position、contentShape 换着摆都是这个结果。 换句话说:透明代理的尺寸是画给布局系统看的,命中区域仍然由实际绘制内容决定。 一个 Color.clear 撑出来的框,报给你的几何信息并不承诺「点这里我能收到」。

更早还有一版,坑不一样

用 offset 摆位的那版是另一个毛病:命中区跟着 offset 走了, 但 accessibility 报回来的框停在布局原位。于是 query.firstMatch 按几何排序时 给到的是另一个元素,失败信息完全指不到真因 —— 你以为是命中问题,其实是查询问题。

解法:把命中判断收回代码里

不再让 SwiftUI 帮忙算命中。画布上只铺一块手势面:

Color.clear
    .contentShape(Rectangle())
    .gesture(DragGesture(minimumDistance: 0).onEnded { value in
        hitTest(value.location)
    })

点下来的坐标自己按元素矩形算 —— LayoutTuneView.hitTest, 从最后一个往前找,因为视觉叠序就是后画的压住先画的。 元素那一层只保留 accessibility 标记,压在手势面下面,不接任何手势。

这样命中区域完全由我算出来的矩形决定,和画出来多大就是一回事,没有第二个真相来源。

用例侧跟着改了三点

  • 倒着点。从最后一个元素往前逐个点,因为 bug 就藏在叠序末尾。 只测第一个元素的用例,永远发现不了这类问题。
  • 断言读面板标题里的编号。选中态唯一确定的证据是下面面板显示的是第几个元素, 而不是「有没有高亮」这种看着像的话。
  • 写死 tune.hit.<i> identifier。不用 firstMatch 猜顺序 —— 上面已经看到,按几何猜序本身就不可靠。

顺带记两条测试基础设施的坑

「有卡片就点第一张」会点到旧卡

创作中心用 seed 账号登录时本来就挂着 7 件作品,pop 回来那一刻 onAppear 的 reload 还在路上, firstMatch 立刻命中旧卡 —— 实测点到了「周末倒计时」,页面标题变成「编辑新版本」, 看着像产品逻辑错了,其实是等错了元素。改成按名字轮询卡片,失败信息把实际看到的卡片名全列出来。

粘住不消失的 toast 会抢 BEGINSWITH

待办用例的提示文案以「已完成」开头,和列表的「已完成 N」分组头同前缀, firstMatch 点到了 toast 上。分组头改用 label MATCHES '^已完成 [0-9]+' 精确锚定。

还有一条更阴的:系统相册授权框里「不允许」这个按钮,也包含「允许」两个字。 用 label CONTAINS '允许' 找按钮,firstMatch 命中「不允许」, 权限被拒,向导第一步永远打不上勾,而失败信息只说「元素找不到」。 包含匹配必须配排除:CONTAINS '允许' AND NOT label CONTAINS '不' AND NOT label CONTAINS '取消'。


这个坑教我的不是「SwiftUI 有 bug」,而是:当框架能报给你一份几何信息时, 那份信息是布局结果,不是命中承诺。需要精确命中的场景,把坐标判断收进自己的代码, 让「点得到」和「看得见」共用同一份数据。

上一篇点一下桌面组件,把定位授权框叫出来 下一篇家庭账本的增量同步
← 全部笔记

家庭账本的增量同步:墓碑、幂等和 30 秒轮询

2026-10-17 · 帮您记账 · 约 7 分钟

家庭记账的核心矛盾很具体:两台手机同时在记,谁也不该覆盖谁;一边删掉的一笔,另一边也得消失。 帮您记账的同步策略最后写成一句话 —— 宁多发、不漏发, 前提是每一次「多发」都必须无害。这条前提才是全部工程量的来源。

顺序固定:先 push 后 pull

反过来会被云端旧数据盖掉本地刚做的改动。这个顺序不是风格问题,是正确性问题。

客户端只推 dirty || 没有 remote_id 的记录。只看 dirty 会漏推 —— 这是 NAS 版「记账同步不到」的真凶:某些路径下本地写入了但脏标记没置上,那条记录就永远留在本机。

反过来,push 的响应回带服务端 id 和 updated_at, 客户端真正拿到 id 才清 dirty,否则下一轮又推一条。

幂等靠唯一键,不靠逻辑判断

每条记录带一个客户端生成的唯一号,服务端按 (family_id, client_uid) 建唯一索引兜底。 同 uid 重发只 UPDATE,不产生重复。有了这个,「宁多发」才成立 —— 重复下发同一条记录,apply 一万次也还是一条。

但判「是不是新建」不能用 affectedRows

MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE, 当传入值和原值完全相同时 affectedRows 仍然返回 1, 因为 MySQL 认为「匹配到一行并做了更新」。这就和真正插入新行区分不开了。

这个坑的直接后果很尴尬:家人记账提醒把编辑旧账也当成新建, 改一笔金额,全家手机都收到「XXX 记了一笔」。正确做法是先按 client_uid SELECT 判存在性,再决定走 INSERT 还是 UPDATE,由 store.upsert() 明确返回 { row, created }。提醒只挂在 created === true 上。

相关联的另一条:真要判「有没有改到东西」,得用 affectedRows === 2(MySQL 语义) 或驱动提供的 changedRows,不能用 !== 0。

增量游标:比较一律用 >=

pull 请求带 since 游标,服务端返回 updated_at >= since 的记录加墓碑。 游标在查询之前取服务端时间。

时间列是秒级精度。用 > 会永久漏掉同一秒内写入的改动 —— 那一秒的记录,下一次拉取时 since 已经越过它了。用 >= 的代价是游标那一秒内的记录会被重复下发,而因为有幂等键,重复下发无害。

其他几条同源规矩:每个家庭独立游标(换家庭等于换 cursor 重新拉); apply 时先按 client_uid 匹配、remote_id 兜底(老数据没有 uid 也能对上); 本地 dirty 的记录跳过更新,否则云端旧值会盖掉本地未推送的改动,还顺手把 dirty 清了; 从云端拉来的新记录沿用服务端 UUID,不重新生成。

删除必须走墓碑队列,不能走旁路 REST

这是一次真实事故。早期实现是「本地删掉 + 尽力而为调一次 DELETE /expenses/:id」。 请求一旦失败(切后台、断网、初始化还没就绪),服务端的记录还在,而且 updated_at 没变 —— 于是双方增量拉取都永远拉不到这条改动,两台设备的条数永久不一致。 实测一端 71 笔、另一端 80 笔。

现在的做法是把删除并入同步主通道:

  1. iOS 本地删除前,先把 client_uid 写进按家庭隔离的待删队列。
  2. push 时并入 body.deleted,失败随下一轮 push 重试,直到拿到 ack。
  3. 服务端按 client_uid 删真表并写墓碑;不存在的 uid 也写墓碑, 防另一台设备稍后推来同 uid 记录把它复活。
  4. 响应回 deleted ack,客户端收到才清队列。
  5. 同 uid 重新 upsert(备份恢复)时清掉墓碑,否则其他设备会把刚恢复的记录再删一遍。

墓碑保留 90 天。三个月没打开 App 的设备再上线,也能收到这期间的删除。

数据对齐必须双向

设置页有个「重新同步云端数据」。它做的不是单向拉取,而是:先全量拉(把服务端真相含墓碑落到本机), 再把本机全部记录强制幂等推上去,最后再拉一次拿新的 remoteId。

为什么必须双向:服务器换库重部署之后,设备本地数据带着旧 remoteId 且 dirty=false,常规 push 的筛选条件(dirty || remoteId == nil) 一条都推不上去 —— 单向 resync 只会从那座空库里拉回空气。 两台设备各跑一次这个双向流程,就收敛到同一份全集。

什么时候会同步

用户能感知的刷新时机是明确列出来的,不是靠玄学轮询:

场景多久更新
停在 App 里不动每 30 秒自动拉一次
收到家人记账推送立刻同步,不等下一个周期
切回前台 / 冷启动 / 切家庭 / 恢复网络立刻同步
自己改动数据1.2 秒防抖后同步,先推后拉
完全切到后台不更新(iOS 不允许后台常驻轮询)

最后一条是平台限制,不是偷懒。要让后台也更新得接 BackgroundTasks,目前没做。 但点通知进来会立刻同步 —— 因为通知本身就是「我想现在看」的信号。


帮您记账 记账页,家人共用一本账
同步做对了的话,用户只会觉得「家人记的那笔自己就出现了」。

整套东西的验收标准其实很朴素:两台设备各记各的、各删各的,最后条数和内容要一模一样。 听起来是基本要求,实现它需要唯一键、>= 游标、墓碑队列、ack 后才清、双向对齐, 缺一条就会在某个偶发时刻差一笔 —— 而用户对「账对不上」的容忍度是零。

上一篇SwiftUI 的透明命中层,报给你的框是假的 下一篇把账单搬进 App
← 全部笔记

把账单搬进 App:零依赖解析微信、支付宝导出件

2026-10-16 · 帮您记账 · 约 6 分钟

记账 App 最大的流失点不是不好用,是历史账不在这里。用户装完打开一看,空的。 所以微信、支付宝导出的账单必须能搬进来。而这两家给的文件格式,恰好一个是 XLSX、一个是 GB18030 编码的 CSV。

整个后端和客户端都刻意保持零第三方依赖,所以解析器是自己写的。

先别信扩展名

入口是 fileImporter,用户可能从「文件」、微信、支付宝、邮件附件里选一个文件出来, 扩展名被改过、或者压根没有,都很正常。所以识别按内容不按扩展名: 读文件头,看到 PK 魔数就是微信的 XLSX,否则按支付宝 CSV 处理。

另一条和文件读取有关的必现坑:fileImporter 回调给的是 security-scoped URL, 不调 startAccessingSecurityScopedResource() 直接读会报「没有查看它的权限」, 从「文件」App 或微信里选来的文件必现。规矩是:在作用域内一次性 Data(contentsOf:) 读进内存,然后才异步解析,defer 里 stop。

手写一个最小的 zip 读法

XLSX 本质就是一个 zip 包,里面装 XML。要读的只有两个部件: sharedStrings.xml(字符串表)和 xl/worksheets/sheet1.xml(单元格)。 那就按 zip 的格式自己走一遍:

  1. 从文件尾往前找 EOCD(End of Central Directory),拿到中央目录的偏移和条目数。
  2. 遍历 central directory,按文件名定位到需要的部件,读出它 local header 的偏移。
  3. 跳过 local header,取压缩数据。微信的 XLSX 是 deflate,用 compression_decode_buffer 解 raw deflate —— 这是系统能力,不算依赖。
  4. 两段 XML 交给 XMLParser,先把 sharedStrings 摊平成数组, 再按单元格的 t="s" 索引取字符串。

一个 SDK 变更导致的静默失败

XMLParserDelegate 的元素回调方法,参数标签在 iOS 27 SDK 上改成了 qualifiedName qName:。如果还按老写法实现 qName:, Swift 会认为你实现的是另一个方法 —— 不报错,只给个 warning, 然后 delegate 静默不回调,解析结果一片空白。

这类「签名不匹配 → 协议方法没接上」的失败特别难查,因为程序逻辑上完全正确,只是没在跑。

Excel 的序列日期,锚点必须用固定偏移

XLSX 里日期存的是一个数字:从某个锚点起算的天数。锚点是 1899-12-30。 用 Calendar / DateComponents 构造这个锚点时, 时区必须写成固定偏移 TimeZone(secondsFromGMT: 8 * 3600),不能用 Asia/Shanghai。

原因是 Asia/Shanghai 在 1901 年之前用的是 LMT,偏移量是 +8:05:43 而不是 +8:00。用命名时区构造 1899 年的锚点,会凭空差出 5 分 43 秒 —— 换算成日期,边界那天就错一天。

这是那种「看起来是时区老问题,其实是时区老问题的历史数据」的坑。

支付宝的 CSV:编码和头部

支付宝导出的是 GB18030 编码的 CSV,不是 UTF-8。按 UTF-8 读,中文列全是乱码。 头部有约 28 行元数据(导出说明、账号、统计区间),要跳过,真正的表头行以「交易时间」开头 —— 所以还是那句话,按内容定位而不是按行号硬数。

CSV 的引号转义用一个小状态机 splitCSVLine 处理, 因为备注字段里可能出现逗号和转义引号,components(separatedBy: ",") 会切错列。

去重、分类和「只导支出」

导入的账本大概率和本机已有记录重叠 —— 用户可能之前手记过几笔,或者重复导了同一个文件。 去重键是 Int(时间戳) | amount(保留两位):同一时刻、同一金额,认为是同一笔。 用本机已有记录建一个 Set,命中就跳过。

分类走三层:官方分类映射表 → 关键词兜底 → 结果必须属于 App 自己的可见分类集合, 否则归到「其他」。这一层校验是必要的,映射表来自外部文件,写错或过期的可能性得兜住。

只导支出。收入、中性交易、0 元单会跳过,并且把跳过条数展示给用户看 —— 不写清楚的话,导入完条数对不上,用户以为丢了数据。

写入分批 300 条 ctx.insert 一次 save,然后走既有的同步通道 push。 不另开一条导入专用的上传路径,是为了让导入的数据天然带上 client_uid, 和手记的记录在幂等语义上完全等价。来源字段新增 alipay / wechat 两个枚举值, 后端白名单同步放行。

截图记账:识别留在本机

另一条录入路径是账单截图。用 App Intents 做「截图记账」快捷指令, 绑定系统的「截屏」自动化之后,每截一张账单图就自动识别一次; 也可以从相册把图分享给这个 App。

识别用 Apple 的 Vision 框架,在本机跑完,图片不上传。 这既是隐私选择,也是省服务器的选择 —— 但代价是识别质量只能靠规则调。几条经验:

  • 「有没有识别出金额」绝对优先于行数。一次识别出很多行文字但没有金额的,直接丢掉。
  • 弱结果等 0.4 秒换一张备用图再试,最后一步才关掉语言校正。
  • 缩图上限:分享扩展 2000px、快捷指令 2600px。再大对识别没帮助,只增加内存压力。
  • 商户字段不做 OCR —— 它是最后录入的辅助信息,识别错了反而要用户改。

识别出来的结果不直接入账,而是生成一份待确认草稿,用户点一下确认才写进账本。 自动化的价值在于少填几个字段,不在于跳过判断。


帮您记账 记一笔页面,字段顺序为金额、分类、时间、备注、支付方式、商户
手记时的字段顺序固定:金额 → 分类 → 时间 → 备注 → 支付方式 → 商户。

导入这件事,做完之后回头看,技术难度不在「解析 XLSX」,而在把外部数据变成自己账本里合法的一条记录: 编码要对、时区要对、去重要稳、分类要收敛、还得走同一条同步通道。少任何一步,用户看到的都是一句 「导入的数据不对」。

上一篇家庭账本的增量同步 更多关于本站
工 工具小站

一个人做的 iOS 工具,和做它们时留下的记录。域名 bang0.com。

作品
TongWidget 帮您记账
笔记
AI 生成组件 点组件请求定位 账本增量同步
站点
首页 关于
© 2026 工具小站 · bang0.com 蜀ICP备2026063032号-1