阅读视图

发现新文章,点击刷新页面。

CocoaPods 正在退场,SwiftPM 才刚到第二章 - 肘子的 Swift 周报 #135

谷歌近期宣布,从下一个 Flutter 稳定版 3.44 开始,Swift Package Manager 将在默认路径上取代 CocoaPods,成为 iOS 和 macOS 应用的默认依赖管理器。CocoaPods 的 Trunk 仓库计划于 2026 年 12 月 2 日正式进入只读状态——这个时间点我们在 2024 年的周报中就讨论过了,但当 Flutter 真正开始在默认路径上用 SPM 替换 CocoaPods 时,还是引发了社区的广泛热议。

Android Studio 新版本与 Gradle 7.x 构建报错解决方案

问题现象

在 Android Studio 中 Build / Sync 报错:

1
2
3
4
5
6
7
8
Could not compile initialization script '.../ijMapper1.gradle'.
> startup failed:
General error during conversion: Unsupported class file major version 65

java.lang.IllegalArgumentException: Unsupported class file major version 65
at groovyjarjarasm.asm.ClassReader.<init>(ClassReader.java:199)
...
at com.intellij.gradle.toolingExtension.impl.modelAction.GradleModelFetchAction...

从命令行直接执行 ./gradlew tasks 完全正常,只有通过 Android Studio 触发构建时才报错。


根本原因

这是一个 Android Studio 版本 × Gradle 版本 的二进制不兼容问题,与项目代码无关。

层级 说明
class file major version 65 Java 21 编译产物的标识(Java 11 = 55,Java 17 = 61,Java 21 = 65)
Android Studio Koala(2024.1)及更新版本 自带 JDK 21,其 Tooling Extension JARs(如 GradleOpenTelemetryGradleModelFetchAction)使用 Java 21 编译
Gradle 7.4.2 内置 Groovy 3.0.9 / ASM 9.1 ASM 9.1 最高只能解析 Java 17(class version 61)的 class 文件,无法读取 Java 21 产物

Android Studio 每次构建会向 Gradle 注入一个临时 init script(ijMapper1.gradle),Groovy 编译这个脚本时需要解析 classpath 上的 Android Studio Tooling JARs。新版 AS 的这些 JAR 是 Java 21 编译的,Gradle 7.x 内置的 ASM 读不了,直接崩溃。

命令行不受影响的原因:直接执行 ./gradlew 不经过 Android Studio 的 Tooling API,不会注入这个 init script。


解决方案

✅ 方案一:在 Android Studio 中指定 Gradle JDK 为 JDK 11(本文实际采用,推荐)

修改项目的 .idea/gradle.xml,将 gradleJvm 改为 JDK 11 的路径(路径需已在 Android Studio 的 JDK Table 中注册):

1
2
<option name="gradleJvm"
value="$USER_HOME$/Library/Java/JavaVirtualMachines/corretto-11.0.19/Contents/Home" />

注意:这里必须使用 $USER_HOME$ 变量形式,而非绝对路径,否则 Android Studio 在 jdk.table.xml 中匹配不到对应条目,报 “Undefined jdk.table.xml entry” 错误。

配置完成后,Android Studio 会:

  1. 用 JDK 11 启动 Gradle daemon
  2. 选用与 JDK 11 兼容的 Tooling Extension JARs(class version ≤ 61),避免 ASM 解析失败

此方案不影响其他同事(.idea/gradle.xml 中的路径是各自机器上注册过的条目,不同机器只要都注册了 JDK 11 即可)。


方案二:升级 Gradle 到 8.4+(需团队协调)

Gradle 8.x 采用 Groovy 4.x / ASM 9.5+,原生支持 Java 21 class 文件,从根本上消除兼容问题。

1
2
# gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip

代价:AGP 7.x 不兼容 Gradle 8.x,需同步将 AGP 升级到 8.x,改动较大,需要团队统一升级。


方案三:降级 Android Studio(临时方案)

降回 Android Studio Jellyfish(2023.3.1) 或更早版本,其 Tooling Extension JARs 基于 Java 17 编译(class version 61),Gradle 7.x 的 ASM 9.1 可以正常解析。


排查思路总结

遇到此类问题时,快速定位的关键线索:

  1. 只在 IDE 内报错,命令行正常 → 问题出在 IDE 注入的 init script,而非项目构建脚本本身
  2. Unsupported class file major version 65 → 某个依赖/工具 JAR 用 Java 21 编译,运行时的 ASM/JVM 版本不支持
  3. 错误堆栈含 com.intellij.gradle.toolingExtension → 确认是 Android Studio Tooling Extension 和 Gradle 的版本兼容性问题

兼容性速查表

Android Studio 版本 内置 JDK Tooling JAR class version 最低兼容 Gradle
Jellyfish 2023.3.1 及以前 JDK 17 61(Java 17) Gradle 7.x 可用
Koala 2024.1.1 及以后 JDK 21 65(Java 21) 需 Gradle 8.4+ 或手动指定 Gradle JDK 为 17/11

Fighting Hyrum's Law in LLVM

With a sufficient number of users of an API, it does not matterwhat you promise in the contract: all observable behaviors of yoursystem will be depended on by somebody. — Hyrum's Law

In a compiler, the most common form of Hyrum's Law is dependence onunspecified behavior — hash bucket order, the order of equalelements after std::sort, padding offsets. The same framingcovers a few cases that are technically undefined behavior (use of aninvalidated iterator) or plain incidental properties (ABI struct layout,ELF section offsets).

When the compiler itself harbors such a dependency, the symptom isusually output that varies build-to-build: an unstable sort that landsdifferently after the standard library changes, a hash map whoseiteration order shifts when the hash function does. Occasionally thevariation is run-to-run within a single build —DenseMap<void *, X> keys with an ASLR-derived seedreorder buckets each invocation. Either way, reproducible builds,bisection, and bug reports all assume same input → same output, and astealth Hyrum dependency breaks that.

This post surveys some mechanisms that perturb the contract's blindspots so dependencies cannot quietly form.

Hash seed perturbation

The first line of defense is the hash function itself.llvm/include/llvm/ADT/Hashing.h:

1
2
3
4
5
6
7
8
inline uint64_t get_execution_seed() {
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
return static_cast<uint64_t>(
reinterpret_cast<uintptr_t>(&install_fatal_error_handler));
#else
return 0xff51afd7ed558ccdULL;
#endif
}

The seed XORed into every llvm::hash_value is theruntime address of install_fatal_error_handler — underASLR, different every process. The header comment is explicit:

the seed is non-deterministic per process (address of a functionin LLVMSupport) to prevent having users depend on the particular hashvalues.

Every hash_combine / hash_integer_valuecall picks up the seed, and every DenseMap<K, V>keyed by a hash_value-using type then reorders its bucketsper run. MD5, BLAKE3, SHA1, SHA256 stay byte-stable — those are theright tools when you actually want a digest.

My commitce80c80dca45 introduced the seed in 2024.

Container iteration order

Code can grow dependencies on the iteration order.LLVM_ENABLE_REVERSE_ITERATION walks hash containersbackwards to flag violations.llvm/include/llvm/Support/ReverseIteration.h:

1
2
3
4
5
6
7
template <class T = void *> constexpr bool shouldReverseIterate() {
#if LLVM_ENABLE_REVERSE_ITERATION
return detail::IsPointerLike<T>::value;
#else
return false;
#endif
}

DenseMap flips its BucketItTy tostd::reverse_iterator<pointer>;SmallPtrSet swaps begin() andend(); StringMap bitwise-NOTs the hash beforebucket selection — the only thing that perturbs StringMap,since its hash bypasses get_execution_seed.

Unlike the hash seed, reverse iteration isn't auto-on withassertions; -DLLVM_REVERSE_ITERATION=ON opts in explicitly.In 2026 has already merged fixes triggered by it: 7f703cabf728(MLIR SSA-value completion order), 0b3afd35c41d(MLIR SROA alloca order), and f5e2c5ddcec7(a clang test).

Iterator invalidation

Orthogonal to iteration order: what happens to an existing iteratorafter a mutation. llvm/include/llvm/ADT/EpochTracker.h:

1
2
3
4
5
6
7
8
9
10
11
12
class DebugEpochBase {
uint64_t Epoch = 0;
public:
void incrementEpoch() { ++Epoch; }
~DebugEpochBase() { incrementEpoch(); } // catches use-after-free

class HandleBase {
bool isHandleInSync() const {
return *EpochAddress == EpochAtCreation;
}
};
};

DenseMap and friends inherit fromDebugEpochBase. Mutations bump the epoch; iterators captureit at construction and assert on mismatch. The destructor bumps too, sostale iterators into destroyed containers assert rather than read freedmemory.

Without it, mutate-during-iteration "happens to work" depending onbucket layout — and bucket layout is what the hash seed and reverseiteration above perturb. The epoch check turns the latent bug into aclean assert regardless of which "lucky" layout the run lands on.Collapses to a no-op under NDEBUG.

Pre-shuffling unstable sorts

The same defensive pattern shows up twice in the monorepo, indifferent sub-projects, years apart.

llvm::sort underEXPENSIVE_CHECKS

llvm/include/llvm/ADT/STLExtras.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ifdef EXPENSIVE_CHECKS
namespace detail {
inline unsigned presortShuffleEntropy() {
static unsigned Result(std::random_device{}());
return Result;
}

template <class IteratorTy>
inline void presortShuffle(IteratorTy Start, IteratorTy End) {
std::mt19937 Generator(presortShuffleEntropy());
llvm::shuffle(Start, End, Generator);
}
} // end namespace detail
#endif

template <typename IteratorTy, typename Compare>
inline void sort(IteratorTy Start, IteratorTy End, Compare Comp) {
#ifdef EXPENSIVE_CHECKS
detail::presortShuffle<IteratorTy>(Start, End);
#endif
std::sort(Start, End, Comp);
}

std::sort and qsort are unstable; codeobserving the order of equal elements is depending on undocumentedbehavior. Pre-shuffling makes that observation different every run. commit5a3d47fabcb6 added the wrapper in 2018, motivated by PR35135.

LLVM also ships its own llvm::shuffle rather thancalling std::shuffle, "so that LLVM behaves the same whenusing different standard libraries." A reproducibility tool whosereproducibility depends on the host stdlib is worse than no tool — andthe linker section below relies on this.

llvm::stable_sort deliberately does not pre-shuffle; itis the explicit opt-in for code that legitimately needs ordering ofequal elements.

libc++_LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY

libc++ has a near-perfect parallel mechanism, designed for downstreamusers rather than the project's own internals.libcxx/include/__debug_utils/randomize_range.h:

1
2
3
4
5
6
7
8
9
10
template <class _AlgPolicy, class _Iterator, class _Sentinel>
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX14
void __debug_randomize_range(_Iterator __first, _Sentinel __last) {
#ifdef _LIBCPP_DEBUG_RANDOMIZE_UNSPECIFIED_STABILITY
if (!__libcpp_is_constant_evaluated())
std::__shuffle<_AlgPolicy>(__first, __last, __libcpp_debug_randomizer());
#else
(void)__first; (void)__last;
#endif
}

Three callsites:

  • std::sort — pre-shuffles the input.
  • std::partial_sort — pre-shuffles the input andre-shuffles the unsorted tail afterward.
  • std::nth_element — pre-shuffles, then re-shuffles eachside of the partition.

Seed handling rhymes with get_execution_seed: ASLR orstatic std::random_device for per-process variation, with_LIBCPP_RANDOMIZE_UNSPECIFIED_STABILITY_SEED=<n> as afixed-seed escape hatch. Off by default; C++11 and later only.

libcxx/docs/DesignDocs/UnspecifiedBehaviorRandomization.rstexplains the motivation:

Google has measured couple of thousands of tests to be dependenton the stability of sorting and selection algorithms. As we also plan onupdating (or least, providing under flag more) sorting algorithms, thiseffort helps doing it gradually and sustainably.

It cites PR20837 — aworst-case O(n²) std::sort — as the upgradelibc++ specifically wanted to ship. The shuffle is the gating tool: ifdownstream tests pass with it enabled, they will pass after thealgorithm change too.

Comparing the two is more interesting than either alone:

  • llvm::sort's wrapper is internal hygiene: LLVM is itsown primary user, so the shuffle lives in STLExtras.hbehind a build flag with no docs.
  • libc++'s wrapper is user-facing — DesignDocs/ page,public macro, public seed override, explicit "Patches welcome."invitation. It has to be: libc++'s users are not libc++, and thecontract being defended is the C++ standard itself.
  • libc++ generalizes the primitive:__debug_randomize_range applies at three callsites, eachdeclaring which sub-range the algorithm leaves unspecified. LLVM'swrapper only covers the simpler equal-element case.
  • Hashed containers — std::unordered_* iteration order —are unspecified in both, but libc++ does not randomize them.LLVM-the-library does; on this one surface LLVM is ahead of its ownstdlib.
Linkeroutput: --shuffle-sections and--randomize-section-padding

Two ELF-only lld knobs perturb layout details that no contractcovers.

--shuffle-sections=<glob>=<seed>

lld/ELF/Writer.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (const auto &patAndSeed : ctx.arg.shuffleSections) {
...
const uint32_t seed = patAndSeed.second;
if (seed == UINT32_MAX) {
// If --shuffle-sections <section-glob>=-1, reverse the section order.
// The section order is stable even if the number of sections changes.
// This is useful to catch issues like static initialization order
// fiasco reliably.
std::reverse(matched.begin(), matched.end());
} else {
std::mt19937 g(seed ? seed : std::random_device()());
llvm::shuffle(matched.begin(), matched.end(), g);
}
}

Three regimes in one option:

  • seed = -1 — deterministic reverse, stable even as newsections appear. Glob .init_array* to -1,rebuild, run the test suite: anything that breaks is a realstatic-init-order bug. One flag, no Frankenstein link script.
  • seed > 0 — deterministic random shuffle,reproducible across runs and hosts (because llvm::shuffleis host-independent). Useful in CI without breaking bisection.
  • seed = 0std::random_device()-seeded.Fresh nondeterminism every link.

History: 423cb321dfaeintroduced the =-1 reverse mode; 16c30c3c23efgeneralized to per-glob seeds, which is what makes the.init_array*=-1 recipe possible; c135a68d426ffixed a bug where the feature itself produced an invalid dynamicrelocation order — even Hyrum mitigations have correctness traps.

--randomize-section-padding=<seed>

The sister option perturbs section offsets by insertingpadding between input sections and at segment starts(lld/ELF/Writer.cpp):

1
2
3
4
static void randomizeSectionPadding(Ctx &ctx) {
std::mt19937 g(*ctx.arg.randomizeSectionPadding);
// Insert padding between input sections and at segment starts.
}

Callers grow dependencies on padding-induced offsets the linker neverpromised — profile-guided pipelines, side-channel research, exploittoolchains pinning to specific addresses. A seeded perturbation makesthose dependencies visible.

Both options are ELF-only; MachO and COFF ports have nothingequivalent.

ABI break detection

llvm/include/llvm/Config/abi-breaking.h.cmake:

1
2
3
4
5
6
7
8
9
#if LLVM_ENABLE_ABI_BREAKING_CHECKS
ABI_BREAKING_EXPORT_ABI extern int EnableABIBreakingChecks;
LLVM_HIDDEN_VISIBILITY
__attribute__((weak)) int *VerifyEnableABIBreakingChecks =
&EnableABIBreakingChecks;
#else
ABI_BREAKING_EXPORT_ABI extern int DisableABIBreakingChecks;
...
#endif

Every TU including the header takes a weak reference toEnableABIBreakingChecks orDisableABIBreakingChecks depending on its own build flag.Mixing the two against the same libLLVM produces anunresolved symbol at link time. MSVC gets the same guarantee via#pragma detect_mismatch.

Out-of-tree users routinely compile against headers from one tree andlink against a different libLLVM. Without this gate,whichever struct layout the link happens to pick silently miscompiles;with it, the link fails.

What LLVM is not doing

The mechanisms above all target surfaces no stable consumer shouldcare about: bucket order, equal-element sort order, init-array order.Debuggers, profilers, sanitizers, and reproducible-build infrastructureconsume those outputs and need them stable.

In some cases, stronger guarantee is only provided with explicitoptions. For example, Bitcode and textual IR preserve use-list orderonly under -preserve-bc-uselistorder /-preserve-ll-uselistorder.

