阅读视图

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

和 AI 聊游戏设计

最近一段时间和 AI 聊游戏设计比较多。我主要用的是 google 首页上的 AI 模式,也试过 twitter 上的 grok 。

去年也和朋友聊过很多,但对理清楚自己的想法帮助有限。因为和人聊容易陷入不断的细节解释当中,一些天马行空的想法更容易被质疑,一旦陷入辩论就不太容易跳出来。而且每个人的时间并不固定,很容易造成时间和精力的浪费。和 AI 聊要轻松得多,AI 毕竟见多识广,随便提到的点都能接得上话。不想聊下去尽可以中断,不用担心浪费时间。即使怀疑 AI 出现幻觉,也可以随时暂停下来通过搜索核实。

不过,我觉得和 AI 讨论也有另一方面的问题。那就是太容易顺着你的思路夸大其词。它更像是一个貌似领域知识渊博但只是想讨好你的同好,不断的放出一些华丽的辞藻却在逻辑上经不起推敲。分析起游戏来头头是道,直到谈到我真的玩过上百小时的游戏时,对游戏的细节错误百出。如果我没有这些游戏的真实体验,几乎不可能分辨真伪。一开始我还会想和真人讨论时一样指出它的错误,让它修正后重新发表观点。后来就渐渐放弃了从 AI 那里直接获得真知。把它当成一个比搜索引擎更方便的信息获取入口就够了。

但我依然偶尔被 AI 的总结惊艳到。比如说有一场主题为模拟类型游戏的话题,聊到最后 AI 总结:

成功的模拟游戏本质上是“熵增模拟器”。设计师的任务是制造一个不断趋向于“热寂”和“混乱”的世界(交通拥堵、热量堆积、资源耗尽、情感崩溃),而玩家的乐趣在于投入自己的脑力作为“负熵”,去建立一个脆弱但精密的秩序。

所以,在设计时,不要害怕设计“笨拙”的规则(如巡逻员、有体积的货车、会变质的食物)。这些“物理摩擦力”正是通往成就感的阶梯。如果没有这些混乱,游戏就只是一张算好了答案的报表。

要让玩家感到“战胜了混乱”而不是“被垃圾规则恶心”,需要遵循以下原则:确定性的混乱:混乱的原因必须是可回溯的。 提供“高级工具”来解决“基础问题”:游戏初期给玩家简单的工具去面对混乱,中后期给玩家更高级的逻辑工具。从“点”到“面”的连锁反应:混乱不应是孤立的错误,而应该是系统性的连锁反应。

我觉得在这种形而上的话题上,它讲得还是蛮有道理的。但我依然不觉得这些总结真的可以成为游戏设计的指导工具。

当然,让 AI 说什么依然极度依赖你对它说了什么。大多数时候 AI 会信誓旦旦的帮忙做一些具体设计(当我想设计卡牌驱动的游戏时),可我真的实体化这些卡片试玩后,完全玩不下去。我实在没信心再和它讨论这些卡片设计上的具体细节:真的不如我自己从零设计高效。但有时候,AI 也会对设计游戏这件事情有所畏缩,一旦强调我应该先用卡纸或在桌面模拟器中自己尝试做个原型试试。总之,让我感觉本质上它还是在跟着我的情绪走。完全没有独立思考的痕迹。

意识到这点,我现在更想把今天的 AI 当成一种更高级的知识搜索引擎。从这个角度看,这段时间 AI 的确给我推荐了不少不错的游戏。虽然我仔细玩过之后,这些游戏给我的体验并非完全符合 AI 的描述,我还是感谢 AI 让我挖掘出了它们。

尤其值得一提的是 AI 极力向我推荐的 dotAGE 这个游戏。我一口气玩了接近 160 小时。

其实它刚上 steam 的时候我就玩过 demo ,当时只是觉得还不错,但没有深入。前几天 AI 反复督促我要仔细玩一下,我才沉浸了进去。这是款回合制没有战斗的城市建设游戏。随机元素很少,几乎所有要面对的灾难,都在游戏规则下提前预示给玩家。玩家要做的就是提前规划每个回合的行动,通过精算赢得游戏。值得一提的是,游戏推荐的难度和最高难度给我的几乎是截然不同的游戏体验。在默认难度下,即使对游戏规则不甚了解,不需要精确规划,也能通过运气赢得游戏胜利。那种“Push Your Luck”机制驱动带来的胜利是一种相当刺激的游戏体验;但在最高难度下,游戏变得无法通过运气获胜,转而必须精密规划。而这种精算带来的又是另一种成就感。

在玩游戏之余,我又阅读了作者在游戏发售前夕于 reddit 上写的两篇文章,方才了解到这个游戏几乎是作者一人在攻读完游戏设计专业的博士学位后,独自花了 9 年时间制作出来的,颇为震撼。怪不得我在玩的时候感觉这个游戏要深度有深度要广度有广度,完全不像是能短期做出来的。只能说,设计出好的游戏真的很难。

死了么 - 官方正版惨遭下架,背后原因竟是ta!

背景

2026年初,独居安全类App“死了么”走红并登顶苹果AppStore付费榜。目前于今日,该App官方版从中国大陆区AppStore下架,其他地区上架状态正常。此次并非平台强制清榜,而是开发者主动操作,核心原因是App拟更名但未完成大陆地区备案手续,凸显移动应用合规运营的必要性。

企业微信20260115-123104.png

事件核心是更名与备案衔接缺失。“死了么”2025年6月上线,核心功能为“连续两日未签到自动通知紧急联系人”,但直白名称引发争议,还被质疑与“饿了么”近似侵权。2026年1月13日,团队官宣拟更名为全球化品牌“Demumu”,停用原中文主名称。

更名本是正常品牌调整,却因未同步更新大陆备案触规。根据工信部要求,大陆提供服务的App必须备案,名称等信息变更需申请备案变更,未完成备案的App不得上架分发。

“死了么”虽已完成初始备案,但更名后需提交材料申请变更备案,审核通过方可更新上架。因官宣到下架时间过短,备案流程未完成,为规避强制下架、罚款等更严重后果,团队选择主动下架大陆区版本,其他地区未受影响,并且已经顺利更名为“Demumu”。

值得注意的是,“Demumu”更名方案因用户质疑已撤回,团队正全网征集新名称。此次主动下架也为其梳理品牌与合规流程争取了时间,同时给全行业敲响警钟:移动互联网时代,合规是产品生存的前提。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 如何主动提防苹果3.2f的进攻,自查防御手册(代码篇)

# 如何主动提防苹果3.2f的进攻,自查防御手册(ASO篇)

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

在 iOS 26 上@property 的一个小 bug

前段时间阅读了iOS 26 你的 property 崩了吗? 这篇文章,当时没太注意,但是这一阵子我也遇到了类似的 bug 。刚好最近我重新阅读新出的 objc-950 的源码,正好阅读到了这一部分,也就顺便从源码的角度来分析一下这个 bug 以及如何解决这个问题。

objc_storeStrong 的修改

对比源码,很容易发现 objc_storeStrong 函数发生了修改。

老版本

void
objc_storeStrong(id *location, id obj)
{
    id prev = *location;
    if (obj == prev) {
        return;
    }
    objc_retain(obj);
    *location = obj;
    objc_release(prev);
}

新版本

void
objc_storeStrong(id *location, id obj)
{
    id prev = *location;
    if (obj == prev) {
        return;
    }

#if __INTPTR_WIDTH__ == 32
#define BAD_OBJECT ((id)0xbad0)
#else
#define BAD_OBJECT ((id)0x400000000000bad0)
#endif
    *(volatile id *)location = BAD_OBJECT;

    objc_retain(obj);
    *location = obj;
    objc_release(prev);
}

简单对比一下,非常明显。

在老版本里,步骤简单清楚:

  1. objc_retain(obj) ---- 引用计数 + 1
  2. *location = obj ---- 指针赋值
  3. objc_release(prev)---- 释放旧值

而在新版本中,写入了一个哨兵值 BAD_OBJECT ((id)0x400000000000bad0),当读取到它,向它发送消息或者访问时,程序会立即崩溃,且崩溃地址直接非常明显。

那么问题来了,为什么在新版本中加入了这一个必现且地址非常明显的崩溃呢?我们就要审视一下原本的逻辑有什么问题。

objc_retain 和 objc_release 的线程安全

先看一下 这两个函数的实现

__attribute__((always_inline))
static id _Nullable _objc_retain(id _Nullable obj) {
    if (_objc_isTaggedPointerOrNil(obj)) return obj;
    return obj->retain();
}
__attribute__((always_inline))
static void _objc_release(id _Nullable obj) {
    if (_objc_isTaggedPointerOrNil(obj)) return;
    return obj->release();
}
----------------------------------------------
简化代码
ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant) {
    isa_t oldisa;
    isa_t newisa;

    oldisa = LoadExclusive(&isa().bits);
    
    newisa = oldisa;
    newisa.extra_rc++; 
    
    
    slowpath(!StoreExclusive(&isa().bits, &oldisa.bits, newisa.bits)));

    return (id)this;
}
----------------------------------------------
ALWAYS_INLINE bool 
objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant)
{
    isa_t newisa, oldisa;

    oldisa = LoadExclusive(&isa().bits);

    newisa = oldisa;
    uintptr_t carry;
    newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry); 
        
    slowpath(!StoreExclusive(&isa().bits, &oldisa.bits, newisa.bits)));

}



在这里,runtime 使用 LoadExclusiveStoreExclusive 构成原子操作循环,保证线程安全,这是 CAS 操作。

  • CAS,即 Compare-And-Swap,这是 CPU 级别的原子指令,用于实现无锁同步,现代多线程编程中实现高性能无锁数据结构的基石。

可以看到,objc_retainobjc_release 本身就是线程安全的,但是仅限于内部实现

而在老版本的 objc_storeStrong 实现中,这潜伏着危险!

假设我们有这样一份代码:

@property (atomic, strong) NSObject *obj;

从最坏的角度,我们推演一下可能发生的问题:

self.obj 一开始指向 A

时间线 线程 A 线程 B 状态
1 id prev = *location; (读到 A)
2 id prev = *location; (读到 A)
3 objc_retain(B);
4 *location = B;
5 objc_retain(C);
6 *location = C;
7 objc_release(A); A 的 RC 变 0, A 被销毁
8 objc_release(A); CRASH!Boom!

两个线程都读到了同一个旧值 A,然后都认为自己有责任去释放它。A 被释放了两次。第二次释放时,访问了已经回收的内存,导致 Crash

还有另外一种情况,如果线程 A 正在赋值,线程 B 读取,线程 B 可能会读到旧对象。不过这个概率要比第一种情况要低,就不过多讨论。

是否可以在 objc_storeStrong 里面加锁?

这是一种解决的思路,但是很可惜不能这么实现。

道理很简单,objc_storeStrong 是一个极其高频调用的函数。如果在这里加锁,性能会下降数个数量级。并且对于大多数情况下,其实并不怎么需要保证线程安全,比如说一个局部变量单次赋值,或者主线程 UI 刷新;所以 runtime 将这个处理并发安全的责任交给了开发者来处理。

但是很可惜的是,对于大多数开发者(包括我在内),一般情况下考虑不到这里;并且这种崩溃的发生概率实际上是非常低的,即使发生了,也很难通过崩溃记录想到这里。