A near-cousin: clang's -frandomize-layout-seed /__attribute__((randomize_layout)). Mechanically the same —seeded std::shuffle on struct fields — and it doescoincidentally invalidate offsetof dependencies. But theintent is exploit mitigation, cribbed from GrSecurity's Randstruct GCCplugin: per-build kernel hardening, not a developer tool.

老司机 iOS 周报 #370 | 2026-05-11

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

新闻

🐕 DanceUI 开源

@Kyle-Ye: DanceUI 是 ByteDance 开源的类 SwiftUI 声明式 UI 框架,目标是在 SwiftUI 风格的 DSL 和更低系统版本兼容之间做一层工程化实现。项目提供了与 SwiftUI 对齐的基础能力,包括 StateBindingEnvironmentPreferenceKeyNavigationViewScrollView 等,同时通过 DanceUIRuntime、DanceUIGraph、OpenCombine 和 DanceUIObservation 相关模块支撑状态更新与渲染链路。整体预期是可以直接复用 SwiftUI 生态且最低支持 iOS 13。

文章

🐢 Trust, Then Verify

@Cooper Chen:以这篇文章《Trust, Then Verify》开头,你会很快意识到 : 真正拉开 AI 编程差距的,不是“写得快”,而是“能自证”。作者围绕 iOS 场景搭建了一套完整验证闭环 : 构建侧用 xcodebuild + xcbeautify 控噪并保留原始日志兜底,交互侧用 AXe 与 simctl 将启动、点击、读值、断言流程脚本化,让 Agent 从“像是对了”走向“可验证地对了”。更可贵的是文中沉淀出的工程方法论 : 入口宽松、断言严格;在最靠近原因的位置让失败可见;把经验性步骤持续机械化;通过使用驱动迁移而非一次性大审计。对 iOS 团队和所有实践 Agentic Coding 的开发者来说,这篇文章都值得细读。

🐕 Anthropic 产品团队为何快过所有人

@zhangferry:Anthropic 的成功不能简单归功于 Claude Code、Co-Work 这类明星级产品,而是应该着重关注这类产品迭代背后的方法论。这篇文章的访谈嘉宾 Cat Woo 是 Claude Code 和 Co-work 的产品负责人,她系统性地分享了 Claude Code 团队的实战经验。他们的产品是 AI,他们的工作方式也因为 AI 被全面重塑;访谈涵盖了一个核心命题:当模型能力每隔几个月就跃升一次,产品经理的角色该如何重新定义?

🐎 Why Your pbxproj Is Bloated (and How to Fix It)

@david-clang:为什么 .pbxproj 容易臃肿和冲突?因为它记录了所有文件的 UUID,而使用 Groups 会让 Xcode 追踪每一个文件变动。文章介绍的解决办法很简单,用 Folders 代替 Groups,Xcode 只引用目录,不追踪单文件。另外,使用 XcodeGen 也能解决,它将 .xcodeproj 移出 Git,全靠本地按需生成,实现零冲突。

🐕 Six Years Perfecting Maps on watchOS

@Barney:David Smith 在这篇文章里复盘了 Pedometer++ 为 watchOS 打磨地图体验的六年历程,内容很适合拿来理解小屏设备上的产品设计为什么往往是工程能力、交互约束和视觉语言一起决定的。作者最初用服务端生成地图验证方向,随后因为离线能力、性能和交互需求,转向自研基于 SwiftUI 的地图渲染引擎;在界面层面,则经历了大量失败方案,最终收敛到“地图作为顶部主页面,指标信息层叠覆盖”的结构,并通过点击进入浏览模式来解决地图交互与 watchOS 手势冲突。文章后半段也很有意思:为了适配 watchOS 26 的 Liquid Glass,他甚至定制了新的底图与深色模式样式,并解释了为什么最终没有直接采用 MapKit。整体来看,这是一篇把平台限制、设计迭代和技术取舍讲得非常完整的实战复盘。

🐕 Synchronization in Swift: Actors vs Queues vs Locks

@Smallfly:这篇是 Swift 并发同步机制的深度指南,系统对比四类核心同步工具的设计哲学、适用场景与权衡取舍,为开发者构建线程安全代码提供全面决策框架。文章从语言级到硬件级逐层拆解各工具特性:Actors 适合异步环境下的缓存、服务类等状态组件,DispatchQueue 适配依赖执行上下文协调的场景,Locks/Mutexes 性能开销低适合同步小操作,Atomics 则适配计数器、状态标志等单操作场景。文中还给出各工具典型误用的修复方案,打破性能优先的误区,综合安全性、API 设计清晰度、认知负荷等维度给出选择指导,通过场景化分析与可运行示例,帮助开发者理解 Swift 并发同步本质,在复杂系统中做出合理技术选择。

工具

🐎 Cupertino v1.0.0 "First Light"

@阿权:Cupertino MCP 发布 v1.0.0 "First Light" 版本。Cupertino 是一款本地 Apple 文档爬虫 MCP,使用 Swift 编写。用于解决 LLM 对 Apple API 回答幻觉问题,让模型推理时能获取精准、排名合理的 Apple 官方文档,本次版本实现了全流程(爬取、索引、排名、服务、分发)稳定,具体如下:

  1. 搜索准确度大幅提升。优化语料库和权重策略;apple-docs 内部使用 BM25F 算法结合 AST 符 号索引,叠加多个搜索规则以精准命中目标;提升 SQL 性能。
  2. 分发与部署简化。合并三个数据库为单包。
  3. 架构与兼容性提升。数据库升级、MCP 协议升级、爬虫强化。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

为什么要写作

Paul Graham 在 Writes and Write-Nots 中预见了一个分裂为「写作者」与「非写作者」的世界。他说,这远比听起来的要危险, 因为它本质上是一个「思考者」与「非思考者」的世界。文中他做了一个类比:

在工业革命之前,大多数人的工作都能强健体魄。现在如果你想变得强壮,你得主动去健身。强壮的人依然存在,但只有那些”选择”强壮的人才会如此。写作(即思考)正在经历同样的转变。

为什么写作跟思考会强相关?因为人的思维是网状的、跳跃的,而文字是线性的。写作本质上是一个将混沌思想进行结构化的过程,它强迫你梳理逻辑、暴露盲区、发现矛盾。我们时常以为自己想明白了,但很可能只是产生了一种理解的幻觉。脑子里那些「感觉很清楚」的想法,一旦要落到纸上,就会变得支离破碎。所以写作最深层的价值不在于输出,而是倒逼你更清晰地思考。

当你以为自己已经足够了解某个话题,觉得没有必要再写出来时,创作冲动就在这种「我已经懂了」的幻觉中消解了。只有当真正坐下来写的时候,才会发现自己的理解漏洞百出。

Tip

这其实就是认知心理学中的 “解释深度错觉”(Illusion of Explanatory Depth)。人们通常会高估自己对某个事物的理解程度,直到被要求把它完整地写下来或讲出来时,才会暴露出无知。不写作的人,往往终生被困在这种错觉中。

假如你养成了写作的习惯,你和世界的关系会发生一个微妙但深刻的转变:一切经历都变成了素材。这会带来一种视角转换:你不再只是经历生活,同时也在观察和收集。哪怕是生活中的低谷、摩擦与困境,经过文字的咀嚼,也能转化为绝佳的素材。我在李娟的文字中深刻地感受到了这点,当她用平实的文字包裹真挚的感情来描写她与妈妈、妹妹、外婆还有「叔叔」发生在阿勒泰的点点滴滴时,不禁感慨,会写作真好。

如果不写作,今天想通的道理,三个月后可能就忘了。强迫自己写下来,可以看到认知轨迹的变化:回头看几年前写的东西,哪些观点被推翻了,哪些直觉被验证了。这种「和过去的自己对话」的体验,是其他方式很难替代的。更重要的是,这些文字是只属于你自己的数据,它们构成了你独一无二的思想档案,在 AI 时代,这些数据投喂给 AI 后,可以让它更懂你,成为真正个性化的思维伙伴。

如果持续写作,你写下的每一篇文章,都是一个可以被搜索、被发现、被传播的节点。短期来看,它帮你完成了思考;长期来看,它有可能产生意想不到的连接 — 吸引志同道合的人、打开新的机会、甚至改变你的职业轨迹。持续写作会提升驾驭文字的能力,这会带来一种掌控感和踏实感。把心里想的东西完整、细腻地写出来,这种满足感是刷短视频无法比拟的。

写作除了能帮你整理思维、记录生活,它还是一项通用且重要的能力。 37Signals 在 Getting Real 里有一章专门聊了写作: Hire good writers

如果你在几个候选人之间犹豫不决,请务必聘用那个文笔更好的人。无论对方是设计师、程序员、市场营销还是销售人员,优秀的写作能力都会显现出其价值。因为简洁、高效的写作与编辑,往往意味着同样简洁、高效的代码、设计、邮件及日常沟通。

这本书出版于 2006 年,距今已有 20 年,这个观点过时了吗?我觉得没有,一个擅长写作的人依旧能在职场中获得额外的加分。

既然写作是一件收益很高的事,为什么真正写作的人却越来越少了呢?因为它本质上就是一件难事。

首先,它要求你同时做好两件截然不同的事:思考和表达。想要写得好,必须先想得清,而清晰的思考绝非易事,它需要你静下心来去啃透问题的方方面面,确保没有错误和遗漏,然后再形成一个大纲。但如果按照这个大纲去写,又容易掉进另一个陷阱:忽视了读者视角。gwern 在 First, Make Me Care 这篇文章专门讲了这个问题,他以威尼斯这座城市为例,展示了读者视角和非读者视角在内容呈现上的差异。前期准备工作完成后,精准的语言表达也极具挑战,如果没有经过刻意训练,文字和真实想表达的内容之间就可能出现错配。

除此之外,还容易被自己的品味打压。作为阅读者,你的品味往往远高于你当前的写作能力,当你写出第一稿时,你的高品味会立刻觉察到它的粗糙,这种落差会带来极大的自我怀疑和挫败感。要缩小这个差距,唯一的办法就是持续创作。_why 的这段话,用在写作上也一样合适:

当你停止创造,你的才能就不再重要,你所拥有的只剩下你的品味。而品味会裹挟你,让你排斥他人、变得狭隘。所以,去创造吧。

除了过程痛苦,结果的不确定性也会劝退很多人。它不像学编程,可以通过做 App 来营收,也不像内容创作(视频、播客)那样有相对成熟的变现路径。写作的回报是高度延迟和间接的:思维更清晰、表达更精准、认知更深刻 — 这些好处真实但不可量化,而且很难在短期内看到立竿见影的效果。所以除非你从写作的过程本身获得满足,否则很难坚持下去。

既然写作这么难,又这么重要,而 AI 又很擅长文字,可以让 AI 来代劳吗?这是我们下意识会想到的解决方案,但写作的过程就是思考的过程,而思考是不应该被外包的。让 AI 替你写,等于让别人替你想 — 你得到了一段文字,但失去了思考本身带来的所有价值。

Tip

虽然不应该让 AI 来写作,但可以让 AI 来帮助自己完善思维盲区和误区,前提是得先有自己的想法、框架、初稿,然后让 AI 在此基础上帮你查漏补缺、拓展视角。

这就是为什么要写作,它是无需天赋的思维健身,具有普遍性、重要性、高门槛、复利效应的特性,而且只要你愿意,可以一直写下去。

HyperFrames 实战:用 HTML 写一支 41 秒的产品介绍视频

介绍

HyperFrames 把视频当成 HTML 来写。 一个 index.html 就是一支视频:

  • data-* 属性控制时间
  • GSAP(一个老牌的 JavaScript 动画库)控制动画
  • CSS 控制外观
  • 借助 FFmpeg 生成 MP4

它由 HeyGen 开源,配套 CLI、Skills、Studio 预览器和 13 个相关 skill 包,安装命令:

1
npx skills add heygen-com/hyperframes

为什么值得试

做产品介绍视频,常见的三类工具各有痛点:

路径 优势 痛点
Premiere / After Effects 视觉上限高 工程文件不可版本控制、模板化扩展难
Remotion 程序化 + React 需要搭工程、依赖链长
文生视频模型 上手快 数据准确性不保证、定制化弱

HyperFrames 的吸引点是:保留”代码即源”的可维护性,但把心智模型压缩到只有 HTML / CSS / GSAP 三件事 —— 适合不需要太复杂动效,偏内容呈现类的视频生成。

实战尝试

我用它做了一支介绍斑马思维机发展历程的视频。

claude code 的提示词如下:

帮我使用 npx skills add heygen-com/hyperframes 来安装 hyperframes 这个 skill,然后读取网上关于的斑马思维机的介绍,帮我做一个 30s-45s 的介绍斑马思维机发展历程的视频,里面要涵盖机器和题卡上升的时间线。

视频要有配音,可以找一些开放版权的背景音乐。

它做出来是横版的,我又让它生成了一个竖版的,提示词如下:

帮我另外再生成一个适合在手机上呈现的竖版的版本

效果视频

这是横版生成的效果:

参考链接

安庆之旅

这个五一节和家人去安徽旅游了一趟,30 号出发,先在合肥玩了一天,然后在安庆玩了 4 天。

安庆给我的第一印象是一个类似绵阳的非省会城市。小巧精致,环境干净。不管是打车,还是亚朵酒店,还是吃饭,还是去旅游景点,都很方便有序。

安庆好吃的很多,比较有特色的是一种用红薯粉做的丸子,放到鸡汤或者鱼汤中,把汤汁的鲜味都吸进去了,很美味。另外,每家店都有牛肉锅贴或者牛肉煎包,这也是当地人很喜欢的小吃。小吃街(当地人叫七街)上的美食主要集中在炒饭,炸串(当地人叫油炸),烧烤。物价不贵,点餐一顿饭人均不超过 70。

安庆紧临长江,很多历史名人出自安庆,最有名的可能就是中国共产党早期的核心领导人陈独秀了。陈独秀的两个孩子陈延年,陈乔年都为革命牺牲了,在安庆,我们参观了他们的纪念馆。这次参观,我又有新的思考,我在想:陈独秀虽然留过学,但是他也没有显赫的家世背景,他是如何聚集起大家,形成一个巨大的有凝聚力的政党的呢?最终我发现了一个一直被我忽视的事情:他创办了《新青年》。《新青年》作为一个媒体渠道,在那个年代可以极大化个体的声音,不但可以激发大家反抗,也使得志趣相投的人士被召唤起来。所以,陈独秀在那个时代,选择了一个极其有效的启动模式,让共产党能够逐步发展壮大。

安庆是黄梅戏的发源地。我们全家去听了一场黄梅戏。我对这种艺术表演形式一直不太感冒,但是这次提前做了一些功课,倒也理解了黄梅戏。黄梅戏的成功还是因为它获得了当时劳动人民的喜爱,因为它的内容讲的都是劳动人民的生活,唱腔又容易理解,形式又不复杂,这些都利于劳动人民在劳作之余作为娱乐消遣的形式。

在安庆旅游期间,正值斯诺克世锦赛期间,吴宜泽最终击败了墨菲,拿到了世锦赛冠军。吴宜泽的比赛跌宕起伏,多次陷入失败的边缘,又多次神奇地反转。特别是半决赛中,他的对手在赛点打丢了本可致胜的黑球,而那一球的难度并不大,让人唏嘘。吴宜泽在和墨菲的最后一场中,从容冷静,最后依赖一个后斯诺让墨菲给自己留了一个机会球,最终他利用这个机会上手,完全超分和最后的胜利。

在观看这场神奇的比赛时,我也在感叹命运,在一个变化的时代,拥有好心态,努力做好当下,其实就拥有了无限的可能。

Agent Loop 简介

一、一个反直觉的事实

先说一个看起来有点反常识的事:LLM 本身是无状态的

每次调用模型,本质上就是一次”文本补全”——你扔一段 prompt 进去,它根据这段 prompt 续写一段输出,然后整个过程结束。下一次再调用,模型对上一次的事一无所知。从机制上讲,它和 2020 年的 GPT-3 没有本质区别,都是一次性的补全器。

但 2024 年之后,我们看到的 Claude Code、Cursor Agent、各种 deep research 工具,明明可以连续工作几十分钟、调用几十个工具、修改几百个文件,看起来”自主”得不得了。

这两件事怎么对得上?

答案藏在外面那个 while 循环里。

Agent ≠ 模型
Agent = 模型 + Loop + Tools + Context 管理

模型本身没有变,变的是包在它外面的那层东西。这层东西现在常被称作 harness(脚手架),而 harness 里最核心的部件,就是 Agent Loop