所以在最新版本里,官方事实上没有从系统的角度解决这个问题,而是通过插入哨兵值,让这种非原子的并发访问行为必然导致 Crash ,从而暴露问题。

该如何解决

对于这个问题,有以下几种方法来解决:

修改原代码

既然问题根源是“多线程读写非线程安全的属性”,那么解决方案就是保证线程安全:

  1. 加锁(强烈推荐) : 在读写该属性时,使用 os_unfair_lockpthread_mutexNSLock 保护。

    os_unfair_lock_lock(&_lock);
    self.obj = newItem;
    os_unfair_lock_unlock(&_lock);
    
  2. 使用 atomic 属性 :

    @property (atomic, strongNSObject *obj;
    

    虽然 atomic 性能略低,但它保证了 getter/setter 的原子性,绝对不会读到 BAD_OBJECT 。但是仅限于读写这一瞬间的并发安全,它远远不等同于“线程安全”。如果牵扯进复杂操作,依然无法保证线程安全。

  3. 串行队列 : 将对该属性的所有读写操作都放入同一个串行队列(如主队列或自定义队列)中执行。

Hook objc_storeStrong

既然问题的核心是 “新版 objc_storeStrong 写入了哨兵值导致崩溃” ,那么兜底方案就是 “把 objc_storeStrong 还原回旧版本的实现” 。

这里我们就要引入 fishhook 了。

创建一个新文件,代码如下:

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import "fishhook.h" 

OBJC_EXPORT id objc_retain(id);
OBJC_EXPORT void objc_release(id);

// 保存原始函数的指针
static void (*orig_objc_storeStrong)(id *location, id obj);

void safe_storeStrong(id *location, id obj) {
    id prev = *location;
    if (obj == prev) {
        return;
    }
    objc_retain(obj);   
    *location = obj;    
    objc_release(prev); 
}


// 启动时自动 Hook

__attribute__((constructor))
static void installSafeStoreStrongHook() {
    // 使用 fishhook 重绑定符号
    struct rebinding storeStrongRebinding;
    storeStrongRebinding.name = "objc_storeStrong";
    storeStrongRebinding.replacement = (void *)safe_storeStrong;
    storeStrongRebinding.replaced = (void **)&orig_objc_storeStrong;
    
    struct rebinding rebinds[] = { storeStrongRebinding };
    
    // 执行重绑定
    rebind_symbols(rebinds, 1);
    
    NSLog(@"[SafeStoreStrong] 已成功 Hook objc_storeStrong,降级为旧版无哨兵模式。");
}

这个方案可以作为你的“急救包”,让你有时间慢慢去重构代码,而不是因为一个系统更新就让 App 瘫痪。但是我们依然要明白,这个方案 没有修复线程安全问题 。它只是把“必现的崩溃”变回了“偶现的崩溃”

我们可以写一个测试用例:

@interface ViewController ()

// 必须是 nonatomic 且 strong 才能触发 objc_storeStrong
@property (nonatomic, strong) NSString *targetString;
@property (nonatomic, assign) BOOL stopTest;

@end
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.stopTest = NO;
    self.targetString = @"objc_storeStrong_test";
    
    NSLog(@"开始并发读写测试...");
    NSLog(@"如果 Hook 生效,App 应该能坚持运行一段时间,然后崩溃");

    // 线程 1: 疯狂写
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
        int i = 0;
        while (!self.stopTest) {
            // 构造新字符串,确保是堆对象
            self.targetString = [NSString stringWithFormat:@"Value-%d", i++];
        }
    });
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
        while (!self.stopTest) {
            NSString *str = self.targetString;
            if (str.length > 0) {
                // 模拟使用对象
                // NSLog(@"Read: %@", str); // 打印太快会卡死控制台,这里仅访问属性
            }
        }
    });
}

总结

objc_storeStrong 在旧版本是线程不安全的,本质在于: “读取旧值、更新指针、释放旧值”这三个步骤不是一个原子事务 。新版本(objc-950)的改动并没有解决这个非原子问题,而是通过插入哨兵值,让这种非原子的并发访问行为 必然导致 Crash ,从而暴露问题。

2025年总结

TODO

一如既往,主要在工具链领域耕耘。但由于工作忙碌在opensource社区投入的时间减少了。

Blogging

不包括这篇总结,一共写了18篇文章。

llvm-project

  • 翻新了integrated assembler,写了4篇相关的blog posts: https://maskray.me/blog/tags/assembler/

  • Reviewednumerous patches. queryis:pr created:>2025-01-01 reviewed-by:MaskRay => "989Closed"

Linux kernel

贡献了两个commits,被引用了一次。

ccls

  • clang.prependArgs
  • 支持了LLVM 21和22

ELF specification

尝试推进compactsection header table,没有取得共识。 一些成员希望采用generalcompression (likezstd)的方式,像SHF_COMPRESSED那样压缩section headertable。包括我在内的另一些人不喜欢采用general compression。

Misc

Reported 6 feature requests or bugs to binutils.

  • ld --build-id does not use symtab/strtab content
  • gas: monolithic .sframe violates COMDAT group rule
  • gas: Clarify whitespace between a label's symbol and its colon
  • ld: Add --print-gc-sections=file
  • ld riscv: Relocatable linking challenge with R_RISCV_ALIGN
  • ld: add --why-live

旅行

  • 第一次去:台南、西安、兰州、天水、Sacramento、Puerto Vallarta,Jalisco, Mexico、Mazatlán, Sinaloa, Mexico
  • 曾经去过:台北(上一次是近11年前)、北京

Frida Hook 流程

一、整体流程总览(先建立全局认知)

Frida Hook = 三件事同时成立

① 手机上运行 frida-server

② 电脑上安装 frida / frida-tools

③ 电脑 → 通过 USB / WiFi attach 或 spawn App

二、手机端:安装 & 运行 frida-server(最关键)

把 frida-server.deb 传到手机

下载地址:github.com/frida/frida…

scp frida-server-17.5.2-ios-arm64.deb mobile@192.168.1.11:/private/var/tmp/

发送前要建立与手机的连接

ssh mobile@192.168.1.11

192.168.1.11是你的手机IP

这时需要输入密码

mobile@192.168.1.11's password:

输入root用户的密码,

Dopamine 默认:alpine,如果不对可以去Dopamine修改成你自己的密码

如果成功,在终端上能看到:(越狱手机可以下载一个终端Terminal)

ls /private/var/tmp | grep frida

在手机上安装 frida-server

su
dpkg -i /private/var/tmp/frida-server-17.5.2-ios-arm64.deb

确认安装成功:

which frida-server
# 输出:
/var/jb/usr/sbin/frida-server

启动 frida-server(⚠️ 必须)

/var/jb/usr/sbin/frida-server &

验证是否成功:

ps -ef | grep frida

你应该看到类似:

root   xxxx   /var/jb/usr/sbin/frida-server

确认端口监听(27042)

netstat -an | grep 27042

如果有:

127.0.0.1.27042 LISTEN

三、电脑端:安装 Frida 工具

1️⃣ 确认 Python 可用

python3 --version

2️⃣ 安装 frida & frida-tools(用户级,避免权限坑)

python3 -m pip install --user frida frida-tools

如果 frida 命令找不到:

echo 'export PATH="$HOME/Library/Python/3.x/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

3️⃣ 验证 Frida 版本

frida --version

👉 必须与你手机上的 frida-server 主版本一致(17.x)

4️⃣ 验证能看到手机

frida-ps -U

如果你能看到 App 列表:

PID   Name     Identifier
30311 0.test   com.xxxx.test

👉 电脑端 OK

四、准备 Hook 的 JS 脚本(改变工程中isJailbroken的返回值)

if (ObjC.available) {
    console.log("[*] ObjC runtime available");

    var targetMethod = null;

    ObjC.enumerateLoadedClasses({
        onMatch: function (className, methods) {
            if (methods.indexOf("+ isJailbroken") !== -1) {
                try {
                    var cls = ObjC.classes[className];
                    var method = cls["+ isJailbroken"];
                    if (method) {
                        targetMethod = method;
                        console.log("[+] Found:", className, "+ isJailbroken");
                    }
                } catch (e) {}
            }
        },
        onComplete: function () {
            if (!targetMethod) {
                console.log("[-] isJailbroken not found");
                return;
            }

            Interceptor.attach(
                targetMethod.implementation,
                {
                    onLeave: function (retval) {
                        console.log("[*] Original:", retval.toInt32());
                        retval.replace(0); // NO
                        console.log("[*] Bypassed -> NO");
                    }
                }
            );
        }
    });
} else {
    console.log("[-] ObjC runtime not available");
}

五、Hook App

✅ 方式 :Attach

1️⃣ 先手动在手机上打开 App

确保 App 正在前台运行。

2️⃣ Mac 终端执行:

frida -U -n com.compass.--test -l bypass_jailbreak.js

看到:

[*] ObjC runtime available
[+] Found: XXSecurity + isJailbroken

👉 Hook 成功

六、验证 Hook 是否生效

在 App 里触发:

[Security isJailbroken]

Frida 输出:

[*] Original: 1
[*] Bypassed -> NO

👉 App 逻辑被你成功欺骗

黑白厨师

这几天一口气刷完了 Netflix 出品的《黑白厨师》,感触颇深。没想到 Cooking 也能套上《鱿鱼游戏》的外壳:极简的规则,极端的赌注,有限的时间,封闭系统内的零和博弈。

在看之前,我对「温馨的烹饪」能否与「大逃杀美学」兼容是非常存疑的。但看完后不得不佩服,Netflix 不仅处理得很好,还在这场残酷的阶级叙事中,端出了一盘关于「人性」与「身份」的顶级料理。

机制:绝对真空里的「强制公平」

如果把《黑白厨师》看作一部韩剧,「机制」就是它最精彩的剧本。烹饪综艺最大的痛点在于:味觉是主观的,如何保证结果的公正与说服力?

节目组做了一个极其大胆的设定——蒙眼试吃

当白钟元和安成宰蒙上眼睛,张嘴等待喂食时,这画面不仅充满了某种荒诞的宗教仪式感,更是一种暴力的强制公平。它强行抹去了「白汤匙」积累数十年的名声红利,把米其林三星主厨和外卖店老板拉到了同一条起跑线(味蕾)上。这种「在一个不公平的世界里创造一个残酷但公平的真空」,正是大逃杀题材最迷人的地方。

评委的「双璧」设定,则是机制中另一个神来之笔。白钟元代表着「大众的味蕾」与「商业的敏锐」,追求直觉的爽感;安成宰则代表「精英的标准」与「技术的严苛」,追求意图的精准。两人的争论,实际上是将「好吃究竟有没有标准」这一哲学命题具象化了。这种价值观的碰撞,也是一大看点。

场景:感官的高压工厂

Netflix 的综艺有一种你一看就知道是「Netflix 出品」的质感。巨大的仓库、整齐划一的 40 个烹饪台、冰冷的不锈钢,当 40 名黑汤匙同时开火,那个画面不像厨房,更像是一个高压工厂战场。为了满足 4K/HDR 的严苛画质,灯光采用了电影级的布光,甚至连声音设计都做到了极致——备菜的切剁声、炉火的轰鸣声,营造出一种 ASMR 般的沉浸感(一种让人头皮发麻、脊背发凉但又感到极度舒适和放松的生理反应)。这种极致的物理压迫感,会把屏幕前的观众也拉进去(所以一定要看高清的)。

玩家:艺术家与谋略家

如果说机制如剧本,厨师就是演员。除了对「厨艺」和「哲学」的考核,这档节目更深层地展现了「生存博弈」

这就不得不提崔铉硕主厨。如果说其他人是在比赛做菜,那么崔铉硕更像是在「玩游戏」。在团队海鲜战中,他疯狂囤积扇贝让对手无材可用;在餐厅经营战中,他制定超高价策略,以极少的出餐量换取了最高的营业额。他敏锐地捕捉到了现代餐饮的残酷真相:厨艺好不等于会经营,商业头脑往往决定生死。

他的存在,打破了传统厨师的刻板印象,为节目注入了《鱿鱼游戏》的智斗感。

当然,这也是一个群像极佳的舞台。制作组显然精心挑选了那些拥有「鲜活、粗糙且充满生命力故事」的人。如果没有这些故事,这就是一场单纯的技艺展示;有了这些故事,菜品就有了灵魂。

核心人物:诗意与狂傲

最终的决战也是我心目中最好的结局,两位风格迥异的厨师,分别代表了烹饪的两个极端。

Edward Lee:寻找归途的诗人

看到 Edward Lee 的第一眼,就感觉到一种不一样的气质:说话不疾不徐,有一种淡淡的诗意。即使年过半百,依然像个少年一样在寻求突破。

对于他而言,这不仅仅是比赛,更是一场「身份认同的寻根之旅」。作为一个在美国长大的韩裔,他的料理(如拌饭口味的冰淇淋)本身就在挑战「正统韩餐」的定义。决赛那道「辣炒年糕点心」是整季的高光时刻。当他用不熟练的韩文念出那封信,讲述「Edward 喜欢威士忌,但李均(他的韩文名)喝玛格丽」时,那种异乡人的孤独与对故土的深情,瞬间把我击穿。

那不勒斯美味黑手党(权圣晙):专注的狂徒

如果 Edward 是水,权圣晙就是火。他身上体现的是年轻人的自信、狂傲,以及极致的专注。

在败者复活赛中,当所有人都在做咸口菜时,他独辟蹊径选择做甜品「栗子提拉米苏」。为了防止食材被拿走,他守在冷柜前啃巧克力的画面,有一种「认真的拙劲」。他选择 Edward Lee 战队时的果断,以及决赛前的放狠话环节,都展现了他极强的策略性和胜负欲:

“爱德华主厨,为了让你早点回家休息,今天我会速战速决。”

这种狂傲并不让人讨厌,因为他有与之匹配的实力。


我有时也会切换视角:假如自己是 Netflix 的决策者,面对这样一个项目,如何确保「基本能回本」(保底能力),同时又可能挣很多(其实就是价值投资)?对于白汤匙、黑汤匙、白钟元、安成宰,他们决定参与的动机是什么?杠杆点在哪儿?如果最后项目失败了,最可能是哪些地方出了问题?这样的思考也充满了乐趣。

ET,福尔摩斯,马尔科维奇,爱迪生:四种思维训练法

关于好奇心的重要性,怎么强调都不为过。尤其是在工作了一段时间之后,好奇心往往最先被消磨:流程变得熟悉、问题开始重复、注意力被琐碎事务和压力不断切割,慢慢地,我们便不再追问「为什么」。

为了对抗这种精神熵增,我总结了一套简单易行的思维训练法。通过四种「角色扮演」模式,强制切换视角,外加一个通用框架作为辅助工具,帮助我们找回对世界的敏锐度。


1. ET 模式(外星人视角):对抗「习以为常」

核心理念:去熟悉化(Vuja De)

我们常说 Déjà vu(既视感),即对陌生环境感到熟悉;而 ET 模式追求的是完全相反的状态——Vuja De(未视感)。即:面对最熟悉的事物,强迫自己把它当成第一次见到,甚至完全不理解其用途。

  • 「火星人观察报告」:尝试描述一件日常小事,但不使用约定俗成的名词。以「开会」为例,如果剥离掉「会议」这个概念,在 ET 眼中看到的是:一群碳基生物围坐在一张木板旁,地位最高的雄性发出声波,其余低阶生物低头在发光的玻璃板上快速移动手指。洞察:这种视角的价值在于剥离了社会强加给事物的「功能固着」。当不再把「低效的会议」理所当然地看作「工作流程」,才更有可能发现其本质——比如「信息传递效率极低」,进而思考:为什么不直接进行脑电波传输(发文档)?
  • 「为什么追问链」:因为 ET 从没见过地球的物品,所以一切都值得质疑。顺着这个逻辑链条深挖:为什么手机屏幕是长方形的?(为了适应手掌抓握);为什么一定要手持?(因为要随时观看);为什么一定要用眼睛看?(目前的信息交互受限于视觉)。这种像孩子一样的连续追问(比如我小时就很好奇,为什么大人们打招呼通常都是「饭吃了吗」),往往能带我们穿透表象,触达事物的底层逻辑或生理极限。

2. 福尔摩斯模式(侦探视角):对抗「视而不见」

核心理念:观察而非仅仅「看见」

福尔摩斯有一句名言:「你只是在看,你没有在观察。」 (You see, but you do not observe.) 这个模式要求我们将模糊的现状清晰化,寻找因果链条和逻辑漏洞。

  • 从废弃中寻找线索:最近在看《黑白大厨》时,注意到一个细节:评审白钟元注意到角落里有一碟被回收的、只吃了一半的牛肉。其他选手都没有注意到,他还拿起其中一块没被吃过的牛肉品尝,发现牛肉过于干柴。 这就是侦探视角——从被忽略的细节(剩菜)中反推过程(烹饪失误),进而挖掘出被掩盖的真相。
  • 关注「沉默的证据」:除了看到的,还要关注没发生的。比如在福尔摩斯的《银色马》一案中,关键线索是「那只在晚上没有叫的狗」。因为狗没叫,所以牵走马的人一定是熟人。在工作中,如果能注意到「谁没有发声」、「哪个数据没有变化」,有时能发现比喧嚣表面更重要的信息。

3. 马尔科维奇模式(体验视角):对抗「自我中心」

核心理念:深度沉浸与换位思考

概念源自电影《成为马尔科维奇》,主角通过一道暗门能直接进入马尔科维奇的大脑,透过他的眼睛看世界。在生活中,这个模式几乎随处可用。

比如在咖啡馆里,可以尝试切换视角:

  • 作为店员:

    • 为什么选择这家店?
    • 需要哪些技能?
    • 如果跳槽,可能会去哪里?
  • 作为老板:

    • 为什么选址在这里?
    • 与周围咖啡馆的差异是什么?
    • 收入、成本、利润结构大概如何?
    • 哪些地方还有改进空间?

看剧时同样适用。比如:如果我是《绝命毒师》里的老白,在被 Tuco 掳走、Tuco 又被杀之后,该如何解释自己的失踪,既合情合理,又不引起怀疑?

4. 爱迪生模式(实验视角):对抗「光想不做」

核心理念:假设与验证

爱迪生代表的是实干派与实验精神。当对某个现象产生好奇,比如「为什么这类小红书帖子会火?」不只停留在分析。试着提出假设(可能是封面图夸张,也可能是标题引发焦虑),然后设计一个低成本的实验——发几篇不同风格的帖子去验证你的假设。在产品领域,这就是先做 Demo 验证可行性。唯有实验,才能将好奇心转化为确定的认知。

一个通用框架:3W2H

最后分享一个我自己经常使用的框架:3W2H。它是在黄金圈法则(Why–How–What)基础上的扩展,更适合日常思考。

以「电视」这个习以为常的物品为例:

  • What(本质):它是什么?是显示屏,还是家庭娱乐中心?
  • Why(根源):为什么我们需要电视?是为了获取信息,还是为了背景噪音?
  • How(机制):它的运作原理和组成是什么?
  • How much(量化):它的价格构成是怎样的?覆盖了多少人群?
  • What if(假设):如果世界上没有电视会怎样?有完美的替代品吗?

这套组合拳能迅速将一个单薄的概念拆解得立体而丰满,在短时间内建立对陌生领域的深度认知。


好奇心不仅是一种能力,更是一种对抗平庸的武器。当我们开启 ET 的眼睛,用福尔摩斯的大脑思考,钻进马尔科维奇的躯壳,并像爱迪生一样去动手实验时,原本枯燥乏味的世界就会立刻生动起来。

世界没有变,变的是我们看待世界的分辨率。希望这四种模式和工具,能帮你擦亮积灰的镜头,重新发现那个充满惊奇的「新世界」。

Capacitor + React 的 iOS 侧滑返回手势

Capacitor + React 的 iOS 侧滑返回手势

适用对象:用 Capacitor 把 React SPA 打包成 iOS App,二级页面希望支持系统左侧边缘右滑返回(侧滑返回手势)。

问题的背景

在 WKWebView(Capacitor iOS 容器)里跑 React Router 这类 SPA,经常会遇到:

  • 二级页面无法侧滑返回;
  • 能侧滑但上级页面预览是白屏/黑屏(看不到上一页内容);
  • 自己实现手势(截图 + 叠层 + history.back())会引入大量时序/兼容性坑。

解决的结论:优先用系统能力,而不是自研手势

核心思路:让 WebView 直接启用 WebKit 自带的交互式返回手势。

在 iOS 上,这个开关就是:

webView.allowsBackForwardNavigationGestures = true

这比“自研 edge-pan + snapshot”可靠得多:交互曲线、阈值、上一页预览快照、渲染时机都由系统处理。

最小可用改动(3 步)

下面这 3 步就是本次修复能通过验收的关键(其余优化都可以后放)。

1) 用自定义 CAPBridgeViewController 子类接管 WKWebView 配置

文件:ios/App/App/AppDelegate.swift

  • 新增 AppViewController: CAPBridgeViewController
  • viewDidLoad() 里统一设置背景色(减少“闪白/闪黑”)并启用系统手势:
@objc(AppViewController)
class AppViewController: CAPBridgeViewController {
  override func viewDidLoad() {
    super.viewDidLoad()

    // 设置背景(防止任何空白闪现)
    self.view.backgroundColor = UIColor.systemBackground
    self.webView?.isOpaque = true
    self.webView?.backgroundColor = UIColor.systemBackground
    self.webView?.scrollView.backgroundColor = UIColor.systemBackground

    // 永久启用系统原生侧滑返回手势(最简单、最可靠)
    self.webView?.allowsBackForwardNavigationGestures = true
  }
}

2) storyboard 把初始 VC 指向你的自定义 VC(否则上一步永远不会生效)

文件:ios/App/App/Base.lproj/Main.storyboard

把初始控制器从 Capacitor 的 CAPBridgeViewController 改成你自己的:

  • customClass="AppViewController"
  • customModule="App"

完整示例:

<viewController id="BYZ-38-t0r" customClass="AppViewController" customModule="App" sceneMemberID="viewController"/>

3) Web 侧配合(强烈建议):禁用浏览器自动滚动恢复(减少“返回预览白屏/错位”)

文件:src/App.tsx

useEffect(() => {
  if ('scrollRestoration' in history) {
    history.scrollRestoration = 'manual'
  }
}, [])

原因很简单:系统侧滑返回预览依赖 WebKit 的历史快照,但 SPA 在返回时的“自动滚动恢复”会让快照捕获到不一致/空白的中间态(尤其是列表页、滚动后返回最明显)。