这篇文章想回答三个问题:

  1. 这个 loop 长什么样?
  2. 它为什么这样设计?
  3. 它什么时候会失效?

二、最小可运行的 Agent Loop

把所有花哨的东西都剥掉,一个 Agent Loop 的本质大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
messages = [{"role": "user", "content": user_input}]

while True:
response = llm(messages, tools=available_tools)
messages.append(response)

if response.stop_reason != "tool_use":
# 模型没要求调用工具,说明它认为任务结束了
return response.text

# 模型要求调用一个或多个工具
for tool_call in response.tool_calls:
result = execute(tool_call)
messages.append({
"role": "tool",
"content": result
})
# 进入下一轮,让模型看到工具结果,决定下一步

就这么二十行。这就是 Claude Code、Cursor、几乎所有 coding agent 的核心。

拆解一下,里面只有四个动作:

  1. 模型推理:把当前 messages 丢给 LLM,让它产出下一步的 response
  2. 工具调用判断:如果 response 里有 tool_use,就执行;如果没有,循环结束
  3. 工具执行:在沙箱/真实环境里跑这个工具,拿到结果
  4. 结果回灌 context:把工具结果塞回 messages,进入下一轮

到这里,请允许我强调一个关键认知:

所谓”Agent 的自主性”,本质就是模型在每一轮看到更新后的 context,自己决定下一步。没有任何魔法。

不是模型变得”会规划”了,是循环让它有机会根据上一步的结果,再做一次补全。它的”思考”只发生在每一次模型调用的那一瞬间,loop 只是一遍遍把它叫醒,告诉它”环境又变了,你再看看”。

理解了这一点,后面所有的工程设计,都只是这个最小循环的变体。


三、Loop 的关键设计决策

最小循环能跑,但不够用。一旦把它放到真实场景里,会立刻撞到一堆问题:循环什么时候停?context 越涨越长怎么办?工具调用错了怎么办?要不要并行?

围绕这些问题做的工程取舍,决定了一个 Agent 框架的性格。下面是五个最关键的决策维度。

1. 终止条件:什么时候跳出 loop

最朴素的写法是”模型不再要求调用工具就停”,但这在生产里远远不够。常见的多重终止条件:

  • 模型主动 stop:response 里没有 tool_use,正常出口
  • 达到 max_iterations:硬性步数上限(比如 50 步),防止失控
  • 检测到循环:连续几次调用相同工具+相同参数,强制中断
  • 用户中断:Ctrl+C、关闭对话窗口
  • 预算耗尽:token 数或时间超限

每个出口背后都是一次工程权衡:上限太小,复杂任务做不完;上限太大,一旦模型卡住就烧钱。

2. Context 增长策略:长任务下怎么办

工具结果一律塞回 context,会带来一个朴素但致命的问题——context 是线性增长的

一个改 50 个文件的任务,可能要读 100 次文件,每次读取的内容都进 context。跑到一半,context 已经塞了几十万 token,模型注意力开始稀释,关键信息被淹没。

工程上有几种常见思路:

  • 全量回灌:最简单,短任务够用,长任务必崩
  • 滑动窗口:只保留最近 N 轮,老的丢掉,但可能丢关键信息
  • 摘要压缩:触发阈值后,让模型自己总结前面的内容,用摘要替换原文
  • 分层压缩:Claude Code 的 /compact 机制就属于这一类——保留最近上下文 + 历史摘要 + 关键信息(如已修改的文件列表)

这一项是目前差异最大的设计点。后面会看到 learn-claude-code 项目专门有一节叫 s06_context_compact,就是为了解决这件事。

3. 工具选择机制:模型怎么”选工具”

两种主流做法:

  • 原生 function calling:通过 API 把工具 schema 一并传给模型,模型在 response 里直接产出结构化的 tool_use 块。Claude、GPT、Gemini 都支持。优点是稳定,几乎不会出格式错误。
  • 提示词约定格式:在 system prompt 里告诉模型”想调用工具就输出 <tool>...</tool> 这样的 XML”,外层用正则解析。早期 ReAct 论文就是这么做的,胜在通用,任何模型都能用。

现在新项目基本都默认用 function calling,但提示词约定法在一些场景仍有价值——比如要让一个本地小模型当 agent,或者要做更细粒度的格式控制。

4. 错误处理:工具调用失败怎么办

工具会出错。文件不存在、API 超时、参数类型不对、权限不足……

两种处理思路,本质是信任谁

  • 塞回 context 让模型自我纠正:把 error message 当作普通的 tool_result 回灌,相信模型能看懂错误并调整。优点是灵活,模型常常能从”file not found”反推出”我应该先 ls 一下”。
  • 外层拦截:harness 直接处理特定错误类型,比如重试、降级、报警。优点是可预测。

实践里通常是混合策略:致命错误外层拦截,业务错误丢给模型。这件事的判断需要工程经验。

5. 并行 vs. 串行:单 Agent 还是多 Agent

单 Agent 的极致是一轮内并行调用多个工具。Claude 现在原生支持一次返回多个 tool_use 块,harness 并行执行后一次性把所有结果回灌。这能显著降低延迟。

更复杂的是多 Agent 协作:主 agent 派发子任务给子 agent,子 agent 独立 loop,结束后回报。这里立刻冒出一个新问题——路由:主 agent 怎么知道哪个子 agent 适合这个任务?是基于 metadata 标签匹配,还是让主 agent 读子 agent 的描述自己判断?这两种思路的优劣,是另一篇文章的话题了。


四、从 50 行到 1000 行:一个 Agent 是怎么长出来的

讲完抽象的设计维度,看一个具体的项目——开源项目 learn-claude-code

这个项目的好处是:它把一个 nano 版 Claude Code 的演化拆成了 12 个递进的 Python 脚本,每一步只引入一个新机制。从 s01 到 s12,代码从大约 50 行长到 1000+ 行。

读它,相当于把上一节的设计决策亲眼看一遍怎么落到代码里。

下面挑几个最关键的节点:

s01 Agent Loop:朴素的 while

1
2
3
4
5
6
while True:
response = model(messages, tools)
if response.stop_reason != "tool_use":
return response.text
results = execute(response.tool_calls)
messages.append(results)

这就是上一章伪代码的真实版本。50 行,能跑,能调用 bash 完成简单任务。这是一切的起点

s02 Tool Use:从一个工具到多个工具

引入 read_filewrite_filebashgrep 等多个工具。重点不在工具本身,而在 wire——怎么把工具 schema 注册给模型,怎么 dispatch 到真实函数。

到这里,agent 已经能完成”读文件、改文件、跑测试”这种基础编程任务了。

s03 TodoWrite:对抗”目标漂移”的第一道防线

一个有意思的设计:让 agent 自己维护一个 todo list。

为什么?因为长任务里,模型很容易跑偏。任务一大,model 在第 30 轮已经忘了第 1 轮的目标是什么。TodoWrite 工具强制 agent 在开工前把任务拆成清单,每完成一项划掉一项。

这本质上是用工具调用替代记忆——不指望模型记住,而是把目标固化成 context 里随时可见的状态。Claude Code 现在就是这么做的,效果非常显著。

s04 Subagent:什么时候该拆出去

主 agent 不是万能的。当一个子任务的 context 会污染主 agent 的判断(比如要读一大堆代码才能定位 bug),就该把它丢给子 agent。

子 agent 有自己独立的 loop、独立的 context,跑完只把结论返回给主 agent。这是用 context 隔离换取主 loop 的清晰

s06 Context Compact:长任务的生存策略

直接对应第三章讲的”context 增长策略”。当 messages 长度超过阈值,触发 compact:让模型把前面的对话总结成一段摘要,用摘要替换原始消息,保留最近几轮原文。

这是目前所有长任务 agent 的共同方案。没有 compact,agent 就走不远

s07–s12:再往后

任务系统、后台任务、多 agent 团队、worktree 隔离……每一层都是在同一个 loop 上叠加工程能力。但本质都没变:还是那个 while 循环


读完这个项目,最值得记住的是它的核心宣言:

The model is the agent. The code is the harness.
模型才是 Agent,代码只是脚手架。

这句话听起来像废话,其实暗藏一个反直觉的判断——你写的那一千行 harness 代码,不是在让 Agent “更聪明”,只是在帮模型别搞砸。模型本身已经具备 Agent 能力,harness 的工作是给它工具、管好上下文、防止失控。

Harness 越薄,说明模型越强。


五、Loop 的边界与失效模式

Agent Loop 不是银弹。在生产里,它会以几种典型方式翻车:

1. 上下文窗口爆炸

最常见。长任务跑到一半,context 涨到几十万 token,模型注意力被稀释,开始重复读同一个文件、忽略关键约束。Compact 是缓解,但不是根治——压缩本身也会丢信息。

2. 工具调用幻觉

模型有时会编造不存在的工具,或者给真实工具传错误参数(比如发明了一个本不存在的 flag)。这件事在小模型上尤其严重。缓解办法是收紧 tool schema 的描述、用 function calling 而不是提示词约定,以及在 harness 里做参数校验。

3. 死循环

模型反复调用同一个工具拿同样的结果,不收敛。常见于”修一个 bug 但根本没想清楚”的场景:跑测试 → 失败 → 改一行 → 跑测试 → 失败 → 改回来。需要在 harness 里检测这种模式并强制中断。

4. 目标漂移

多轮之后忘了原始任务。前面提到的 TodoWrite 是一种缓解,更激进的做法是定期”自检”:让 agent 每隔 N 轮 reflect 一次,对照原始目标审视当前进展。

工程上常见的缓解组合是:context 压缩 + 工具白名单 + step budget + 显式 reflection 节点。每一项都不彻底,但叠在一起能撑很久。


六、Agent Loop 是临时方案,还是终极形态

最后留一个开放问题。

回看现在所有的 Agent 框架——Claude Code、Cursor、LangGraph、OpenClaw、learn-claude-code——本质都在围绕同一个 while 循环做工程优化。终止条件、context 压缩、子 agent、todo 管理……每一项都是因为模型本身做不到,所以 harness 替它做

但模型还在变强。

Claude 已经支持 extended thinking——模型在一次调用里能做更长的内部推理。原生的 tool use 在每一代都更稳。multi-step 的 planning 能力肉眼可见地在涨。

那么一个不那么好回答的问题是:

当模型本身具备足够长的推理链和原生工具使用能力时,外部那个 while 循环还需要存在吗?

也许某一天,你只需要一次 API 调用,模型在内部就完成了全部规划、工具调用、上下文管理。harness 被吸收进了模型本身。我们今天精心设计的这些 loop 控制机制,会变成一段历史。

也可能不会。也许 harness 永远存在——因为外部环境永远是 harness 的边界,模型再强,也需要一个东西替它和真实世界对接。

不知道。但这就是现在做 Agent 最有意思的地方:你不知道自己写的这一千行 harness 代码,到底是产品的核心资产,还是即将过时的过渡方案

唯一确定的是,所有故事的开头,都还是那个最朴素的 while 循环。


参考项目:shareAI-lab/learn-claude-code,一个把 Claude Code 拆成 12 个递进版本的开源教学项目。建议从 s01_agent_loop.py 开始读。

我对《缺氧》的游戏理解

最近一个月,我一直在玩《缺氧》(Oxygen Not Included) 。前几年玩过 100 多小时,算是比较熟悉了。但这个月又高强度的玩了 300 多小时,目前总游戏时长为 485 小时,感觉对这款游戏有了一些新的理解。

最初喜欢上这个游戏,是想找一个类似《异星工厂》的以自动化为核心玩法的基地建设类游戏。Factorio 是我最喜欢的游戏之一,游戏总时长达 2905 小时,是放置类游戏之外我花的时间最多的游戏。我很想看看类似游戏还能向什么不同方向发展。这两个游戏的目标都非常类似:在无人星球上殖民,建设一个基地发射火箭逃出升天。它们的拓展玩法有相似之处:发射第一枚火箭只是游戏的开始,需要继续探索星空和不同的星球,面对更复杂的挑战。所以,我一开始是从 Factorio 的角度去看待 ONI ,随着对游戏的理解,才发现它们其实有不同的内核。

ONI 初看的确像是 Factorio 和 Rimworld 的结合体(btw, Rimworld 我也有 123 小时的游戏时长,对它也有初步了解)。和 Factorio 的传送带特色不同,ONI 是基于类似 Rimworld 的工人驱动基地运作的。但 ONI 里的工人没有 Rimworld 中复杂的社会关系和社会情感联系,更像是一群无情工作的机器人。所以我认为它们像是 Factorio 里的无人机加上了细致编排任务的能力。

但玩了这么长时间后,我认为 ONI 和 Factorio 有着巨大的区别。

Factorio 的运作方式是简单清晰明确的,玩家可以在明确规则下不断扩大生产规模,而不同规模下的自动化需要解决不同的问题。所以,Factorio 玩家常说 The Factory Must Grow 。所以,Factorio 鼓励蓝图的使用、Mod 和游戏本体之间相互促进、不断完善更丰富的自动化手段。游戏除了标志性的机械爪传送带外,还有流体、电力和热量系统,它们都以相当简单的规则运作。其中略复杂的流体系统,在 2.0 也被简化为超级水箱,把“流动”去掉了。

ONI 的底层逻辑或许也很简洁。但它模型并非基于确定性规则的物流。相对比 Factorio ,玩家首先理解的是物品怎么在传送带上移动、如何被机械爪抓取;液体如何被传递,这些都和物流有关;但 ONI 首先传达给玩家的是气体的扩散和液体的流动,它们都是在环境中自动进行的:不需要玩家铺设轨道,玩家也难以精确控制它们。稍微深入游戏后,玩家还会发现,贯穿游戏的难题是热量。热同样以某种规则在环境中以单元格为单位交换,但热却无法作为一个实体直接操控。玩家需要去控制某个区域的温度,但却没有直接的手段。游戏后期最大的挑战是制备液氢制造远程火箭,这需要极低的温度;还需要驯服金属火山和岩浆,这又需要处理上千度的高温。

在缺氧中,资源在初期丰富但却有限。从游戏中期开始,玩家就会发现资源越来越紧缺,玩家的绝大部分手段都是在做资源转换:将 A 转换为 B 并可能伴随着质量损失。而绝大部分原始质量就是地图板块上的那些砖块,并不会凭空变多;相比而言 Factorio 的地图趋于无限,只要你肯向远方发展,永远有采不完的矿,解决好物流即可。同时,随着 ONI 中的生产活动,花掉的能量全部转换为热量。大多数游戏手段都是把热从 A 传递给 B ,而让热净减少的手段却极其有限,且藏得很深。

不看攻略的话,从游戏内对各种设施的字面解释很难直接找到减少热的方案。这也是新手通常都会在中期把基地变成 40 度以上的蒸笼而束手无策。初见游戏时,看到游戏界面中的文字大篇幅的罗列每种材料的比容、热传导率、热特性、固态液态气态的转换温度等会觉得离自己很远,但熟悉游戏后会发现,这些才是核心要素。

我最初玩 ONI 完全不得章法,基地盖得奇形怪状。这倒是和最早玩 Factorio 很像。但和 Factorio 不同,我并不完全靠自己摸索理清条理。看了几篇 ONI 的攻略后,我照着攻略指示修建基地,知道每个阶段要解决什么问题,大致怎么做。和 Factorio 明确的科技树驱动不同,ONI 的科技树其实爬得很快。玩家很少被卡在科技上,甚至在游戏中期就能解锁大部分科技,整个游戏过程也不会被科技进度卡住。真正困难的是,大部分科技解锁的物件,从字面理解上都很难想到它能做什么,有什么副作用。我感觉从这点上,ONI 的门槛比 Factorio 要高,很需要攻略引导。

前几年,我最初的 100 小时游戏就是按某篇攻略引导玩进去的,并深得其乐。但最近几百小时,我发现自己琢磨能玩出非常不一样的感受。游戏流程也和之前攻略引导的体验截然不同。最显著的差异就是:我最新的一盘直到在第三星球开荒,一共只养了四个小人。其中三个是开局选的,第四个是在第二星球上系统送的。也就是整个游戏过程,我都没有在传送门要一个新的小人。

绝大部分 ONI 的攻略都不会介绍这样的玩法。玩家或许把不加人手的玩法视为高手的挑战,但我是在理解了这个游戏的内核后,发现这是推进游戏进程的最佳手段之一,而且游戏过程会非常轻松。我来解释一下这种游戏思路的内在逻辑:

前面说到,游戏的大部分资源都是地图上的方块。只有喷泉和流星雨是从外部补充的净增加质量,对眼冒金星 DLC 而言,母星去掉了流星雨就只剩喷泉。游戏过程的生产活动,本质上都是资源转换。例如,你可以把小人看成将氧气加食物转换为二氧化碳和废水的转换器;食物则通常是由动物或植物将泥土转换而来,烹饪过程可能有净水参与。把两者联合起来看,小人把氧气 + 泥土 + 水转换为了二氧化碳和废水。

最大的例外是科研,基础科技是对水和泥土的净消耗。也就是水和泥土消失了,点亮了科技树。

同时,所有的生产活动都需要消耗能量。这是一个能量到热量的转换过程,最终反映为地图温度的升高。这个游戏本质上是在治理混乱,即减少地图的熵。把地图上的不同砖块转换为有序的基地,有效的维持玩家主动导向的转换过程,同时系统以某种内在规则让物质在地图上自然流动:这包括了重力作用下的液体流动、开采的砖块碎片自然掉落、气体分层等。由于一切转换器(工人、动植物、机器)都有适用环境,生物需要对应的气(液)体环境、光照、温度;机器相比生物对环境的要求没那么苛刻,但也是存在的。所以玩家建设基地就是分两个阶段处理问题:一开始的建设阶段把对应的材料搬运到位、随后的维护阶段维持环境的稳定性。

无论玩家养多大的工人规模,科研的总净开销是一样的。游戏的前半段,需要的核心转换是 1200 kg 的钢,用于制造第一台制冷机。因为制冷机+蒸汽机组合是游戏最稳定的将热净减少的方式。铁转换为钢的过程受限于石灰的产能,通常在初期是蛋壳。需求和产能也是恒定的,也和工人规模无关。

而且,游戏里大量的资源转换环境其实起的作用更大,并不需要花特别多的人力,而玩家只要用小人下达指令后,更多的等下去静待花开。

更少的工人意味着在产出第一台制冷机前,更少的生产活动,更少的做资源转换。维持工人的核心在于平衡氧气到二氧化碳的转换过程。这里分两个问题:制备氧气和处理二氧化碳。

制备氧气在前期主要是两个途径:用藻类转换或分解水。

藻类是相对有限的,但养活三个工人和八个工人其实区别不大(通常不会消耗完),细微的差别在于挖空地图导致的空间扩大导致的气体扩散。虽然总量不变,但熵增加了。新手很容易到处开挖,但我的经验是越早把基地封起来有选择的逐步扩展才会减少要处理的问题。

电解水制氧看起来干净的多:不需要挖藻类,而初期基地周围的环境水本身就需要治理(否则无法按规则规划基地)。但游戏隐藏了一个副作用是新手很难注意到:电解水制氧会产生额外的热。前面说到,游戏本质上的核心挑战就是热治理。所以我认为把这个问题推迟(到科技树基本爬完)有极大好处。所以,保持一个极小团队,有利于推迟电解水制氧。事实上我最近一盘游戏直到游戏后期需要氢气之前都没有电解水。

另一个问题是处理二氧化碳。在发射近程二氧化碳火箭之前,二氧化碳几乎没用。有两种手段处理它:用碳素脱离器处理掉,或存起来。因为中后期一定会适用二氧化碳火箭,我认为存起来比较好。但在开发太空前,很难找到低温区液化或固态化二氧化碳,保存气态二氧化碳非常占空间。所以,二氧化碳转换得越少越好。早期在开发太空前一定会用煤炭发电过渡,这是部分二氧化碳源头,另一部分就是工人的日常呼吸了。更少的工人意味着呼出越少的二氧化碳。电力消耗也会因为工人数量减少而略微减少,但少的不多。人数增加而增加的电耗主要是在食物制备。科研、生产石灰、精炼金属这些基本需求倒是和工人规模相关性较少。

工人偏少最明显的劣势是干活的人少了,玩家可能会觉得游戏节奏无意义的变慢,实则不然。在 Factorio 里,新手通常不太愿意扩大生产规模,因为那意味着脱离已经经营好的舒适区。但 ONI 不同,规模化生产在游戏大部分时段几乎难以带来好处。玩家在中前期要解决的问题并不太多,一步步总能做完,它们并不能靠扩大生产规模提升效率。相反,人越少要做的维持生存方面的工作越少,专心做推进科研和基地发展的步骤就可以了。用三人团队发展,从游戏内时间看,迈入游戏中期的总周期数比一个八人团队明显要长,但实际游戏时间却不会增加太多。这是因为,游戏内小人干得慢了,但可以用最高速度推进游戏时间;而大规模团队通常会用最慢速度玩游戏,甚至还要时常暂停。本质上来说,维持最小团队,推进游戏需要(点鼠标)的操作数量变少了。小团队也会大量减少中后期工人闲置的时间。

另一个优势在于:工人干活是会加经验升级的。升级带来了能力的成长,提高了工作效率。因为总的工作量差不多,所以越小的团队,经验越集中,就能更快的得到几个高素质的全能工人。劣势或许是人数太少发展需要的技能不够,在多人团队中,这往往是不同发展方向的人承担的。无论开局怎么刷,三个人都无法全部覆盖需要的专长。但我的经验时,在中期洗点,只要规划好每个阶段需要做什么,完全够用。例如:只有在装修和做化石勘探任务时才需要大师艺术,做完就洗掉即可;同理,铺设传输轨道需要的高级技能,也可以在需要时再点出来,做完项目就洗掉即可。

最近玩 ONI 给我的感受是:玩游戏不能着急,需要规划好,一次做一个工程。这其实是一个慢节奏游戏,让小人生存并不难。下指令容易,但执行需要很长的游戏内时间。相比 Factorio 会发现,修建一个设施需要极长的时间:改造场地环境、远距离搬运材料、建造;改建(拆除)甚至比建新的还久。但 ONI 一盘游戏必须要做的工程并不算太多,几乎都是一次性的。所以,这个游戏不像 Factorio 那样依赖蓝图,反而因地制宜处理问题更多一些。尤其是,环境的自然变化:液体流动、气体扩散都需要很长的时间,把游戏节奏慢下来,利用好环境的自然变化反而要做的总工作量会减少。欲速不达是新手常犯的错误。例如,不把基地封好就出门到处乱挖,导致后期治理要花更多时间。尤其是病毒进入基地、不可呼吸气体混入氧气环境都是一瞬间,但再想处理干净却是及其费事的。

这些小问题(环境的恶化)并非致命,但会潜在削弱长期的工作效率,或增加远期治理的工程量。新手和老手基地往往在视觉上就有极大不同:整齐规划的干干净净。装修房间,清理杂物是看起来短期收益最小的工作,装饰度提高的长期收益很容易被忽略,尤其是人手不足的时候不想先做。但实际上,这种迟早要完成的工程,只要不影响生存,反而应该早点完成。


ONI 对我来说,最重要的游戏体验是不断发现小问题并提出解决方法。这得益于游戏内的物理规则制造的环境让同样的问题有不同的解决方案。每种方案都很难做到完美,总有一些副作用,而游戏者对游戏理解越多,就越能清楚如何承担这些副作用。

比方说,制备氧气是游戏的基础,游戏名就叫做 Oxygen Not Included 。但所有的制氧方案都是把氧气排放到环境中的。好在小人生存需要的氧气也是从环境中摄取。但一旦需要提取氧气使用:比如冲入氧气面罩或太空服,就需要把氧气放进管道,从环境中分离氧气就麻烦的多。直接的方法是用抽气机加气体分离器。看起来很彻底,但需要的能耗却不应忽视。不想 Factorio 那样,缺电就想办法扩展电网,ONI 里要考虑烧煤导致的二氧化碳治理问题,能量消耗带来的热量问题,这些都是短期看不到的问题,但长期游戏必将受到影响。

藻类制氧可以制造一个纯氧房间,这样就能节省一个分离器。但人工添加藻类时可能带入的二氧化碳就可能是一个干扰因素。运输轨道和无人机运输都是解决方法。环境气体元素信号器不耗电,可以用信号控制减少制氧室混入的其它气体,也能解决一部分问题,但不彻底。不过,ONI 中其实不需要彻底解决问题。因为和 Factorio 不同,在 Factorio 的传送带上混入杂质会堵塞整条流水线,必须手工清理;而 ONI 偶尔在氧气管道中混入一点杂质气体,只会引起设备的损坏,小人会自动修理。只需要权衡这个维修开销是否能值回票价:剩下的气体分离器的开销。为了让优化掉气体分离器更有价值,ONI 里大部分机器其实是不太耗电的,或是有极短的工作时间,大部分闲置,所以整个机器需要的总电量在优化得当时并不高。而气体分离器这种只要通气就得需要长期工作的机器反而显得功率占比很大。对比 Factorio ,传送带筛选器是不耗电的,除了太空上的空间限制,都是鼓励你使用。这个差异导向了不同的游戏体验。

同理,电解水制氧,你可以在管道中分离氢气和氧气(以及环境中可能存在的杂气),也可以设计好房间利用气体的自然环境分层。但依赖环境一则需要用时间来换,二是气体扩散过程的随机因素导致不能 100% 确定。

凡涉及气体隔离和液体分离都有类似问题。最常见的是制作真空室,它是做氯气消毒室的前置,也是做辐射管道的基础,还可以用于隔热。从多道气闸的信号控制,再或不同水门(用液体隔开不同的空间,同时让人可以穿行)的搭建方法,都伴随着很多隐晦的副作用。例如看似完美解决问题(隔离真空室)的水门可能带来一瞬间让小人湿身的负面 buf ,或是可能让无人机浸水,还可能因为温度变化液体发生相变。ONI 中并没有直接提供一个可以完全隔离两个空间的气闸门,而是设计成开门会有一小段时间漏气或漏水,这留下了很多的操作空间。

ps. 如果你真的想不耗电过滤气体,在充分理解 ONI 的流体系统后,可以用气阀和管桥巧妙的搭建出一个机构解决这个问题。有兴趣可以在 youtube 上找 3 Ways To Filter Gas! Oxygen Not Included Tutorial / Guide 这个视频来看。


最后,介绍一下我的游戏开荒流程,可以作为针对网络上其它常见攻略流程的一份补充。开荒指基本开发完母星和第二星,用短程火箭开发第三星,并研究出中程火箭,可以去更远的星球。

2.0 眼冒金星的标准模式中,第二星和母星有传送器互联,可以双向传输人和物资,所以可认为是一体的。如果玩经典模式,即更大的母星则需要做一些调整。

如前文所述,我的游戏流程最大的不同是只用系统给的工人,不招募任何新人。所以初期一直用三个人,在第二星上获取第四个。如果有“神秘隐士”这个故事特质,可以在最后招募一个高属性小人作为补充。但最好不要选“梦境合成器”故事特质,因为需要通过延长睡眠时间(甚至专门的做梦团队)获得全员属性提升很不划算。毕竟全员也没几个人。

可以把游戏开荒过程看成是若干个小的项目,因为人手少,所以大致串行完成这些项目即可。

第一个项目是挖出基本空间,并开发初级科技。

开发初级科技只需要泥巴(一级)和水(二级),这是一切的基础,所以必须最先完成。挖出最小空间额外建两房间,其中一间卧室,一间临时厕所。初始传送门自带光源,所以可以就地改造成科研室。房间全部用 16 * 4 的规整空间,可规划为以中间通道为轴堆成,每层左右两间,纵向发展。我倾向于左侧生活区,即科研室、卧室、卫生间、食堂、温室,后期保持 25 度以下环境温度;右侧偏生产,放置更多热源。左右两侧之间留两格的通道即可,一列纵梯,预留一列滑杆。

由于高压电缆和变压器有极高的负面装饰,所以我倾向于放在工作区的更右侧并用墙隔开,然后每层靠墙设一个变压器,然后是检修用的第二梯子加纵向高压电缆。高压电缆的右侧可以留下未来的无人值守区,用于发电、蒸气室等。进入无人区需要留一个房间放氧气面罩站。

综上,基地横向每层三个 16 * 4 的房间,两个纵向通道。

在这个阶段,厕所是临时的,可以扔在右侧工业区,未来会拆掉。而生活区的卧室是永久的,所以可以建在科研室的正上方(初期氧气充沛)。至于水源,早期基地附近肯定有,可能面临的问题是占据了规划中的房间位置。所以需要留出足够位置,不用破环规划。

在第一个阶段,如果克制的开挖空间,是不需要制氧的。因为不招募新人,所以地图上的氧石挥发氧气就足够用了。食物也不需要补充,开局送的营养棒和挖土翻出来的淤泥根够吃,所以不需要修建食物压缩机。唯一要建的是人力发电机和科研台(唯一耗电设备)。

第二个项目是建造卫生间。

我之前看的攻略大多是快速建立煤炭发电来取代人力发电机以节省人力。但我认为人少的时候初期生存压力也少(因为系统开局送的生存资源是一样多的),人力其实完全够用。三个人大致的分工是一个科研,一个发电,一个建设。相比烧煤发电,通旱厕反而是更浪费人力的工作。如果顺利的话,完全可以在两个旱厕都堵住前,让自动化卫生间投入使用。

卫生间的水是可以自循环的。即冲厕所和吸收用的水远少于小人排除的废水,配合净水器反而有废水的净产出。需要考虑的是如何处理多余的废水不要堵塞管道的问题。一般的解法是让多余的废水送去液培砖种芦苇。之后做太空服正好需要芦苇。

至于地图附近有没有芦苇可以拔来种要看随机刷的运气,通常是有的只是远近问题。采芦苇时应该采取最小空间破坏原则,挖到就把路重新堵上,避免带入过多病毒,以及不必要的氧气扩散。

卫生间和净水房分开,我试过两个方案,其一是和卫生间上下两层,净水房后面兼做农场;后来发现更好的是左右两间,兼做仓库。

注意这里卫生间产生的废水净化后不要引入净水储备,因为其中有食物中毒病毒。让它们自循环和种芦苇即可,和基地其它用水完全隔离。如果节奏安排得当,还可以点出装桶和倒桶科技,同样放在净水房中。这时就可以拆掉一开始的手压水泵,并把拆掉旱厕扔出来的废水投入卫生间的水循环中。这可以省掉基地外额外挖一个坑倒废水的工作。

废水最好能尽快处理,尤其是在它挥发太多的污染氧之前完成。基地中混入一些污染氧虽不致命,但影响工作效率。

这个阶段,工作量其实是不均等的。科研的活最多,但当然不能让小人闲下来。但原则是整理基地,即使是收拾杂物也比向外开挖更重要。

第三个项目是修建米虱壁虎农场和哈奇煤炭生产间。

米虱是重要的食物来源,人少的话可以吃很久,而且腌制米虱由于保质期很长,还可以在其后用于短途太空旅行。不少攻略建议这时开始种蘑菇,我认为在人少够吃的情况下完全不必。倒不是种蘑菇麻烦,是因为处理菌泥带来的病毒需要的步骤较多(需要收集氯气消毒)。如果不处理病毒的话,就涉及后面会面临的病毒治理问题。

普通壁虎很好捉,但养出产塑料的变种比较花时间,所以要尽早养。如果运气好在附近挖出小动物变异器这个故事特质就更省事一些,不然多生几次蛋也能出来。塑料不是很着急,开荒需求也不多,完全可以等养出滑鳞壁虎产出。不需要特地去建石油产线做塑料。

哈奇可以把砂岩转化为煤炭,开荒期电力省点用的话,就不需要出去挖煤了。而且哈奇产蛋量较大,蛋壳是开荒要的那 1200kg 钢的原料,石灰的稳定来源;而且少量的生蛋可以作为食物补充。对于稍微有点规模的基地,比如传统的 8 人基地,这点生蛋肯定不够吃,但超小的 3 人基地,则不容忽视。这也是不需要种蘑菇,后期也不需要种冰霜小麦的原因之一。

如果运气不错在地图中间找到同伴芽的话,可以挖回来种上传播花香。但这属于锦上添花。

这个阶段如果氧气不足,可以随便加两个藻类制氧机。

第四个项目是装修基地,扩建出氧气室和发电房。

随着基地的扩大,为了提高物流效率,早点点出滑杆科技是有价值的。因为煤炭发电出的比较晚,所以二氧化碳问题不会太严重。空出一个房间专门制氧是有必要的。通常放在基地上方右侧的工业区,因为一般而言制氧过程都伴随着热量产出(单纯藻类制氧不严重)。为了减少后面分离出纯氧的难度,早点在上方留出纯氧室比较好。