Web 端跳转配置的配合(建议)

  • Tab 根页面之间(例如首页/我的)建议用 replace,避免把 Tab 切换写入回退栈,出现“Tab 之间也能侧滑回退”的反直觉体验。
    • <Link to="/home" replace />navigate('/home', { replace: true })
  • 二级页面(例如设置/资料)保持默认的 push,让它进入历史栈,从而可被系统侧滑返回。

一句话:该 push 的 push,该 replace 的 replace。这决定了 iOS 系统手势到底会回到哪里。

经验与踩坑

  • 优先使用系统 API:自研手势要处理快照时机、渲染竞态、手势冲突、webView.isHidden 黑屏等问题,成本和风险都很高。
  • Native 与 Web 必须联动:WKWebView 的历史栈 + SPA 的路由栈 + 滚动恢复,是同一个系统。
  • 真机 + 滚动场景必测:很多“预览白屏”只有在页面滚动后才会暴露。
  • 背景色是兜底不是解法:它只能减少“闪一下”的主观感受,核心仍是让系统正确拿到历史快照。

keywords

Capacitor iOS swipe back、WKWebView allowsBackForwardNavigationGestures、React Router iOS 手势返回、history.scrollRestoration manual、CAPBridgeViewController 自定义、iOS WebView 返回白屏、Capacitor 返回手势

Swift 6.2 列传(第十七篇):钟灵的“雷电蟒”与测试附件

在这里插入图片描述

摘要:一个失败的测试报告,如果只写着“某某参数错误”,那和一张“失物招领”有什么区别?Swift Testing 新增的 Attachments(附件)功能(ST-0009),就像是给失败的测试现场,打上了一个**“现场取证包”**,直接将调试日志和关键数据附着在报告上,让 Bug 无所遁形。

0️⃣ 🐼 序章:万劫谷的“失灵”现场

万劫谷,一个充满了陷阱和奇门遁甲的虚拟测试环境。

大熊猫侯佩正在谷中寻找他那四根宝贝竹笋的踪迹(它们现在安全地储存在 InlineArray 里),一路上他习惯性地摸了摸头顶,确认自己的黑毛依然茂密,头绝对不秃

他身边站着一个活泼可爱的绿衫少女,正是钟灵。钟灵的特点是天真烂漫,喜欢饲养各种“小宠物”,尤其是她那条能放出电流的“雷电蟒”(现在是她编写的 Character Struct 数据模型)。

在这里插入图片描述

“侯大哥,你看!”钟灵指着屏幕上的测试报告,气得直跺脚,“我的测试又失败了!它明明应该生成一个名为 Rem 的角色,结果生成了 Ram!报告上只写了预期值和实际值,可是这个失败的 Ram 角色内部状态到底是什么?它的 UUID 是多少?我完全不知道!”

侯佩叹了口气:“这就是测试的黑箱困境。失败的报告,就像是你看到一只断了腿的兔子,但不知道它是在哪条路上、被谁咬伤的。我们得找到现场遗留的物证。”

在本次大冒险中,您将学到如下内容:

  • 0️⃣ 🐼 序章:万劫谷的“失灵”现场
  • 1️⃣ 📦 驯服数据:Attachable 协议的契约
  • 2️⃣ 📝 现场取证:Attachment.record() 的铁律
  • 3️⃣ 🚧 侠客的遗憾:现阶段的不足
  • 4️⃣ 🐼 尾声:条件判断的“外功”与“内力”

钟灵急道:“对!我要把我的‘雷电蟒’(数据模型)的全部信息,直接打包塞进这个失败报告里!(Swift Testing: Attachments)”

在这里插入图片描述


1️⃣ 📦 驯服数据:Attachable 协议的契约

要让数据能够被 Swift Testing 系统识别并打包,它必须遵守新的“江湖规矩”:Attachable 协议。

侯佩指导钟灵,将她那可爱的“雷电蟒”数据模型进行武装升级:

import Foundation
import Testing 

// 钟灵的角色结构体,它就是那条“雷电蟒”
struct Character: Codable, Attachable { 
    var id = UUID() // 关键的内部状态,比如角色的唯一标识符
    var name: String
}

在这里插入图片描述

侯佩解释道:“Attachable 协议就像是你给你的宠物签订了一份‘随行契约’。只要有了这个契约,系统就知道在关键时刻,应该如何‘捕捉’和‘打包’它。”

🔑 技术关键点:Codable 的加持 注意,这个 Character 结构体不仅遵循了 Attachable,还遵循了 Codable。对于像结构体这样的自定义数据类型,Swift Testing 会利用 Codable 的能力,将其实例自动编码DataString 格式,然后再进行附加。这样才能确保数据是有头有脸、完整地出现在报告中。

2️⃣ 📝 现场取证:Attachment.record() 的铁律

在这里插入图片描述

现在,钟灵只需要在她的生产代码(Production Code)中生成她的角色:

// 生产代码:生成一个新角色
func makeCharacter() -> Character {
    // 默认生成一个名叫 "Ram" 的角色
    Character(name: "Ram")
}

然后,在测试代码中,无论测试是成功还是失败,她都要确保这个角色的所有状态,都被系统记录下来:

@Test func defaultCharacterNameIsCorrect() {
    let result = makeCharacter()
    
    // 💔 测试失败断言:预期 Rem,实际 Ram
    #expect(result.name == "Rem") 

    // 🎒 关键步骤:记录附件!
    // 将整个 result 实例附着到本次测试结果中,并命名为 "Character"
    Attachment.record(result, named: "Character") 
}

“太神了!”钟灵惊呼道,“当这个测试运行失败时,Xcode 就会自动将这个 result 实例的 JSON 编码数据,直接显示在测试报告的旁边!我一眼就能看到这个失败的 Ram 角色的 UUID 是多少,它的内部状态是不是被某个毒药(Bug)污染了!”

在这里插入图片描述

侯佩点头:“这就叫 ‘证据确凿’。以前你只能 望洋兴叹,现在你可以 一目了然。”

3️⃣ 🚧 侠客的遗憾:现阶段的不足

侯佩作为精通技术的工程师,也指出了这一功能在 Swift 6.2 版本的些许遗憾。

  • 🚫 图像缺失症: “目前,Swift Testing 尚不支持附加图像(Image),”侯佩遗憾地说,“这就像你抓到了一个间谍,却不让你拍下他的照片。如果我做 SwiftUI 界面测试,失败了却不能附上截图,那会让人非常抓狂。”
  • ♻️ 生命周期控制的缺席: “另一个遗憾是,它不像 XCTest 的同类功能那样,支持生命周期控制(Lifetime Controls)。”

“生命周期控制是什么?”钟灵好奇地问。

在这里插入图片描述

“就是如果你的测试成功了,系统可以自动删除你附加的这些日志文件和数据。这样可以保持测试环境的轻量化。现在嘛,你成功了,这些文件还是会留在那,徒增烦恼。”

4️⃣ 🐼 尾声:条件判断的“外功”与“内力”

在这里插入图片描述

解决了附件问题,钟灵的测试调试效率提升了百倍。但她很快又遇到了新的困惑。

“侯大哥,我的宠物‘雷电蟒’需要在不同的硬件环境(例如 M1 芯片和 Intel 芯片)上运行不同的代码。Swift Testing 有一个很方便的功能叫做 ConditionTrait,可以用来定义‘只在 M1 上运行’的测试条件。”

在这里插入图片描述

侯佩点头:“是的,ConditionTrait 是测试的‘内功’,决定测试是否应该被执行。”

钟灵苦恼道:“但是,我能不能在非测试函数(Non-test function),比如我的生产代码里,也引用和判断这个‘内功’?比如,我想写一段普通的函数,判断‘我现在是不是在 M1 芯片上运行?’,并根据结果调整代码逻辑。”

在这里插入图片描述

侯佩眼中闪过一丝精光,他知道,钟灵提出的需求,已经触及到了 Swift Testing 的深层奥秘。

“钟灵姑娘,你提出了一个跨越测试与生产代码边界的哲学问题。你需要的不是附件,而是将测试的‘内功心法’,转化为人人可用的‘外功’招式。”

在这里插入图片描述

(欲知后事如何,且看下回分解:Swift Testing: Public API to evaluate ConditionTrait —— 如何在普通函数中,运用测试框架的‘条件判断’心法。)

在这里插入图片描述

App 暴毙现场直击:如何用 MetricKit 写一份完美的“验尸报告”

在这里插入图片描述

引子

在新深圳(Neo-Shenzhen)第 42 区阴雨连绵的夜晚,王代码(Old Wang)坐在全息屏幕前,手里捏着半截早已熄灭的合成烟草。作为一名在赛博空间摸爬滚打二十年的“数字清道夫”,他见过各种各样的 App 暴毙现场。

“又是 OOM(内存溢出)?”旁边的全息 AI 助手艾达(Ada)一边修剪着并不存在的指甲,一边冷嘲热讽,“你的代码就像这该死的天气一样,总是漏个不停。”

王代码没有理会她的挖苦,只是死死盯着那个被称为 Xcode Organizer 的官方监控面板。它就像个只会打官腔的衙门老头,告诉你结果,却永远不告诉你原因。

在这里插入图片描述

“这老东西只告诉我 App 死了,”王代码指着屏幕上毫无生气的图表骂道,“却不告诉我它是怎么死的。是被系统暗杀了?还是自己吃太饱撑死的?Xcode Organizer 简直就是个‘庸医’。”

在本篇文章中,您将学到如下内容:

  • 引子
  • 🕵️‍♂️ 第 1 幕:告别那个只会报丧的 Xcode Organizer
  • 🧱 第 2 幕:搭建秘密情报网
  • 🩸 第 3 幕:植入间谍(AppDelegate 集成)
  • 💀 第 4 幕:解读死因(Payload 的奥秘)
  • ⏳ 第 5 幕:耐心的猎人
  • 🎬 终章:真相大白

要想在这个代码丛林里活下去,光靠那个“庸医”是不够的。王代码从加密硬盘里掏出了他的秘密武器——MetricKit

“看来,我们得给自己找点更猛的药了。”

在这里插入图片描述


🕵️‍♂️ 第 1 幕:告别那个只会报丧的 Xcode Organizer

我们要承认,Xcode Organizer 确实提供了不少有用的情报:Crashes(崩溃)、Energy Impact(电量消耗)、Hangs(卡顿)、Launch Time(启动时间)、Memory Consumption(内存消耗)以及 App Terminations(App 终止)。

在这里插入图片描述

但是,它就像是那个只会在案发现场画白线的警察,对于某些棘手案件——特别是 App Terminations(App 莫名其妙被杀掉),它总是显得“智商捉急”。它能告诉你 App 挂了,但无法提供足够的细节来破案。

在这里插入图片描述

为了不让我们的 App 死不瞑目,Apple 上帝发了慈悲,赐予我们 MetricKit 框架。这玩意儿就像是法医手里的解剖刀,能让我们收集全面的诊断数据,构建一个详尽的“验尸报告”仪表盘。


🧱 第 2 幕:搭建秘密情报网

“要抓鬼,先得撒网。”王代码一边敲击键盘,一边嘟囔。

监控 App 性能的最直观方法,就是收集数据并将其导出以供分析。我们不能指望系统自动把凶手送到面前,我们得建立自己的 Analytics(分析)协议。