这时不推荐电解水制氧,原因前文已经阐述了。但养壁虎需要一点氢气,推荐在地图上抽过来,否则电解一点水也也是可以的。

发电房放在基地最右下,后面会和其它部分隔离开,所以要留出一个房间用于内部的氧气检查站柜。

第五个项目是出门前的准备,包括密封基地,氯气室,氧气站、太空服等。

这个项目的目标室把基地和外部完全隔离开,出门带上氧气面罩,最好是太空服。氯气室用来消毒。但不需要一步到位,一开始只需要抽取附近环境中的氯气。扩建基地一定会遇到氯气区,这时需要先在入口先建好气泵,然后密封抽真空。这个过程漏一点氯气无所谓,反正随着时间会自然分层,到时候在基地下方和堆积的二氧化碳一起分离即可。抽出的氯气装箱后,通到基地的出口洗矿。这是很多新手会忽略的开发步骤,因为病毒的危害并不会立刻显现,但是处理病毒的过程会比较漫长。

如果病毒进入基地,处理起来也不算麻烦。如果前面卫生间水循环搭建正确,应该不会有食物中毒的问题,主要会遇到的是粘液肺,多见于挖开菌泥区。如果种蘑菇的话,不洗掉菌泥上的粘液肺,就很容易在基地蔓延开。粘液肺在纯氧环境会慢慢消失,所以除了隔绝病毒外,重要的是净化掉基地内的污染氧。同时,吸入一口污染氧还会给小人一个短期的负面 buf 。所以在基地口的氧气面罩检查站外,需要和出门气闸间留一点空间,避免开着门换衣服。

前面几个项目按部就班的话,因为只有三个小人,所以生存完全不会有压力。操作强度也不大,或许游戏内的周期过了不少,但大部分时间都是在加速运行的,真实游戏时间不需要太长。

接下来要做的事情主要有两个,都是需要出门完成的:为开发第二星做周全的准备以及开发星球表面发展太阳能和火箭基地。

开发二星一般需要挖通三个设施,分别是小人传送站和发送以及接收物资的站点。我觉得把物流提前打通,也就是把管道都修好再去二星会让后面的工作简单很多。这样一到二星,就立刻可以利用母星的资源。

眼冒金星 DLC 的开局母星非常小,所以都不会在很远的位置,应该马上就能看见。挖路要尽量少挖,用最短距离挖过去,然后把管线拉通即可。穿好太空服再做这个工程可以提升不少效率。顺便还可以把附近的故事特质完成了,尤其是小动物变异器对获取滑鳞壁虎很方便。

但是,铺设固体传输管道需要大量金属,所以可能需要专门开采铜矿。采矿机就非常有用了,可以节省大量人力。但如果从机器拉电缆可能比较费事,比较简单的方法是做电池,使用两个袖珍放电器就可以带动一个采矿机。电池还可以用于物流无人机,早点做两三个无人机,完全就不会有物流负担,基地的杂物也会自动被整理的干干净净。

另一方面,直接向上挖通地表即可,也穿上太空服。到了地表后第一件事就是铺太阳能板。早点关掉煤炭发电可以省去好多麻烦。路上如果遇到低温区,可以把玻璃和金属精炼等热量大户先临时塞进去,这样就不会破坏基地内部的温度。后面建好蒸汽房还可以搬回来。

一旦攒出 1200kg 的钢,就可以开始搭建蒸汽房了。蒸汽机加冷凝机是最通用的热量消除机构。因为蒸汽机是唯一一个确定且直接的设备,可以热量转换为能量。它吸入 125 度以上的蒸汽,转换为 95 度的水,同时发电。这里发电是次要的,最重要的用途是这个过程热量消失了。但为了获得 125 度的蒸汽,除了在后期可以利用环境外,稳定的主动手段就是使用冷凝机。它的工作原理是输入高温液体,输出低温液体(可以用于基地其它的降温用途),其中的温差变成热量有机器本身散发到环境种。所以,冷凝机本身不消除热量,它只搬运热量。虽然系统本身热量减少了,但冷凝机的工作过程会产生大量的环境热,它正好用于把水烧为超过 125 度的蒸汽。但这样,冷凝机本身必然处于高温中,所以必须用耐高温的钢来制作。这就是开荒需要 1200kg 钢的原因——制造第一台用于烧开水的钢制冷凝机。

怎么搭蒸气房网上有很多介绍,这里就不细讲了。但我想说的是,可以参考攻略,但完全不需要抄攻略中的图纸。一旦明白原理,自然会有很多想法,肯定会做出不同的蒸汽房设计。ONI 和 Factorio 不同,它更难存在最优解,一切都和游戏过程相关。

如果是三人基地,其实搭蒸汽房降温的需求并不强烈。比如我玩的最新一盘,搭好蒸汽房后,基地平均温度才不到 20 度,要解决的是略微增温而不是降温。但温度调节迟早是需要的,工业化温控这是必须完成的基地设施。当然这不是唯一的路径,有兴趣的话还可以试试用冰霜萝卜控温,或是将高温二氧化碳到地表固体化带走基地的热量。

一般来说,开发第二星的主要目标是建立起石油产线。表面上看起来,石油是工业化生产塑料的基础。但其实游戏的开荒期塑料需求并不大:装修完基地,改造地板和梯子,建立通向地表的载人管道,这些用壁虎产出就足够了,完全不需要通过石油生产。

石油除了中后期做石油引擎的中程火箭外,最重要的用途是用于金属精炼的冷却剂。所以我们只需要做一点点出来就够用。

一开始只能用水做金属精炼冷却剂。如果背靠冷源,比如附近就有低温喷泉,那么这种天然冷却源就可以稳定的工作很久。但如果自己在基地内部做冷却循环,就会发现经常需要修机器。因为金属精炼,尤其是炼钢,会放出大量的热,让冷却液迅速升温。而水超过 100 度就会气化,太低温度会结冰,这些相变都会破坏管道。放置温度巨变要么需要一个相当大的热容器,比如上面提到的大水池,尤其是天然冷源;要么就需要很复杂的自动化控制机构。虽然把玩自动化机构也值得玩很久,但更简单的方法是换成石油做冷凝剂。油的比热容比水小,炼钢时温差更大。但这反而是优势。因为超过 125 度的油就可以用来烧开水,用蒸汽机带走热量,同时还能回收部分电能。

所以,游戏中蒸汽机加炼钢也是一套基础的机构。懂得原理的话,也可以玩出很多很多不同的设计。

第三个星球就需要找出火箭去了。它通常很近,所以用二氧化碳引擎最简单。这时,游戏前期存的那些二氧化碳就用得上了,而且二星上的石油工业副产品也是二氧化碳,可以直接传送回母星,基本是不缺燃料的。

二氧化碳引擎速度快,尾焰温度低,对环境破坏最小。唯一的缺点是不能造大火箭。但小小的单人空间把弄起来也格外有趣。火箭部分我完全没看过攻略,有了前面足够的游戏经验,我感觉自己摸索更为有趣。火箭上主要需解决的问题是怎么让小人在里面舒服的活上几天。燃料和航程在这个阶段都不需要考虑。

而小人的需求无非是食物、卫生和氧气,以及避免高压力。

这个时候,因为人少的优势,每个人都会成长的很好,所以洗掉不必要的技能点,只点出驾驶的话,压力完全不会是问题。短途并不需要储备太多的食物,如果是两三天往返的话,随便扔点食物在火箭内就不会挨饿。

氧气用藻类制氧机就能解决,只要在出发前排空舱内的二氧化碳即可。如果肯盯着高气压的负面 buf 的话,把高压氧气压入舱内也能用很久,这样也可以不必设制氧机。所以这里也有很多不同的解决方案。舱内空间非常的小,所以需要做很多空间上的选择。

最后是舱内上厕所的问题。无疑需要用壁挂强排厕所最省空间,但充厕所的水怎么办?我第一反应是装个水箱,但一个水箱(3x2)就占掉了一半的有效空间。随之发现,其实排灌器就是用来这里的。1x2 的空间可以存 200kg 的水,只是用于冲厕所搓搓有余。

等开发完第三星球,以及搜罗完太空的数据卡,基本上科技树就爬完了。这时可以拆掉基地的科研设施,开始转石油火箭去更远的星球拿石墨做富勒烯,制造超级制冷剂。利用它降温才能制备液态氢,然后就是做液态氢引擎达到最大航程通关游戏了。

我暂时还没有玩到最后,所以这里就无法介绍后期的游戏体验。

Swift 并发正被更广泛地接纳 - 肘子的 Swift 周报 #133

从 Swift 5.5 引入符合现代编程思想的新并发模型算起,一转眼快 5 年了。从 5.5 到目前的 6.3,Swift 社区一直在采用小步迭代的方式,积极推进并发 API 的演进。但在应对过多的新关键字、复杂的隔离概念以及一些容易引发困扰的“反模式”时,这个过程对开发者来说并不算顺利。

独立开发了一款健康记录 App,聊聊几个让我纠结很久的设计决策

上线一周,下载量是零。

说实话这很正常,冷启动就是这样。但我还是想把「健康手账」这个项目的一些设计思路写出来,因为做的过程中有几个决策点我觉得挺有意思,适合和做 iOS 工具类 App 的朋友聊聊。

为什么又做了一个健康记录 App

这类 App 的竞品多到数不清。我当时下载了七八个,用下来有个共同问题:录入太麻烦。

打开 App、点击新建、手动输入收缩压、再输舒张压、再输脉搏——三个字段,最快也要 15 秒。对于每天早晚要测两次血压的高血压患者,这个摩擦力不小。更别说帮父母操作,老人对数字键盘并不友好。

我想解决的核心问题就一个:把「记一次数据」压缩到 3 秒以内。

拨轮交互:试了三个方案,前两个全删了

最直接的想法是自动识别——用手机摄像头拍血压计,OCR 识别数值。我试了一周,识别率在不同光线下差异很大,而且用户还得配合把手机对准屏幕,反而更麻烦。

第二个方案是预设范围的快捷选择,类似「上次是 128/82,这次有变化吗?」。问题是这个交互对新用户完全不直觉,而且首次录入没有历史数据根本跑不起来。

最后用的方案是物理拨轮(Picker 风格,但带阻尼感的自定义实现)。收缩压、舒张压、脉搏各一个拨轮,默认值锁定在上次录入附近,打开就能拨,拨完直接存。实际操作下来,熟悉之后真的 3-5 秒能完成一次录入。

这个交互特别适合老年用户,因为拨轮比点击键盘容错率高——拨过了再拨回来,不需要删除重输。

数据模型:tagIDs 关联干预行为

健康数据好记,但「今天血压偏高是因为没睡好还是昨晚喝酒了」这种因果关系很难追踪。

我在 HealthRecord 上加了一个 tagIDs: [String] 字段,对应一套可自定义的状态标签(StatusTag)。内置的有「降压药」「运动」「好睡眠」「黑咖啡」等,用户每次录入时可以顺手打几个印章。

@Model
final class HealthRecord {
    var id: UUID
    var timestamp: Date
    var systolic:  Int?
    var diastolic: Int?
    var pulse:     Int?
    var weight:    Double?
    var tagIDs:    [String]   // 关联当次的干预行为
    var profileID: String = "default"
}

趋势图里,这些标签会作为事件标记叠加在折线上。比如连续几天运动后血压数值的变化,一眼就能看出来。这个设计借鉴了运动 App 里「训练日志」的思路,但放在健康场景下我觉得更有价值,因为慢病管理真正需要的是「行为-数据」的对照。

PDF 就医报告:一个被低估的功能

大多数健康 App 的数据只能在 App 里看,或者最多导出 CSV。但去医院看诊时,医生没时间看你手机屏幕,更不可能帮你分析折线图。

我加了一个「生成就医报告」功能,一键输出标准格式 PDF:患者基本信息、最近 N 天的血压/体重数据表格、趋势图、备注。打印出来或者直接发给医生。

这个功能在开发时我有点犹豫要不要做,感觉实现成本不低(PDF 布局、图表渲染都要搞一遍)。但想想「数据记了,但医生看不懂」这个痛点,还是做了。说实话现在觉得这是产品里最有差异化的地方。

Siri 快捷指令:让录入更快一步

做完拨轮之后我想,录入的最大摩擦其实不是界面操作,而是「打开 App 这个动作本身」。

用 AppIntents 实现了一个 Siri 快捷指令,说「用健康手账记录健康数据」直接跳到录入界面,不需要找图标、不需要滑动。实现上用了一个 NotificationCenter 的广播机制——intent perform 之后 post 一个通知,主视图监听到就弹出录入 sheet。

struct LogHealthRecordIntent: AppIntent {
    static let title: LocalizedStringResource = "记录健康数据"
    static let openAppWhenRun: Bool = true

    @MainActor
    func perform() async throws -> some IntentResult {
        try await Task.sleep(for: .milliseconds(200))
        NotificationCenter.default.post(name: .healthLogShowInputSheet, object: nil)
        return .result()
    }
}

延迟 200ms 是因为 App 冷启动时视图层级还没就绪,直接 post 通知会丢失。这个 bug 我在真机上踩了才发现,模拟器里完全复现不了。

本地存储 + 可选 iCloud 同步

数据全部用 SwiftData 存在本地。这是一个主动决策,不是因为懒得做后端。

健康数据比较敏感,尤其是帮父母记录的场景,很多用户对「数据上云」有顾虑。本地存储让这个顾虑直接消失,也不需要注册账号、不需要联网。

iCloud 同步作为可选项保留,用 CloudKit 实现,在设置里手动开启。代码里的处理也很直接:

let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: icloud ? .automatic : .none
)

买断制也是基于同样的逻辑——慢病患者要长期用,订阅制的心理负担对他们不友好。

多人档案:一个 App 管全家

HealthRecord 上有个 profileID 字段,支持创建多个独立档案。这个场景是子女帮父母管理健康数据时用的:爸妈各一个档案,切换一下就能看各自的趋势。

下次陪父母去复诊,不用临时整理数据,直接切到对应档案导出 PDF 就完事了。

一些还没做好的地方

趋势图的异常检测现在还比较简陋,只是超过阈值就标红,没有考虑到「白大衣高血压」这种场景下连续几次都偏高但实际没问题的情况。这块我想引入一个滑动窗口均值,但暂时还没动。

血压分类标准支持切换(国内标准 vs ACC/AHA 2017),但界面上没有做到位,大多数用户根本发现不了这个设置在哪。


这个项目目前还在冷启动阶段,有兴趣的朋友可以在 App Store 搜「健康手账」试试——特别是家里有需要记血压的长辈的,帮他们装一个比较实在。

如果你也在做类似的健康或工具类 App,欢迎在评论区聊聊你在数据录入和用户习惯培养上的做法,我挺好奇不同产品的解法有什么差异的。

数据持久化与缓存策略:在离线与在线间架起桥梁

引言:数据无处不在,存储何去何从?

在现代移动应用中,数据如同血液般流淌于每个功能模块之间。然而,网络并非永远可靠,用户期待的是无缝的体验——无论在地铁隧道中、飞行模式下,还是在信号微弱的乡村。这种期待催生了对数据持久化与缓存策略的深度思考。一次关于本地数据丢失的故障排查,让我们意识到:数据的生命周期管理远比简单的"保存与读取"复杂得多。本文将从实际案例出发,探讨如何构建一个既能保证数据一致性,又能提供流畅离线体验的存储架构。

一、存储方案的选择:从UserDefaults到数据库的演进之路

// 初级做法:滥用UserDefaults
UserDefaults.standard.set(userProfile, forKey: "currentUser")
UserDefaults.standard.set(accessToken, forKey: "authToken")
UserDefaults.standard.set(products, forKey: "cachedProducts")

然而,UserDefaults本质上是一个plist文件,适合存储配置信息和小量数据,但不适合存储复杂对象或大量数据。当应用需要存储用户聊天记录、商品目录或离线文章时,我们需要更专业的解决方案。

下图展示了不同存储方案的选择路径,帮助开发者根据数据特性做出合理决策:

image.png

二、架构核心:构建统一的数据访问层

随着应用复杂度增加,直接在各种业务模块中操作不同存储方案会导致代码高度耦合。更好的做法是构建一个统一的数据访问层(Data Access Layer),为上层业务提供一致的接口。

// 统一存储协议
protocol DataStorageProtocol {
    associatedtype T
    
    func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError>
    func load(forKey key: String) -> AnyPublisher<T, StorageError>
    func delete(forKey key: String) -> AnyPublisher<Void, StorageError>
    func clear() -> AnyPublisher<Void, StorageError>
}

// 具体实现:UserDefaults存储
class UserDefaultsStorage<T: Codable>: DataStorageProtocol {
    private let userDefaults: UserDefaults
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    
    func save(_ item: T, forKey key: String) -> AnyPublisher<Void, StorageError> {
        return Future<Void, StorageError> { promise in
            do {
                let data = try self.encoder.encode(item)
                self.userDefaults.set(data, forKey: key)
                promise(.success(()))
            } catch {
                promise(.failure(.encodingFailed))
            }
        }.eraseToAnyPublisher()
    }
}

这种抽象带来了多重好处:业务代码无需关心底层是使用UserDefaultsCore Data还是文件系统;存储实现可以独立替换;统一的错误处理;以及易于测试的接口。

三、缓存策略:智能数据的生命周期管理

缓存不仅仅是"保存一份数据副本",而是需要精心设计的策略。一个完整的缓存系统需要考虑以下维度:

  1. 缓存粒度:是按页面缓存、按接口缓存,还是按数据实体缓存?
  2. 失效策略:基于时间(TTL)、基于事件(数据更新),还是混合策略?
  3. 存储位置:内存缓存、磁盘缓存,还是多级缓存?
  4. 同步机制:如何保证缓存与服务器数据的一致性?

让我们设计一个支持多级缓存的智能系统:

class SmartCacheManager {
    // 内存缓存(快速但易失)
    private let memoryCache = NSCache<NSString, NSData>()
    
    // 磁盘缓存(持久但较慢)
    private let diskStorage: DataStorageProtocol<Data>
    
    // 网络层用于刷新数据
    private let networkService: NetworkServiceProtocol
    
    func fetchData<T: Codable>(for key: String,
                              maxAge: TimeInterval = 300, // 默认5分钟
                              forceRefresh: Bool = false) -> AnyPublisher<T, Error> {
        // 1. 检查是否需要强制刷新
        guard !forceRefresh else {
            return fetchFromNetwork(key: key)
        }
        
        // 2. 检查内存缓存
        if let cachedData = memoryCache.object(forKey: key as NSString) as Data?,
           let cachedItem = decodeData(cachedData) as T? {
            return Just(cachedItem)
                .setFailureType(to: Error.self)
                .eraseToAnyPublisher()
        }
        
        // 3. 检查磁盘缓存
        return diskStorage.load(forKey: key)
            .tryMap { data in
                // 检查缓存是否过期
                if self.isCacheValid(for: key, maxAge: maxAge) {
                    return try JSONDecoder().decode(T.self, from: data)
                } else {
                    throw CacheError.expired
                }
            }
            .catch { _ in
                // 4. 缓存无效或不存在,从网络获取
                return self.fetchFromNetwork(key: key)
            }
            .eraseToAnyPublisher()
    }
}

下图展示了智能缓存系统的工作流程,从数据请求到返回的完整决策链:

image.png

## 四、数据同步:离线优先的架构哲学 在需要离线能力的应用中,我们常常采用"离线优先"(`Offline-First`)策略。这意味着应用优先使用本地数据,同时在后台同步最新数据。这种策略需要解决几个关键问题:
  1. 冲突解决:当本地修改与服务器数据冲突时如何处理?
  2. 增量同步:如何高效地只同步变化的数据?
  3. 同步状态管理:如何向用户展示同步进度和状态?

我们可以设计一个基于操作队列的同步管理器:

class SyncManager {
    private let operationQueue = OperationQueue()
    private let pendingOperationsStorage: DataStorageProtocol<[SyncOperation]>
    
    // 记录待同步的操作
    func enqueueOperation(_ operation: SyncOperation) {
        // 保存到本地,确保即使应用崩溃也不会丢失
        var pendingOps = (try? pendingOperationsStorage.load(forKey: "pending")) ?? []
        pendingOps.append(operation)
        pendingOperationsStorage.save(pendingOps, forKey: "pending")
        
        // 添加到操作队列
        operationQueue.addOperation(operation)
    }
    
    // 监听网络状态变化
    func setupNetworkObserver() {
        NotificationCenter.default.publisher(for: .networkReachable)
            .sink { [weak self] _ in
                self?.retryPendingOperations()
            }
            .store(in: &cancellables)
    }
}

这种设计确保了即使用户在离线状态下进行操作,这些操作也会被安全地保存,并在网络恢复时自动同步。

五、性能优化:存储的效率与安全平衡

数据持久化不仅关乎功能,更直接影响应用性能。我们需要在多个维度上寻找平衡点:

  1. 读写性能:大量小文件 vs 少数大文件
  2. 内存占用:缓存大小限制与淘汰策略
  3. 电池消耗:磁盘IO对电池寿命的影响
  4. 数据安全:敏感信息的加密存储

对于敏感数据如用户凭证,我们应使用iOS的Keychain服务:

class SecureStorage {
    func saveSecureItem(_ item: String, forKey key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: item.data(using: .utf8)!
        ]
        
        SecItemDelete(query as CFDictionary) // 先删除旧项
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }
}

对于大量数据的存储,我们需要考虑分页加载和懒加载策略,避免一次性加载过多数据导致内存压力。

六、总结:构建可靠的数据基石

数据持久化与缓存策略是移动应用架构中最为基础也最为复杂的一环。它不仅仅是技术选择的问题,更是对用户体验、性能表现和安全保障的综合考量。

通过构建统一的数据访问层,我们实现了存储实现的解耦;通过智能缓存策略,我们平衡了性能与数据新鲜度;通过离线优先的同步机制,我们确保了应用的可用性;通过性能优化措施,我们保障了应用的流畅运行。

这再次印证了本系列文章的核心思想:优秀的架构设计在于预见复杂性并提前规划应对策略。当数据层稳固可靠时,上层业务开发便能够专注于创造价值,而不必担心数据丢失、同步冲突或性能瓶颈。在数据驱动的时代,一个精心设计的数据持久化架构,是应用成功的基石,也是技术卓越的体现。

iOS 足迹 App 的成就系统,我推倒重做了一次——踩了3个坑之后

上线三周,成就页的打开率掉到了 2%。我盯着这个数字看了好一会儿,意识到设计完全错了。

「雁过留痕」是我做的一个足迹记录 App,核心思路是把你走过的路变成可量化的探索面积(km²),用 25m 精度网格覆盖地图,慢慢把省份和城市染色。这个核心玩法我觉得还不错,但成就系统上线之后直接拖了后腿。

第一版成就系统到底错在哪

最早的版本只有几个维度:总距离、总录制天数、省份解锁数量。听起来挺完整,上线之后发现问题很具体——用户解锁了头三个徽章,然后成就页就再也不打开了。

原因其实事后想想很显然:目标太稀疏,中间段完全是空白期。你解锁了「初探者」,下一个目标要再走 500km 才能到「漫游者」,这中间几个月看不到任何进展反馈,等于告诉用户「别来了」。

游戏设计里有个基本原则:玩家需要随时都能看到「我离下一个里程碑还有多远」。我第一版完全忽略了这件事。

推倒之后怎么重建

重做的核心思路是把成就拆成多个 Track,每个 Track 内部是连续的多级徽章,保证任意时刻都有「快到了」的感觉。

enum BadgeTrack: String, CaseIterable, Identifiable {
    case all
    case exploration   // 面积、城市、省份
    case consistency   // 连续打卡、累计月数
    case china         // 省级/大区解锁
    case world         // 全球探索
    case pro           // Pro 会员专属

    func matches(_ definition: BadgeDefinition) -> Bool {
        self == .all || definition.badgeTrack == self
    }
}

这个分组做出来之后,成就页的平均停留时长从 8 秒涨到了 19 秒。说实话这个数字比我预期高,主要原因我猜是「中国赛道」——省份解锁这个玩法对国内用户有天然吸引力,很多人打开成就页就是去看自己还差哪几个省。

数据聚合这块踩的坑

成就判断需要的数据维度很多:总距离、连续天数、省份数量、面积……最开始每个徽章自己去查数据库,成就页一打开要跑几十次查询,加载卡顿肉眼可见。

后来抽了一个 BadgeMetrics 结构统一做一次聚合,所有徽章判断共用同一份数据:

struct BadgeMetrics {
    let totalDistanceKilometers: Double
    let recordedDays: Int
    let currentStreakDays: Int
    let longestStreakDays: Int
    let chinaProvinceCount: Int
    let chinaAreaKm2: Double
    let cityUnlockCount: Int
    let globalAreaKm2: Double

    static func build(
        stats: TraceStats,
        segments: [TraceSegment],
        geo: GeographicProfile,
        proMembershipActive: Bool,
        proMembershipActivatedAt: Date?
    ) -> BadgeMetrics { ... }
}

顺带提一个细节:segments 在 build 里有一步过滤,只保留 pointCount >= 8 的记录。这个阈值对应大约 10-15 秒的有效移动,过滤掉了打开 App 又马上锁屏的噪声。这个数值调了好几次,太小的话徽章进度会被一堆无效数据撑高,用户觉得「奇怪,我没走多少怎么进度涨这么快」,反而破坏信任感。

中国坐标系的坑,顺便说一下

省份解锁要判断「这个 GPS 点是否在某个省内」,但 GPS 原始数据是 WGS-84,国内地图用 GCJ-02,直接拿坐标去匹配行政区边界,边境附近会出现「明明走在省内却没解锁」的情况。

我在 GeographicProfile 里做了坐标系转换,省级和地市级边界数据全部内置,不走网络请求。好处是离线也能正常触发成就,坏处是包体增加了大概 3MB——这个取舍我觉得值,足迹类 App 很多场景就是在没网络的山里。

现在纠结的一个设计问题

下一步想加「状态徽章」:比如「连续 30 天记录」解锁之后不是永久持有,断了会变灰,需要重新激活。

但我现在真的没想清楚该不该做。

没压力就没粘性,这个逻辑说得通,健身 App 基本都用这套。但足迹记录和健身不一样——用户可能就是出去旅行才开,平时根本不用,强迫他们「每天打卡」会让 App 变成一个焦虑来源。我不想做那种让人觉得「没开就有罪恶感」的产品。

但如果完全没有时间压力,成就全部永久持有,用户解锁完一批之后可能又回到当初那个 2% 的困境。

这个矛盾我现在还没有好答案。如果你做过类似的游戏化设计,或者作为用户对「会过期的成就」有什么感受,真想听听。

我用 SpriteKit 给存钱罐装了个物理引擎

调参数调到一半,我顺手往测试账号里又存了 200 块,纯粹是因为看硬币掉落太过瘾。那一刻我觉得这个 App 的核心方向对了。

存钱 App 不少,但我自己用了一圈,基本上三天就卸载了。问题不是功能不够,而是「存进去」这个动作没有任何正向反馈。往银行账户转 500 块,余额多了个数字,然后呢?没声音没动静,脑子里毫无反应,下次就很难再重复这个行为。做「聚沙攒钱」大概花了三个月,现在刚上线,核心想解决的就是这一件事:让存款这个动作本身变得有意思。

用 SpriteKit 做硬币物理动画

这是整个 App 里我花时间最多的部分。存款的时候,硬币从屏幕上方掉落,碰到罐子边缘会弹一下,堆在罐底。存的金额越大,掉的硬币越多。用 SpriteKit 的物理引擎实现,核心逻辑大概是这样:

func spawnCoins(count: Int, into scene: SKScene) {
    for _ in 0..<count {
        let coin = SKSpriteNode(imageNamed: "coin")
        coin.physicsBody = SKPhysicsBody(circleOfRadius: coin.size.width / 2)
        coin.physicsBody?.restitution = 0.4
        coin.physicsBody?.friction = 0.6
        let startX = CGFloat.random(in: scene.size.width * 0.3...scene.size.width * 0.7)
        coin.position = CGPoint(x: startX, y: scene.size.height + 20)
        scene.addChild(coin)
    }
}

restitution 控制弹性,friction 控制摩擦。这两个值调了挺久——最开始设 restitution = 0.8,硬币在罐里弹来弹去像乒乓球,完全不对;换成 0.1,又像石头直接沉底,没有金属感。来回试了大概二十组,0.4 + 0.6 是我自己觉得最接近「真实硬币掉进陶瓷罐」的感觉。就是在调这个参数的过程中,我忍不住顺手存了那笔 200 块。

双模式:短期愿望 vs 长期定投

产品结构上做了两种模式。

愿望模式:适合「我要攒钱买 AirPods Max」这种有明确目标的场景,设目标金额,每次存款推进度条,距离目标还差多少天一目了然。

聚沙模式:基于 DCA(定期定额)逻辑,设定每周或每月固定存入金额,内置复利计算器,输入年化收益率之后可以看到 N 年后的预估结果,适合想养成长期储蓄习惯的场景。

两种模式放在一起,设计阶段我自己也担心会让人觉得混乱。但在早期十几个测试用户里,有三四个两个模式都开着——一个用来存旅行基金,一个用来强迫自己每月定存。这个比例让我觉得放在一起是对的,两种心理状态确实可以并存。

成就徽章系统

参考了健身 App 的逻辑,把可见的里程碑作为习惯强化手段。徽章判断条件全部基于 StatsSummary 这个结构体,包括总存款金额、连续天数、存款时间段等等:

BadgeDefinition(id: "streak_7", name: "Week Streak",
    description: "Deposit 7 days in a row",
    category: "streak") {
    $0.currentStreak >= 7
},
BadgeDefinition(id: "night_owl", name: "Night Owl",
    description: "Deposit 10 times at night",
    category: "special") {
    $0.nightDeposits >= 10
}

「Night Owl」和「Early Bird」是我比较喜欢的两个,晚上存了 10 次和早上存了 10 次分别解锁。有测试用户看到「Night Owl」的时候说「这个 App 懂我」,这个反馈挺好的——徽章在记录的不只是金额,还有一个人存钱的时间节奏。

每日语录:18×18 组合生成

这个模块有点意思。我不想手写几百句鸡汤,所以用了组合逻辑——18 个「主语」乘以 18 个「谓语」,生成 324 种组合,足够一年内不重复。

比如「固定的存钱节奏」+「会让焦虑一点点淡下去」,「一杯奶茶的钱」+「能抵消很多小小的冲动消费」。有些组合挺通顺,有些拼出来确实略生硬,读起来像机器写的。生硬的那些我做了一个黑名单手动过滤,大概淘汰了 40 句,剩下的整体可读性还不错。说白了,这是个半自动流程,机器打草稿,人工做最后一道筛。

做错了的几个决定

订阅定价改了两次。最开始想做纯免费带广告,后来发现存钱 App 里放广告体验很糟,用户存钱存到一半弹出来一个游戏广告,心情直接崩了。改成一次性内购之后反而顺一些。

数据备份功能上线比预想晚了一个版本。有个测试用户换手机之后数据全没了,找我反馈,最后一条消息就是「我的数据没了」,然后就没再说话。我盯着那条消息看了挺久,没法回复什么。那之后备份功能直接插队到下个版本,别的需求全往后推。用户数据这件事,v1 就该做好,没有借口。

「聚沙模式」的 UI 一开始做得太复杂,复利计算器有七八个输入项,我自己用的时候都觉得烦,后来砍掉大半只留核心参数。试了三个方案,最后全删了重来。功能多不等于有用。

一个没解决的 SpriteKit 问题

目前有个穿模问题没有根治:硬币数量一多,相互重叠之后会出现轻微穿透。我现在的做法是限制单次最大生成数量,同时用 categoryBitMask 给硬币单独分一个碰撞分组,让它们只和罐壁、罐底以及彼此发生碰撞,不影响 UI 层的其他节点:

coin.physicsBody?.categoryBitMask = PhysicsCategory.coin
coin.physicsBody?.collisionBitMask = PhysicsCategory.coin | PhysicsCategory.jar
coin.physicsBody?.contactTestBitMask = PhysicsCategory.jar

这样能减少无关碰撞计算,但硬币堆多了之后还是会穿模,治标不治本。有做过 SpriteKit 堆叠物理的朋友吗?是怎么处理这个问题的?

7 个开源 iOS 应用,让你成为更好的开发者

多年来,我注意到开发者成长的一个规律。

教程很适合学习语法。课程有助于理解概念。但在某个阶段,最大的提升来自于阅读有经验的团队如何在真实代码库中解决真实问题。