protocol Analytics {
    // 记录普通事件,比如“这破 App 又重启了”
    func logEvent(_ name: String, value: String)
    // 记录崩溃详情,这是法医鉴定的关键
    func logCrash(_ crash: MXCrashDiagnostic)
}

接下来,我们需要引入 MetricKit 并签署一份“灵魂契约”——设置订阅以接收数据。

在这里插入图片描述


🩸 第 3 幕:植入间谍(AppDelegate 集成)

王代码熟练地在 AppDelegate 中植入了监听器。这就像是在系统的血管里装了一个纳米机器人。

// 别忘了继承 MXMetricManagerSubscriber,这是入场券
final class AppDelegate: NSObject, UIApplicationDelegate, MXMetricManagerSubscriber {
    private var analytics: Analytics?

    func applicationDidFinishLaunching(_ application: UIApplication) {
        // 向组织(MXMetricManager)注册自己,有消息第一时间通知我
        MXMetricManager.shared.add(self)
    }

    // 重点来了:这是系统把“尸检报告”丢给你的时候
    // 注意:这个方法是非隔离的 (nonisolated),因为它可能在任意线程被调用
    nonisolated func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            // 让我们看看它是怎么退出的... 
            // applicationExitMetrics 是关键证据
            if let exitMetrics = payload.applicationExitMetrics?.backgroundExitData {
                
                // 异常退出计数:是不是有什么不可告人的秘密?
                analytics?.logEvent(
                    "performance_abnormal_exit",
                    value: exitMetrics.cumulativeAbnormalExitCount.formatted()
                )
                
                // CPU 资源超限:是不是算力过载,脑子烧坏了?
                analytics?.logEvent(
                    "performance_cpu_exit",
                    value: exitMetrics.cumulativeCPUResourceLimitExitCount.formatted()
                )
                    
                // 内存压力退出:这就是传说中的“被系统嫌弃占地儿太大而清理门户”
                analytics?.logEvent(
                    "performance_memory_exit",
                    value: exitMetrics.cumulativeMemoryPressureExitCount.formatted()
                )
                
                // OOM(内存资源限制)退出:吃得太多,直接撑死
                analytics?.logEvent(
                    "performance_oom_exit",
                    value: exitMetrics.cumulativeMemoryResourceLimitExitCount.formatted()
                )
            }
        }
    }

    // 这里接收的是诊断信息,比上面的指标更硬核
    nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            // 如果有崩溃诊断信息
            if let crashes = payload.crashDiagnostics {
                for crash in crashes {
                    // 把崩溃现场记录在案
                    analytics?.logCrash(crash)
                }
            }
        }
    }
}

“看到了吗,艾达?”王代码指着屏幕上的 applicationExitMetrics,“这才是我们要的真相。”

在这里插入图片描述

技术扩展说明: 如代码所示,我们利用 MXMetricManager 的共享实例来添加订阅者。我们的 AppDelegate 必须遵守 MXMetricManagerSubscriber 协议。这个协议提供了两个可选的“接收器”函数,让我们能够分别捕获 metrics(指标)和 diagnostics(诊断)。

在这里插入图片描述


💀 第 4 幕:解读死因(Payload 的奥秘)

艾达投影出一道蓝光,扫描着数据结构:“这两个 Payload 看起来很有料。”

MXMetricPayload 类型包含了一系列扩展自 MXMetric 抽象类的属性。其中最让王代码兴奋的是 applicationLaunchMetrics(应用启动指标)和 applicationExitMetrics(应用退出指标)。

在这里插入图片描述

在上面的代码中,王代码重点记录了几个引人注目的“后台终止”数据:

  • Cumulative Memory Pressure Exit Count:系统内存紧张时,你的 App 因为是个“显眼包”而被优先处决了。
  • Cumulative CPU Resource Limit Exit Count:你的 App 在后台偷偷挖矿或者死循环,耗尽了 CPU 配额,被系统当场击毙。

这些数据能让我们深刻理解——为什么系统觉得你的 App 不配活下去。

在这里插入图片描述

MXDiagnosticPayload 类型则包含扩展自抽象类 MXDiagnostic 的属性集合。例如 cpuExceptionDiagnostics(CPU 异常诊断)和 crashDiagnostics(崩溃诊断)。通过 logCrash 函数,我们能提取出极具价值的堆栈信息和元数据。

更妙的是,这两个 Payload 都能轻松转化为 JSONDictionary。这意味着我们可以毫不费力地把这些“罪证”上传到我们自定义的 API 端点,然后在后端慢慢审讯它们。

在这里插入图片描述


⏳ 第 5 幕:耐心的猎人

“现在我们只需要等待。”王代码靠在椅背上。

“等多久?现在的客户可没有耐心。”艾达提醒道。

“这是 MetricKit 的规矩。”王代码叹了口气。

关键点注意: MXMetricManager 并不会像喋喋不休的推销员一样实时给你推送数据。系统非常“鸡贼”,为了省电和性能,它会把数据聚合起来,通常按每天一次的频率投递。

在这里插入图片描述

也就是说,你今天埋下的雷,可能明天才能听到响。在极少数情况下,它可能会发得频繁点,但你千万别把身家性命压在这个“特定时间表”上。

不过好在,这两个 Payload 都提供了 timeStampBegintimeStampEnd 属性。这就好比尸检报告上的死亡时间推断,让我们能精准地确定这些数据覆盖的时间范围。


🎬 终章:真相大白

窗外的雨停了,新深圳的霓虹灯映在王代码疲惫但兴奋的脸上。

通过 MetricKit,他终于填补了 Xcode Organizer 留下的巨大空白。这不仅仅是看几个数字那么简单,这是对 App 在真实世界(Real-World Conditions)中行为的系统级洞察。

在这里插入图片描述

通过订阅 MXMetricManager 并处理 MXMetricPayloadMXDiagnosticPayload,王代码获得了关于 App 启动、终止、崩溃和资源使用的“上帝视角”。而在过去,想要搞清楚 App 是怎么在后台悄无声息死掉的,简直比让产品经理承认需求不合理还难。

“案子破了,艾达。”王代码站起身,披上风衣,“是内存泄漏导致的 OOM,凶手就在那个循环引用的闭包里。”

在这里插入图片描述

艾达关掉了全息投影,嘴角露出一丝不易察觉的微笑:“干得不错,老王。但别高兴得太早,下周还有新的 Bug 等着你。”

在这里插入图片描述

感谢阅读这篇来自赛博边缘的性能监控指南。如果你觉得这次冒险有点意思,或者对抓 Bug 有什么独到的见解,欢迎关注我的博客并向我提问。

咱们下周见,祝宝子们的代码永远不做“内存刺客”,棒棒哒!👋

在这里插入图片描述

一款轻量、低侵入的 iOS 新手引导组件,虽然大清都亡了

PolarisGuideKit:轻量、低侵入的 iOS 新手引导组件(遮罩挖孔 + Buddy View + 插件化)

关键词:UIKit · 新手引导 · 低侵入 · 插件扩展

GitHub:github.com/noodles1024…

demo_cn_tiny.webp


背景:我为什么做这个组件?

可能是新手引导这个功能太小,随便实现一下也能用,导致没有人愿意认真写一个iOS下的新手引导组件,搜遍整个github也找不到一个在现实项目中能直接拿来用的。如果只考虑某一个具体的新手引导界面,实现起来很容易(特别是现在在AI的加持下,UI仔都不需要了)。但在不同项目、不同场景下,经过和和产品经理&设计师的多次沟通中,我发现了做“新手引导/功能提示”时的一些令人头疼的问题:

  • 需要高亮某个控件,但布局变化、屏幕旋转后挖孔(高亮)位置容易偏
  • 指引说明(箭头/气泡/按钮)形态不固定,可能还伴随着音频播放等附加功能,复用困难
  • 点击高亮区域时,难以做到不侵入原有点击业务逻辑
  • 显示新手引导时难以在不改变原有逻辑的情况下阻止NavigationController的滑动返回
  • UITableView/UICollectionView reloadData 后高亮经常失效

于是我做了 PolarisGuideKit:一个基于 UIKit 的轻量新手引导组件,主打低侵入 + 可扩展 + 动态高亮


PolarisGuideKit 能解决什么?

能力 说明 带来的价值
高亮遮罩 遮罩挖孔高亮 focusView 高亮区域自动跟随,内置高亮效果,可自定义
Buddy View 说明视图可自由定制 文案、箭头、按钮任意组合
步骤编排 多步骤引导流程 支持下一步、跳过、完成
动态 focusView reloadData 后自动修正 TableView/CollectionView场景稳定
插件化扩展 Audio/埋点/持久化 可插拔、解耦

快速上手(3 分钟接入)

import UIKit
import PolarisGuideKit

final class MyViewController: UIViewController {
    private var guide: GuideController?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        let step = GuideStep()
        step.focusView = myButton
        step.buddyView = MyBuddyView()
        step.forwardsTouchEventsToFocusView = true
        step.completer = ControlEventCompleter(control: myButton, event: .touchUpInside)

        let controller = GuideController(hostView: view, steps: [step])
        controller.onDismiss = { _, context in
            print("引导结束,原因 = \(context.reason)")
        }

        _ = controller.show()
        guide = controller
    }
}

核心概念一图速览

  • GuideController:流程编排器,负责 show/hide/切换步骤
  • GuideStep:一步引导配置(focus、buddy、style、completer)
  • FocusStyle:高亮形状(矩形/圆形/圆角/无高亮)
  • GuideBuddyView:说明视图(可继承自定义)
  • GuidePlugin:生命周期扩展(音频/埋点/持久化)

重点能力拆解

1) FocusStyle:高亮样式可拔插

内置样式包含:

  • DefaultFocusStyle(矩形)
  • RoundedRectFocusStyle(圆角矩形)
  • CircleFocusStyle(圆形)
  • NoHighlightFocusStyle(全屏遮罩)
let step = GuideStep()
step.focusView = someCard
step.focusStyle = RoundedRectFocusStyle(
    focusCornerRadius: .followFocusView(delta: 2),
    focusAreaInsets: UIEdgeInsets(top: -6, left: -6, bottom: -6, right: -6)
)

2) 动态 FocusView:Table/CollectionView不卡壳

UITableView / UICollectionView 复用导致高亮错位?
使用 focusViewProvider 动态获取最新 cell:

let step = GuideStep()
step.focusViewProvider = { [weak self] in
    guard let self else { return nil }
    var cell = self.tableView.cellForRow(at: targetIndexPath)
    if cell == nil {
        self.tableView.layoutIfNeeded()
        cell = self.tableView.cellForRow(at: targetIndexPath)
    }
    return cell
}

3) 触摸转发 + 自动完成

在不侵入原有业务逻辑的前提下,高亮按钮依然能触发业务逻辑,同时自动关闭引导:

let step = GuideStep()
step.focusView = myButton
step.forwardsTouchEventsToFocusView = true
step.completer = ControlEventCompleter(control: myButton, event: .touchUpInside)

✅ 设置forwardsTouchEventsToFocusView和completer保证了“引导不侵入原有业务逻辑”。


4) Buddy View:说明视图随便做

继承 GuideBuddyView,自定义 UI + 布局:

final class MyBuddyView: GuideBuddyView {
    override func updateLayout(referenceLayoutGuide layoutGuide: UILayoutGuide, focusView: UIView) {
        super.updateLayout(referenceLayoutGuide: layoutGuide, focusView: focusView)
        // 根据 layoutGuide 布局你的文案 / 按钮 / 箭头
    }
}

5) 插件系统:音频 / 埋点 / 持久化

内置 AudioGuidePlugin,可在显示引导时播放音频文件,且可在BuddyView中配合显示音频播放动画(可选功能):

let step = GuideStep()
step.focusView = myCard
step.addAttachment(GuideAudioAttachment(url: audioURL, volume: 0.8))

let controller = GuideController(
    hostView: view,
    steps: [step],
    plugins: [AudioGuidePlugin()]
)

如果想要加埋点、标记“引导是否已显示”,可通过自定义 GuidePlugin 实现。


Demo 示例一览

  • 圆角矩形高亮 + 圆角模式切换
  • 圆形高亮 + 半径缩放
  • 多步骤引导 + 平滑转场
  • 触摸转发 + 自动完成
  • 点击外部关闭(dismissesOnOutsideTap)
  • 音频 + Lottie 同步演示
  • UITableView 动态高亮

架构 & 视图层级

flowchart TB
    subgraph Core["核心组件"]
        GuideController["GuideController<br/>(流程编排器)"]
        GuideStep["GuideStep<br/>(步骤配置)"]
    end

    subgraph ViewHierarchy["视图层级"]
        GuideContainerView["GuideContainerView<br/>(透明容器)"]
        GuideOverlayView["GuideOverlayView<br/>(遮罩 + 触摸转发)"]
        MaskOverlayView["MaskOverlayView<br/>(遮罩基类)"]
        GuideBuddyView["GuideBuddyView<br/>(说明视图)"]
        GuideShadowView["GuideShadowView<br/>(焦点追踪器)"]
    end

    subgraph Extensions["扩展机制"]
        FocusStyle["FocusStyle<br/>(高亮形状)"]
        GuideAutoCompleter["GuideAutoCompleter<br/>(完成触发器)"]
        GuidePlugin["GuidePlugin<br/>(生命周期钩子)"]
        GuideStepAttachment["GuideStepAttachment<br/>(插件数据)"]
    end

    GuideController -->|"管理"| GuideStep
    GuideController -->|"创建并承载"| GuideContainerView
    GuideController -->|"派发事件"| GuidePlugin
    
    GuideContainerView -->|"包含"| GuideOverlayView
    GuideContainerView -->|"包含"| GuideBuddyView
    
    GuideOverlayView -.->|"继承"| MaskOverlayView
    GuideOverlayView -->|"创建"| GuideShadowView
    GuideOverlayView -->|"使用"| FocusStyle
    
    GuideStep -->|"配置"| GuideBuddyView
    GuideStep -->|"使用"| FocusStyle
    GuideStep -->|"通过...触发"| GuideAutoCompleter
    GuideStep -->|"携带"| GuideStepAttachment

view_hierarchy.png


安装

Swift Package Manager

  1. Xcode → File → Add Packages…
  2. 输入仓库地址:https://github.com/noodles1024/PolarisGuideKit
  3. 选择 PolarisGuideKit

CocoaPods

pod 'PolarisGuideKit'
import PolarisGuideKit

注意事项(踩坑清单)

  • focusView 必须是 hostView 的子视图
  • 多 Scene / 多 Window 建议显式传 hostView
  • GuideAutoCompleter 触发后会结束整个引导(建议用于最后一步)
  • 动画转场在复杂形状下可关闭动画:animatesStepTransition = false

项目地址 & 交流

AT 的人生未必比 MT 更好 -- 肘子的 Swift 周报 #118

issue118.webp

AT 的人生未必比 MT 更好

学车时我开的是手动挡,起初因为技术生疏,常搞得手忙脚乱,所以第一台车就直接选了自动挡。但开了几年,我开始追求那种完全掌控的驾驶感,于是又增购了一台手动挡。遗憾的是,随着交通日益拥堵,换挡的乐趣逐渐被疲惫抵消,最终这台车也被冷落。算起来,我已经快二十年没认真开过手动挡了,但内心深处,我仍会时不时地怀念那段“人车合一”的时光。

随着 AI 深度介入我的工作与生活,我感觉自己的人生正从 MT 转向 AT。毫无疑问,AI 助我突破了许多能力瓶颈,也在熟悉领域带来了巨大的效率提升。但奇怪的是,我对它的“惊叹”却在与日俱减。看似它节省了我的时间,但我并未从这些“多出来的时间”里获得预期的满足感。也许是我对它的期望阈值不断提高,但一个不争的事实是:我已经有一段时间没有在学习和开发过程中,体会到那种单纯的快乐了。

幸好,几天前我又找回了这种久违的开心。在准备 iOS Conf SG 的 Keynote 时,由于 Keynote 在动画构建上相对“原始”且缺乏 AI 辅助,我难得地拥有一段完整的时间,去纯手工地尝试和修改。哪怕只是一个简单的并行动画,究竟是用转场、普通动画还是“神奇移动”,我都玩得不亦乐乎。那些在专家手里可能两三分钟搞定的设置,我折腾了大半天。尽管毫无效率可言,但我乐在其中。看着最终那个略显“简陋”的效果,我竟被自己感动了。

对于职场中人,效率和完成度固然是硬指标;但能否体会到过程带来的“实感”,或许才是作为“人”最朴素的追求。

我大概率不会再买 MT 的车了,但在我还能握紧方向盘的时候,也不会轻易让“智能驾驶”代劳。写代码也是如此,当初爱上它,正是因为它能给我带来纯粹的快乐。当所有工具都在催促我们变得更快更好时,我想应该给自己留一点变慢、变“笨”的空间——毕竟,AT 的人生未必比 MT 更好。

本期内容 | 前一期内容 | 全部周报列表

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

近期推荐

告别“可移植汇编”:我已让 Swift 在 MCU 上运行七年

从 2024 年开始,Swift 官方正式提供了对嵌入式系统的支持,但要在这个领域获得显著份额,仍有很长的路要走。其实,早在官方下场之前的 2018 年,Andy Liu 和他的 MadMachine 团队就开始在探索和实践将 Swift 应用于嵌入式领域,并陆续推出了相关硬件。他们坚信,在功能日益复杂的开发场景中,Swift 的现代语言特性将展现出巨大的优势。在本文中,Andy 分享了过去几年中在该领域的探索历程。我真心希望,Swift 能够在更多的场景中,展现其魅力。


在 SwiftUI 中构建基础拨号滑块组件 (Building a Base DialSlider Component in SwiftUI)

在 AI 功能越来越强大的今天,看到一个动效,让 AI 帮你实现已经越来越容易了。但每当看到开发者凭借自己的"奇思妙想"不断探索实现方式并打磨效果时,我仍会由衷赞叹。codelaby 在这篇复刻"老式电话拨号盘"的文章中,巧妙运用 SwiftUI 的 .compositingGroup().blendMode(.destinationOut) 实现了动态"镂空"效果,使旋转层下的静态数字清晰显现,相比单纯旋转图片更具灵活性和原生质感。此外,文章对环形手势处理、角度计算(atan2)以及限位逻辑(Stopper)的阐述也十分透彻清晰。


CKSyncEngine 答疑与实战经验分享 (CKSyncEngine Questions and Answers)

不少开发者对 Core Data 的 NSPersistentCloudKitContainer 颇有诟病,认为其不透明且缺乏定制性。但真正想自己着手解决 CloudKit 的数据同步问题时,才发现需要考虑的地方实在太多,难度远超想象。苹果在几年前推出的 CKSyncEngine 彻底打破了这个困境,提供了更清晰的状态管理和错误处理,并自动处理了诸多复杂的边缘情况,让开发者可以专心构建数据同步逻辑。

Christian Selig 通过自问自答的方式,分享了他在使用 CKSyncEngine API 时的经验,详细拆解了 CKSyncEngine 如何作为一个完美的中间层,在保留数据存储灵活性(你可以继续用 SQLite、Realm 或 JSON)的同时,接管了最令人头疼的同步状态管理。


我对 iOS 26 Tab Bar 的吐槽 (My Beef with the iOS 26 Tab Bar)

SwiftUI 在 iOS 18 中对 Tab Bar 的调整,其影响几乎堪比当年 NavigationStack/NavigationSplitView 取代 NavigationView,不仅改变了设计语言,对应用的实现方式和数据流走向都产生了颠覆性影响。而为 iOS 26 Liquid Glass 风格引入的"搜索标签"功能进一步推进了这种变革。Ryan Ashcraft 在这篇文章中直言不讳地指出了新 Tab Bar 设计的诸多问题:默认的浮动样式在某些界面中显得突兀,与应用整体视觉风格难以协调;新的边距和间距规则打破了长期以来的设计惯例,让开发者需要重新调整大量现有界面;更重要的是,这些改动并未明显提升用户体验,反而增加了适配成本。

我个人对新 Tab 的最大感受是,它会显著影响开发者在开发应用时对最低系统版本的决策。为 Tab 维护两套代码是否值得?如果为了简化实现而不得不将最低版本提高到 iOS 18,这或许正是 SwiftUI 团队的另一个设计意图?