不是示例项目,不是演示应用,而是真正上线的产品。

那种处理你根本想不到的边界情况的代码。那种经历了三年功能迭代仍然健壮的架构。那种只有亲眼看到它们在大规模下运作才能理解的决策。

我花了不少时间浏览开源 iOS 应用,以下是我认为真正值得深入研究的七个。每一个都能教会你不同的东西——关于架构、安全、设计模式,或者仅仅是良好的工程习惯。

以下是清单。

1. Firefox for iOS

仓库: mozilla-mobile/firefox-ios

许可证: MPL 2.0

这是 Mozilla 为 iOS 打造的完整浏览器,完全使用 Swift 编写。

这是一个庞大的代码库,而这正是它的价值所在。你很少有机会看到如此规模的项目如何在一个地方同时处理标签页管理、同步、遥测、内存压力和无障碍访问等问题。

最让我惊讶的是,尽管项目规模巨大,代码的可读性却相当高。Mozilla 积极标记 good first issue 工单,贡献流程文档也非常完善。

你可以学到:

  • 大规模 iOS 应用如何管理状态和内存
  • 复杂 UI 中无障碍访问的处理方式
  • 重大开源项目中的贡献流程是什么样的

如果你好奇一个生产级浏览器在底层是什么样子,这里是最好的起点。

2. Signal for iOS

仓库: signalapp/Signal-iOS

许可证: GPL-3.0

Signal 是一款注重隐私的即时通讯应用,数百万人信赖它进行安全通信。

从学习角度来看,Signal 代码库最有趣的地方在于它在每一层都极其认真地对待安全问题。端到端加密、安全本地存储、密钥管理——这些不是事后补充,而是嵌入到架构本身之中。

该应用还非常实际地混合使用了 UIKit 和 SwiftUI,这反映了当今许多生产应用的真实面貌——不是纯粹地使用其中一种,而是经过深思熟虑的混合方案。

你可以学到:

  • 安全导向的 iOS 工程模式
  • 推送通知和后台任务在真实通讯应用中如何运作
  • 团队如何在同一项目中管理 UIKit 和 SwiftUI 的共存

阅读 Signal 的代码会改变你对自己应用中数据处理的思考方式。

3. WordPress for iOS

仓库: wordpress-mobile/WordPress-iOS

许可证: GPL-2.0

这是 Automattic 官方的 WordPress 应用——最成熟的开源 iOS 项目之一。

该代码库涵盖了真正广泛的 iOS 挑战:Core Data、REST 和 GraphQL 网络请求、富文本编辑、离线同步、模块化架构。很难找到一个项目能同时涉及这么多领域。

WordPress 让我印象最深的是它的贡献体验。文档详尽,上手流程顺畅,项目周围有真正的导师文化。如果你想做出第一个有意义的开源贡献,这里是最好的起点之一。

你可以学到:

  • 生产级 Core Data 和离线优先架构
  • 如何在 iOS 上构建富文本编辑器
  • 一个成熟、维护良好的开源项目从内部看是什么样子的

4. NetNewsWire

仓库: Ranchero-Software/NetNewsWire

许可证: MIT

NetNewsWire 是一款免费的 RSS 阅读器,支持 iOS 和 macOS,由 Brent Simmons 开发。

如果你不熟悉这个名字,Brent 是 Apple 开发者社区最具影响力的元老之一。他几十年来一直在构建 Mac 和 iOS 应用,这在代码库的每一个角落都体现得淋漓尽致。

我喜欢 NetNewsWire 的地方在于它的 Swift 代码多么干净、多么地道。没有过度设计,没有不必要的抽象,只有结构良好的代码,恰好做它需要做的事。

它也是我见过的 iOS 和 macOS 之间跨平台代码共享的较好范例之一。项目规模足够小,你实际上可以通读整个代码库并理解所有部分是如何连接的。

你可以学到:

  • 地道的、干净的 Swift 在实践中是什么样子
  • 如何有效地在 iOS 和 macOS 之间共享代码
  • 经验丰富的开发者如何为长期可维护性来组织项目

如果你想从头到尾研究一个代码库,这是我推荐的仓库。

5. Wire for iOS

仓库: wireapp/wire-ios

许可证: GPL-3.0

Wire 是一款安全的即时通讯应用,支持语音通话、视频通话和群聊——全部默认加密。

对于 iOS 开发者来说,Wire 特别有趣的地方在于它的真实 WebRTC 集成。如果你好奇音频和视频通话在 iOS 代码层面到底是如何工作的,这是为数不多的能让你看到完整实现的开源项目之一。

该项目也是大规模模块化 Swift 架构的良好范例。它被拆分为边界清晰、定义明确的模块,这使得在如此规模的项目中导航比预期要容易。

你可以学到:

  • WebRTC 如何集成到原生 iOS 应用中
  • 音视频通话架构的实践
  • 如何用清晰的边界模块化大型 Swift 代码库

6. Element X for iOS

仓库: element-hq/element-x-ios

许可证: AGPL-3.0

这是下一代 Matrix 客户端,也是本列表中最现代的代码库之一。

Element X 完全使用 SwiftUI 构建,底层基于 matrix-rust-sdk。仅凭这个组合就值得研究——你能看到 SwiftUI 在生产规模下的使用,也能看到 Rust 和 Swift 如何通过 FFI 在真实应用中进行通信。

项目非常活跃,团队响应迅速,并且定期为新人标记 issue。如果你想找一个能反映 iOS 开发未来方向的项目——SwiftUI 优先,性能关键层用 Rust 编写——就是它了。

你可以学到:

  • SwiftUI 在真实生产应用中如何规模化
  • Rust 到 Swift 的 FFI 在实践中如何运作
  • SwiftUI 优先代码库中的现代架构模式

7. Kickstarter for iOS

仓库: kickstarter/ios-oss

许可证: Apache-2.0

Kickstarter 开源了他们的整个原生 iOS 应用,这是 iOS 社区中被引用最多的代码库之一。

它被广泛引用的原因在于其严谨性。函数响应式编程、MVVM 架构、依赖注入,以及真正有意义的测试覆盖率。每种模式在整个项目中都得到了一致的应用,这使它作为参考极其有用。

他们的 Pull Request 风格和代码审查文化也值得学习。从阅读他们的 PR 和提交信息中,你能学到和代码本身一样多的东西。

你可以学到:

  • 函数响应式编程在真实应用中的一致应用
  • 如何为可测试性构建依赖注入
  • 大规模下严谨的测试覆盖率到底是什么样子

如何真正用好这份清单

七个仓库确实很多。你不需要全部看完。

我的建议是:选择一个与你当前工作或好奇心相关的项目。克隆它,在 Xcode 中运行。然后挑一个功能——也许是登录流程,也许是同步层,也许是他们如何处理导航——从头到尾读一遍。

不要试图一次理解整个代码库。聚焦于一条代码路径,从 UI 层一直追踪到数据层。

你花一个下午阅读生产级代码所学到的东西,比跟着教程学一个月还要多。

如果你想做出自己的第一个开源贡献,这些项目中的大多数都会积极标记 good first issue 工单。这意味着有一个明确的入口在等着你。

最后的想法

阅读优秀的代码是一种会随时间悄然复利的好习惯。

你会开始注意到你从未想到过的模式。你会开始理解为什么某些架构决策会存在。你会培养出一种直觉——什么样的代码容易修改,什么样的代码对每一次改动都充满抗拒。

这些都不是来自某个突破性的瞬间。它们来自于持续地接触精心编写的代码,让这些模式重塑你对自己工作的思考方式。

希望这份清单能给你一个好的起点。

选一个。克隆它。开始阅读。

我做了一个把专注计时变成「声音护照」的 iOS App,聊聊数据可视化和成长系统的设计思路

最近上线了一个叫「声境护照」的 iOS App,做的事情说起来很简单:番茄钟 + 环境音 + 数据可视化。但我想聊的不是功能本身,而是做这个 App 过程中一些有意思的设计决策——尤其是「把专注数据包装成旅行叙事」这条路到底值不值得走。

从一个具体的厌倦感出发

我用过很多专注类 App,Forest、潮汐、番茄ToDo,都挺好用。但用着用着有个感受:完成了专注,然后呢?数字加一,然后就没了。

说实话,这种「完成即消失」的感觉有点可惜。你花了 25 分钟认真写东西,这件事值得被记住。所以我想做一个让每次专注都留下「印记」的工具。

护照的比喻就是从这里来的——每次专注是一次起飞,声景是目的地,累计时长变成飞行里程,连续打卡天数是你的「航班记录」。

成长系统的数据结构设计

游戏化成长系统是这个 App 最核心的部分,我在 ExpeditionModels.swift 里把整个探险体系建模成章节 + 任务的结构:

struct ExpeditionChapterDefinition: Identifiable, Codable, Equatable {
    let id: String
    let sceneId: String
    let cityName: String       // 对应一个声景目的地
    let tagline: String
    let bonusBounces: Int
    let missions: [ExpeditionMissionDefinition]
}

enum ExpeditionMissionKind: String, Codable {
    case sessionCount    // 完成 N 次专注
    case focusMinutes    // 累计 N 分钟
    case deepFocusCount  // 深度专注 N 次
}

每个「城市章节」绑定一个声景 ID,完成章节里的任务才能解锁下一个城市。这样声音选择就不只是 UI 装饰,而是有推进感的目标。

ExpeditionMissionKind 只有三种,我故意控制得很少。试了几个方案,加过「连续打卡天数」「特定时段专注」等类型,最后都删了——任务类型越多,用户反而不知道该干什么。

会话战报的「下一步建议」逻辑

每次专注结束会弹出一张战报,这个战报除了展示当次数据,还会给出下一次专注的建议。这个逻辑在 SessionReportSheetViewModel 里:

func nextActionAdvice(taskCompleted: Bool) -> SessionNextActionAdvice {
    let remainingTodayPlan = max(0,
        store.weeklyPlanTodayTargetSegments - store.weeklyPlanTodayActualSegments
    )
    let streakHint: String
    if store.streakDays >= 5 {
        streakHint = "你已连续 \(store.streakDays) 天,重点是稳定复用。"
    } else if store.streakDays >= 2 {
        streakHint = "再坚持 1-2 天可进入稳定习惯区。"
    } else {
        streakHint = "建议先连续 3 天完成每日最小闭环。"
    }
    // ...
}

这里有个取舍:建议文案是写死的字符串模板,不是 AI 生成的。我考虑过接 LLM,但一来成本不好控制,二来我发现这类「行为引导」场景其实不需要千变万化的文案,固定的几条反而更有仪式感,用户知道这是 App 在认真跟踪自己的状态。

连续天数的分层(1天 / 2-4天 / 5天以上)是我根据习惯养成的一般规律拍的,不是什么严格实验得出的结论。连续 3 天是个心理门槛,5 天以上用户大概率已经进入节奏了,策略应该从「建立习惯」切换到「维持节奏」。

分享卡片:让专注数据变成内容

这是我觉得最值得展开讲的部分。

专注类 App 的自然增长渠道几乎只有两条:AppStore 搜索,和用户分享。Forest 靠的是「种了一棵树」的视觉,潮汐靠的是精美的音景截图。我的切入点是「数据卡片」——把当次战报或周回顾渲染成一张可以直接发朋友圈的图片。

ShareCardFormatter 负责格式化卡片里的时间信息,战报卡片、成就徽章卡片、周回顾卡片用的日期格式各不相同(yyyy/MM/dd HH:mm vs yyyy/MM/dd),看起来细节,但如果格式乱掉整张卡片的质感就垮了。

卡片设计我做了三个版本,第一版太「仪表盘」,数字密密麻麻;第二版太「极简」,信息量不够,朋友看不出你做了什么;第三版找到了平衡——突出时长和等级称号,次要展示声景和任务名,底部放一行小字的里程数。

StatsService 和 GrowthService 的分层

统计相关的逻辑我拆成了两个 Service:

  • StatsService:纯数据聚合,负责按时间范围汇总 FocusLog,输出 StatsData
  • GrowthService:负责把 FocusLog 转换成 GrowthProfile,计算等级、经验值、称号

这两个 Service 都是无状态的纯函数风格,输入 logs 数组输出结果,在多个 ViewModel(StatsSheetViewModelProfileSheetViewModelWeekReviewSheetViewModel)里复用。

有一个小设计:当 focusLogs 为空时,会调用 StatsService.createDemoFocusLogs() 生成演示数据。新用户第一次打开统计页不会看到空白界面,而是看到一个「如果你用了两周会是什么样子」的预览。这个 onboarding 细节我觉得挺重要——空页面对新用户很劝退。

现在的状态和一些遗憾

App 刚上线 1.3 版本,下载量还很少,老实说基本还在 0 起步阶段。

有几个功能是做到一半放在 _disabled_features 目录里的——统计报告、周回顾、分享卡片这些模块代码都写完了,但 UI 打磨还不够,我没有在 1.3 开放。这种「功能写完了但藏起来」的状态有点难受,但比发出去然后体验很差要好。

声景库目前内容量不够丰富,「东京雨夜」「咖啡馆白噪音」这类场景音是有的,但城市章节太少,探险系统的推进感不强。这是接下来要重点补的。

还有一个我没想清楚的问题:护照 + 飞行里程这套叙事对喜欢旅行的用户很有共鸣,但对完全不在意这个比喻的用户来说可能显得有点奇怪。这个产品定位的边界到底在哪,我还在摸索。


如果你也在做类似的「工具 + 游戏化」方向的 iOS App,或者对专注类产品有什么看法,欢迎在评论区聊聊——我对「游戏化到底会不会让用户厌倦」这个问题挺好奇的,想听不同角度的判断。

鸿蒙呼吸动画踩了三个坑:GPU降级时机、设计Token校验、i18n漏key——具体怎么处理的

在鸿蒙上做呼吸动画,我以为最难的是 ArkTS 语法,结果最麻烦的是——我根本不知道用户的设备跑到哪一档了。

呼吸动画是「呼吸视界」这个 App 的核心体验:吸气时圆圈缓慢扩张,屏气时保持,呼气时收缩。这个动画一旦卡顿,「跟着 App 呼吸」的节奏就断了,用户能感觉到「哪里不对」,但不会告诉你是帧率问题。

先说一下这个 App 是干什么的,方便后面的技术背景理解。

产品背景:一个给自己做的呼吸训练工具

「呼吸视界」(iOS App Store ID: 6758613852)做的是结构化呼吸训练引导——4-7-8 呼吸法、盒式呼吸、Wim Hof 法这些。网上这些方法的文字说明很多,但照着文字练,你得自己数秒、记顺序,练着练着就分心了。

我做这个的起因很功利:开会前容易紧张,想找个东西帮我两分钟之内把状态重置一下。找了一圈没找到合适的,就自己写了。

App 有三块核心功能:带动画节奏的引导式练习、本地持久化的训练记录、以及一个课程进度系统(不只是单次练习,而是完整的训练计划)。iOS 版目前评分 5 分,样本量不大,但有个用户说「可以跟随练习呼吸,保持稳定的心情」——说实话这个反馈比我预期的更朴实,我自己用下来觉得更直接的感受是:开会前真的有用,两分钟够了。

鸿蒙版最近发布,把移植过程里踩的几个坑整理一下。

坑一:呼吸动画的 GPU 降级,我不知道该在哪个阈值切

呼吸动画用 GPU 渲染时效果最好,过渡顺滑,缩放曲线自然。但鸿蒙设备碎片化比 iOS 严重得多,中低端机上 GPU 渲染直接掉帧,整个动画变得一顿一顿的。

所以我做了一套自适应降级:检测到性能不足时切到 Canvas fallback 模式,同时把当前渲染质量分成 highbalancedlow 三档。

问题来了:切换阈值怎么定?

我的判断方式是盯 frameMs(单帧渲染耗时)和连续低帧计数:

// 连续低帧超过阈值时触发降级
if (frameMs > 22 && consecutiveLowFpsCount >= 3) {
  // 22ms ≈ 45fps,低于此值且连续3帧 → 切 balanced
  adaptRenderer('degrade quality -> balanced');
  consecutiveLowFpsCount = 0;
}
if (frameMs > 33 && consecutiveLowFpsCount >= 3) {
  // 33ms ≈ 30fps,连续3帧 → 切 canvas fallback
  adaptRenderer('switch renderer -> canvas fallback');
}

这个阈值不是凭感觉拍的,是我把 hilog 日志抓出来跑脚本分析的结果。日志里会输出每帧的 fpsframeMstickMs 以及当前渲染质量档位,降级事件会打 BF_PERF_ADAPT 标签,比如 degrade quality -> balanced 或者 switch renderer -> canvas fallback

对独立开发者来说这套日志分析挺重要——没有 QA、没有用户主动反馈卡顿,只能靠工具自己发现问题。我在没有真机的情况下,靠日志回放重现了好几个卡顿场景。

目前 Canvas 模式下动画过渡还是不如 GPU 顺滑,这个还在打磨,算是没解决干净的问题。

坑二:设计 Token 漏用——一个脚本比 code review 更可靠

App 的调性是「平静克制」,UI 上我比较在意所有间距、圆角、阴影、动画时长要统一。如果哪个地方直接写了魔法数字,整体质感就散了。

鸿蒙版我把所有设计 token 收进一个 Style.ets,导出四个命名空间:SPACERADIUSSHADOWMOTION。问题是开发过程中很容易手滑——改某个组件时直接写 borderRadius(8) 而不是 RADIUS.card,这种事我自己也干过。

所以我写了一个 check_design_foundation.py,逻辑很简单:用 path.read_text() 读取关键文件内容,用字符串匹配检查是否包含预期的 token 引用。比如检查 AppBackdrop.ets 里有没有 export struct AppBackdrop,检查 SheetBackground.ets 里有没有调用 AppBackdrop(,检查根页面 RootPage.ets 里有没有 AppBackdrop({

不是正则匹配魔法数字(那个误报太多),而是检查「关键结构是否存在」——更像一个架构约束验证器。

真实案例:有一次我重构了背景组件 AppBackdrop,改了对外接口,但忘了更新 SheetBackground 里的调用方式,就是被这个脚本拦下来的。如果没有这个检查,这个问题可能得等到真机运行时才会发现。

我还把几个类似的脚本整合进一个 check_foundation_alignment.py,统一管理:设计 token 校验、按压反馈检查、页面过渡检查、i18n 对等检查、路由检查——提交前一起跑,哪个挂了去修哪个。独立开发没有 code review,这套东西算是自己给自己兜底。

坑三:i18n 漏 key,双语维护是个持续性的低级错误

App 支持中英双语,维护四个语言文件:strings_app_en.etsstrings_app_zh-Hans.ets 以及对应的 base 版本。每次加新功能往里填 key,英文填了忘了填中文,或者反过来,这种事经常发生。

check_i18n_keys.py 做的事很直白:把四个文件里的 key 全部提取出来做集合差运算,输出「哪些 key 在英文有但中文没有」以及反向的情况。

这个脚本帮我发现过好几次漏掉的 key,有时候漏的是边缘功能的文案,有时候是一个按钮标题——后者如果漏了,用户看到的就是 key 字符串本身,很难看。

课程进度系统:本地存储是主动选择,不是偷懒

ProgramProgressRecord 记录用户在某个训练计划里完成了哪些 session、当前在第几阶段。数据全部本地存储,没有云同步。

说实话云同步我也不想做。OAuth 接入、服务器费用、隐私合规、多端数据冲突处理……这一套对独立开发者来说投入产出比太低。用户的训练记录放本地就够了,鸿蒙的 Preferences 和 RelationalStore 用起来比我预期顺手,持久化这块没遇到太大麻烦。

自定义呼吸节奏的交互,我还没想明白

用户可以自由设置吸气、屏气、呼气各阶段的时长。这个功能的交互我试了三版:滑动条、数字步进器、转盘——感觉都差点意思。滑动条精度不够,步进器操作次数太多,转盘在小屏上很难操作。

这个目前还搁着,UI 做得比较简陋。如果你做过类似的时长输入控件——尤其是整数秒精度、范围大概 1-30 秒的场景——很想听听你用了什么方案。

一个 iOS 埋点 SDK 从 0 到 1,再到真实项目接入打磨

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。


最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。

真正麻烦的地方,是代码抽出去之后才出现的:

  1. 哪些能力应该放进SDK
  2. 哪些逻辑必须留在业务项目
  3. SDK 要不要负责埋点上报发送请求
  4. 日志到底是给开发看,还是给测试和产品验收使用
  5. 文档和版本号没跟上时,同事会不会直接集成失败

因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。

一、为什么我会做这个 SDK

起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:

  1. 自动采集公共事件属性
  2. 自动补一组固定用户属性
  3. 统一时间格式
  4. 固定首次安装时间和安装时区
  5. 构建事件请求
  6. 构建用户属性请求
  7. 埋点上报发送请求
  8. 失败后自动重试
  9. 打调试日志

于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。

所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。

二、原来那套代码为什么不适合直接复用

我最开始手上拥有的,是一套项目里我写好的埋点管理代码。

这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:

  1. 事件名
  2. 公共属性
  3. 用户属性
  4. 时间格式
  5. 请求参数构建
  6. 请求发送
  7. 失败重试
  8. 日志它

以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:

func track(_ eventName: SC_MQ09EventName,
           properties: [String: Any] = [:],
           timestamp: Date? = nil) {
    let resolvedTimestamp = timestamp ?? Date()
    var payload = buildEventPayload(
        eventName: eventName,
        properties: properties,
        timestamp: resolvedTimestamp
    )

    guard JSONSerialization.isValidJSONObject(payload) else {
        SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
        return
    }

    do {
        let data = try JSONSerialization.data(withJSONObject: payload, options: [])
        routeEventPayload(
            payload: payload,
            payloadData: data,
            allowRetryStore: true,
            eventName: eventName.rawValue
        )
    } catch {
        SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
    }
}

这段代码本身没有错,问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。

在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。

这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。

第一,职责太杂。

它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。

第二,边界不清。

你很难回答一个问题:

到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。

这也是我后来感受最强的一点:

项目里能跑通,不代表它已经具备跨项目复用条件。

三、我怎么划 SDK 和业务项目的边界

真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。

我想清楚了以下3点:

1. 必须放进 SDK 的,是稳定的基础能力

比如这些:

  1. 公共事件属性采集
  2. 固定用户属性采集
  3. 时间格式统一
  4. 安装时间与安装时区
  5. 事件请求参数构建
  6. 用户属性请求参数构建
  7. 可选的埋点上报发送能力
  8. 失败重试
  9. 日志输出

这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。

2. 必须留在业务项目里的,是具体业务逻辑

比如:

  1. 某个页面的事件名
  2. 某个业务字段怎么算
  3. 哪个时机触发埋点
  4. 哪组字段是这个业务独有的

这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。

3. 埋点上报发送能力必须做成可选

我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。

有的项目想要的是:

  1. SDK 帮我构建参数
  2. 我自己发请求

有的项目则希望:

  1. SDK 帮我构建参数
  2. SDK 直接把请求也发了

所以我最后没有把发送写死,而是保留了两条路:

  1. 标准 SDK 接法:直接 track / setUserProperties
  2. 直接发送完整请求参数:项目先把所有参数组合好,再交给 SDK 发

标准接法的入口最后被压得很薄:

public func track(
    eventName: String,
    properties: [String: Any] = [:],
    timestamp: Date? = nil,
    eventType: String = "track"
) {
    let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
    let payload = makeEventPayload(
        eventName: eventName,
        properties: rawParams,
        timestamp: timestamp,
        eventType: eventType
    )
    sendPayloadIfPossible(
        payload,
        endpointType: .event,
        startLogContext: .event(eventName: eventName, params: rawParams)
    )
}

业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数,至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。

这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:

  1. 先让 SDK 在原来已经在跑的那条上报路径旁边,并行对照一段时间
  2. 先看 SDK 组合出来的参数,和项目里原来那套上报逻辑是不是一致
  3. 确认没问题以后,再正式切到只走 SDK 这一条上报路径

这件事现在回头看也特别值得记下来。

因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。

四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈

如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。

真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。

distinct_id 这条比较清楚:

  1. distinct_id == device_id
  2. 这个值必须由接入方自己提供
  3. SDK 不再内部默认生成

真正麻烦的是 account_id

一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。

但真实接入时,很快就暴露出一个更具体的问题:

很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid

也就是说,问题不只是“account_id 要不要传”,而是:

SDK 初始化完成的时候,account_id 很可能还拿不到。

后来产品把规则也确认得更明确了:

  1. distinct_id 必须有
  2. account_id 的值就是外部拿到的 adjustid
  3. account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
  4. 传进去以后,后续标准事件上报和用户属性上报都要自动传参 account_id
  5. account_id 还要作为用户属性,再主动补报一次 user_setOnce

一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。

所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id

最后 SDK 对外多补充了一个明确方法:

Adjust.adid { adid in
    guard let adid, adid.isEmpty == false else { return }
    ZZHAnalyticsSDK.shared.setAccountID(adid)
}

也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:

  1. distinct_id 一开始就由项目传进来
  2. account_id 等 Adjust 返回后,再立刻传给 SDK
  3. 标准上报路径后续自动传参 account_id
  4. SDK 主动补一次 user_setOnce 用户属性 account_id

这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。

2. 用户属性更新方式为什么要改成枚举

一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:

  1. 表面看起来统一
  2. 实际每个项目都可能传不同字符串
  3. 最后 SDK 很难保证大家传的是同一套规则

所以后来我只保留了两种明确的写法:

  1. user_set
  2. user_setOnce

这个改动看起来不大,但它背后的意思是:

SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。

3. 失败重试为什么不能只停在当前进程内

一开始 SDK 只有自动重试 2 次。

这对临时网络失败来说够用,但接入方很快会问一个问题:

如果这次重试两次都失败,下次 App 重启以后怎么办?

这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。

因为一旦你要支持 App 重启后继续重发,就意味着:

  1. 这次请求的 bodyData 不能丢
  2. 不能下次再重新组合一遍参数
  3. 否则字段和时间可能会和第一次不一致

所以后来这一块的核心原则就变成:

重试永远基于第一次生成的请求内容,而不是重新构建请求参数。

这也是我觉得很值得写出来的一条工程经验。

很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。

而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time

如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:

  1. 当时的网络状态
  2. 当时的权限状态
  3. 当时的安装相关字段
  4. 那次请求真正想表达的时间点

所以后来我对这条原则的理解就更明确了:

埋点请求一旦生成,就应该尽量把它当成那一刻的快照。

4. ta_app_install 事件上报的时间后来为什么还要单独修改

这也是产品验收时发现的一个问题。

一开始我会默认觉得:

  1. 普通事件上报接口传参 time 用当前时间
  2. 这是很自然的做法

这对绝大多数事件都没问题。

ta_app_install 不一样。

因为产品验收时看的不只是传参的 time,还会一起看:

  1. #install_time
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time

这几个时间字段本质上都应该指向同一个安装时间点。

当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。

但 SDK 标准接法如果只是:

ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")

旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。

这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。

这个问题特别能说明一件事:

把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。

后来的修法也很克制:

  1. 普通事件还是继续用当前时间
  2. 只有 ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间

这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:

当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。

五、埋点日志系统是怎么一步一步优化和完善的

如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:

SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。

1. 最开始的日志,其实只对 SDK 开发者有用

最开始 SDK 里的日志更像网络请求调试日志:

  1. 打印发送过程
  2. 打印状态码
  3. 打印成功失败

但这类日志有个问题:

做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。

因为测试真正关心的不是网络请求内部过程,而是:

  1. 这次到底发到哪个 URL
  2. 请求头是什么
  3. 请求参数是什么
  4. 服务端响应了什么
  5. 到底成功还是失败

所以后来日志被拆成了两类:

  1. 发起日志
  2. 结果日志

代码里也尽量保持这个拆分方式:

public func send(snapshot: ZZHAnalyticsRequestSnapshot,
                 completion: @escaping (Bool) -> Void) {
    var request = URLRequest(url: snapshot.url)
    request.httpMethod = "POST"
    request.httpBody = snapshot.bodyData

    #if DEBUG
    ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
    #endif

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error {
            #if DEBUG
            ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
            #endif
            completion(false)
            return
        }

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            #if DEBUG
            ZZHAnalyticsDebugLog(
                Self.responseLog(for: snapshot, response: response, data: data, success: false)
            )
            #endif
            completion(false)
            return
        }

        #if DEBUG
        ZZHAnalyticsDebugLog(
            Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
        )
        #endif
        completion(true)
    }.resume()
}

这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。

2. 发起日志解决“准备发什么”的问题

发起日志最终打印的是:

  1. 时间
  2. 事件名或者用户属性更新方式
  3. URL
  4. Headers
  5. Params

它解决的问题很明确:

它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。

3. 结果日志解决“最终发得对不对”的问题

结果日志则继续保留:

  1. URL
  2. Headers
  3. Params
  4. StatusCode
  5. Response
  6. Success

它解决的是另一层问题:

埋点上报请求最终到底成功没有,服务端返回了什么。

4. 为什么后来还要加埋点日志系统的代理方法

做到这里,我以为已经完成任务了。

后来同事那边又提了一个很真实的诉求:

他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。

这时我才意识到,日志不只是“打印出来”,还得“送出去”。

于是后来又补了一层日志代理:

  1. SDK 在 Xcode 打什么
  2. 代理方法就原样返回什么
  3. 接入方拿到以后,直接塞进自己的日志窗口

最终对外暴露的协议是这样的:

public protocol ZZHAnalyticsLogDelegate: AnyObject {
    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveEventStartLog message: String,
        eventName: String,
        params: [String: Any]
    )

    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveUserPropertyStartLog message: String,
        updateType: String,
        params: [String: Any]
    )

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveEventResultLog message: String)

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveUserPropertyResultLog message: String)
}

这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params

这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。

做到这一步,我自己的总结是:

很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。

六、同一个 params,在不同接法下代表的内容不一样

这是我在真实项目接入时遇到的一个具体问题。

一开始我把发起日志代理设计成:

  1. message:完整日志原文
  2. params:给接入方自己打印一条简洁发起日志

看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。

1. 标准 SDK 接法

如果你走的是:

track(eventName:properties:)
setUserProperties(...)

params 很好理解,就是业务方最开始传进来的参数。

2. 直接发送完整请求参数

如果你走的是:

sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)

那 SDK 拿到的已经是完整的请求参数了。

这时 SDK 内部根本没法再判断:

  1. 哪些是页面最开始传进来的业务参数
  2. 哪些是 SDK 自动获取的公共、固定字段

所以这时发起日志里的 params,默认只能是请求参数里现成的 properties

而当前我这个项目,真实主路径其实更接近这一种。

也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。

这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。

这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。

3. 固定用户属性自动补发又是一个特例

后面又出现了第三种情况。

SDK 会自动补发一组固定用户属性,比如:

  1. country
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time
  5. install_ts_time_timezone

这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。

所以最后我又单独给它做了一个特例:

  1. 普通发起日志:params 继续代表外部原始入参
  2. 固定用户属性自动补发:params 特例代表 SDK 这次自动补发的固定字段

这件事说明了一个问题:

同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。

这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。

七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分

如果只看代码,这套 SDK 其实已经“能用了”。

但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。

还有以下这些东西:

  1. 使用文档写得是不是够直白
  2. 示例代码是不是和真实接法一致
  3. 版本号和 tag 有没有同步
  4. 其他同事复制文档接入时,会不会直接编译报错

我这轮就真实踩到了几个这样的坑:

  1. 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
  2. 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
  3. 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名

这些问题不容小觑,是 SDK 工程化本身的一部分。

因为对接入方来说,他们真正关心的是:

  1. 我怎么接
  2. 我怎么调
  3. 我怎么验
  4. 我装下来的这个版本,到底是不是文档里写的那个版本

所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

而且到后面我还发现,低成本接上这件事,其实也分两层。

第一层是:

  1. README 写清楚
  2. tag 发对
  3. Pod 依赖能装上

第二层是:

  1. 同事能不能只写一个包名
  2. pod install 的时候会不会还要处理私有仓库认证

这轮我其实只把第一层基本收住了。

也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。

这也让我后面更确定一件事:

SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。

八、这轮工作最后让我确定的几条原则

最后,总结 6 个我这次做 SDK 后真正踩出来的经验。

1. 项目里能跑通的代码,不一定适合直接做成 SDK

很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。

2. 发送能力最好可选,不要默认 SDK 接管一切

不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。

3. 重试一定要基于第一次生成的请求内容

埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。

4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试

对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。

5. 文档、版本号和 Pod 接入方式,也要一起维护

文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。

6. SDK 是靠真实接入反馈一点点打磨出来的

我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。

一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。

这可能也是我这轮工作里,最值得留下来的那部分。

❌