Dia:深度剖析 The Browser Company 的 macOS 浏览器架构 (Dia: A Technical Deep Dive into The Browser Company's macOS Browser)

Arc 是第一个使用 Swift 构建的大型 Windows 平台应用,而且 The Browser Company 也因此为 Swift 社区的 Windows 工具链做出了突出贡献。在从 Arc 转型到 Dia 后,开发团队并没有放弃使用 Swift,那么 macOS 端的 Dia 具体使用了哪些开发框架呢?

Everett 在本文中揭示了 Dia 独特的技术架构:这是一个基于 AppKit + SwiftUI 的原生 macOS 应用,但其核心渲染引擎并非 WebKit,而是嵌入了自行修改的 Chromium(ArcCore)。此外,在 Dia 的二进制文件中发现了大量与本地 AI 相关的库(如 Apple MLX 和 LoRA 适配器),这预示着 Dia 并非只是为了"快",而是已经为设备端 AI 推理做好了底层工程准备。


关于罗技开发者证书过期的迷思 (Myths about Logitech Developer ID certificate expiration)

几天前,不少 macOS 用户发现罗技鼠标的自定义按钮失效。由于控制台日志中充斥着代码签名(Code Signing)相关的报错,不少用户和媒体将其归咎于"苹果撤销了证书"。Jeff Johnson 通过分析系统日志为苹果在本次事件中的角色进行了平反:这并非苹果的证书服务故障,而是罗技自身软件工程问题导致的。Logi Options+ 的后台进程在更新后,未能通过 macOS taskgated 的代码签名有效性验证,从而被系统直接终止。这篇文章不仅是一份故障分析报告,更提醒开发者:在 macOS 严格的安全机制下,应用更新的签名验证流程容不得半点马虎。

“如果你的证书过期了,用户仍然可以下载、安装和运行用该证书签名的 Mac 应用程序版本。但是,你需要一个新的证书来签署更新和新申请。” —— 苹果官方文档


拒绝 LLM 生成的平庸代码 (Stop Getting Average Code from Your LLM)

不可否认,在人类长久以来累积的信息海洋中,高质量的数据与信息只占少数。对于个体来说,我们完全可以有目的地去甄别和学习这些优质内容。但是,受限于机制,LLM 默认倾向于训练数据的“平均值”,这就导致它生成的内容在各个方面都显得比较平庸。具体到 Swift 开发领域,这往往意味着它会生成大量旧版的、非结构化的代码。

要想获得高质量、符合 Swift 6 标准甚至特定架构风格的代码,关键在于对抗这种“回归均值”的本能。Krzysztof Zabłocki 详细介绍了如何利用 Few-Shot Prompting(少样本提示)和上下文注入技术,通过提供具体的代码范例和架构规范,强迫 LLM “忘记”平庸的默认设置,转而生成精准匹配项目标准的高质量代码。

工具

swift-effect: 一种基于类型驱动的副作用处理方案

Alex Ozun 长期关注 Swift 中的 类型驱动设计,这个库是他对“代数效应(Algebraic Effects)+ 处理器(Effect Handlers)”在 Swift 里的实践。 swift-effect 不是把副作用变成“数据结构再解释”,而是将副作用建模为可拦截的全局操作(@Effect),通过 handler 在运行时组合和替换行为,让业务代码保持线性/过程式风格,同时又能精细控制 I/O、并发等行为。

核心亮点:

  • 保持代码线性:调用 Console.print 等 effect 就像普通函数,但行为可由 handler 动态决定。
  • 无 Mock 的行为测试:用 withTestHandler 逐步拦截/断言 effect 序列,像“交互式脚本”一样测试流程。
  • 并发可控:支持对 Task/AsyncSequence 的确定性测试,解决并发顺序不稳定的问题。

Codex Skill Manager: 一款面向众多 CLI 的 macOS 工具

很多开发者都会同时使用多种 AI 编程服务,尽管它们拥有类似的概念、设定和工具类型,但在具体设置和细节描述上仍有差异,这导致开发者很难对所有服务进行统一管理。Thomas Ricouard 开发的 Codex Skill Manager 将 Codex、Claude Code(以及 OpenCode、Copilot)的技能集中在一个 UI 里查看、搜索、删除和导入,避免在多个隐藏目录中手动寻找。

核心功能

  • 本地技能:扫描 ~/.codex/skills/public~/.claude/skills 等路径,展示列表与详情
  • 详情渲染:Markdown 视图+引用预览
  • 远程 Skill:Clawdhub 搜索/最新列表、详情拉取与下载
  • 导入/删除/自定义路径:支持从 zip 或文件夹导入、侧边栏删除、添加自定义路径
  • 多平台安装状态:为不同平台标记已安装状态

活动

LET'S VISION 2026|邀请你与我们同行!

✨ 大会主题:Born to Create, Powered by AI

  • 📍 地点:上海漕河泾会议中心
  • 时间:2026 年 3 月 27 日 - 3 月 29 日
  • 🏁 重点:汇聚顶尖创作者与 AI 技术大咖,共同探索 AI 应用的未来边界
  • 🌍 官网letsvision.swiftgg.team

别走开!请关注官方账号和主理人 SwiftSIQI,我们将持续放送更多精彩内容!


Swift Student Challenge 2026

每年一度的学生挑战赛再次登场。挑战赛为数以千计的学生开发者提供了展现创造力和编程能力的机会,让他们可以通过 App Playground 呈现自己的作品,并从中学习在职业生涯中受用的实际技能。

今年作品提交通道将于 2026 年 2 月 6 日至 2 月 28 日开放。

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

iOS日志系统设计

1 为什么需要日志

软件系统的运行过程本质上是不可见的。绝大多数行为发生在内存与线程之中。在本机调试阶段,我们可以借助断点、内存分析、控制台等手段直接观察系统状态;而一旦进入生产环境,这些能力几乎全部失效。此时,日志成为唯一能够跨越时间和空间的观测手段

但如果进一步追问:日志究竟解决了什么问题? 这个问题并没有那么简单。日志的核心价值并不在于文本本身,而在于可见性。它最基础的作用,是让这些不可见的行为“显影”。一次简单的日志输出,至少隐含了三类信息:时间、位置、描述。

这也是我们不建议使用简单 print 的原因:结构化日志在每次记录时,都会自动携带这些关键信息,从而形成稳定、可检索的观测数据。

之后当系统规模变大、异步逻辑增多时,单条日志已经很难解释问题。真正有价值的,是一组日志所呈现出的因果关系. 在这一层面上,日志更像是事件证据,用于在事后还原一段已经发生过的执行流程。

接下来,我们将从这些问题出发,逐步讨论一套面向真实生产环境的日志与可观测性设计。

2 日志系统

尽管日志在实践中无处不在,它本身仍然存在天然局限:日志是离散的事件,而不是连续的流程;日志天然是“事后信息”,而不是实时状态;在客户端等受限环境中, 如 应用可能随时被杀掉, 各种网络情况不稳定, 隐私与安全限制,日志可能丢失、不可获取、不可上报。

这意味着,日志并不是系统真相本身,它只是我们理解系统的一种工具。当系统复杂度继续提升,仅靠“多打日志”往往无法解决根本问题。

2.1 范围与功能

这样我们应该勾勒出日志系统的一些边界范围.

  1. 不必要求日志的「绝对可靠上报」
  2. 不通过日志修复「系统设计问题」(业务依赖、生命周期、指责转移错误)
  3. 不将日志系统演化成「消息系统」(不持久化、不通过日志兜底)

所以我们通过边界范围基本确定了我们日志系统的一些要求:

  • 结构化并非文本化: 日志首先应该是结构化数据,而不是简单字符串。文本只是表现形式,结构才是核心价值.每条日志必须具备稳定的时间、位置、级别与上下文.日志应当天然支持检索、过滤与关联.
  • 本地优先, 而非远端依赖: 本地记录必须是同步、低成本、稳定的.远端上报只能是 best-effort 行为.系统不能因为远端日志失败而影响主流程

2.2 系统架构

LoggerSystemArchitectureDiagram.svg

我们刻意限制了日志系统的职责范围,使其始终作为一个旁路的、可退化的基础设施存在。这些约束并非功能缺失,而是后续实现能够保持稳定与可演进的前提。所有依赖关系均为单向:日志系统不会反向调用业务模块或基础设施。

3 本地日志

在整体架构中,本地日志被视为整个日志系统中最基础、也是最可靠的一环。无论远端日志是否可用、网络环境是否稳定,本地日志都必须能够在任何情况下正常工作。

因此,在实现层面,我们选择优先构建一套稳定、低成本、符合平台特性的本地日志能力,而不是从远端导出反推本地设计。

3.1 为什么是os.Logger

在 iOS 开发里,print 很直观,但它更像调试手段:没有结构、难以检索、性能成本不可预测,也无法进入系统日志体系。进入生产环境后,这些缺点会被放大。

os.Logger 是系统级日志通道,它的设计目标本身就面向“可长期运行”的生产场景。本文中,os.Logger 指 Apple 的系统日志实现,Logger 指本文封装的对外 API。我们选择它,主要基于这些原因:

  • 低成本写入:日志被拆分为静态模板与动态参数,格式化发生在读取阶段,而非写入阶段
  • 系统工具链一致性:天然接入 Console、Instruments 与系统日志采集工具
  • 隐私与合规能力:原生支持隐私级别控制
  • 结构化上下文:时间戳、级别、分类、源码位置可以稳定保留

因此,日志可以作为“长期存在的基础能力”,在核心路径中持续开启,而不是仅限于调试阶段。需要说明的是,系统日志在生产环境的获取也受平台权限与采集策略限制,所以它是“本地可靠”,但并不是“远端万能”。

在使用层面,Logger API 保持简单直接:

let logger = Logger(subsystem: "LoggerSystem")
logger.info("Request started")
logger.error("Request failed", error: error)

3.2 附加功能

除了系统日志的即时写入,我们还提供了几个本地诊断能力:通过 LogStore / OSLogStore 进行日志检索(按时间、级别、分类)并支持导出为文本或 JSON;同时集成 OSSignpost / OSSignposter 作为性能事件记录方式,用于衡量关键路径耗时。这些能力不进入主写入路径,只在排查与分析时启用。

4 远端日志与 OpenTelemetry

4.1 OpenTelemetry 在客户端日志系统中的位置

OpenTelemetry 由 CNCF 托管,起源于 2019 年 OpenCensus 与 OpenTracing 的合并,并于 2021 年成为 CNCF 顶级项目。它并不是某一个具体 SDK,而是一套用于描述可观测性数据的开放标准,定义了日志、指标与链路追踪的统一语义与数据模型,并配套给出了标准化的传输协议(OTLP)。

在本章中,我们并不试图完整覆盖 OpenTelemetry 的体系,而是聚焦于其中与远端日志相关的部分:

日志数据在 OTel 语义下如何被结构化、如何被分组、以及如何被导出。

认证、上下文传播等问题会显著影响系统依赖关系,本文刻意将其延后,在下一章单独讨论。

4.1.1 Remote Logger 的最小闭环

下图展示了在 OTel 语义下,客户端远端日志链路的最小可用闭环

这一闭环的目标并非“可靠投递”,而是在客户端约束条件下,提供一条可控、可退化的日志导出路径

从数据流动的角度看,这条链路可以被抽象为:

LogRecord[]
  → LogRecordAdapter
  → ResourceLogs / ScopeLogs / LogRecords
  → ExportLogsServiceRequest (OTLP)
  → OTLP Exporter (HTTP / gRPC)

在这一结构之上,可以按需叠加增强能力,例如批处理、失败缓存或延迟调度,但这些能力不会改变日志的协议语义

4.1.2 结构化与分组:从 LogRecord 到 OTel Logs

在 OTel 模型中,LogRecord 仍然是最小的事件单元,用于描述“发生了什么”。

但真正的上传结构并不是一组扁平的日志列表,而是按以下层级组织:

  • ResourceLogs:描述日志产生的资源环境(设备、系统、应用)
  • ScopeLogs:描述产生日志的逻辑作用域(模块、库)
  • LogRecords:具体的日志事件

这一分组方式的意义在于:

  • 避免重复携带环境信息
  • 明确日志的来源与归属
  • 为后端聚合与分析提供稳定结构

在客户端侧,这一阶段通常通过一个 Adapter 或 Mapper 完成,其职责只是语义映射,而非业务处理。

4.1.3 批处理与调度:Processor 的职责边界

在日志被结构化之后,下一步并不是立刻发送,而是进入处理阶段。

LogRecordProcessor 位于这一阶段的入口位置。以 BatchLogRecordProcessor 为例,它负责:

  • 将多条日志聚合为批次
  • 控制发送频率
  • 降低网络与系统调用成本

需要注意的是,Processor 层体现的是策略位置,而不是协议逻辑。

它不关心日志如何被编码,也不关心最终通过何种方式发送,只负责决定什么时候值得尝试导出

4.1.4 导出边界:Exporter 作为协议适配层

LogRecordExporter 是远端日志链路中的协议边界

在这一层中,结构化日志会被转换为 OTLP 定义的 ExportLogsServiceRequest,并通过具体传输方式发送。常见实现包括:

  • OTLP/HTTP
  • OTLP/gRPC

无论采用哪种方式,Exporter 的核心职责都是一致的:

编码日志结构,并完成协议级发送。

它不感知业务上下文,也不参与重试策略之外的系统决策。

4.2 RemoteLogger 架构图

下面这张图是 RemoteLogger 在 OTel 语义下的最小闭环:

RemoteSinkSystem.svg

5 上下文边界

从 RemoteLogger 开始真正发日志的那一刻,日志系统就第一次碰到外部依赖:鉴权。它不是“可选附加项”,而是远端链路的必经之门。

5.1 鉴权

日志端点是基础设施入口(infra endpoint),它的目标是控制写入来源,而不是验证用户身份。因此更合理的鉴权方式是:IP allowlist、mTLS、ingestion key、project‑level key,配合采样与限流。这些机制与用户态解耦、无需刷新、不参与业务控制流,且失败也不会影响客户端主流程。

在这种模型下,日志系统只做一件事:携带已准备好的凭证。它不维护鉴权状态,也不触发刷新,更不等待鉴权完成。

5.1.1 当鉴权被卷入业务流程

问题发生在日志复用业务鉴权体系时:access token 短期、频繁刷新、刷新依赖网络与生命周期,而鉴权失败本身又需要被记录。直觉做法是“刷新后重试”,但这会形成典型的依赖循环:

Logger
  → RemoteExporter
     → AuthManager
        → RefreshToken
           → Network
              → Logger

这不是实现复杂的问题,而是依赖方向错误的问题:日志系统依赖了本应被它观测的系统,直接造成 auth blind zone(鉴权失败本身无法被观测的区域)

现实里,很多团队不得不复用业务鉴权,但这时唯一能做的是“隔离副作用”:不触发刷新、不重试鉴权、失败即丢弃,并保持凭证只读与短生命周期缓存。这样做无法消灭问题,却能把依赖循环缩到最小。

结论只有一句:日志系统只能消费鉴权结果,不能成为鉴权流程的一部分。

5.2 Traceparent

traceparent 是 W3C Trace Context 标准里的核心头部,用于跨进程传播 Trace。它不是日志系统的一部分,而是“流程上下文”的载体。日志系统只负责把它携带出去,而不负责生成、维护或推进它。

它的基本结构非常固定:

traceparent: {version}-{trace-id}-{parent-id}-{trace-flags}
  • version:版本号,当前常见为 00
  • trace-id:16 字节(32 hex)的全局 trace 标识
  • parent-id:8 字节(16 hex)的当前 span 标识
  • trace-flags:采样与调试标记(如 01 表示采样)

这四个字段共同决定“这条日志属于哪条 trace、处于哪一段 span 上下文”。

5.2.1 构造与传递

在客户端日志系统中,traceparent 应当被视为外部上下文

  • 它由业务流程或 tracing 系统生成
  • 它随着请求/事件生命周期变化
  • 它不由日志系统创建,也不由日志系统维护

日志系统只做一件事:在日志生成或导出时附带 traceparent,让后端能够把日志与 Trace 对齐。

这也意味着:日志系统不能尝试“修复” traceparent,也不能在缺失时伪造它。缺失就缺失,伪造会制造错误的因果链。

traceparent 的构造来自 tracing 系统(SDK 或上游服务),它会在一次请求开始时生成新的 trace-id,并在 span 变化时更新 parent-id。日志系统只需在“生成日志的瞬间”读取当前上下文并携带即可。

换句话说,traceparent 的生命周期与日志系统无关,而与业务流程一致。日志系统需要尊重这个边界,否则它会再次变成主流程的一部分。

5.3 Context 的角色

如果说 traceparent 是“具体的上下文载体”,那么 Context 是“上下文在系统里的容器与作用域”。它回答的不是“字段长什么样”,而是“这份上下文从哪来、到哪去、何时结束”。

在日志系统里,Context 只应当承担两件事:

  1. 携带流程信息,让日志具备可关联性
  2. 限定生命周期,避免上下文在系统内滞留

这意味着 Context 的设计重点不是“存什么”,而是“何时注入、如何传播、何时释放”。

更宽泛地看,Context 的生命周期其实是一种“作用域建模”。它既像 tracing 里的 active span,也像 DI 里的 scope:谁负责创建、谁负责结束、跨线程如何继承,这些都会直接决定 traceparent 的 parent-id 何时变化。换句话说,Context 的问题往往不是协议问题,而是作用域与生命周期策略的问题。

Context 最难的部分其实是作用域与注入方式:

  • 它和依赖注入(DI)的关系应该是什么
  • 在多线程/异步场景中,Context 的边界如何定义
  • Context 是否应该是显式传参,还是隐式绑定

这些问题没有一个完美答案,需要团队给出清晰的工程约定。

5.4 结语

这套日志系统的核心不是“更多日志”,而是“正确的边界”:本地优先、远端旁路、结构化可关联、上下文可控。真正决定系统稳定性的,不是某一个 API,而是你如何定义依赖方向与生命周期。最后想留下的一句话是:可观测性不是无限的,它永远受平台约束。

AT 的人生未必比 MT 更好 - 肘子的 Swift 周报 #118

学车时我开的是手动挡,起初因为技术生疏,常搞得手忙脚乱,所以第一台车就直接选了自动挡。但开了几年,我开始追求那种完全掌控的驾驶感,于是又增购了一台手动挡。遗憾的是,随着交通日益拥堵,换挡的乐趣逐渐被疲惫抵消,最终这台车也被冷落。算起来,我已经快二十年没认真开过手动挡了,但内心深处,我仍会时不时地怀念那段“人车合一”的时光。

Agent 模型的思维链是什么

关于 Agent 模型的思维链,之前被几个高大上的词绕晕了,claude 提出 Interleaved Thinking(交错思维链),MiniMax M2 追随和推动标准化,K2 叫 Thinking-in-Tools,Deepseek V3.2 写的是 Thinking in Tool-Use,gemini 则是 Thought Signature(思考签名)。了解了下,概念上比较简单,基本是一个东西,就是定义了模型思考的内容怎样在 Agent 长上下文里传递。

是什么

在25年年初 deepseek 的轰炸下,思考模型大家都很熟悉了,在 Chatbot 单轮对话中,模型会先输出思考的内容,再输出正文。再早的 GPT-o1 也一样,只不过 o1 不把完整的思考内容输出。

在 Chatbot 进行多轮对话时,每一次思考的内容是不会再带入上下文的。每次到下一轮时,思考的内容都会被丢弃,只有用户 prompt 和模型回答的正式内容会加到上下文。因为在普通对话的场景下没必要,更倾向于单轮对话解决问题,长上下文会干扰模型,也会增加 token 消耗。

这些思考模型用到 Agent 上,就是下图这样,每次模型输出工具调用,同时都会输出 thinking 内容,思考应该调什么工具,为什么调,但下次这个思考内容会被丢弃,不会带入上下文:

Agent 的 loop 是:用户输入 → 模型输出工具调用 → 调用工具得出结果 → 模型输入下一步工具调用 → 调用工具得出结果 → …. 直到任务完成或需要用户新的输入。

这不利于模型进行多轮长链路的推理,于是 claude 4 sonnet 提出把 thinking 内容带入上下文这个事内化到模型,以提升 Agent 性能,上下文的组织变成了这样:

就这样一个事,称为 Interleaved Thinking,其他的叫法也都是一样的原理。

为什么要带 thinking

面向 Chatbot 的模型,倾向于一次性解决问题,尽量在一次 thinking 一次输出解决问题。

Agent 相反,倾向于多步不断跟环境( tool 和 user )交互解决问题。

Agent 解决一个复杂问题可能要长达几十轮工具调用,如果模型对每次调用工具的思考内容都抛弃,只留下结果,模型每次都要重新思考每一轮为什么要调这个工具,接下来应该调什么工具。这里每一次的重新思考如果跟原来的思考推理有偏移,最终的结果就会有很大的出入和不稳定,这种偏移在多轮下几乎一定会发生。

如果每一轮调用的思考内容都放回上下文里,每次为什么调工具的推理逻辑上下文都有,思维链完整,就大大减少了模型对整个规划的理解难度和对下一步的调用计划的偏差。

有没有带 thinking 内容,对效果有多大差别?MiniMax-M2 提供了他们的数据,在像 Tau 这种机票预订和电商零售场景的任务 benchmark 提升非常明显,这类任务我理解需要操作的步数更多(比如搜索机票→筛选过滤→看详情→下单→支付),模型在每一步对齐前面的思路很重要,同一个工具调用可能的理由随机性更大,每一步的思考逻辑带上后更稳定。

工程也能做?

这么一个简单的事,不用模型支持,直接工程上拼一下给模型是不是也一样?比如手动把思考内容包在一个标签(<think>)里,伪装成 User Message 或 ToolResult 的一部分放在里面,也能达到保留思考的效果。

很多人应该这样做过,但跟模型原生支持还是有较大差别。

工程手动拼接,模型只会认为这部分仍是用户输入,而且模型的训练数据和流程没有这种类型的用户输入和拼接,效果只靠模型通用智能随意发挥。

模型原生支持,训练时就可以针对这样规范的上下文训练,有标注大量的包含思考过程的 trajectory 轨迹数据训练,响应的稳定性必然会提升,这也是 Agent 模型的重点优化点之一。

签名

上述工具调用的 thinking 内容带到下一轮上下文,不同的模型做了不同额外的处理,主要是加了不同程度的签名,有两种:

thinking 内容原文,带签名校验

claude 和 gemini 都为 thinking 的内容加了签名校验,带到下一轮时,模型会前置判断思考内容有没有被篡改。

为什么要防 thinking 内容被篡改?毕竟 prompt 也可以随便改,同样是上下文的 thinking 内容改下也没什么。

主要应该是篡改了模型的 thinking 内容会打乱模型的思路,让效果变差,这也是需要避免的。

另外模型在训练和对齐时,已经默认 thinking 是模型自己的输出,不是用户随意的输入,这是两个不同类型的数据,如果实际使用时变成跟Prompt一样可随意篡改,可能有未知的安全问题。

不过国内模型目前没看到有加这个签名校验的。

thinking 内容加密

claude 在一些情况下不会输出自然语言的 thinking 内容,而是包在redacted_thinking里,是一串加密后的数据。

而 gemini 2.5/3.0 的 Agent 思维链没有明文的 thinking 字段,而是 thought_signature,也是一串加密后的数据。

用这种加密的非自然语言数据,一个好处是,它可以是对模型内部更友好、压缩率更大的数据表述方式,也可以在一些涉及安审的场景下内容不泄露给用户。

更重要的还是防泄漏,这就跟最开始 GPT o1 不输出所有思考内容一样,主要是为了不暴露思考过程,模型发布后不会太轻易被蒸馏。

最后

目前 claude 4 sonnet、gemini 3 在 Agent 工具调用的场景下,都强制要求带工具调用的思考内容和签名,这个链路正常是能很大程度提升整体的推理执行效果,是 Agent 多步骤推理的必需品。

但目前 Agent 模型的稳定性还是个问题,例如在某些场景下,业务逻辑明确需要下一步应该调工具 A,但模型思考后可能就是会概率性的调工具B,在以前是可以直接 hack 替换调工具调用,或手动插入其他工具调用,没有副作用。

但在思维链这套机制下比较麻烦,因为没法替模型输出这个工具调用的思考内容,一旦打破这个链,对后续推理的效果和稳定性都会有影响。

可能模型厂商后续可以出个允许上层纠错的机制,例如可以在某个实际告诉函数工具选择错误,重新思考,原生支持,弥补模型难以保障稳定的不足。

❌