阅读视图

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

第2章:声明式 UI 基础

2.1 声明式 vs 命令式 UI 对比

命令式 UI(UIKit)

命令式编程是一种传统的编程范式,开发者需要明确告诉计算机“如何”完成任务。在 UIKit 中,你需要:

  1. 创建视图对象
  2. 配置视图属性
  3. 添加视图到视图层级
  4. 设置布局约束
  5. 手动更新视图状态

示例代码(UIKit)

import UIKit

class ProfileViewController: UIViewController {
    private let nameLabel = UILabel()
    private let avatarImageView = UIImageView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1. 创建视图
        view.backgroundColor = .white
        
        // 2. 配置 nameLabel
        nameLabel.text = "张三"
        nameLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
        nameLabel.textColor = .black
        nameLabel.textAlignment = .center
        
        // 3. 配置 avatarImageView
        avatarImageView.image = UIImage(named: "avatar")
        avatarImageView.contentMode = .scaleAspectFill
        avatarImageView.layer.cornerRadius = 40
        avatarImageView.clipsToBounds = true
        
        // 4. 添加到视图层级
        view.addSubview(avatarImageView)
        view.addSubview(nameLabel)
        
        // 5. 设置约束
        avatarImageView.translatesAutoresizingMaskIntoConstraints = false
        nameLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100),
            avatarImageView.widthAnchor.constraint(equalToConstant: 80),
            avatarImageView.heightAnchor.constraint(equalToConstant: 80),
            
            nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20),
            nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
    
    // 6. 更新数据时需要手动刷新 UI
    func updateProfile(name: String, avatar: String) {
        nameLabel.text = name
        avatarImageView.image = UIImage(named: avatar)
    }
}

声明式 UI(SwiftUI)

声明式编程是一种现代的编程范式,开发者只需要描述“是什么”,而不需要关心“如何”实现。在 SwiftUI 中,你只需要:

  1. 描述界面的结构
  2. 绑定状态
  3. 系统自动处理更新

示例代码(SwiftUI)

import SwiftUI

struct ProfileView: View {
    let name: String
    let avatar: String
    
    var body: some View {
        VStack(spacing: 20) {
            // 1. 头像
            Image(avatar)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: 80, height: 80)
                .clipShape(Circle())
            
            // 2. 姓名
            Text(name)
                .font(.system(size: 18, weight: .medium))
                .foregroundStyle(.primary)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(.systemBackground))
    }
}

// 使用示例
ProfileView(name: "张三", avatar: "avatar")

核心差异

维度 命令式 UI(UIKit) 声明式 UI(SwiftUI)
代码量 多(需要手动管理所有细节) 少(只描述结果)
状态同步 手动更新 UI 自动同步状态
可读性 较低(逻辑分散) 高(逻辑集中)
维护性 较低(容易遗漏更新) 高(状态驱动自动更新)
错误率 较高(手动操作容易出错) 较低(框架保证一致性)
开发效率 低(重复代码多) 高(简洁明了)

2.2 View 协议与 body 计算属性

View 协议

在 SwiftUI 中,所有的视图都必须遵循 View 协议:

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

核心概念

  • associatedtype Body:关联类型,表示视图的内容类型
  • body:计算属性,返回视图的内容
  • @ViewBuilder:属性包装器,允许使用声明式语法组合多个视图

body 计算属性

body 是 SwiftUI 视图的核心,它是一个计算属性,每次状态变化时都会重新计算。

重要特性

  1. 计算属性:不是存储属性,每次访问都会重新计算
  2. 轻量级:应该保持简洁,避免复杂计算
  3. 返回类型:必须返回一个遵循 View 协议的类型
  4. 自动合成:@ViewBuilder 允许使用简洁的语法组合多个视图

示例

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, SwiftUI!")
            Button("Tap Me") {}
        }
    }
}

理解 some View

some View 是一个不透明类型(Opaque Type),它表示:

  • 这个函数返回一个遵循 View 协议的类型
  • 但具体是什么类型,不需要暴露给调用者
  • 编译器可以进行更多的优化

2.3 结构体视图与值类型

视图是结构体

在 SwiftUI 中,视图是使用结构体(struct)实现的,这与 UIKit 中的类(class)不同。

结构体的优势

  1. 值类型:传递时会复制,避免引用计数问题
  2. 轻量级:分配在栈内存上,创建和销毁成本低
  3. 不可变:默认不可变,状态通过 @State 等包装器管理
  4. 线程安全:值类型天生线程安全

结构体的生命周期

SwiftUI 视图的生命周期与结构体的实例化无关:

  • 结构体可能被频繁创建和销毁
  • 但底层的真实视图对象由 SwiftUI 管理
  • 视图的身份(Identity)由其在视图树中的位置决定

示例

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

count 改变时:

  1. SwiftUI 检测到状态变化
  2. 重新创建 CounterView 结构体实例
  3. 调用 body 计算属性生成新的视图树
  4. 与旧视图树对比,只更新变化的部分

2.4 修饰符(Modifier)的使用

什么是修饰符?

修饰符是 SwiftUI 中用于修改视图属性和行为的方法。它们通常以点语法链式调用。

修饰符的工作原理

修饰符不是直接修改原视图,而是返回一个新的视图,这个新视图包含了原视图和应用的修改。

示例

Text("Hello")
    .font(.largeTitle)        // 返回一个新的 Text 视图,字体为 largeTitle
    .foregroundStyle(.blue)    // 返回一个新的视图,文本颜色为蓝色
    .padding()                // 返回一个新的视图,带有内边距

常用修饰符

布局修饰符

  • padding():添加内边距
  • frame():设置视图大小和对齐方式
  • background():设置背景
  • foregroundStyle():设置前景样式(颜色、渐变等)
  • clipShape():裁剪视图形状
  • overlay():在视图上叠加内容

排版修饰符

  • font():设置字体
  • bold():加粗文本
  • italic():斜体文本
  • multilineTextAlignment():多行文本对齐
  • lineLimit():限制文本行数

交互修饰符

  • onTapGesture():添加点击手势
  • disabled():禁用视图
  • accessibility():添加无障碍支持

动画修饰符

  • animation():添加动画
  • transition():添加转场动画

修饰符的顺序

修饰符的顺序很重要,因为每个修饰符都会作用于前一个修饰符返回的视图。

示例

// 先设置背景,再添加内边距
Text("Hello")
    .background(Color.blue)
    .padding()

// 先添加内边距,再设置背景
Text("Hello")
    .padding()
    .background(Color.blue)

这两种写法会产生不同的效果,第一种背景只覆盖文本区域,第二种背景会覆盖整个内边距区域。

实战:创建一个信息卡片

需求分析

创建一个包含以下元素的信息卡片:

  1. 标题
  2. 副标题
  3. 描述文本
  4. 图标
  5. 卡片样式(圆角、阴影)

代码实现

import SwiftUI

struct InfoCardView: View {
    // 卡片数据
    let title: String
    let subtitle: String
    let description: String
    let iconName: String
    let iconColor: Color
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // 图标和标题区域
            HStack(alignment: .center, spacing: 12) {
                // 图标
                Circle()
                    .fill(iconColor.opacity(0.1))
                    .frame(width: 48, height: 48)
                    .overlay {
                        Image(systemName: iconName)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 24, height: 24)
                            .foregroundStyle(iconColor)
                    }
                
                // 标题和副标题
                VStack(alignment: .leading, spacing: 4) {
                    Text(title)
                        .font(.headline)
                        .fontWeight(.semibold)
                    Text(subtitle)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
            
            // 描述文本
            Text(description)
                .font(.body)
                .foregroundStyle(.primary)
                .lineLimit(nil) // 不限制行数
        }
        .padding(16) // 卡片内边距
        .background(
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemBackground))
                .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
        )
        .padding(.horizontal, 16) // 卡片外边距
    }
}

// 使用示例
struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            InfoCardView(
                title: "SwiftUI 简介",
                subtitle: "现代 UI 框架",
                description: "SwiftUI 是一个声明式 UI 框架,允许开发者使用 Swift 语言创建跨 Apple 平台的用户界面。它提供了简洁、直观的语法,使 UI 开发变得更加高效。",
                iconName: "star.fill",
                iconColor: .yellow
            )
            
            InfoCardView(
                title: "声明式编程",
                subtitle: "现代编程范式",
                description: "声明式编程让开发者只需要描述界面的样子,而不需要关心如何实现。系统会自动处理视图的创建、更新和销毁。",
                iconName: "code",
                iconColor: .blue
            )
            
            InfoCardView(
                title: "跨平台",
                subtitle: "一次编写,多处运行",
                description: "SwiftUI 支持 iOS、iPadOS、macOS、watchOS 和 tvOS,让你的代码可以在所有 Apple 平台上运行。",
                iconName: "globe",
                iconColor: .green
            )
        }
        .padding(.vertical, 16)
        .background(Color(.systemGroupedBackground))
    }
}

#Preview {
    ContentView()
}

代码解析

  1. 结构体参数:通过参数传递卡片数据,使视图可复用
  2. VStack 和 HStack:使用栈布局组织视图
  3. Circle:创建圆形背景
  4. overlay:在圆形上叠加图标
  5. RoundedRectangle:创建圆角矩形背景
  6. shadow:添加阴影效果
  7. spacing:设置栈视图的间距
  8. alignment:设置栈视图的对齐方式

小结

本章介绍了声明式 UI 的基础概念,包括:

  • 声明式 vs 命令式 UI 的对比
  • View 协议与 body 计算属性
  • 结构体视图与值类型的特性
  • 修饰符的使用方法和顺序
  • 一个信息卡片的实战实现

通过本章的学习,你已经了解了 SwiftUI 的基本工作原理和核心概念,为后续的学习打下了坚实的基础。

参考资料

第1章:SwiftUI 与开发环境简介

距离上一次学习SwiftUI已经过去几年的时间了,好多知识点都些忘记了,最近刚好有有一些时间就好好的在从零回顾一下吧。


1.1 什么是 SwiftUI?

官方定义

根据 Apple 官方文档,SwiftUI 是一个现代的声明式 UI 框架,它允许开发者使用 Swift 语言创建跨 Apple 平台的用户界面。

SwiftUI 的核心优势

  1. 声明式语法:描述界面“是什么”而不是“怎么做”
  2. 跨平台:一次编写,在 iOS、iPadOS、macOS、watchOS 和 tvOS 上运行
  3. 实时预览:在 Xcode 中实时查看界面效果
  4. 与 Swift 语言深度集成:充分利用 Swift 的类型安全和现代特性
  5. 自动适配:自动处理不同尺寸设备的布局

与 UIKit 的对比

特性 SwiftUI UIKit
编程范式 声明式 命令式
代码风格 简洁、直观 冗长、命令式
布局系统 自动布局,基于栈 Auto Layout,需要手动设置约束
状态管理 自动状态同步 手动更新 UI
跨平台 支持所有 Apple 平台 主要针对 iOS/tvOS
开发效率 相对较低

1.2 Xcode 开发环境配置

系统要求

  • macOS:最新版本(推荐 macOS Sonoma 或更高)
  • Xcode:最新版本(推荐 Xcode 15 或更高)
  • Swift:Swift 5.7 或更高
  • iOS:iOS 15.0 或更高(如果需要支持旧版本,最低可到 iOS 13.0)

安装 Xcode

  1. 打开 App Store
  2. 搜索 “Xcode”
  3. 点击 “获取” 进行安装
  4. 安装完成后,打开 Xcode 并同意许可协议

安装额外组件

首次打开 Xcode 时,会提示安装额外的组件,包括:

  • 命令行工具
  • 模拟器运行时
  • 其他必要的开发工具

1.3 创建你的第一个 SwiftUI 项目

步骤 1:打开 Xcode

步骤 2:创建新项目

  1. 点击 “Create a new Xcode project”
  2. 选择 “iOS” 标签页
  3. 选择 “App” 模板
  4. 点击 “Next”

步骤 3:配置项目信息

  1. Product Name:输入项目名称,例如 “SwiftUIHelloWorld”
  2. Team:选择你的开发团队(如果没有,可以选择 “None”)
  3. Organization Identifier:输入你的组织标识符,例如 “com.yourname”
  4. Interface:选择 “SwiftUI”
  5. Language:选择 “Swift”
  6. Life Cycle:选择 “SwiftUI App”
  7. 取消勾选 “Use Core Data”(暂时不需要)
  8. 点击 “Next”

步骤 4:选择保存位置

选择一个合适的文件夹保存项目,然后点击 “Create”

步骤 5:项目结构介绍

创建完成后,你会看到以下文件结构:

  • SwiftUIHelloWorldApp.swift:应用程序入口
  • ContentView.swift:主视图
  • Assets.xcassets:资源文件
  • Info.plist:应用配置

1.4 认识 Xcode 预览功能

预览面板

Xcode 右侧的预览面板是 SwiftUI 最强大的特性之一,它允许你实时查看界面效果。

使用预览

  1. 打开 ContentView.swift 文件
  2. 确保右侧的预览面板可见(如果不可见,点击 Xcode 顶部的 “Editor” → “Canvas”)
  3. 你会看到 ContentView 的实时预览
  4. 修改代码,预览会自动更新

预览配置

你可以在预览代码中添加多个预览,例如:

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .previewDisplayName("默认预览")
            
            ContentView()
                .previewDisplayName("暗黑模式")
                .preferredColorScheme(.dark)
            
            ContentView()
                .previewDisplayName("iPhone 15 Pro")
                .previewDevice(PreviewDevice(rawValue: "iPhone 15 Pro"))
        }
    }
}

预览快捷键

  • Option + Command + P:刷新预览
  • Command + K:清除构建

1.5 SwiftUI 项目结构解析

应用程序入口

SwiftUIHelloWorldApp.swift 是应用的入口点,它定义了应用的结构:

import SwiftUI

@main
struct SwiftUIHelloWorldApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

主视图

ContentView.swift 是应用的主视图:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

关键概念解析

  1. @main:标记应用程序的入口点
  2. App 协议:定义应用的结构
  3. Scene:表示应用的一个场景
  4. WindowGroup:创建应用的窗口
  5. View 协议:所有 SwiftUI 视图必须遵循的协议
  6. body:计算属性,返回视图的内容
  7. #Preview:Xcode 15+ 的新语法,用于创建预览

资源管理

  • Assets.xcassets:管理应用的图片、颜色等资源
  • Info.plist:应用的配置信息,如应用名称、版本号等

实战:创建一个简单的欢迎页面

需求分析

创建一个包含以下元素的欢迎页面:

  1. 应用图标
  2. 应用名称
  3. 欢迎标语
  4. 开始按钮

代码实现

import SwiftUI

struct WelcomeView: View {
    // 状态变量,用于控制是否显示欢迎页面
    @State private var isWelcomeShown = true
    
    var body: some View {
        if isWelcomeShown {
            VStack(spacing: 20) {
                // 应用图标
                Image(systemName: "star.fill")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.yellow)
                
                // 应用名称
                Text("欢迎使用 SwiftUI")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                
                // 欢迎标语
                Text("一个现代、简洁的 UI 框架")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                
                // 开始按钮
                Button("开始探索") {
                    // 点击按钮后隐藏欢迎页面
                    isWelcomeShown = false
                }
                .buttonStyle(.borderedProminent)
                .tint(.blue)
            }
            .padding()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(
                LinearGradient(
                    colors: [.blue.opacity(0.1), .purple.opacity(0.1)],
                    startPoint: .top,
                    endPoint: .bottom
                )
            )
        } else {
            // 主内容页面
            VStack {
                Text("探索 SwiftUI 的世界!")
                    .font(.title)
                Text("这里是你的应用主界面")
                    .foregroundStyle(.secondary)
            }
            .padding()
        }
    }
}

#Preview {
    WelcomeView()
}

代码解析

  1. @State:用于创建视图的本地状态
  2. VStack:垂直堆叠视图
  3. Image:显示图片,使用系统图标
  4. Text:显示文本
  5. Button:创建按钮,带有点击动作
  6. LinearGradient:创建线性渐变背景
  7. 条件渲染:使用 if-else 控制显示不同的内容

小结

本章介绍了 SwiftUI 的基本概念和开发环境搭建,包括:

  • SwiftUI 的核心优势和与 UIKit 的对比
  • Xcode 的安装和配置
  • 创建第一个 SwiftUI 项目的步骤
  • Xcode 预览功能的使用
  • SwiftUI 项目的基本结构
  • 一个简单欢迎页面的实现

通过本章的学习,你已经了解了 SwiftUI 的基本概念和开发环境,为后续的学习打下了基础。

参考资料

让 Claude Code 在你睡觉时持续运行:完整实战指南

让 Claude Code 在你睡觉时持续运行:完整实战指南

Claude Code 可以通过 -p 标志、权限绕过、循环模式和终端持久化的组合,实现数小时甚至整夜的无人值守运行。 开发者社区已经形成了一套可靠的操作手册:容器化运行环境、使用 “Ralph Wiggum” 循环模式、安装四个关键 Hook 防止卡死、保持 CLAUDE.md 精简。有开发者记录了 27 小时连续自主会话完成 84 个任务;另一位在睡觉时让 Claude 构建了一个 15,000 行的游戏。但社区也反馈,大约 25% 的过夜产出会被丢弃,而且如果没有适当的防护措施,Claude 曾在至少一位开发者的机器上执行过 rm -rf /。以下是你今晚就能用上的完整设置方案。


一、消除人工干预的三种模式

Claude Code 提供三个级别的自主运行模式,每个级别都在安全性和速度之间做取舍。理解它们是所有过夜方案的基础。

模式 1:-p(print/pipe)标志 —— 所有自动化的核心。 这是非交互式运行模式。接收 prompt,执行到完成,输出到 stdout,然后退出。无需 TTY,512MB 内存的服务器也能跑。

1
claude -p "查找并修复 auth.py 中的 bug" --allowedTools "Read,Edit,Bash"

模式 2:--permission-mode auto —— 更安全的折中方案。 2026 年初推出,使用 Sonnet 4.6 分类器自动批准安全操作,同时阻止高风险操作。分类器分两阶段运作:快速判定(8.5% 误报率),对标记项目进行思维链推理(0.4% 误报率)。如果连续 3 次操作被拒绝或单次会话累计 20 次被拒,系统会升级到人工介入——或者在 headless 模式下直接终止。

1
claude --permission-mode auto -p "重构认证模块"

模式 3:--dangerously-skip-permissions —— 完全绕过权限。 所有操作无需确认直接执行。Anthropic 自己的安全研究员 Nicholas Carlini 也使用这个模式,但有一个关键前提:*”在容器里跑,不要在你的真实机器上。”* 一项调查发现 32% 的开发者使用这个标志时遭遇了意外的文件修改,9% 报告了数据丢失

1
2
# 仅限 Docker/VM —— 绝对不要在宿主机上运行
claude --dangerously-skip-permissions -p "构建这个功能"

推荐的过夜运行方式是将 -p 与细粒度工具白名单 --allowedTools 结合使用,允许特定命令而非授予全面访问权限:

1
2
3
4
claude -p "修复所有 lint 错误并运行测试" \
--allowedTools "Read" "Edit" "Bash(npm run lint:*)" "Bash(npm test)" "Bash(git *)" \
--max-turns 50 \
--max-budget-usd 10.00

--max-turns--max-budget-usd 是无人值守会话的必备成本控制手段。没有它们,一个失控的循环可以在几分钟内烧光你的 API 预算。


二、Ralph Wiggum 循环:开发者的实际过夜方案

最经过实战验证的长时间自主工作模式是 Ralph Wiggum 循环——以《辛普森一家》中的角色命名,现已成为 Anthropic 官方插件。概念非常简单:一个 bash while 循环持续向 Claude 喂相同的 prompt。每次迭代中,Claude 查看当前文件状态和 git 历史,选择下一个未完成的任务,实现它,然后提交。

1
2
3
4
5
while true; do
claude --dangerously-skip-permissions \
-p "$(cat PROMPT.md)"
sleep 1
done

那位记录了 27 小时会话 的开发者使用了这个模式,配合一个详细的 prompt 文件,包含架构说明、目标、约束条件和明确的”完成”标准。他的核心发现:*”一句话 prompt 在一两个小时后就没劲了。27 小时的会话能持续下去,是因为 prompt 文件有足够多的上下文。”*

Prompt 文件比循环本身更重要。 一个有效的过夜 PROMPT.md 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 任务:测试并加固认证系统

## 上下文
- 后端:Express + TypeScript,位于 src/api/
- 数据库:PostgreSQL,schema 在 prisma/schema.prisma
- 认证流程:JWT 中间件在 src/middleware/auth.ts

## 目标
- 查看 docs/plan.md,选择下一个未完成的任务
- 实现它,包含完善的错误处理
- 运行测试,修复失败,确认没有回归
- 做通用修复,不要打临时补丁
- 每完成一个任务后用描述性消息提交

## 成功标准
- 每次修改后所有测试通过
- 不会引入之前修复的回归
- 当 plan.md 中所有任务完成后输出 DONE

社区有几个工具扩展了这个基础循环。Ralph CLI 增加了速率限制(100次调用/小时)、熔断器、会话过期(默认24小时)和实时监控仪表板。Nonstop 增加了飞行前风险评估和阻塞决策框架——走之前输入 /nonstop 即可。Continuous-claude 自动化完整 PR 生命周期:创建分支、推送、创建 PR、等待 CI、合并。


三、防止过夜灾难的四个 Hook

开发者 yurukusa 记录了 108 小时无人值守运行,识别出七类过夜事故——包括 Claude 执行 rm -rf ./src/、进入无限错误循环、直接推送到 main 分支,以及产生每小时 8 美元的 API 费用。解决方案:四个关键 Hook,共同预防最常见的故障模式。

10 秒快速安装:

1
npx cc-safe-setup

Hook 1:No-Ask-Human 阻止 AskUserQuestion 工具调用,强制 Claude 自主做出决定,而不是坐在那里等几小时等人回复。这个 Hook 决定了 Claude 是整夜工作还是在晚上 11:15 卡住。在你坐在电脑前时,用 CC_ALLOW_QUESTIONS=1 覆盖。

Hook 2:Context Monitor 将工具调用次数作为上下文使用量的代理指标,在四个阈值(剩余 40%、25%、20%、15%)发出分级警告。在临界水平时,配套的空闲推送脚本会自动向终端注入 /compact 命令——两个进程,共 472 行代码,零人工干预

Hook 3:Syntax Check 在任何文件编辑后立即运行 python -m py_compilenode --checkbash -n,在错误级联成 50 次调试之前就捕获它们。

Hook 4:Decision Warn 在执行前标记破坏性命令(rm -rfgit reset --hardDROP TABLEgit push --force)。通过 CC_PROTECT_BRANCHES="main:master:production" 配置受保护分支。

.claude/settings.json 中配置:

1
2
3
4
5
6
{
"permissions": {
"allow": ["Bash(npm run lint:*)", "WebSearch", "Read"],
"deny": ["Read(.env)", "Bash(rm -rf *)", "Bash(git push * main)"]
}
}

四、tmux 设置与保持机器不休眠

Claude Code 的交互模式需要 TTY —— 不能用 nohup 或将其作为 systemd 服务运行(大约 15-20 秒后会因 stdin 错误崩溃)。tmux 是会话持久化的必备工具

1
2
3
4
5
6
7
8
9
10
11
12
13
# 启动命名会话
tmux new -s claude-work

# 在其中启动 Claude
claude --permission-mode auto

# 分离(Claude 继续运行):Ctrl+B,然后按 D

# 从任何地方重新连接(SSH、手机 Termius 等)
tmux attach -t claude-work

# 不连接就查看进度
tmux capture-pane -t claude-work -p -S -50

对于真正的 7×24 运行,社区推荐 VPS + Tailscale + tmux 方案:便宜的 VPS(Hetzner、Vultr、DigitalOcean)提供永不关机的算力,Tailscale 提供私有网络,mosh 在不稳定网络上保持连接持久性。给 Claude 一个任务,分离,合上笔记本,明天再回来。

macOS 防止休眠:

1
2
3
4
5
# 绑定到 Claude 进程
caffeinate -i -w $(pgrep -f claude) &

# 或者在接通电源时全局禁用休眠
sudo pmset -c sleep 0

管理多个并行会话方面,Amux 是一个约 12,000 行的 Python 文件,提供 Web 仪表板、手机 PWA 监控、自愈看门狗(自动重启崩溃会话)、按会话 token 追踪和 git 冲突检测。Codeman 提供类似的 Web UI,带 xterm.js 终端,支持最多 20 个并行会话。

一个强大的过夜 agent tmux 配置:

1
2
3
4
5
6
7
8
9
#!/bin/bash
tmux new-session -d -s claude-dev
tmux rename-window -t claude-dev:0 'Claude'
tmux new-window -t claude-dev:1 -n 'Tests'
tmux new-window -t claude-dev:2 -n 'Logs'
tmux send-keys -t claude-dev:0 'claude --permission-mode auto' Enter
tmux send-keys -t claude-dev:1 'npm run test:watch' Enter
tmux send-keys -t claude-dev:2 'tail -f logs/app.log' Enter
tmux attach-session -t claude-dev

五、CLAUDE.md 与长时间运行的上下文管理

过夜失败的最大原因是上下文窗口耗尽。Claude Code 的上下文窗口大约 200K token,使用率超过 70% 时性能开始下降。自动压缩在接近阈值时触发,但会丢失信息——仅保留 20-30% 的细节。有开发者报告 Claude 压缩后遗忘了所有内容,重新开始同一个任务,浪费了三个小时。

解决方案是检查点/交接模式,能够在上下文重置后存活:

1
2
3
4
5
6
# 在 CLAUDE.md 中
当上下文变大时,将当前状态写入 tasks/mission.md。
包括:已完成的、下一步的、被阻塞的、未解决的问题。
错误处理:最多重试 3 次。如果没有进展,记录到
pending_for_human.md 然后转到下一个任务。
压缩前,务必保存完整的已修改文件列表。

将 CLAUDE.md 控制在 200 行以内——每个词在每个会话中都消耗 token。从 800 行切换到 100 行的开发者达成社区共识:更短的配置实际上表现更好,因为 Claude 不会忽略被噪音淹没的指令。使用”仅在不可逆时才提问”规则,将提问频率降低约 80%:

1
2
3
4
5
6
# 自主运行的决策规则
- 技术方案不确定 → 选择传统方案
- 两种可行实现 → 选择更简单的那个
- 尝试 3 次后仍有错误 → 记录到 blocked.md,切换任务
- 需求模糊 → 应用最合理的理解,记录假设
- 永远不要提问。做出最佳判断然后继续。

CLAUDE.md 文件是分层的:~/.claude/CLAUDE.md(全局)、./CLAUDE.md(项目级,git 追踪)、.claude/CLAUDE.local.md(个人覆盖,gitignore)。自主运行时,全局文件保持最小,把运行特定指令放在项目文件中。

关键 token 节省技巧:在里程碑后主动使用 /compact,而非等待自动压缩;对独立任务使用子 agent(每个有自己的上下文窗口);不相关的工作启动新会话;积极使用 .claudeignore 排除无关文件。


六、过夜运行的速率限制处理

速率限制作为三个独立的、重叠的约束运作:每分钟请求数、每分钟输入 token 数、每分钟输出 token 数。一个可见的命令在内部可能产生 8-12 个 API 调用(lint、修复、测试、修复循环)。15 次迭代后,单个请求可能发送 20 万+ 输入 token

过夜运行速率限制生存策略:

在非高峰时段运行。 Anthropic 确认工作日太平洋时间早 5 点到 11 点限制更严格。过夜运行和周末会话完全避开高峰期限流——恰好就是你在睡觉的时候。

利用 Ralph 循环的内置重试。 运行 while 循环时,速率限制错误只会导致当前迭代失败,但循环不在乎——它在速率限制窗口重置后的下一次迭代中重试。有开发者警告:*”不要在 API/按用量计费模式下运行——重试会烧光你的预算。”*

运行中切换模型。 Sonnet 能处理 60-70% 的常规任务,每 token 成本比 Opus 低约 1.7 倍。过夜工作设置 --model sonnet,将 Opus 留给复杂推理。也可以设置 --fallback-model sonnet,让 Claude 在主模型过载时自动降级。

Token 消耗的真实数据:20 条消息会话消耗约 105,000 token;30 条消息会话跳到 232,000 token。大约 98.5% 的 token 花在重新读取对话历史——只有 1.5% 用于实际输出。这就是为什么全新会话和积极压缩如此重要。

成本估算:持续运行 Sonnet 大约 $10.42/小时。基于 cron 每 15 分钟运行一次的 agent,预计约 $48/天。使用 --max-budget-usd 作为硬上限。


七、CI/CD 流水线与 Cron 任务集成

对于计划性的自动化工作,Claude Code 可直接与 CI/CD 系统集成。官方 GitHub Action 是 anthropics/claude-code-action@v1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "审查这个 PR 的安全和代码质量问题。"
claude_args: "--max-turns 5 --model claude-sonnet-4-6"

对于基于 cron 的自主 agent,Boucle 模式通过 state.md 文件在运行之间维持状态:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# run-agent.sh —— 由 cron 调用
STATE="$HOME/agent/state.md"
LOG="$HOME/agent/logs/$(date +%Y-%m-%d_%H-%M-%S).log"

claude -p "你是一个自主 agent。读取你的状态,决定做什么,
然后用你学到的内容更新 state.md。
$(cat $STATE)" \
--allowedTools Read,Write,Edit,Bash \
--max-turns 20 \
--max-budget-usd 1.00 \
--bare 2>&1 | tee "$LOG"
1
2
# crontab -e
0 * * * * /path/to/run-agent.sh

200 次迭代后的关键教训:state.md 必须保持在 4KB 以下(它会被注入每个 prompt),使用结构化键值对而非散文,并添加文件锁防止重叠运行。每次迭代后 git commit——git log 就是你最好的调试工具。

CI 环境使用 --bare 模式(跳过 hook、MCP 服务器、OAuth 和 CLAUDE.md 加载,最快最可复现的执行方式)和 --permission-mode dontAsk(拒绝所有未显式允许的操作——自动化环境中最安全的模式)。


八、已知陷阱与可能出错的地方

社区已广泛记录了以下故障模式:

故障模式 后果 预防方法
破坏性命令 Claude 运行 rm -rfgit reset --hard 或覆盖生产数据 PreToolUse hook 阻止危险命令;Docker 配合 --network none
无限错误循环 修复 → 测试 → 同样错误 → 修复 → 重复 20+ 次 CLAUDE.md 规则:”最多重试 3 次,然后记录到 blocked.md 继续下一个”
压缩后上下文丢失 Claude 遗忘一切,重新开始同一任务 压缩前将状态写入 mission.md;使用 Ralph 循环获得全新上下文迭代
权限提示阻塞 会话无限期挂起等待人工输入 No-Ask-Human hook;--dangerously-skip-permissions--permission-mode auto
直接推送到 main 未测试的代码部署到生产环境 分支保护规则;PreToolUse hook 阻止 git push 到受保护分支
API 成本失控 子 agent 进入循环调用外部 API($8/小时) --max-budget-usd;速率限制 hook;熔断器
OAuth token 过期 中途打断自主工作流 所有自动化使用 ANTHROPIC_API_KEY 环境变量而非 OAuth
订阅 ToS 违规 用 Pro/Max 订阅(非 API key)的 headless 模式可能违反消费者条款 自动化/脚本使用务必用 ANTHROPIC_API_KEY

最重要的单一安全措施是容器化。多位经验丰富的开发者独立推荐使用带网络隔离的 Docker:

1
2
3
4
5
docker run -it --rm \
-v $(pwd):/workspace -w /workspace \
--network none \
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
claude-code:latest --dangerously-skip-permissions -p "$(cat PROMPT.md)"

正如一位开发者所说:*”用 --dangerously-skip-permissions 运行 Claude Code 就像不做防护措施。所以用个套… 我是说容器。”*


九、今晚的快速启动清单

15 分钟设置过夜自主运行:

  1. 创建 git 检查点git add -A && git commit -m "pre-autonomous checkpoint"
  2. 安装四个关键 Hooknpx cc-safe-setup
  3. 编写 PROMPT.md,包含架构上下文、任务列表、成功标准,以及每完成一个任务就提交的指令
  4. 启动 tmux 会话tmux new -s overnight
  5. 防止休眠(macOS):caffeinate -s &
  6. 启动循环
1
2
3
4
5
6
7
8
while true; do
claude -p "$(cat PROMPT.md)" \
--allowedTools "Read" "Edit" "Bash(npm run *)" "Bash(git *)" \
--max-turns 30 \
--max-budget-usd 5.00 \
--permission-mode acceptEdits
sleep 2
done
  1. 分离 tmuxCtrl+B,然后按 D
  2. 去睡觉

早上起来:tmux attach -t overnight,然后查看 git log(git log --oneline)看 Claude 完成了什么。预计保留大约 75% 的产出,丢弃 25%。这很正常——正如一位开发者说的,*”不是完美,甚至不是最终版,但是在前进。”*

Xcode 26.4 下老项目与 Pod 兼容性修复指南

Xcode 26.4 下老项目与 Pod 兼容性修复指南

救命!Xcode 26.4 把我的老项目按在地上摩擦😭

谁懂啊家人们!更新完 Xcode 26.4 本想体验新功能,结果一编译直接红一片,全是老旧 Pod 库搞的鬼!AFNetworking、ZFPlayer、YYText 这些曾经的 iOS 开发神器,如今没人维护,遇上新版 Xcode 直接摆烂,留我们打工人疯狂擦屁股!

第一波暴击:私有头文件硬闯,直接被锁死

老旧 Pod 里还在无脑引用 <netinet6/in6.h> 这个 Darwin 私有头,Xcode 26.4 直接重拳出击,毫不留情甩报错:

Use of private header from outside its module: 'netinet6/in6.h'

说白了就是苹果把私有头锁门了,老库还硬要闯,纯纯作死!关键这头文件完全多余,<netinet6/in6.h> 早就把所有需要的内容都包含了,纯纯无用代码坑后人!

第二波暴击:YYText 逆天语法,数学式比较被制裁

YYText 里的链式比较 X < Y < Z 真的离大谱!照着数学公式写代码,以前 Xcode 睁一只眼闭一只眼,现在 26.4 直接较真报错:

Chained comparison 'X < Y < Z' does not behave the same as a mathematical expression

OC 根本不支持这种写法,老库不维护,bug 全留给我们,新版 Xcode 一严格,直接编译失败,血压瞬间拉满!

终极救星!一键脚本全自动修复

别手动改 Pod 源码了!直接把这段脚本塞进 Podfile,pod install 一键搞定所有报错,懒人狂喜,专治各种老库适配不服!

post_install do |installer|
 # Xcode 26 Explicit Modules:不允许直接 #import Darwin 私有头 <netinet6/in6.h>,
  # 相关类型已由 <netinet/in.h> 间接提供,移除即可(AFNetworking、ZFPlayer 等旧版 Pod 常见)。
  pods_dir = File.join(installer.sandbox.root, 'Pods')
  Dir.glob(File.join(pods_dir, '**', '*.{h,m,mm,c}')).each do |file|
    begin
      content = File.read(file)
      next unless content.include?('#import <netinet6/in6.h>')

      File.chmod(0644, file)
      new_content = content.gsub(/^\s*#import\s+<netinet6\/in6\.h>\s*\n/, '')
      File.write(file, new_content)
      puts "[Podfile] Removed #import <netinet6/in6.h> from #{file}"
    rescue => e
      puts "[Podfile] Warning: failed to patch #{file}: #{e.message}"
    end
  end

# Xcode 26:修复 YYText 中 YYTextLayout.m 文件的非法链式比较问题
  # 问题:Objective-C 中的链式比较语法 `a < b < c` 在 Xcode 较新版本中会产生编译错误
  # 修复方法:将链式比较转换为使用三元运算符的明确比较 `(a < b) ? x : y`
  yytext_layout_file = File.join(pods_dir, 'YYText', 'YYText', 'Component', 'YYTextLayout.m')
  if File.exist?(yytext_layout_file)
    begin
      content = File.read(yytext_layout_file)
      new_content = content
        # 修复垂直方向的链式比较
        .gsub(
          # gsub:"global substitute"(全局替换)
          # 基本语法 string.gsub(pattern, replacement)
          'position = fabs(left - point.y) < fabs(right - point.y) < (right ? prev : next);',
          'position = (fabs(left - point.y) < fabs(right - point.y)) ? prev : next;'
        )
        # 修复水平方向的链式比较
        .gsub(
          'position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next);',
          'position = (fabs(left - point.x) < fabs(right - point.x)) ? prev : next;'
        )

      if new_content != content
        File.chmod(0644, yytext_layout_file)  # 添加写权限
        File.write(yytext_layout_file, new_content)  # 写入修复后的内容
        puts "[Podfile] Fixed chained comparison in #{yytext_layout_file}"
      end
    rescue => e
      puts "[Podfile] Warning: failed to patch #{yytext_layout_file}: #{e.message}"
    end
  end
end

最后疯狂吐槽

苹果更新 Xcode 爽歪歪,我们适配老项目苦哈哈! 经典三方库集体停更,技术栈无人维护,Xcode 规则一收紧,全是编译坑。打工人的命也是命啊!

还好有这个一键脚本,复制粘贴就能解决所有报错,赶紧给你的老项目续命吧!


总结

  1. Xcode 26.4 严格校验私有头和语法,老旧未维护 Pod 库必报错

  2. 核心问题:netinet6/in6\.h 私有头引用、YYText 链式比较语法错误

  3. 粘贴脚本 → pod install → 编译成功,三步搞定,无需手动修改库源码

iOS逆向工程:详细解析ptrace反调试机制的破解方法与实战步骤

Ptrace 提供了一种父进程可以控制子进程运行的机制,并可以检查和改变它的核心image。

  • 它主要用于实现断点调试。

1、一个被跟踪的进程运行中,直到发生一个信号,则进程被中止,并且通知其父进程。 2、在进程中止的状态下,进程的内存空间可以被读写。父进程还可以使子进程继续执行,并选择是否是否忽略引起中止的信号。

  • 本文采用tweak 的方式进行MSHookFunction

在iOS应用安全中,除了反调试机制,代码混淆也是一种重要的保护手段。IpaGuard是一款强大的iOS IPA文件混淆工具,无需源码即可对代码和资源进行混淆加密,支持多种开发平台,有效增加反编译难度。它提供代码混淆、资源文件混淆、调试信息清理等功能,可以帮助开发者保护应用免受逆向工程攻击。

软件环境:Xcode 硬件环境:iPhone5越狱手机、Mac 开发工具:Cycript、LLDB、logos Tweak、hopper、MonkeyDev、AFLEXLoader、dumpdecrypted、debugserver、ssh、class_dump、hook

  • 破解方案

1、运行时期,断点ptrace,直接返回 2、分析如何调用的ptrace,hook ptrace 3、通过tweak,替换disable_gdb函数 4、修改 PT_DENY_ATTACH

I、运行时期,断点ptrace,直接返回

初始化应用程序,而不是运行中附着

iPhone:~ root#  debugserver -x posix *:12345  /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet

初始化程序,目的是从程序入口就开始进行附着,这样我们就可以在一些安全防护代码执行之前,进行破解。

跳过ptrace:过命令thread return直接返回,以跳过函数的逻辑。

 (lldb) br set -n ptrace
 Breakpoint 2: where = libsystem_kernel.dylib`__ptrace, address = 0x00000001966af2d4
 (lldb) br command add 2
 Enter your debugger command(s).  Type 'DONE' to end.
 > thread return
 > c
 > DONE

II、分析如何调用的ptrace,hook ptrace

去掉ptrace的思路

  • 当程序运行后,使用 debugserver *:1234 -a BinaryName 附加进程出现 segmentfault 11 时,一般说明程序内部调用了ptrace 。
  • 为验证是否调用了ptrace 可以 debugserver -x backboard *:1234 /BinaryPath(这里是完整路径),然后下符号断点 b ptrace,c 之后看ptrace第一行代码的位置,然后 p $lr 找到函数返回地址,再根据 image list -o -f 的ASLR偏移,计算出原始地址。最后在 IDA 中找到调用ptrace的代码,分析如何调用的ptrace。
  • 开始hook ptrace。

2.0 准备工作:砸壳

  • 砸壳

blog.csdn.net/z929118967/…

  • 查找app路径
iPhone:~ root# ps -e |grep AlipayWallet
  714 ??         0:26.44 /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
  736 ttys000    0:00.01 grep AlipayWallet

  • 获取NSDocumentDirectory
iPhone:~ root# cycript -p AlipayWallet
cy# [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]
#"file:///var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/"

  • scp 拷贝文件
devzkndeMacBook-Pro:decrypted devzkn$ scp /Users/devzkn/Downloads/kevin-software/ios-Reverse_Engineering/dumpdecrypted-master/dumpdecrypted.dylib iphone150:/var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/

devzkndeMacBook-Pro:decrypted devzkn$ scp iphone150:/var/mobile/Containers/Data/Application/89313E1C-76C2-41E3-8ECD-F4BDC1A78524/Documents/AlipayWallet.decrypted /Users/devzkn/decrypted/AlipayWallet

  • class-dump
devzkndeMacBook-Pro:bin devzkn$ class-dump --arch armv7 /Users/devzkn/decrypted/AlipayWallet10.1.8/AlipayWallet.decrypted -H -o /Users/devzkn/decrypted/AlipayWallet10.1.8/head

-查看 bundleIdentifier

iPhone:~ root# cycript -p AlipayWallet
cy# [[NSBundle mainBundle] bundleIdentifier]
@"com.alipay.iphoneclient"

2.1 编写 tweak 分析

%hook DFClientDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    %log();

    // 打印某个类的所有方法的,查看所有方法的执行顺序

     [KNHook hookClass:@"H5WebViewController"];//aluLoginViewController
     [KNHook hookClass:@"TBSDKServer"];//getUaPageName    aluMTopService _tokenLoginInvoker

     [KNHook hookClass:@"TBSDKMTOPServer"];//getUaPageName    aluMTopService _tokenLoginInvoker

    return %orig;
}
%end

2.2 具体步骤

2.2.1 debugserver

iPhone:~ root#  debugserver *:12345 -a AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Attaching to process AlipayWallet...
Segmentation fault: 11

当程序运行后,使用 debugserver *:1234 -a BinaryName 附加进程出现 segmentfault 11 时,一般说明程序内部调用了ptrace 。

iPhone:~ root#  debugserver *:12345 -x backboard /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Segmentation fault: 11

2.2.2 分析如何调用的ptrace

  • debugserver -x
iPhone:~ root# debugserver -x *:12345 /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
error: invalid TYPE for the --launch=TYPE (-x TYPE) option: '*:12345'
Valid values TYPE are:
  auto       Auto-detect the best launch method to use.
  posix      Launch the executable using posix_spawn.
  fork       Launch the executable using fork and exec.
  backboard  Launch the executable through BackBoard Services.

总共有四种类型

debugserver -x backboard *:1234 /var/mobile/...... 把这个backboard改成posix试试

iPhone:~ root#  debugserver -x posix *:12345  /var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet
debugserver-@(#)PROGRAM:debugserver  PROJECT:debugserver-320.2.89
 for armv7.
Listening to port 12345 for a connection from *...

  • 在ptrace上下断点,找到调用ptrace的地方
(lldb)  b ptrace
Breakpoint 1: no locations (pending).
WARNING:  Unable to resolve breakpoint to any actual locations.

(lldb) c
Process 1657 resuming

  • 关闭Target,重新启动Target
1 location added to breakpoint 1
Process 1657 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x37a13e64 libsystem_kernel.dylib`__ptrace
libsystem_kernel.dylib`__ptrace:
->  0x37a13e64 <+0>:  ldr    r12, [pc, #0x4]           ; <+12>
    0x37a13e68 <+4>:  ldr    r12, [pc, r12]
    0x37a13e6c <+8>:  b      0x37a13e74                ; <+16>
    0x37a13e70 <+12>: rsbeq  r9, r11, #192, #2
Target 0: (AlipayWallet) stopped.
(lldb)  p/x $lr
(unsigned int) $0 = 0x0000bfbb

由此可见ptrace函数在libsystem_kernel.dylib这个动态库中,使用时才进行加载,不是静态放在本地的,所以我们不能简单地去tweak ptrace函数。

(lldb) image list -o -f |grep AlipayWallet
[  0] 0x00000000 /private/var/mobile/Containers/Bundle/Application/A612F542-81EF-456A-A6A0-B23046EF57BA/AlipayWallet.app/AlipayWallet(0x0000000000004000)

所以ptrace的调用者位于0x0000bfbb - 0x00000000 = 0x0000bfbb处,如图所示:

  • 在hopper使用go to add ,快捷键G

Flutter应用代码混淆完整指南:Android与iOS平台配置详解

Flutter中的代码混淆

代码混淆可以隐藏你的Dart代码中的函数和类名,让 反编译 App变得困难。对于更全面的混淆需求,特别是针对iOS IPA文件,可以使用专业工具如IpaGuard,它支持无需源码的代码和资源混淆,兼容Flutter等多种开发平台,有效增加反编译难度。

注:Dart的混淆还没有经过完全的测试,如果发现问题请到GitHub上提 issue 。关于混淆的问题,还可以参考 Stack Overflow 上的这个问题。

Flutter中的混淆配置其实是在Android和iOS端分别配置的。

Android

<ProjectRoot>/android/gradle.properties 文件中添加如下代码:

extra-gen-snapshot-options=--obfuscate

默认情况下,Flutter不会混淆或者缩减Android host,如果你使用了第三方的Java或者Android库,那么你可能需要减小APK体积,或者防止你的App被反编译。

  • Step 1:配置Proguard文件

新建 /android/app/proguard-rules.pro 文件,然后添加如下配置:


#Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.**  { *; }
-keep class io.flutter.util.**  { *; }
-keep class io.flutter.view.**  { *; }
-keep class io.flutter.**  { *; }
-keep class io.flutter.plugins.**  { *; }

上面的配置只保护Flutter库,其他额外的库(比如Firebase)需要你自己添加配置。

  • Step 2:

打开 /android/app/build.gradle 文件,定位到 buildTypes 处,在 release 配置中将 minifiyEnableduseProguard 标志设为true,同时还需要指向Step1中创建的ProGuard文件:


android {
    ...
    buildTypes {
        release {
            signingConfig signingConfigs.debug
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

注意混淆和缩减无用代码会加长App的编译时间。

iOS

  • Step 1:修改 "build aot"

<ProjectRoot>/packages/flutter_tools/bin/xcode_backend.sh 文件中添加 build aot flag:

${extra_gen_snapshot_options_or_none}

然后定义这个flag:


local extra_gen_snapshot_options_or_none=""
if [[ -n "$EXTRA_GEN_SNAPSHOT_OPTIONS" ]]; then
  extra_gen_snapshot_options_or_none="--extra-gen-snapshot-options=$EXTRA_GEN_SNAPSHOT_OPTIONS"
fi
  • Step 2:应用你的修改

在你的App的根目录下运行以下两条命令:


git commit -am "Enable obfuscation on iOS"
flutter
  • Step 3:更改release配置

<ProjectRoot>/ios/Flutter/Release.xcconfig 中添加下面这行:

EXTRA_GEN_SNAPSHOT_OPTIONS=--obfuscate

对于iOS平台,如果需要更强大的混淆保护,可以考虑使用IpaGuard这样的工具,它可以直接对IPA文件进行混淆加密,支持代码和资源文件的全面混淆,无需源码即可操作,并兼容Flutter应用,提供即时测试功能。

AI没我们想的那么聪明!复盘我的Vibe Coding翻车案例

我已经失去了编写代码的能力,变成只会写提示词的提示词工程师。但也没有完全丧失,我还会Review代码,偶尔手动改几行代码。

比如用Swift写UI,我在完全不复制粘贴的情况下,已经不会从0行代码写出一个页面,只会在AI生成的代码微调布局。

准确的说,不是丧失了能力,而是丧失了记忆!

回顾最近在开发ApiCatcher这款iOS端HTTPS抓包和调试工具的过程中,除了用AI开发核心功能遇到困难,其实在开发UI过程中也遇到一些困难。

今天分享我用AI开发ApiCatcher UI遇到的两个问题。解决这两个问题,我从Gemini 3.1 Pro模型,升级到Claude Opus 4.6模型,又从Claude Opus 4.6模型降到Gemini 3 Flash,最新只用Gemini 3 Flash就解决了问题。

第一个案例,我让AI帮我开发ApiCatcher的“AI对话生成脚本”功能,这里遇到的问题就是Markdown渲染。AI响应的消息里面包含代码。

关于UI,我的提示词大概是这样的:

页面做成聊天对话界面,底部是输入框和发送按钮,其余空间是显示聊天记录,用户只能发送文本消息,AI响应的消息是Markdown格式,AI响应的消息包含代码块,需要能高亮渲染代码块,代码不自动换行,而是代码块可以左右滚动。

这里的难点就是要支持代码块的高亮和代码块可以水平滚动。

无论是Gemini还是Claude,他们的思路都是先用开源的Swift语言编写的Markdown渲染开源库,怎么调都调不出令我满意的效果,渲染结果太丑了。在尝试完几个开源库后,Gemini和Claude会走向自己实现,写得非常复杂,然后实现的效果更加糟糕!

在无数次挫败后,我开始自己思考解决方案。我告诉Claude,SwiftUI的Markdown渲染开源库不好用,我们可以用Web技术栈,找JS开源库,然后用WebView来渲染Markdown消息。

这次Claude终于开窍了,但也没完全开窍。他把每个Markdown消息,都用一个WebView来渲染....

我再告诉他,为什么不是只用一个WebView来渲染所有消息呢?这时候Claude限制额度了......

我降回了Gemini 3 Flash。终于在几次调整后,Gemini做出了我想要的效果。

最终用到的技术栈:WebView、CodeMirror、Highlight.js。

第二个案例,JSON可折叠可搜索。

这次一上来就用Claude Sonnet 4.6模型,提示词:帮我实现JSON全屏预览页面,要求:JSON高亮显示,路径可以折叠和展开,可以搜索,搜索结果高亮,可以点击“下一个”按钮跳转到下一个搜索结果的位置。

Claude还是一样的思路,先找SwiftUI的开源库,但折腾半天卡在了搜索功能。还是一样折腾到耗尽Claude额度。

SwiftUI没有好用的开源库,那就换个思路,于是我又想到了WebView。最后选择WebView+CodeMirror+Highlight.js,加上CodeMirror的一个行号插件、搜索插件,用Gemini 3 Flash开发,完美实现!

在这两个案例中,无论是Claude还是Gemini,他们的思考方式都是先找SwiftUI可以使用的开源库,如果达不到目的,就改变策略,自己实现,但这种复杂的任务,他们根本没能力实现好。他们无法变通到通过WebView去乔接,从使用Swift技术栈,转变为使用Web开发技术栈。

我们只要告诉AI方法,他们就能实现,所以,目前AI的能力,其实受限于使用者的能力、认知。初级程序员+AI 无法超越 高级程序员+AI!

未来提示词会替代编程语言,程序员从写代码变成写提示词,但我们依然需要学习提升写提示词的能力。编程能力从来不是指代码写得多漂亮,而是对底层原理的认知深度,同样写提示词的能力也并非是指表达能力,同样是对底层原理的认知深度和广度。

以上面两个案例为例,我们需要认识到Swift原生开发并不是只能使用Swift,SwiftUI开发的原生应用也可以使用WebView来渲染网页,可以Swift生成网页代码传递给WebView渲染,并且可以生成JS代码让WebView执行。我们可以不懂怎么去写代码实现,只需要知道可以这样做,这极大降低了学习难度!

AI放大了我们个人的能力:从需要学会如何编码实现,了解具体的底层原理,到只需要知道可以这样实现。

但只是放大个人能力,AI遇强则强,遇弱则弱,我们依然需要保持学习。

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身

iOS三方库精读 · 第 9 期


一、一句话介绍

AFNetworking 是 iOS / macOS 平台上最早也是最成功的 Objective-C HTTP 网络库,它让网络请求从繁琐的 NSURLConnection / NSURLSession API 变得简洁优雅,成为 OC 时代的事实标准。

属性
GitHub Stars 33k+
最新版本 4.0.0
License MIT
支持平台 iOS 9+ / macOS 10.10+ / watchOS / tvOS
维护状态 维护模式(Maintenance Mode)

二、为什么选择它

原生痛点(2011 年的 iOS 世界)

在没有 AFNetworking 之前,iOS 开发者不得不面对:

  • NSURLConnection 样板代码:每次请求都要实现 delegate 回调,代码分散难以维护
  • JSON 解析手动处理:iOS 5 之前没有 NSJSONSerialization,需要第三方库
  • 图片加载无缓存:网络图片每次都要重新下载,没有内存/磁盘缓存
  • 网络状态监听复杂:Reachability API 繁琐,代码量大
  • HTTPS 配置困难:自签名证书、SSL Pinning 需要深入了解安全 API

AFNetworking 核心优势

  1. 一行代码发起请求:GET / POST / PUT / DELETE 统一 API,无需 delegate
  2. 自动序列化:JSON / XML / Plist / Image 响应自动解析
  3. UIImageView CategorysetImageWithURL: 一行搞定网络图片加载
  4. Reachability 内建:实时监听网络状态,断网自动提示
  5. SSL Pinning 支持:证书校验一行配置,安全合规
  6. Block 回调:告别分散的 delegate,代码逻辑更清晰

原生 API vs AFNetworking

场景 原生 NSURLSession AFNetworking
GET 请求 10+ 行代码 + delegate [manager GET:success:failure:]
JSON 解析 NSJSONSerialization 手动调用 自动解析为 NSDictionary / NSArray
图片加载 需自行实现缓存 setImageWithURL: 一行
网络监听 Reachability 复杂 API setReachabilityStatusChangeBlock:
文件上传 构造 multipart 请求繁琐 POST:constructingBodyWithBlock:

三、核心功能速览

基础层 概念解释、环境配置、基础用法

环境配置

CocoaPods(OC 项目首选)

pod 'AFNetworking', '~> 4.0'

Swift Package Manager

// Package.swift
dependencies: [
    .package(url: "https://github.com/AFNetworking/AFNetworking.git", from: "4.0.0")
]

Swift 项目桥接

// {ProjectName}-Bridging-Header.h
#import <AFNetworking/AFNetworking.h>
#import "UIImageView+AFNetworking.h"

基础 GET 请求

// AFNetworking 4.x (OC)
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];

[manager GET:@"https://jsonplaceholder.typicode.com/users"
  parameters:nil
    progress:nil
     success:^(NSURLSessionDataTask *task, id responseObject) {
         NSLog(@"成功: %@", responseObject);
     }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
         NSLog(@"失败: %@", error.localizedDescription);
     }];

进阶层 最佳实践、性能优化、线程安全

POST 请求带参数

NSDictionary *params = @{
    @"name": @"张三",
    @"email": @"zhangsan@example.com",
    @"age": @28
};

[manager POST:@"https://api.example.com/users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"创建成功: %@", responseObject);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"创建失败: %@", error);
      }];

文件上传

NSData *imageData = UIImageJPEGRepresentation(image, 0.8);

[manager POST:@"https://api.example.com/upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:imageData
                                    name:@"file"
                                fileName:@"photo.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:^(NSProgress *progress) {
         NSLog(@"上传进度: %.0f%%", progress.fractionCompleted * 100);
     }
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSLog(@"上传成功");
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          NSLog(@"上传失败: %@", error);
      }];

图片加载(UIImageView Category)

#import "UIImageView+AFNetworking.h"

// 基础用法
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]];

// 带占位图
[imageView setImageWithURL:[NSURL URLWithString:@"https://example.com/avatar.jpg"]
         placeholderImage:[UIImage imageNamed:@"default_avatar"]];

// 取消加载(cell 复用场景)
[imageView cancelImageDownloadTask];

网络状态监听

[[AFNetworkReachabilityManager sharedManager] setReachabilityStatusChangeBlock:
    ^(AFNetworkReachabilityStatus status) {
        switch (status) {
            case AFNetworkReachabilityStatusNotReachable:
                NSLog(@"无网络连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWiFi:
                NSLog(@"WiFi 连接");
                break;
            case AFNetworkReachabilityStatusReachableViaWWAN:
                NSLog(@"蜂窝网络");
                break;
            default:
                break;
        }
    }];

[[AFNetworkReachabilityManager sharedManager] startMonitoring];

HTTPS 证书校验

manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
manager.securityPolicy.allowInvalidCertificates = YES;  // 允许自签名
manager.securityPolicy.validatesDomainName = YES;

深入层 源码解析、设计思想、扩展定制

核心类关系

AFURLSessionManager (核心)
    ├── AFHTTPSessionManager (HTTP 封装)
    │       ├── requestSerializer (请求序列化)
    │       └── responseSerializer (响应序列化)
    ├── AFSecurityPolicy (安全策略)
    └── AFNetworkReachabilityManager (网络监听)

Category 扩展:
    UIImageView+AFNetworking (图片加载)
    UIProgressView+AFNetworking (进度条)
    AFNetworkActivityIndicatorManager (状态栏网络指示器)

Session Manager 核心设计

// AFHTTPSessionManager 继承 AFURLSessionManager
// 职责分离:网络层、请求构建、响应解析各自独立

@interface AFHTTPSessionManager : AFURLSessionManager
@property (nonatomic, strong) AFHTTPRequestSerializer <AFURLRequestSerialization> *requestSerializer;
@property (nonatomic, strong) AFHTTPResponseSerializer <AFURLResponseSerialization> *responseSerializer;
@end

// 序列化协议设计
@protocol AFURLRequestSerialization <NSObject>
- (NSURLRequest *)requestBySerializingRequest:(NSURLRequest *)request
                                withParameters:(id)parameters
                                         error:(NSError * __autoreleasing *)error;
@end

四、实战演示

场景:完整的网络请求封装

// APIClient.h - 统一网络请求封装
#import <AFNetworking/AFNetworking.h>

@interface APIClient : AFHTTPSessionManager
+ (instancetype)sharedClient;

// 业务方法
- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion;
- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion;
- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion;
@end

// APIClient.m
@implementation APIClient

+ (instancetype)sharedClient {
    static APIClient *instance;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSURL *baseURL = [NSURL URLWithString:@"https://api.example.com/"];
        instance = [[self alloc] initWithBaseURL:baseURL];
        
        // 配置请求/响应序列化
        instance.requestSerializer = [AFJSONRequestSerializer serializer];
        instance.responseSerializer = [AFJSONResponseSerializer serializer];
        instance.requestSerializer.timeoutInterval = 30;
        
        // 添加通用请求头
        [instance.requestSerializer setValue:@"application/json"
                             forHTTPHeaderField:@"Content-Type"];
        
        // HTTPS 配置
        instance.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
    });
    return instance;
}

- (void)fetchUsers:(void(^)(NSArray *users, NSError *error))completion {
    [self GET:@"users"
      parameters:nil
        progress:nil
         success:^(NSURLSessionDataTask *task, id responseObject) {
             completion(responseObject, nil);
         }
         failure:^(NSURLSessionDataTask *task, NSError *error) {
             completion(nil, error);
         }];
}

- (void)createUser:(NSDictionary *)params
        completion:(void(^)(NSDictionary *user, NSError *error))completion {
    [self POST:@"users"
   parameters:params
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          completion(responseObject, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

- (void)uploadAvatar:(UIImage *)image
           completion:(void(^)(NSString *url, NSError *error))completion {
    NSData *data = UIImageJPEGRepresentation(image, 0.8);
    
    [self POST:@"upload"
   parameters:nil
    constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
        [formData appendPartWithFileData:data
                                    name:@"avatar"
                                fileName:@"avatar.jpg"
                               mimeType:@"image/jpeg"];
    }
     progress:nil
      success:^(NSURLSessionDataTask *task, id responseObject) {
          NSString *url = responseObject[@"url"];
          completion(url, nil);
      }
      failure:^(NSURLSessionDataTask *task, NSError *error) {
          completion(nil, error);
      }];
}

@end

五、源码亮点

进阶层 值得借鉴的用法

序列化器组合模式

// 请求序列化器可插拔
manager.requestSerializer = [AFJSONRequestSerializer serializer];   // JSON
manager.requestSerializer = [AFPropertyListRequestSerializer serializer];  // Plist

// 响应序列化器可插拔
manager.responseSerializer = [AFJSONResponseSerializer serializer];  // JSON
manager.responseSerializer = [AFXMLParserResponseSerializer serializer];  // XML
manager.responseSerializer = [AFImageResponseSerializer serializer];  // Image

Block 回调替代 Delegate

// 传统 delegate 分散在多个方法
// AFNetworking 用 block 聚合逻辑
[manager GET:url
  parameters:params
    progress:^(NSProgress *progress) {
        // 进度回调
    }
     success:^(NSURLSessionDataTask *task, id responseObject) {
        // 成功回调
    }
     failure:^(NSURLSessionDataTask *task, NSError *error) {
        // 失败回调
    }];

深入层 设计思想解析

NSURLSession 封装架构

// AFURLSessionManager 核心方法
- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request
                            completionHandler:(void (^)(NSURLResponse *, id, NSError *))handler {
    // 1. 创建 task
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:request];
    
    // 2. 存储 task delegate(用于回调)
    self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)] = delegate;
    
    // 3. 返回 task
    return task;
}

// task delegate 处理各种回调
- (void)URLSession:(NSURLSession *)session
          dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    // 累积数据,最终一次性回调
    [mutableData appendData:data];
}

UIImageView Category 的缓存设计

// UIImageView+AFNetworking 内部使用 AFImageDownloader
// 自动实现内存缓存 + 磁盘缓存

@interface AFImageDownloader : NSObject
@property (nonatomic, strong) AFImageCache *imageCache;  // 内存缓存
@property (nonatomic, strong) NSURLCache *urlCache;       // 磁盘缓存
@end

// 缓存策略
- (void)downloadImageForURLRequest:(NSURLRequest *)request
                        completion:(void (^)(UIImage *, NSError *))completion {
    // 1. 先查内存缓存
    // 2. 再查磁盘缓存
    // 3. 最后发起网络请求
}

六、踩坑记录

问题 1:iOS 9+ ATS 限制

问题:iOS 9 默认要求 HTTPS,HTTP 请求被阻止。

原因:App Transport Security (ATS) 默认禁止明文 HTTP。

解决

<!-- Info.plist 添加例外 -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

问题 2:Response Serializer 编码问题

问题:返回中文乱码或解析失败。

原因:服务器返回非 UTF-8 编码,AFJSONResponseSerializer 默认 UTF-8。

解决

AFHTTPResponseSerializer *serializer = [AFHTTPResponseSerializer serializer];
serializer.stringEncoding = NSUTF8StringEncoding;  // 或其他编码
manager.responseSerializer = serializer;

问题 3:Cell 复用图片错乱

问题:UITableView 快速滚动时,图片显示错误。

原因:Cell 复用时未取消之前的下载任务。

解决

- (void)prepareForReuse {
    [super prepareForReuse];
    [self.imageView cancelImageDownloadTask];  // 取消之前的下载
    self.imageView.image = nil;
}

问题 4:后台任务未处理

问题:App 进入后台后,下载任务中断。

原因:NSURLSession 默认不支持后台。

解决

// 使用后台 session configuration
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.example.background"];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithSessionConfiguration:config];

问题 5:Swift 混编桥接头文件缺失

问题:Swift 项目中使用 AFNetworking 报找不到模块。

原因:未配置 Objective-C Bridging Header。

解决

  1. 创建 {ProjectName}-Bridging-Header.h 文件
  2. 添加 #import <AFNetworking/AFNetworking.h>
  3. Build Settings → Swift Compiler - General → Objective-C Bridging Header 设置路径

七、延伸思考

AFNetworking vs Alamofire 横向对比

维度 AFNetworking Alamofire
语言 Objective-C Swift
首发年份 2011 2014
GitHub Stars 33k+ 41k+
最低版本 iOS 9.0 iOS 12.0
包体积 ~500KB ~300KB
维护状态 维护模式 活跃开发
Combine 集成 ❌ 不支持 ✅ DataRequest.publisher
async/await ❌ 不支持 ✅ async(...)
SwiftUI 支持 ❌ 不支持 需自行封装
学习曲线 中等 低(链式 API)
OC 兼容性 原生 需桥接

API 对照迁移表

AFNetworking (OC) Alamofire (Swift)
[manager GET:success:failure:] AF.request(url).response()
[manager POST:parameters:success:failure:] AF.request(url, method: .post, parameters: params)
[manager downloadTaskWithRequest:progress:destination:completion:] AF.download(url).downloadProgress()
[manager uploadTaskWithRequest:from:progress:completion:] AF.upload(multipartFormData:)
AFNetworkReachabilityManager NWPathMonitor (系统 API)
AFSecurityPolicy ServerTrustManager + PinnedCertificatesTrustEvaluator
UIImageView+AFNetworking AlamofireImage / Kingfisher

选型建议

选 AFNetworking:

  • 维护遗留 OC 项目,迁移成本高
  • 项目需要支持 iOS 9 ~ iOS 11
  • 团队 OC 技术栈成熟,Swift 经验少

选 Alamofire:

  • 新项目,Swift 为主要语言
  • 需要 Combine / async-await 集成
  • 想要更活跃的社区和持续更新
  • 最低支持 iOS 12+

渐进式迁移策略

  1. 共存阶段:OC 模块继续用 AFNetworking,新 Swift 模块用 Alamofire
  2. 抽象层:统一封装 NetworkService 协议,底层可替换
  3. 按模块迁移:优先迁移独立的业务模块,而非逐个请求修改
  4. 测试覆盖:迁移前后确保集成测试通过

八、参考资源

官方资源

相关文章

系列 Demo 仓库


本期互动

小作业

查看你现有的 OC 项目中 AFNetworking 的使用方式,尝试将一个简单的 GET 请求改写为 Alamofire 版本,对比代码量和可读性变化。

思考题

AFNetworking 从 1.x 到 4.x 经历了 NSURLConnection → NSURLSession 的底层迁移,如果你是核心开发者,你会如何设计 API 以保持向后兼容?

读者征集

你在从 AFNetworking 迁移到 Alamofire 的过程中遇到过哪些坑?欢迎评论区分享,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 · 已学习:[✓ Alamofire] [✓ Kingfisher] [✓ Lottie] [✓ MarkdownUI] [✓ SDWebImage] [✓ SnapKit] [✓ ListDiff] [✓ RxSwift] [→ AFNetworking] [○ Charts]

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131

issue131.webp

被 Vibe 摧毁的版权壁垒,与开发者的新护城河

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用(当然,也有分析者指出,这一安排同样有助于防止模型蒸馏,并锁定企业级客户)。但即便这头“猛兽”被暂时按住,当前主流 AI 模型的代码能力,已经足以让复制一款产品变得轻而易举。

上周,Reddit 上一位开发者宣称,自己花了一年时间“逆向 SwiftUI API”,打造了一个全新的 Swift Web 框架。帖子行文流畅、术语考究,一度吸引了不少关注。但 Paul Hudson 很快现身评论区打假:所谓“独立研发”,实际上只是将他的 MIT 开源项目 Ignite 做了简单的字符串替换,甚至连原作者带有个人风格的代码注释都原封不动保留,随后将整个仓库压缩为一次提交以抹除历史,并违规改为具有传染性的 GPL 协议。社区中不少开发者都怀疑,这套“逆向 SwiftUI”的叙事本身也是由 AI 生成。更耐人寻味的是,该作者本就是 Ignite 的主要贡献者之一——当 Vibe Coding 将“重新打包一个项目”的成本降至极低时,“我参与过”本身也可能成为一种模糊责任边界的话术。

几乎在同一时间,macOS 上精致的 AI 工作状态监控应用 Vibe Island 在发布后不久便遭遇了像素级仿制。尽管仿制者打着“开源替代品”的旗号公开了代码,这依然对原作者的商业销售与创作热情造成了明显冲击。然而,即便作者希望采取法律手段,也将面临一个新的时代难题:在确权与维权过程中,他可能需要证明其作品具备足够的人类独创性,并说明 AI 生成内容的参与程度,否则将面临更高的不确定性。

事实上,代码的法律壁垒正从“确权端”开始松动。上个月,中国版权保护中心正式启用新版《计算机软件著作权登记申请表》及相关审查新规,明确要求经办人实名承诺“未使用 AI 开发编写代码、撰写文档或生成登记材料”,并在审查中重点评估人类智力投入是否达到著作权法所要求的“独创性”门槛;缺乏实质人类参与的内容,将难以获得确权。违规者还可能被纳入失信名单,并与个人征信挂钩。

这一趋势也与欧美近期的判例方向趋于一致:如果一段代码主要由 AI 根据提示词快速“改写或重组”生成,其获得著作权保护的可能性将显著降低。

我们必须承认一个残酷的事实:“我有一个绝妙的 Idea,并把它 Vibe 成代码”,已经不足以构成商业壁垒。这种名为 Vibe Coding 的新范式,不仅改变了开发流程、显著提升了效率,也从三个方向同时动摇了软件版权体系的基础逻辑:确权门槛提高、侵权举证变难、功能复刻被默许。

令人遗憾的是,即便这些克隆项目饱受争议,它们依然在 GitHub 上收获了不菲的 Star。这说明,在获取成本极低的前提下,仅靠道德呼吁,已经很难阻止人们对“免费平替”的追逐。

或许,正如我们在 第 120 期周报中讨论 Skip 的开源举措 时所提到的那样——在代码实现成本趋近于零、应用随时可能被 AI 一键克隆的当下,闭门造车“卖工具”将变得愈发困难。与用户建立真实连接,将“出品方的信誉与社区的信任”转化为不可复制的品牌资产,或许才是未来开发者真正的核心竞争力与护城河。

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

近期推荐

Swift Blog Carnival: Tiny Languages

Swift 社区正在发起第一届 Blog Carnival,四月的主题是 Tiny LanguagesChristian Tietze 邀请开发者围绕这一主题撰写博客:自定义 DSL、Result Builder、脚本解析器、路由规则……任何与“微型语言”相关的思考都可以作为切入点,截止日期为 5 月 1 日。

目前已有三篇投稿:

  • Matt Massicotte 回顾了他 从 Rake 到 Make,再到各类 Swift 任务运行器的探索历程,坦言至今仍未找到理想替代品
  • Chris Liscio 分享了 Capo 应用内置 DSL 的设计:用于描述键盘与 MIDI 绑定,基于 Point-Free 的 swift-parsing 库构建
  • Nicolas Zinovieff 则展示了一个 符号数学 DSL 的实验:通过协议与运算符重载,让 (1 + 2 * "X") * (3 - "Y")成为合法的 Swift 表达式,并在提供具体值时惰性求值,核心实现不超过 300 行

在 macOS 菜单中清晰显示当前选中状态 (Indicating Selection in macOS Menus Using SwiftUI)

SwiftUI 提供了不少用于表达选择的组件,例如 PickerToggleMenu,但如何清晰地引导用户进行选择,并准确标识当前选中项,并没有想象中那样理所当然。Gabriel Theodoropoulos 从最基础的 Button 出发,一步步演进到 PickerToggle,系统梳理了几种常见方案及其各自的局限。文章的价值不在于给出“唯一正确解”,而在于提醒开发者:SwiftUI 提供的标准组件,并不会自动带来最佳的用户呈现效果。很多时候,你仍需在“系统一致性”与“实现自由度”之间做出权衡。


构建 List 的替代组件 (Building List replacement in SwiftUI)

如何在 ListScrollView + LazyStack 之间做出选择,一直是困扰不少 SwiftUI 开发者的问题。在本文中,Majid Jabrayilov 在重构其 CardioBot 应用时,选择基于 SwiftUI 的 Container View API(iOS 17+)构建了三个可复用的 UI 组件:ScrollingSurface、DividedCard、SectionedSurface,以替代 List。这些组件在使用方式上与 List + Section 高度相似,但彻底摆脱了 listRowBackgroundlistItemTint 等仅在 List 中生效的限制。

List 并非只是“有默认样式的 LazyVStack”——两者在底层架构、滚动控制、与导航容器的协同、以及大数据集下的性能表现上均有本质差异。如何在两者之间做出综合判断,可以参考这篇旧文。


AppIntents meet MCP

当大家还在将 AppIntents 视为 Siri 与快捷指令的“配套工具”时,Florian Schweizer 给出了一个更值得关注的方向:将 AppIntents 直接暴露为 MCP(Model Context Protocol)工具,从而让 LLM 能够调用你的应用能力。Florian 基于 SwiftMCP,通过宏构建 MCP Server,并将 AppIntents 无缝映射为 MCP Tools,使 AI Agent 能够直接调用应用中的 Intent,实现跨应用自动化。

去年便有传闻,苹果正在为其生态系统引入 MCP 支持。或许再过两个月,在 WWDC 26 上答案便会揭晓。


创建交互式小组件 (Ride the Lightning Air: Building Interactive WidgetKit Widgets)

很多开发者都会被 WidgetKit 的文档误导,错将 AppIntentTimelineProvider 视为实现交互按钮的关键——实则不然。它是为“用户可配置 Widget”(如长按后编辑设置项)准备的,与交互行为并无直接关系。而真正用于实现交互(按钮点击触发行为)的,依然是最基础的 TimelineProviderWesley Matlock 通过一个虚构航空公司的登机状态 Widget,完整演示了正确路径:使用 TimelineProvider + Button(intent:) + AppGroup 共享存储来构建交互式 Widget。

整个数据流形成一个清晰的闭环:用户操作 → Intent 执行 → 状态写入 → Widget 刷新 → UI 更新。


文件存储与 iCloud:从本地到云端的完整认知

在 iOS / macOS 开发和使用中,文件存储往往被当作“基础能力”,但它实际上直接决定了数据的生命周期与系统行为。

Working with files and directories in iOS 一文中,Natascha Fadeeva 系统梳理了 App Sandbox 的结构,以及 DocumentsLibraryCaches 等目录的职责划分,帮助开发者建立“什么数据该放在哪里”的基本认知,并说明如何避免无关文件被 iCloud 备份。

Howard Oakley 撰写的 Understanding and Testing iCloud 则从系统层面揭示了这些数据的“后续命运”。iCloud 并非单一服务,而是由 CloudKit、iCloud Drive、系统更新等多个子系统组成,不同数据类型对应不同的同步与备份路径。

文件的存储位置,并不仅仅是组织问题,更是在定义它是否会被备份、同步,以及在设备之间如何流转。

因此,iCloud 问题往往不是“是否开启同步”这么简单,而是涉及客户端、网络、缓存以及服务端限流等多个环节。

工具

Bad Dock: 让你的 Dock 图标动起来

这是一个“离谱但严肃”的 macOS 实验项目。Eric Martz 利用公开的 NSDockTile / NSDockTilePlugin API,绕过 Big Sur 之后 Dock 图标的 squircle 限制,将视频流直接渲染进 Dock 图标。实现思路并不复杂,但非常完整:使用 AVAssetReader 解码视频、主动降帧至约 12fps、通过 ring buffer 控制内存占用,最终将一个“整活想法”打磨成了结构清晰的技术验证。

这类项目的价值不在功能本身,而在于展示:系统 API 的边界,往往比文档写得更远。

补充说明:该项目实现的是运行时动态 Dock 图标(应用运行时持续绘制);应用退出后,仅能通过 NSDockTilePlugin 保留静态自定义图标。

往期内容

💝 支持与反馈

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

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

🚀 拓展 Swift 视野

被 Vibe 摧毁的版权壁垒,与开发者的新护城河 - 肘子的 Swift 周报 #131

Anthropic 不久前宣布,由于其最新模型 Mythos 在网络安全与代码漏洞挖掘方面的能力“过于强大”,已达到令人不安的程度,因此采取了极为罕见的克制措施:仅向 Project Glasswing 内的少数关键基础设施企业开放,不面向公众发布,普通开发者也无法通过 API 调用

iOS 自定义 UICollectionView 拼图布局 + 布局切换动画实践

🧩 iOS 自定义 UICollectionView 拼图布局 + 布局切换动画实践

本文只讲两件事: 👉 如何自定义 UICollectionView 布局 👉 如何优雅地切换布局并处理动画问题


✨ 一、为什么要自定义布局?

系统的 UICollectionViewFlowLayout 有一个明显限制:

👉 只能做规则网格(grid)布局

但很多场景需要:

  • 拼图布局(不规则)
  • 瀑布流变种
  • 卡片混排

例如👇

┌───────────────┐
│       A       │
├───────┬───────┤
│   B   │   C   │
└───────┴───────┘

👉 这种布局 FlowLayout 是做不了的


🧠 二、自定义布局核心原理

自定义布局本质只做三件事:

1️⃣ 计算每个 cell 的 frame

UICollectionViewLayoutAttributes

2️⃣ 返回可见区域的 attributes

override func layoutAttributesForElements(in rect: CGRect)

3️⃣ 告诉 collectionView 内容尺寸

override var collectionViewContentSize: CGSize

🔥 三、实现一个拼图布局(MosaicLayout)

核心代码

class MosaicLayout: UICollectionViewFlowLayout {

    var layoutAttributes: [UICollectionViewLayoutAttributes] = []
    var contentSize: CGSize = .zero

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { return }

        layoutAttributes.removeAll()

        let count = collectionView.numberOfItems(inSection: 0)
        guard count > 0 else { return }

        let width = collectionView.bounds.width
        let height = width

        let frames: [CGRect] = [
            CGRect(x: 0, y: 0, width: 1, height: 0.6),
            CGRect(x: 0, y: 0.6, width: 0.5, height: 0.4),
            CGRect(x: 0.5, y: 0.6, width: 0.5, height: 0.4)
        ]

        for (index, relativeFrame) in frames.enumerated() {

            let frame = CGRect(
                x: relativeFrame.origin.x * width,
                y: relativeFrame.origin.y * height,
                width: relativeFrame.width * width,
                height: relativeFrame.height * height
            )

            let attr = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0))
            attr.frame = frame
            layoutAttributes.append(attr)
        }

        contentSize = CGSize(width: width, height: height)
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return layoutAttributes.filter { $0.frame.intersects(rect) }
    }

    override var collectionViewContentSize: CGSize {
        return contentSize
    }
}

🎬 五、布局切换动画

collectionView.setCollectionViewLayout(newLayout, animated: true)

⚠️ 六、崩溃问题

The invalidation context is not an instance of UICollectionViewFlowLayoutInvalidationContext

解决方案

collectionView.setCollectionViewLayout(newLayout, animated: false)
collectionView.reloadData()

🎯 七、总结

👉 自定义布局 = 自己控制 frame 👉 动画关键 = 避免同时更新数据源


如果你觉得有用,欢迎点赞 👍

Flutter iOS应用混淆与安全配置详细文档指南

Flutter iOS应用混淆与安全配置文档

概述

本文档详细描述了iOS应用的混淆与安全配置过程。这些配置旨在保护应用代码、API密钥和敏感数据,防止逆向工程和恶意攻击。配置包括 Dart 代码混淆、原生代码混淆、运行时安全检查和数据安全措施。

混淆与安全措施

Dart代码混淆

Flutter提供了内置的代码混淆功能,通过以下参数启用:

--obfuscate --split-debug-info=./symbols
1

这将:

  • 重命名代码中的标识符,使反编译后的代码难以理解
  • 将调试信息分离到单独的文件中,减少发布版本中的可读信息
  • 保留符号信息用于崩溃分析,但不包含在发布版本中

此外,使用像IpaGuard这样的专业混淆工具可以进一步增强应用安全性。IpaGuard是一款强大的iOS IPA文件混淆工具,无需源码即可对代码和资源进行混淆加密,支持Flutter等多种开发平台,有效增加反编译难度。

原生代码混淆与安全

在iOS上,我们通过以下配置增强安全性:

  1. BuildSettings.xcconfig 配置:
// 启用代码混淆和优化
GCC_OPTIMIZATION_LEVEL = s
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
DEAD_CODE_STRIPPING = YES

// 安全设置
ENABLE_STRICT_OBJC_MSGSEND = YES
CLANG_WARN_SUSPICIOUS_MOVE = YES
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES
GCC_NO_COMMON_BLOCKS = YES
STRIP_STYLE = all
STRIP_INSTALLED_PRODUCT = YES
COPY_PHASE_STRIP = YES
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

// 启用应用传输安全
PRODUCT_SETTINGS_URL_SCHEMES = "$(inherit)"
PRODUCT_SETTINGS_APP_TRANSPORT_SECURITY_ALLOWS_ARBITRARY_LOADS = NO

// 添加其他安全属性
OTHER_LDFLAGS = $(inherited) -Wl,-no_pie
12345678910111213141516171819202122
  1. Xcode构建参数
xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Release clean build \
  ENABLE_BITCODE=YES STRIP_INSTALLED_PRODUCT=YES DEPLOYMENT_POSTPROCESSING=YES \
  -sdk iphoneos -allowProvisioningUpdates
123

这些参数确保:

  • 启用Bitcode,允许App Store进一步优化代码
  • 移除不必要的符号和调试信息
  • 进行部署后处理操作,应用额外的优化

运行时安全检查

通过以下Swift代码实现运行时安全检查:

// 检查设备是否已越狱
func isJailbroken() -> Bool {


    #if targetEnvironment(simulator)
    return false
    #else
    // 检查常见的越狱文件路径
    let jailbreakPaths = [
        "/Applications/Cydia.app",
        "/Library/MobileSubstrate/MobileSubstrate.dylib",
        "/bin/bash",
        "/usr/sbin/sshd",
        "/etc/apt",
        "/private/var/lib/apt/",
        "/usr/bin/ssh"
    ]

    for path in jailbreakPaths {


        if FileManager.default.fileExists(atPath: path) {


            return true
        }
    }

    // 检查是否可以写入私有目录
    let stringToWrite = "Jailbreak Test"
    do {


        try stringToWrite.write(toFile: "/private/jailbreak.txt", atomically: true, encoding: .utf8)
        try FileManager.default.removeItem(atPath: "/private/jailbreak.txt")
        return true
    } catch {


        // 无法写入,说明没有越狱
    }

    return false
    #endif
}

// 检查是否连接调试器
func isDebuggerAttached() -> Bool {


    #if DEBUG
    return false
    #else
    var info = kinfo_proc()
    var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
    var size = MemoryLayout<kinfo_proc>.stride
    let status = sysctl(&mib, UInt32(mib.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556

苹果iOS应用开发上架与推广完整教程

苹果应用上架与推广详细指南

作为苹果个人开发者,确保用户能够顺畅地下载并安装自己精心开发的应用,是不可或缺的一环。接下来,我们将深入探讨实现这一目标的一系列核心步骤。

01应用上架准备

> 注册开发者账号

注册开发者账号是发布应用的第一步,必须在苹果开发者官网完成信息填写和费用支付。你需要前往苹果开发者官网,填写包括姓名、联系方式在内的个人真实信息,并支付相应费用。完成注册与身份验证后,你将获得发布应用的权限。

> 确保应用符合准则

在用户能够顺畅地下载并安装应用之前,开发者需要完成一系列的准备工作。这些准备工作的目标是确保应用的下载和安装过程尽可能顺畅,从而提升用户体验。

应用需符合苹果的质量与安全标准,不得包含恶意代码,需具备良好的UI设计和正常功能。苹果对应用的质量、功能及安全性都设有严格标准。应用不得包含恶意代码,必须具备良好的用户界面设计,且功能正常、无显著漏洞。此外,应用还需遵守法律法规,如不得侵犯他人知识产权,不得诱导用户进行不合理付费等。只有满足这些准则的应用,才有可能通过审核并上架供用户下载。

> 选用开发工具与测试

接下来,我们就将详细介绍这些准备工作。在着手构建和测试应用之前,有几个关键步骤需要完成。这些步骤旨在为应用的顺畅下载和安装铺平道路,进而优化用户体验。

使用Xcode进行开发,选择合适的编程语言并在多设备和系统版本上进行全面测试,确保功能完备、兼容性和性能。通常,Xcode被视为开发首选,它提供了从代码编写到编译、调试的一站式服务。依据应用特性,选择如Swift或Objective-C等编程语言。以一个简易笔记应用为例,我们可以运用Swift来构建用户界面,并实现笔记的增删改查功能。务必在不同型号的iOS设备和多种系统版本上进行详尽测试。这包括验证功能的完备性,例如确认笔记应用能否顺利存储和读取数据;确保兼容性,以保证应用在iPhone、iPad等设备上的一致性;以及评估性能,如测试应用的启动速度和响应时间。例如,在iPhone 13与iPhone 8上分别运行笔记应用,观察是否存在布局混乱或功能失效的问题。此外,开发者也可以使用AppUploader等工具来简化iOS证书的申请和管理,支持在Windows、Linux或Mac系统中操作,无需依赖Mac电脑。

02向App Store提交与推广

> 准备元数据与分类选择

接下来,就可以将你的应用提交到App Store了。 提交应用前需准备应用名称、描述、截图及视频,并选择合适的分类以提高用户搜索的精准性

为应用起一个简洁且能体现核心功能的名称,如“速记笔记”,同时撰写详细且吸引人的描述,突出应用特点、功能和优势。此外,还需提供展示关键界面和操作流程的截图及视频预览,使用户能直观了解应用。依此选择合适的分类,如笔记应用可归于“效率”类别。这样能帮助用户在App Store中更精准地搜索到应用。

> 提交审核与推广策略

通过App Store Connect提交审核,积极进行应用推广,包括社交媒体、博主合作及应用内推广,提高用户下载量。

使用Xcode完成应用打包后,通过App Store Connect提交应用进行审核。或者,使用AppUploader工具上传IPA文件到App Store,它支持多平台,比Application Loader更高效,且不携带设备信息。审核过程通常需数日,期间苹果团队将检查应用是否符合各项标准。若审核过程中发现任何问题,将收到反馈通知,需根据反馈进行相应修改后重新提交。同时,积极推广应用也是提高下载量的关键。

  1. 社交媒体推广:借助Twitter、Facebook、Instagram等社交媒体平台,广泛宣传您的应用。您可以分享应用截图、详细的功能介绍以及使用教程等,以此吸引更多潜在用户的关注。例如,在Twitter上发布一系列关于您的笔记应用使用技巧的推文,并附上应用的下载链接。

  2. 与博主和媒体合作:积极联系相关领域的博主、自媒体或科技媒体,向他们介绍您的应用,并努力争取他们的推荐或报道。例如,与专注于效率类应用评测的博主取得联系,邀请他们体验您的应用并分享他们的使用感受。

  3. 应用内推广:如果您还有其他已发布的应用,可以在这些应用内部推广您的新应用,从而引导现有用户下载并体验。

通过上述推广策略,苹果个人开发者可以成功地推动应用的下载安装,并通过广泛的推广活动提高应用的下载量和用户活跃度,从而让您的开发成果得到更多用户的认可和喜爱。

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线

iOS三方库精读 · 第 11 期


一、一句话介绍

DGCharts(原名 Charts)是一个用于 iOS/macOS/tvOS 的图表渲染库,它是 Android 端 MPAndroidChart 的 Swift 移植版,让在 UIKit 与 SwiftUI 中绘制专业级折线图、柱状图、饼图等 8 种图表类型变得像配置数据一样简单。

属性 信息
⭐ GitHub Stars 27.5k+
最新稳定版 5.1.0
License Apache 2.0
支持平台 iOS 13+ / macOS 10.14+ / tvOS 13+
语言 Swift(纯 Swift,无 OC 接口)

二、为什么选择它

原生痛点

苹果官方没有提供专用图表框架(Swift Charts 直到 iOS 16 才随 SwiftUI 一起发布),在此之前,iOS 开发者要想画一条像样的折线图,要么:

  • 用 CoreGraphics 手撸贝塞尔曲线 → 代码量大,动画难实现
  • 用 CALayer + CAAnimation → 正确实现坐标系变换极其麻烦
  • 引入 WebView 渲染 ECharts → 性能差、交互延迟

DGCharts 解决的核心痛点:

  1. 零 CoreGraphics 基础可上手:数据 → DataSet → Chart,三行完成
  2. 内置 8 种图表类型:折线、柱状、饼、雷达、散点、K 线、气泡、组合图
  3. 原生手势支持:缩放、拖拽、高亮点击,全部开箱即用
  4. 可定制到像素级别:颜色、字体、轴线、动画均可覆盖
  5. iOS 13+,Swift Charts 的前辈:大量存量 App 仍在使用,理解它有助于迁移评估

与 Swift Charts 的核心区别(一眼对比)

维度 DGCharts Swift Charts(苹果官方)
平台要求 iOS 13+ iOS 16+
UI 框架 UIKit + SwiftUI SwiftUI only
图表类型 8 种(含 K 线/雷达) 折/柱/面积/点(4 种)
动画控制 精细帧级控制 声明式,较难定制
手势交互 内置缩放/平移 需自己实现
维护状态 社区维护,活跃 Apple 官方,稳定更新

三、核心功能速览

基础层(新手必读)

环境集成

SPM(推荐):

// Package.swift 或 Xcode Add Package Dependency
// URL: https://github.com/danielgindi/Charts.git
// from: "5.1.0"

CocoaPods:

pod 'DGCharts', '~> 5.1.0'
最简用法:3 行画折线图
import DGCharts

let chartView = LineChartView(frame: view.bounds)

// 准备数据
let entries = (0..<10).map { ChartDataEntry(x: Double($0), y: Double.random(in: 10...100)) }
let dataSet = LineChartDataSet(entries: entries, label: "销售额")
chartView.data = LineChartData(dataSet: dataSet)

view.addSubview(chartView)

基础层 概念:ChartDataEntry 是最小数据单元(x, y),XxxChartDataSet 是同类数据的集合(样式配置在这里),XxxChartData 是图表的数据容器,XxxChartView 是最终渲染视图。


进阶层(最佳实践)

8 种图表类型一览
类名 用途
LineChartView 折线图(支持曲线插值)
BarChartView 柱状图(支持堆叠/分组)
PieChartView 饼图 / 甜甜圈图
RadarChartView 雷达图(蜘蛛网图)
ScatterChartView 散点图
CandleStickChartView K 线图(OHLC)
BubbleChartView 气泡图
CombinedChartView 组合图(折+柱叠加)
常用样式定制
// 折线数据集样式
let dataSet = LineChartDataSet(entries: entries, label: "趋势")
dataSet.colors = [.systemBlue]          // 线条颜色
dataSet.lineWidth = 2.0                  // 线宽
dataSet.circleColors = [.systemBlue]     // 数据点颜色
dataSet.circleRadius = 4.0               // 数据点半径
dataSet.drawFilledEnabled = true         // 开启渐变填充
dataSet.fillColor = .systemBlue
dataSet.fillAlpha = 0.3
dataSet.mode = .cubicBezier              // 贝塞尔平滑曲线

// 图表视图样式
lineChartView.rightAxis.enabled = false  // 隐藏右轴
lineChartView.xAxis.labelPosition = .bottom
lineChartView.animate(xAxisDuration: 1.0, easingOption: .easeInOutQuart)
交互设置
chartView.scaleXEnabled = true           // 允许 X 轴缩放
chartView.scaleYEnabled = false          // 禁止 Y 轴缩放
chartView.dragEnabled = true             // 允许拖拽
chartView.pinchZoomEnabled = true        // 双指缩放
chartView.doubleTapToZoomEnabled = false // 关闭双击缩放
X 轴自定义 Formatter
// 将数字索引转为日期字符串
class DateAxisValueFormatter: AxisValueFormatter {
    private let dates = ["1月", "2月", "3月", "4月", "5月", "6月"]
    func stringForValue(_ value: Double, axis: AxisBase?) -> String {
        let index = Int(value)
        return index < dates.count ? dates[index] : ""
    }
}
chartView.xAxis.valueFormatter = DateAxisValueFormatter()

深入层(源码视角)

DGCharts 的核心渲染架构遵循 Renderer 模式

  • ChartView → 入口,持有 ChartData 和多个 DataRenderer
  • DataRenderer(如 LineChartRenderer)→ 负责具体图形绘制,使用 CGContext
  • ChartViewBase → 提供手势识别、坐标变换 Transformer
  • Transformer → 负责将数据坐标系映射到屏幕坐标系(核心矩阵变换)

每次 data 被 set,触发 notifyDataSetChanged()calcMinMax()setNeedsDisplay(),最终回调 draw(_ rect:) 由各 Renderer 完成绘制。


四、实战演示

场景:股票日 K 线 + 成交量组合图

// Swift UIKit,iOS 17+
import UIKit
import DGCharts

class StockChartVC: UIViewController {

    private lazy var combinedChart: CombinedChartView = {
        let chart = CombinedChartView()
        chart.legend.enabled = true
        chart.rightAxis.enabled = false
        chart.xAxis.labelPosition = .bottom
        chart.animate(xAxisDuration: 0.8)
        return chart
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        view.addSubview(combinedChart)
        combinedChart.frame = CGRect(x: 16, y: 100,
                                      width: view.bounds.width - 32,
                                      height: 320)
        setupChart()
    }

    private func setupChart() {
        // ---- K 线数据 ----
        let candleEntries: [CandleChartDataEntry] = [
            CandleChartDataEntry(x: 0, shadowH: 185, shadowL: 162, open: 165, close: 178),
            CandleChartDataEntry(x: 1, shadowH: 190, shadowL: 170, open: 178, close: 188),
            CandleChartDataEntry(x: 2, shadowH: 195, shadowL: 175, open: 188, close: 177),
            CandleChartDataEntry(x: 3, shadowH: 182, shadowL: 165, open: 177, close: 169),
            CandleChartDataEntry(x: 4, shadowH: 175, shadowL: 160, open: 169, close: 172),
        ]
        let candleSet = CandleChartDataSet(entries: candleEntries, label: "K 线")
        candleSet.shadowColor = .darkGray
        candleSet.shadowWidth = 1.5
        candleSet.decreasingColor = .systemRed     // 阴线颜色
        candleSet.decreasingFilled = true
        candleSet.increasingColor = .systemGreen   // 阳线颜色
        candleSet.increasingFilled = true
        candleSet.neutralColor = .systemGray

        // ---- 成交量柱状图 ----
        let barEntries = [
            BarChartDataEntry(x: 0, y: 3200),
            BarChartDataEntry(x: 1, y: 5400),
            BarChartDataEntry(x: 2, y: 4100),
            BarChartDataEntry(x: 3, y: 6800),
            BarChartDataEntry(x: 4, y: 3900),
        ]
        let barSet = BarChartDataSet(entries: barEntries, label: "成交量(手)")
        barSet.colors = [.systemBlue.withAlphaComponent(0.5)]
        barSet.axisDependency = .right  // 绑定右轴(虽然禁用了,仅用于数据缩放)

        // ---- 组合 ----
        let combined = CombinedChartData()
        combined.candleData = CandleChartData(dataSet: candleSet)
        combined.barData = BarChartData(dataSet: barSet)

        // X 轴 Formatter
        combinedChart.xAxis.valueFormatter = IndexAxisValueFormatter(
            values: ["Mon", "Tue", "Wed", "Thu", "Fri"]
        )
        combinedChart.xAxis.granularity = 1
        combinedChart.data = combined
    }
}

五、源码亮点

进阶层:值得借鉴的用法

MarkerView——点击数据点显示气泡
// 自定义 Marker,继承 MarkerView
class BalloonMarker: MarkerView {
    private var label: String = ""

    override func refreshContent(entry: ChartDataEntry, highlight: Highlight) {
        label = String(format: "%.1f", entry.y)
        super.refreshContent(entry: entry, highlight: highlight)
    }

    override func draw(context: CGContext, point: CGPoint) {
        // 用 CoreGraphics 绘制气泡背景 + 文字
        let attrs: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: 12),
            .foregroundColor: UIColor.white
        ]
        let attrStr = NSAttributedString(string: label, attributes: attrs)
        let size = attrStr.size()
        let rect = CGRect(x: point.x - size.width / 2 - 8,
                          y: point.y - size.height - 12,
                          width: size.width + 16, height: size.height + 8)
        context.setFillColor(UIColor.systemBlue.cgColor)
        UIBezierPath(roundedRect: rect, cornerRadius: 6).fill()
        attrStr.draw(in: rect.insetBy(dx: 8, dy: 4))
    }
}

// 绑定
chartView.marker = BalloonMarker()
chartView.drawMarkers = true

深入层:设计思想解析

1. Transformer 矩阵变换

DGCharts 用一个 CGAffineTransform 维护数据坐标 → 屏幕坐标的映射:

屏幕坐标 = 数据坐标 × scale + offset + chart padding

每次缩放/平移,只更新 transform 矩阵然后 setNeedsDisplay,避免重新计算所有点,是渲染性能的核心保障。

2. Protocol-Oriented Renderer
// 每种图表有独立 Renderer,统一协议
public protocol DataRenderer: AnyObject {
    func drawData(context: CGContext)
    func drawValues(context: CGContext)
    func drawExtras(context: CGContext)
    func drawHighlighted(context: CGContext, indices: [Highlight])
}

遵循开闭原则,新增图表类型只需实现此协议,不修改任何现有代码。

3. ChartHighlighter — 触摸高亮的寻值逻辑

触摸事件 → getHighlight(x:y:) → 二分查找最近 x → 在该 x 的所有 DataSet 中找最近 y → 返回 Highlight(x:y:dataSetIndex:) → 触发 highlightValue。整套流程在主线程同步完成,保证交互响应 < 16ms。


六、踩坑记录

问题 1:数据更新后图表没有刷新

  • 原因:修改了 entries 数组但没有通知图表
  • 解决
    chartView.data?.notifyDataChanged()
    chartView.notifyDataSetChanged()
    

问题 2:X 轴标签显示 0, 1, 2 而不是自定义文字

  • 原因:没有设置 xAxis.valueFormatter 或者 granularity 不正确
  • 解决
    xAxis.valueFormatter = IndexAxisValueFormatter(values: yourLabels)
    xAxis.granularity = 1.0   // 必须设置,否则可能跳步
    

问题 3:饼图中间的 "洞" 大小无法控制

  • 原因holeRadiusPercent 默认 0.5(即 50%)
  • 解决
    pieChart.holeRadiusPercent = 0.3  // 调整洞的比例,0 = 实心饼图
    

问题 4:动画结束后视图突然闪烁

  • 原因animateXanimateY 同时调用,两个动画结束时各触发一次 setNeedsDisplay
  • 解决:使用 animate(xAxisDuration:yAxisDuration:) 合并为一次调用

问题 5:在 SwiftUI 中使用时手势冲突

  • 原因:DGCharts 的 UIPanGestureRecognizer 与 SwiftUI ScrollView 的手势抢夺
  • 解决
    chartView.dragEnabled = false       // 在 ScrollView 内关闭拖拽
    chartView.pinchZoomEnabled = false  // 关闭缩放
    

问题 6:Swift Package Manager 拉取超时

  • 原因:Charts 仓库体积较大(含所有 Demo)
  • 解决:在 package.json 中指定 .upToNextMinor(from: "5.1.0"),避免每次重新解析

七、延伸思考

同类库对比

维度 DGCharts Swift Charts (Apple) AAChartKit Charts.js (WebView)
平台要求 iOS 13+ iOS 16+ iOS 11+ iOS (via WKWebView)
图表类型数 8 4 10+ 20+
性能 原生,高 原生,高 原生,良好 WebView,差
K 线图
SwiftUI 支持 UIViewRepresentable 原生 UIViewRepresentable WKWebViewRepresentable
维护状态 社区活跃 Apple 官方 活跃 Web 项目
学习曲线 中等 低(SwiftUI 声明式) 需了解 JS

推荐使用场景

  • ✅ iOS 13~15 的存量 App,无法使用 Swift Charts
  • ✅ 需要 K 线图、雷达图等特殊类型
  • ✅ 需要精细控制动画和交互手势
  • ✅ 同时支持 iOS 和 macOS 的多平台 App

不推荐场景

  • ❌ 全新 App,最低支持 iOS 16+,且只用 SwiftUI → 直接用 Swift Charts
  • ❌ 只需要一个简单静态饼图/柱图 → 手动 CoreGraphics 更轻量
  • ❌ 需要超复杂的动态可视化(如力导向图)→ 考虑 WebView + D3.js

八、参考资源


九、本期互动

小作业

使用 LineChartView 实现一个实时动态折线图:每秒追加一个随机数据点,并保持最多显示最近 20 个点(超出则移除最旧的点),同时保证图表 X 轴自动滚动跟随最新数据。完成后在评论区贴出你的核心更新逻辑。

思考题

DGCharts 的 Transformer 用矩阵变换把数据坐标映射到屏幕坐标,这种设计和 CALayertransform 有何本质区别?如果让你自己实现一套坐标系映射,你会选择哪种方案?为什么?

读者征集

下一期我们将深入 Hero(转场动画库)。你在项目中用过哪些自定义转场方案(Hero / UIViewControllerTransitioningDelegate / NavigationController Push 动画)?欢迎评论区分享你的实践经验,优质回答会收录进下一期《踩坑记录》。


📅 本系列每周五晚更新 ✅ 第1期:Alamofire · ✅ 第2期:SDWebImage · ✅ 第3期:Kingfisher · ✅ 第4期:SnapKit · ✅ 第5期:ListDiff · ✅ 第6期:RxSwift · ✅ 第7期:Lottie · ✅ 第8期:MarkdownUI · ✅ 第9期:AFNetworking · ➡️ 第11期:DGCharts · ○ 第12期:Hero

Objective-C Runtime 完整机制:objc_class /cache/bits 源码解析

Objective-C(以下简称 OC)的灵活性、动态性,核心源于其底层的 Runtime 机制。而 Runtime 所有动态行为(消息发送

objc_class 的核心字段中,superclass(父类指针)、cache(方法缓存)、bits(类数据指针+标志位)三者缺一不可。其中,cache 决定了方法调用的效率,bits 存储了类的核心数据(方法、属性、协议等),二者是理解 Runtime 动态机制的关键。

很多开发者使用 OC 多年,却只停留在“会用”层面,对objc_class 的底层结构、cache 的缓存机制、bits 的数据存储逻辑一知半解。本文将基于 Apple 开源的 objc4 源码(最新稳定版),逐行解析 objc_classcachebits 的底层实现,结合 Runtime 核心流程,让你彻底吃透 OC 类的底层逻辑。

一、前置基础:Runtime 与 objc_class 的核心关联

在解析具体源码前,先明确两个核心前提,避免陷入细节误区:

  1. OC 是“动态语言”,其类和对象的行为并非编译期确定,而是由 Runtime 动态解析——比如方法调用、属性访问,最终都会被 Runtime 转化为底层函数调用(如 objc_msgSend)。

先看最基础的 objc_object 结构体(所有对象的祖宗),它是理解 objc_class 的前提:

// 所有OC对象的底层结构体(精简版,保留核心字段)
struct objc_object {
    isa_t isa; // 64位联合体,存储类指针、引用计数、标志位等信息
};

// isa_t 的核心结构(ARM64架构,iOS真机环境)
union isa_t {
    uintptr_t bits; // 原始64位数值,承载所有信息
    // 位域展开(64位按位分配)
    struct {
        uintptr_t nonpointer : 1;        // bit 0:是否是优化后的isa(0=纯指针,1=包含额外信息)
        uintptr_t has_assoc : 1;         // bit 1:是否有关联对象
        uintptr_t has_cxx_dtor : 1;      // bit 2:是否有C++析构函数
        uintptr_t shiftcls : 33;         // bit 3-35:类指针(右移3位存储,节省空间)
        uintptr_t magic : 6;             // bit 36-41:固定值0x1a,用于调试校验
        uintptr_t weakly_referenced : 1; // bit 42:是否被弱引用
        uintptr_t unused : 1;            // bit 43:未使用
        uintptr_t has_sidetable_rc : 1;  // bit 44:引用计数是否溢出到SideTable
        uintptr_t extra_rc : 19;         // bit 45-63:引用计数-1(存储额外引用计数)
    };
};

简单来说,isa 的核心作用是“标识对象的类型”——通过shiftcls 字段,对象能找到自己对应的类(objc_class),而类的 isa 则指向元类(Meta Class),这是 OC 实现方法调用的基础。

二、核心解析:objc_class 结构体源码拆解

OC 中的“类”(如 NSObject、自定义类),底层本质是 objc_class 结构体的实例。以下是从 objc4 源码中提取的精简版 objc_class 结构体(保留核心字段,省略辅助方法),也是本文的核心分析对象:

// 类的底层结构体(继承自objc_object,因此包含isa字段)
struct objc_class : objc_object {
    // 1. 父类指针:指向当前类的父类(如NSObject的父类是nil)
    Class superclass;
    // 2. 方法缓存:哈希表结构,缓存最近调用的方法,提升调用效率
    cache_t cache;
    // 3. 类数据指针+标志位:存储类的核心数据(方法、属性、协议等)
    class_data_bits_t bits;
    
    // 核心方法:从bits中取出类的可读写数据(class_rw_t)
    class_rw_t *data() const {
        return bits.data();
    }
};

从源码可以看出,objc_class 继承自 objc_object,因此它本身也有 isa 字段(继承而来),同时新增了三个核心字段:superclasscachebits

三者的核心关系的是:superclass 负责继承链的构建,cache 负责方法调用的缓存优化,bits 负责存储类的核心业务数据,三者协同支撑起 OC 类的所有动态行为。

补充:Class 类型的本质

我们日常使用的 Class 类型,本质是 objc_class 的指针别名,源码定义如下:

typedef struct objc_class *Class;

这就是为什么我们可以用 Class cls = [NSObject class]; 获取类对象——本质是获取 objc_class 结构体的指针。

三、深度解析:cache_t(方法缓存)的底层实现

在 OC 中,方法调用是高频操作(如 [self method]),如果每次调用都遍历类的方法列表查找,会严重影响性能。cache_t 的核心作用就是“缓存最近调用的方法”,下次调用时直接从缓存中取出,无需重复查找,这是 Runtime 优化方法调用效率的关键。

1. cache_t 结构体源码(精简版)

// 方法缓存结构体(哈希表实现)
struct cache_t {
    // 缓存存储的数组(数组元素是cache_entry_t类型,存储方法名和函数指针)
    bucket_t *_buckets;
    // 缓存的容量(总是2的幂,如4、8、16,方便哈希计算)
    mask_t _mask;
    // 已缓存的方法数量(当count > mask * 3/4时,会触发缓存扩容)
    mask_t _occupied;
    
    // 核心方法:插入方法缓存
    void insert(SEL sel, IMP imp, id receiver);
    // 核心方法:查找方法缓存
    IMP lookup(SEL sel);
};

其中,bucket_t 是缓存的“桶”,存储单个方法的缓存信息,源码如下:

// 单个缓存项(存储一个方法的信息)
struct bucket_t {
    SEL _sel; // 方法名(选择子,本质是const char*,如@selector(method))
    IMP _imp; // 函数指针(指向方法的具体实现代码地址)
    
    // 辅助方法:获取方法名和函数指针
    SEL sel() const { return _sel; }
    IMP imp() const { return (IMP)((uintptr_t)_imp ^ (uintptr_t)this); }
};

2. cache_t 的核心特性与工作流程

理解 cache_t,关键要掌握“哈希表存储”“缓存插入”“缓存查找”“缓存扩容”四个核心流程,结合源码逻辑逐一拆解:

(1)哈希表存储逻辑

cache_t 采用“开放寻址法”实现哈希表:

  • 用方法名 SEL 的哈希值,对_mask(缓存容量-1)取模,得到当前方法在 _buckets 数组中的索引;
  • 如果该索引对应的桶为空,直接存入当前方法的 SELIMP
  • 如果该索引已被占用(哈希冲突),则顺次查找下一个空桶,直到找到空桶存入。

这里 _mask = 容量 - 1(如容量为8,_mask=7),取模操作可简化为 hash & _mask,效率远高于传统取模运算,这也是缓存容量必须是2的幂的原因。

(2)缓存插入流程(insert 方法核心逻辑)

当我们第一次调用某个方法时,Runtime 会先查找方法列表,找到后将其插入 cache_t,核心步骤如下(结合源码逻辑简化):

  1. 计算方法 SEL 的哈希值 hash = sel_hash(sel)

注意:IMP 存储时会进行“异或加密”(_imp = (IMP)((uintptr_t)imp ^ (uintptr_t)this)),读取时再解密,这是苹果的安全优化,防止恶意篡改方法实现。

(3)缓存查找流程(lookup 方法核心逻辑)

当我们再次调用该方法时,Runtime 会先从 cache_t 中查找,核心步骤如下:

  1. 计算 SEL 的哈希值,得到索引 index = hash & _mask

(4)缓存扩容机制

_occupied(已缓存数量)超过 _mask * 3/4(缓存容量的75%)时,会触发缓存扩容,核心逻辑:

  • 新容量 = 旧容量 * 2(始终保持2的幂);
  • 创建新的 _buckets 数组(容量为新容量);
  • 将旧缓存中的所有方法,重新哈希后插入新数组;
  • 更新 _mask(新容量-1)和 _occupied(重置为旧的数量),释放旧数组内存。

3. cache_t 的实战意义

理解 cache_t 的缓存机制,能帮我们解释很多实际开发中的现象:

  • 为什么“首次调用方法比后续调用慢”?—— 首次调用需要查找方法列表,后续调用直接从缓存中获取,效率更高;
  • 为什么分类(Category)的方法会覆盖原类方法?—— 分类方法会在 Runtime 加载时,插入到类的方法列表头部,首次调用时会优先被缓存,后续调用会直接使用分类的方法;
  • 为什么频繁调用不同方法,会导致缓存命中率下降?—— 缓存容量有限,频繁切换方法会导致缓存被覆盖,需要重新查找方法列表。

四、深度解析:class_data_bits_t(bits)的底层实现

如果说 cache_t 是“方法调用的加速器”,那么 bits 就是“类的核心数据仓库”——它存储了类的所有核心信息,包括方法列表、属性列表、协议列表、成员变量列表等,是 Runtime 实现动态特性的核心载体。

bits 的类型是 class_data_bits_t,它本身是一个“64位整数”,低位存储标志位,高位存储指向 class_rw_t 的指针(类的可读写数据),这种设计既能节省内存,又能高效访问数据。

1. class_data_bits_t 结构体源码(精简版)

// bits的类型:存储类数据指针+标志位
struct class_data_bits_t {
private:
    uintptr_t bits; // 64位整数,核心存储载体
    
public:
    // 核心方法:从bits中取出class_rw_t指针(核心数据)
    class_rw_t *data() const {
        // FAST_DATA_MASK:掩码,用于过滤标志位,取出高位的指针地址
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    
    // 标志位操作方法(示例)
    bool isSwiftLegacy() const { return getBit(FAST_IS_SWIFT_LEGACY); }
    bool isSwiftStable() const { return getBit(FAST_IS_SWIFT_STABLE); }
    
private:
    // 读取指定位置的标志位
    bool getBit(uintptr_t bit) const {
        return (bits & bit) != 0;
    }
};

其中,FAST_DATA_MASK 是关键掩码(ARM64架构下),源码定义如下:

#define FAST_DATA_MASK 0x00007ffffffffff8UL

该掩码的作用是“过滤低位的标志位,保留高位的指针地址”——ARM64架构下,bits 的 bit 346 存储 class_rw_t 指针,bit 02 存储标志位,通过 bits & FAST_DATA_MASK 可快速取出指针。

2. 核心标志位解析(bit 0~2)

bits 的低位(bit 0~2)存储了3个核心标志位,用于标识类的类型和特性,源码定义如下:

  • FAST_IS_SWIFT_LEGACY = 1 << 0(bit 0):是否是旧版 Swift 类(OC 类该标志位为0);
  • FAST_IS_SWIFT_STABLE = 1 << 1(bit 1):是否是新版 Swift 类(OC 类该标志位为0);
  • FAST_HAS_DEFAULT_RR = 1 << 2(bit 2):是否有默认的 retain/release 方法(ARC 环境下,OC 类默认有)。

这些标志位的作用是“快速区分类的类型”,Runtime 在处理方法调用、内存管理时,会根据这些标志位执行不同的逻辑。

3. class_rw_t:bits 指向的核心数据

bits.data() 会返回 class_rw_t 指针,class_rw_t 是“类的可读写数据”结构体,存储了类的方法、属性、协议等核心信息,源码精简如下:

// 类的可读写数据(runtime运行时可修改)
struct class_rw_t {
    // 版本号(用于兼容不同的Runtime版本)
    uint32_t version;
    // 类的flags(标志位,如是否是元类、是否有分类等)
    uint32_t flags;
    
    // 方法列表(存储类的所有方法,包括实例方法和类方法)
    method_array_t methods;
    // 属性列表(存储类的所有属性)
    property_array_t properties;
    // 协议列表(存储类遵循的所有协议)
    protocol_array_t protocols;
    
    // 成员变量列表(存储类的所有成员变量)
    ivar_array_t ivars;
};

其中,method_array_tproperty_array_t 等都是“动态数组”(本质是指针数组),支持 Runtime 运行时动态添加(比如分类添加方法、属性),这也是 OC 支持“动态扩展”的核心原因。

4. bits 的核心工作流程

bits 的工作流程非常简单,核心是“通过掩码取出数据指针,访问类的核心信息”,结合 Runtime 方法查找流程,可总结为:

  1. 当 Runtime 需要查找类的方法时,先通过 objc_class->bits.data() 取出 class_rw_t 指针;

五、三者协同:objc_class / cache / bits 完整工作流程

结合前面的解析,我们用一个“方法调用”的完整流程,串联起 objc_classcachebits 的协同工作,让你彻底理解三者的关联:

  1. 调用 [obj method],OC 编译器将其转化为 Runtime 函数调用 objc_msgSend(obj, @selector(method))

从这个流程可以看出:cache 负责“加速查找”,bits 负责“存储数据”,objc_class 负责“组织关联”,三者协同,构成了 OC 方法调用的底层逻辑,也是 Runtime 动态机制的核心。

六、实战延伸:源码解析的实际应用

很多开发者会问:“搞懂这些源码,对实际开发有什么用?” 其实,Runtime 源码解析的价值,在于“解决底层问题、实现高级特性”,以下是3个常见的实战场景:

1. 解决“方法未实现”崩溃问题

当调用未实现的方法时,会触发 unrecognized selector sent to instance 崩溃。通过理解 cachebits 的查找流程,我们可以通过 Runtime 钩子(如 resolveInstanceMethod),动态添加方法实现,避免崩溃:

// 动态添加未实现的方法
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(unimplementedMethod)) {
        // 动态添加方法实现
        class_addMethod([self class], sel, (IMP)dynamicMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

// 动态方法实现
void dynamicMethod(id self, SEL _cmd) {
    NSLog(@"动态添加的方法实现");
};

2. 实现“方法交换”(Method Swizzling)

方法交换是 OC 开发中常用的高级技巧,其底层依赖 bits 中的方法列表。通过修改 class_rw_t->methods 中方法的 IMP,可以实现方法交换:

// 方法交换
+ (void)swizzleMethod {
    Class cls = [self class];
    // 获取两个方法的SEL
    SEL originalSel = @selector(originalMethod);
    SEL swizzledSel = @selector(swizzledMethod);
    
    // 获取方法实例
    Method originalMethod = class_getInstanceMethod(cls, originalSel);
    Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);
    
    // 交换方法实现
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

3. 动态添加属性(关联对象)

OC 中不能直接给分类添加属性,但可以通过 Runtime 的关联对象机制实现,其底层依赖 objc_objecthas_assoc 标志位(存储在 isa 中)和 bits 中的相关逻辑:

// 给分类添加关联属性
@interface NSObject (Associated)
@property (nonatomic, copy) NSString *associatedStr;
@end

@implementation NSObject (Associated)
- (NSString *)associatedStr {
    return objc_getAssociatedObject(self, _cmd);
}

- (void)setAssociatedStr:(NSString *)associatedStr {
    objc_setAssociatedObject(self, @selector(associatedStr), associatedStr, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
@end

七、总结:Runtime 核心机制的本质

通过对 objc_classcachebits 的源码解析,我们可以发现:OC Runtime 的核心本质,是“用结构体存储类和对象的信息,用哈希表优化查找效率,用动态数组支持扩展”。

总结三个核心要点,帮你快速掌握本文重点:

  1. objc_class 是类的底层载体,继承自 objc_object,包含 superclasscachebits 三个核心字段,负责组织类的继承关系和核心数据;

理解这些底层源码,不仅能帮你解决实际开发中的底层问题,更能让你从根源上理解 OC 的动态性,为后续学习更高级的 Runtime 特性(如元类、消息转发、分类加载)打下基础。毕竟,只有看透底层,才能真正掌控 OC 开发。

Recent lld/ELF performance improvements

Since the LLVM 22 branch was cut, I've landed patches thatparallelize more link phases and cut task-runtime overhead. This postcompares current main against lld 22.1, mold, and wild.

Headline: a Release+Asserts clang --gc-sections link is1.37x as fast as lld 22.1; Chromium debug with --gdb-indexis 1.07x as fast. mold and wild are still ahead — the last sectionexplains why.

Benchmark

lld-0201 is main at 2026-02-01 (6a1803929817);lld-load is main plus the new[ELF] Parallelize input file loading. mold andwild run with --no-fork so the wall-clocknumbers include the linker process itself.

Three reproduce tarballs, --threads=8,hyperfine -w 1 -r 10, pinned to CPU cores withnumactl -C.

Workload lld-0201 lld-load mold wild
clang-23 Release+Asserts, --gc-sections 1.255 s 917.8 ms 552.6 ms 367.2 ms
clang-23 Debug (no --gdb-index) 4.582 s 4.306 s 2.464 s 1.565 s
clang-23 Debug (--gdb-index) 6.291 s 5.915 s 4.001 s N/A
Chromium Debug (no --gdb-index) 6.140 s 5.904 s 2.665 s 2.010 s
Chromium Debug (--gdb-index) 7.857 s 7.322 s 3.786 s N/A

Note that llvm/lib/Support/Parallel.cpp design keeps themain thread idle during parallelFor, so--threads=N really utilizes N+1 threads.

wild does not yet implement --gdb-index — it silentlywarns and skips, producing an output about 477 MB smaller on Chromium.For fair 4-way comparisons I also strip --gdb-index fromthe response file; the no --gdb-index rows above use thatsetup.

A few observations before diving in:

  • The --gdb-index surcharge on the Chromium link is+1.42 s for lld (5.90 s → 7.32 s) versus+1.12 s for mold (2.67 s → 3.79 s). This is currently oneof the biggest remaining gaps.
  • Excluding --gdb-index, mold is 1.66x–2.22x as fast andwild 2.5x–2.94x as fast on this machine. There is plenty of roomleft.
  • clang-23 Release+Asserts --gc-sections (workload 1) hascollapsed from 1.255 s to 918 ms, a 1.37x speedup over 10 weeks. Most ofthat came from the parallel --gc-sections mark, parallelinput loading, and the task-runtime cleanup below — each contributing amultiplicative factor.

macOS (Apple M4) notes

The same clang-23 Release+Asserts link, --threads=8, onan Apple M4 (macOS 15, system allocator for all four linkers):

Linker Wall User Sys (User+Sys)/Wall
lld-0201 324.4 ± 1.5 ms 502.1 ms 171.7 ms 2.08x
lld-load 221.5 ± 1.8 ms 476.5 ms 368.8 ms 3.82x
mold 201.2 ± 1.7 ms 875.1 ms 220.5 ms 5.44x
wild 107.1 ± 0.5 ms 456.8 ms 284.6 ms 6.92x

Parallelize--gc-sections mark

Garbage collection had been a single-threaded BFS overInputSection graph. On a Release+Asserts clang link,markLive was ~315 ms of the 1562 ms wall time (20%).

commit6f9646a598f2 adds markParallel, a level-synchronizedBFS. Each BFS level is processed with parallelFor; newlydiscovered sections land in per-thread queues, which are merged beforethe next level. The parallel path activates when!TrackWhyLive && partitions.size() == 1.Implementation details that turned out to matter:

  • Depth-limited inline recursion (depth < 3) beforepushing to the next-level queue. Shallow reference chains stay hot incache and avoid queue overhead.
  • Optimistic "load then compare-exchange" section-flag dedup insteadof atomic fetch-or. The vast majority of sections are visited once, sothe load almost always wins.

On the Release+Asserts clang link, markLive dropped from315 ms to 82 ms at --threads=8 (from 199 ms to 50 ms at--threads=16); total wall time 1.16x–1.18x.

Two prerequisite cleanups were needed for correctness:

  • commit6a874161621e moved Symbol::used into the existingstd::atomic<uint16_t> flags. The bitfield waspreviously racing with other mark threads.
  • commit2118499a898b decoupled SharedFile::isNeeded from themark walk. --as-needed used to flip isNeededinside resolveReloc, which would have required coordinatedwrites across threads; it is now a post-GC scan of global symbols.

Parallelize input fileloading

Historically, LinkerDriver::createFiles walked thecommand line and called addFile serially.addFile maps the file (MemoryBuffer::getFile),sniffs the magic, and constructs an ObjFile,SharedFile, BitcodeFile, orArchiveFile. For thin archives it also materializes eachmember. On workloads with hundreds of archives and thousands of objects,this serial walk dominates the early part of the link.

The pending patch will rewrite addFile to record aLoadJob for each non-script input together with a snapshotof the driver's state machine (inWholeArchive,inLib, asNeeded, withLOption,groupId). After createFiles finishes,loadFiles fans the jobs out to worker threads. Linkerscripts stay on the main thread because INPUT() andGROUP() recursively call back intoaddFile.

A few subtleties made this harder than it sounds:

  • BitcodeFile and fatLTO construction callctx.saver / ctx.uniqueSaver, both of which arenon-thread-safe StringSaver /UniqueStringSaver. I serialized those constructors behind amutex; pure-ELF links hit it zero times.
  • Thin-archive member buffers used to be appended toctx.memoryBuffers directly. To keep the outputdeterministic across --threads values, each job nowaccumulates into a per-job SmallVector which is merged intoctx.memoryBuffers in command-line order.
  • InputFile::groupId used to be assigned inside theInputFile constructor from a global counter. With parallelconstruction the assignment race would have been unobservable but stillugly; b6c8cba516daabced0105114a7bcc745bc52faaehoists ++nextGroupId into the serial driver loop and storesthe value into each file after construction.

The output is byte-identical to the old lld and deterministic across--threads values, which I verified with diffacross --threads={1,2,4,8} on Chromium.

A --time-trace breakdown is useful to set expectations.On Chromium, the serial portion of createFiles accounts foronly ~81 ms of the 5.9 s wall, and loadFiles (after thispatch) runs in ~103 ms in parallel. Serial readFile/mmap isnot the bottleneck. What moves the needle is overlapping the per-fileconstructor work — magic sniffing, archive member materialization,bitcode initialization — with everything else that now kicks off on themain thread while workers chew through the job list.

Extending parallelrelocation scanning

Relocation scanning has been parallel since LLVM 17, but three caseshad opted out via bool serial:

  1. -z nocombreloc, because .rela.dyn mergedrelative and non-relative relocations and needed deterministicordering.
  2. MIPS, because MipsGotSection is mutated duringscanning.
  3. PPC64, because ctx.ppc64noTocRelax (aDenseSet of (Symbol*, offset) pairs) waswritten without a lock.

commit076226f378df and commitdc4df5da886e separate relative and non-relative dynamic relocationsunconditionally and always build .rela.dyn withcombreloc=true; the only remaining effect of-z nocombreloc is suppressing DT_RELACOUNT. commit2f7bd4fa9723 then protects ctx.ppc64noTocRelax with thealready-existing ctx.relocMutex, which is only taken onrare slow paths. After these changes, only MIPS still runs scanningserially.

Target-specific relocationscanning

Relocation scanning used to go through a generic loop inRelocations.cpp that calledTarget->getRelExpr through a virtual for everyrelocation — once to classify the expression kind (PC-relative, PLT,TLS, etc.) and again from the TLS-optimization dispatch. On anyrealistic link that is a hot inner loop running over tens of millions ofrelocations, and the virtual call plus its post-dispatch switch are areal fraction of the cost.

The fix is to move the whole per-section scan loop intotarget-specific code, so each Target::scanSection /scanSectionImpl pair can inline its owngetRelExpr, handle TLS optimization in-place, andspecialize for the two or three relocation kinds that dominate on thatarchitecture. Rolled out across most backends in early 2026:

  • 4b887533389cx86 (i386 / x86-64). On lld's own object files,R_X86_64_PC32 and R_X86_64_PLT32 make up ~95%of relocations and now hit an inlined hot path.
  • 371e0e2082e9AArch64, 4ea72c1e8cbdRISC-V, cd01e6526af6LoongArch, c04b00de7508ARM, 6d9169553029Hexagon, aec1c984266cSystemZ, 5e87f8147d68PPC32, aecc4997bf12PPC64.

Besides devirtualization, inlining TLS relocation handling intoscanSectionImpl let the TLS-optimization-specificexpression kinds be replaced with general ones:R_RELAX_TLS_GD_TO_LE / R_RELAX_TLS_LD_TO_LE /R_RELAX_TLS_IE_TO_LE fold into R_TPREL,R_RELAX_TLS_GD_TO_IE folds into R_GOT_PC, andgetTlsGdRelaxSkip goes away. What remains in the shareddispatch path — getRelExpr called fromrelocateNonAlloc and relocateEH — is a muchsmaller set.

Average Scan relocations wall time on a clang-14 link(--threads=8, x86-64, 50 runs, measured via--time-trace) drops from 110 ms to 102 ms, ~8% from the x86commit alone.

Faster getSectionPiece

Merge sections (SHF_MERGE) split their input into"pieces". Every reference into a merge section needs to map an offset toa piece. The old implementation was always a binary search inMergeInputSection::pieces, called fromMarkLive, includeInSymtab, andgetRelocTargetVA.

commit42cc45477727 changes this in two ways:

  1. For non-string fixed-size merge sections,getSectionPiece uses offset / entsizedirectly.
  2. For non-section Defined symbols pointing into mergesections, the piece index is pre-resolved duringsplitSections and packed into Defined::valueas ((pieceIdx + 1) << 32) | intraPieceOffset.

The binary search is now limited to references via section symbols(addend-based), which is common on AArch64 but rare on x86-64 where theassembler emits local labels for .L references intomergeable strings. The clang-relassert link with--gc-sections is 1.05x as fast.

Optimizingthe underlying llvm/lib/Support/Parallel.cpp

All of the wins above rely onllvm/lib/Support/Parallel.cpp, the tiny work-stealing-ishtask runtime shared by lld, dsymutil, and a handful of debug-info tools.Four changes in that file mattered:

  • commitc7b5f7c635e2 — parallelFor used to pre-split work intoup to MaxTasksPerGroup (1024) tasks and spawn each throughthe executor's mutex + condvar. It now spawns onlyThreadCount workers; each grabs the next chunk via anatomic fetch_add. On a clang-14 link(--threads=8), futex calls dropped from ~31K to ~1.4K(glibc release+asserts); wall time 927 ms → 879 ms. This is the reasonthe parallel mark and parallel scan numbers are worth quoting at all —on the old runtime, spawn overhead was a real fraction of the work beingparallelized.
  • commit9085f74018a4 — TaskGroup::spawn() replaced themutex-based Latch::inc() with an atomicfetch_add and passes the Latch& throughExecutor::add() so the worker calls dec()directly. Eliminates one std::function construction perspawn.
  • commit5b1be759295c — removed the Executor abstract baseclass. ThreadPoolExecutor was always the onlyimplementation; add() and getThreadCount() arenow direct calls instead of virtual dispatches.
  • commit8daaa26efdda — enables nested parallel TaskGroup viawork-stealing. Historically, nested groups ran serially to avoiddeadlock (the thread that was supposed to run a nested task might beblocked in the outer group's sync()). Worker threads nowactively execute tasks from the queue while waiting, instead of justblocking. Root-level groups on the main thread keep the efficientblocking Latch::sync(), so the common non-nested case paysnothing. In lld this lets SyntheticSection::writeTo callswith internal parallelism (GdbIndexSection,MergeNoTailSection) parallelize automatically when calledfrom inside OutputSection::writeTo, instead of degeneratingto serial execution on a worker thread — which was the exact situationD131247 had worked aroundby threading a root TaskGroup all the way down.

Small wins worth mentioning

  • 036b755daedbparallelizes demoteAndCopyLocalSymbols. Each file collectslocal Symbol* pointers in a per-file vector viaparallelFor, which are merged into the symbol tableserially. Linking clang-14 (--no-gc-sections) with its 208K.symtab entries is 1.04x as fast.

Where lld still loses time

To locate the gap I ran lld --time-trace,mold --perf, and wild --time on the Chromium--gdb-index link (--threads=8). Grouped intocomparable phases:

Work scope lld-0201 lld-load mold wild
mmap + parse sections + merge strings + symbol resolve 376 ms 292 ms 230 ms 113 ms
--gc-sections mark 268 ms 79 ms 30 ms — *
Scan relocations 106 ms 97 ms 60 ms — *
Assign / finalize / symtab 76 ms 100 ms 27 ms 84 ms
Write sections 87 ms 87 ms 90 ms 110 ms
Wall (hyperfine) 1255 ms 918 ms 553 ms 367 ms

* wild fuses --gc-sections marking and relocation-drivenlive-section propagation into one Find required sectionspass (60 ms), so these two rows are effectively merged.

A subtlety on wild's parse number: wild'sLoad inputs into symbol DB phase by itself is only 23 ms,but it does only mmap + .symtab scan +global-name hash bucketing. Section-header parsing, mergeable-stringsplitting, COMDAT handling, and symbol resolution are deferred to laterwild phases. The 113 ms row above sums those(Load inputs into symbol DB 23 +Resolve symbols 12 + Section resolution 21 +Merge strings 57) so it covers the same work lld callsParse input files.

Meaningful gaps, in order of absolute impact:

Parse: lld-load 292 ms vs wild 113 ms ≈ 2.6x. Thebiggest remaining cross-linker gap on this workload, and the samepattern holds on the larger workloads below. The phase is alreadyparallel; the gap is constant factor in the per-object parse path(reading section headers, interning strings, splitting CIEs/FDEs,merging globals into the symbol table). On clang-relassert the 179 msparse gap alone accounts for ~33% of the 551 ms wall-clock gap betweenlld-load and wild.

Assign / finalize / symtab: 100 ms vs mold 27 ms ≈3.7x. finalizeAddressDependentContent,assignAddresses, finalizeSynthetic,Add symbols to symtabs, and Finalize .eh_frametogether cost ~100 ms on this workload; mold's equivalents(compute_section_sizes, compute_symtab_size,create_output_sections, set_osec_offsets)total 27 ms. This gap grows linearly with the number of.symtab entries — on clang-debug it's 127 ms lld vs 27 msmold, on Chromium 570 ms vs ~80 ms. I have a local branch that turnsSymbolTableBaseSection::finalizeContents into aprefix-sum-driven parallel fill and replaces thestable_partition + MapVector shuffle withper-file lateLocals buffers. 1640 ELF tests pass; notposted yet.

markLive: 79 ms, 3.4x faster than the Feb 1baseline (268 ms). This is apples-to-oranges comparison: lldsupports __start_/__stop_ edges,SHF_LINK_ORDER dependencies, linker scriptsKEEP, and others features. lld correctly handles--gc-sections --as-needed with Symbol::used(tests gc-sections-shared.s, weak-shared-gc.s,as-needed-not-in-regular.s):

  • mold over-approximates DT_NEEDED on twoaxes: it emits DT_NEEDED for DSOs referenced onlyvia weak relocs, and for DSOs referenced only from GC'd sections. Italso retains undefined symbols that are only reachable from deadsections in .dynsym.
  • wild handles weak refs correctly but not dead-sectionrefs: weak-only references do not force DT_NEEDED(matching lld), but DSOs referenced only from GC'd sections still getDT_NEEDED entries. wild does drop the correspondingundefined symbols from .dynsym, so itsDT_NEEDED decision and its symtab-inclusion decisiondiverge slightly.
  • lld is strictest on all three axes

Scan relocations: 97 ms vs 60 ms. Clean 1.6x ratio,small absolute. Target-specific scanning (theAdd target-specific relocation scanning for …) removed somedispatch overhead; what remains isInputSectionBase::relocations overhead. wild foldsrelocation-driven liveness into Find required sections,which is why there's no separate wild row.

Interestingly, writing section content is not a gap(87–110 ms across all four). The earlier assumption that.debug_* section writes were a lld weakness didn't survivemeasurement.

One cost that only shows up on debug-info-heavy workloads is--gdb-index construction, which lld does in ~1.3 s vsmold's ~0.9 s on Chromium. The work is embarrassingly parallel perinput, but lld funnels string interning through a shardedDenseMap; mold uses a lock-free ConcurrentMapsized by HyperLogLog. wild does not yet implement--gdb-index.

wild is worth calling out separately: its user time is comparable tolld's but its system time is roughly half, and its parse phase is 4-8xfaster than either of the C++ linkers across all three workloads. moldis at the other extreme — the highest user time on every workload,bought back by aggressive parallelism.

Claude Code 从 AWS Bedrock 切换到 Team 订阅指南

背景

Claude Code 支持多种认证方式,包括 AWS Bedrock、Google Vertex AI、Anthropic API Key 和 Claude 订阅(Pro/Max/Team/Enterprise)。当你从 Bedrock 切换到 Team 订阅时,需要清除 Bedrock 的配置,否则 Claude Code 会一直走 Bedrock 通道。

核心问题

使用 Bedrock 认证时,/login/logout 命令是被禁用的(官方设计如此)。因此你无法在 Bedrock 模式下直接切换登录方式。

Bedrock 配置的来源有两种:

  1. 环境变量 — 通过 export 或写在 ~/.zshrc / ~/.bashrc
  2. settings.json — 写在 ~/.claude/settings.jsonenv 字段中

很多用户(尤其是通过 setup wizard 配置的)的 Bedrock 设置是写在 settings.json 里的,单纯 unset 环境变量并不能解决问题。

切换步骤

第一步:检查 Bedrock 配置来源

1
2
3
4
5
# 检查环境变量
env | grep -i -E "claude_code_use|anthropic|bedrock|aws"

# 检查 settings.json
cat ~/.claude/settings.json

如果在 settings.json 中看到类似以下内容,说明 Bedrock 配置在这里:

1
2
3
4
5
6
7
8
{
"env": {
"CLAUDE_CODE_USE_BEDROCK": "1",
"AWS_REGION": "us-west-2",
"ANTHROPIC_MODEL": "arn:aws:bedrock:...",
"CLAUDE_CODE_AWS_PROFILE": "default"
}
}

第二步:清除 Bedrock 配置

如果配置在 settings.json 中,编辑 ~/.claude/settings.json,删除 env 中所有 Bedrock 相关的键值对:

  • CLAUDE_CODE_USE_BEDROCK
  • AWS_REGION
  • ANTHROPIC_MODEL
  • CLAUDE_CODE_AWS_PROFILE
  • CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS(Bedrock 专用)
  • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC(Bedrock 专用)

保留你仍需要的配置(如代理、权限设置等)。清理后的文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
{
"env": {
"HTTP_PROXY": "http://your-proxy:8118",
"HTTPS_PROXY": "http://your-proxy:8118"
},
"permissions": {
"allow": [
"Bash(*)"
],
"defaultMode": "dontAsk"
}
}

如果配置在环境变量中,清除相关变量:

1
2
3
4
5
unset CLAUDE_CODE_USE_BEDROCK
unset ANTHROPIC_MODEL
unset ANTHROPIC_API_KEY
unset AWS_REGION
unset CLAUDE_CODE_AWS_PROFILE

同时检查并清理 shell 配置文件:

1
2
grep -r "CLAUDE_CODE_USE_BEDROCK\|ANTHROPIC_MODEL\|ANTHROPIC_API_KEY" \
~/.zshrc ~/.bashrc ~/.zprofile ~/.bash_profile 2>/dev/null

第三步:重新启动 Claude Code

1
claude

此时应该会弹出登录方式选择界面,选择 「Claude account with subscription」,然后在浏览器中授权你的 Team 计划。

第四步:确认切换成功

启动后,欢迎界面底部应显示类似:

1
Sonnet 4.6 · Claude Pro(或 Team)

而不是之前的 arn:aws:bedrock:...

也可以在交互界面中输入 /status 确认当前认证方式。

第五步:切换模型(可选)

如果需要使用 Opus 模型,在交互界面中输入:

1
/model

用方向键选择 Opus 即可。

认证优先级

Claude Code 的认证优先级从高到低为:

  1. 云提供商凭据(CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX / CLAUDE_CODE_USE_FOUNDRY
  2. ANTHROPIC_AUTH_TOKEN 环境变量
  3. ANTHROPIC_API_KEY 环境变量
  4. apiKeyHelper 脚本
  5. 订阅 OAuth 凭据(/login

只要高优先级的认证方式存在,低优先级的就不会生效。所以必须彻底清除 Bedrock 配置,订阅认证才能生效。

注意事项

  • 代理地址:Bedrock 用的代理可能无法访问 api.anthropic.com,切换后可能需要更换代理或去掉代理配置。
  • Premium 席位:Team 计划需要 Premium 席位才能使用 Claude Code,确认管理员已分配。
  • 用量共享:Team 计划的用量限额在 Claude 网页端和 Claude Code 之间是共享的。
  • Memory 延续CLAUDE.md 等本地文件不受认证方式影响,切换后照常保留。对话历史不会跨会话保存,这点两种方式一样。

iOS 26 模拟器启动卡死:Method Swizzling 在系统回调时触发 nil 崩溃

一、现象

在 Xcode 26.4 + iOS 26.4 模拟器上运行项目,app 卡在 Launching 界面,始终无法进入主界面。控制台有大量 objc 类重复实现的警告(AuthKitUI / AuthKit 框架重复),但这些是系统 bug,与本次崩溃无关。

使用 LLDB 暂停进程,thread list 看到主线程异常:

thread #1: tid = 0xb124db, 0x0000000118fc9c10 CoreFoundation`-[__NSArrayM insertObject:atIndex:] + 251, queue = 'com.apple.main-thread'

二、定位过程

在 LLDB 中执行 thread select 1 + bt,得到完整调用栈:

frame #0: CoreFoundation`-[__NSArrayM insertObject:atIndex:] + 251
frame #1: FNCategory`-[NSMutableArray safe_insertObject:atIndex:] at NSMutableArray+FN.m:68
frame #2: FNCategory`-[NSMutableArray safe_addObject:] at NSMutableArray+FN.m:51
frame #3: CoreFoundation`-[NSEnumerator allObjects] + 189
frame #4: AXCoreUtilities`-[AXBinaryMonitor _frameworkNameForImage:]
frame #5: AXCoreUtilities`-[AXBinaryMonitor _handleLoadedImagePath:]
frame #6: AXCoreUtilities`___axmonitor_dyld_image_callback_block_invoke

关键结论:系统无障碍框架 AXCoreUtilities 在动态加载镜像(dyld image load)时,触发了一个回调,该回调内部调用了 NSEnumerator allObjects,而这个 allObjects 底层最终调用了 NSMutableArray addObject:

由于项目通过 Method Swizzling 将系统的 addObject: 替换成了自定义的 safe_addObject:,这个系统内部调用被"劫持"进了我们的代码。

safe_addObject: 内部调用了 safe_insertObject:atIndex:,这里对 NSMutableArray 插入对象时发生了崩溃。

三、根本原因

这是一个经典的 Method Swizzling 副作用问题,iOS 26 改变了 AXCoreUtilities 的内部实现,触发了长期潜伏的 bug。

完整调用链如下:

  1. AXCoreUtilities(系统无障碍框架)在 dyld 加载镜像时触发内部回调
  2. 回调内部操作了一个系统私有数组对象,调用了 insertObject:atIndex:
  3. 由于 Method Swizzling,insertObject:atIndex: 已被替换成 safe_insertObject:atIndex:,系统内部调用被"劫持"进了我们的代码
  4. safe_insertObject:atIndex: 内部再调用 [self safe_insertObject:anObject atIndex:index](即原始方法),但此时 self 是系统内部的私有数组类型,不是普通的 __NSArrayM,导致无限递归或调用到了错误的 IMP,最终崩溃

问题的本质是:Swizzling 作用在父类(NSMutableArray)上,但系统传入的是私有子类对象,Swizzling 后的方法实现与私有类的内存布局不兼容,在 iOS 26 收紧了 AXCoreUtilities 的调用时序之后,这个潜在冲突被激活。

正规的修复思路是在 SwizzlingMethod 里加类型保护,确保只 swizzle __NSArrayM 本身而不影响其私有子类。但由于 FNCategory 是 Pod,还有 AFNetworking、DoraemonKit 等我们无法直接修改源码的三方库存在同样问题,所以统一在 Podfile post_install 里做全局兼容处理。

四、踩过的坑

坑 1:以为是 objc 类重复警告导致的

启动时控制台打印了大量 Class AKAlertImageURLProvider is implemented in both AuthKitUI and AuthKit 的警告,误以为是这些重复类导致崩溃。实际上这是 iOS 26.4 模拟器运行时自身的打包问题,与启动卡死无关。

坑 2:只修复了 FNCategory,没有扩大范围

最初只在 FNCategory 的 NSMutableArray+FN.m 里加了 nil 保护,但 AFNetworking 和 DoraemonKit 也有同样模式的 Swizzling,同样存在风险。

五、修复方案

思路

不针对单个文件做字符串替换,而是在 Podfile 的 post_install 阶段,全局扫描所有 Pod 源文件,找到所有 method_exchangeImplementations( 调用,在其前面统一注入 nil 保护。

实现(Podfile post_install)

post_install do |installer|
  # ... 其他 post_install 逻辑 ...

  # 全局修复:为所有 Pod 的 method_exchangeImplementations 调用注入 nil 保护
  # 防止 iOS 26 系统框架在 dyld 镜像加载回调中触发 Swizzled 方法时崩溃
  fixed_count = 0
  Dir.glob('Pods/**/*.{m,mm}').each do |file|
    content = File.read(file)
    next unless content.include?('method_exchangeImplementations(')

    new_content = content.gsub(
      /^(\s*)(method_exchangeImplementations\((\w+)\s*,\s*(\w+)\s*\)\s*;)/
    ) do
      indent = $1
      full_call = $2
      arg1 = $3
      arg2 = $4
      "#{indent}if (#{arg1} && #{arg2}) #{full_call}"
    end

    if new_content != content
      File.chmod(0644, file)
      File.write(file, new_content)
      puts "✅ 已修复 #{file} 的 method_exchangeImplementations nil 保护"
      fixed_count += 1
    end
  end
  puts "共修复 #{fixed_count} 处 method_exchangeImplementations nil 保护" if fixed_count > 0
end

修复效果

执行 pod install 后的输出:

✅ 已修复 Pods/AFNetworking/AFNetworking/AFNetworking/AFURLSessionManager.m 的 method_exchangeImplementations nil 保护
✅ 已修复 Pods/DoraemonKit/iOS/DoraemonKit/Src/Core/Category/NSObject+Doraemon.m 的 method_exchangeImplementations nil 保护
✅ 已修复 Pods/DoraemonKit/iOS/DoraemonKit/Src/Core/Plugin/Performance/StartTime/DoraemonStartTimeViewController.m 的 method_exchangeImplementations nil 保护
✅ 已修复 Pods/FNCategory/FNCategory/Classes/NSMutableArray+FN.m 的 method_exchangeImplementations nil 保护
✅ 已修复 Pods/FNCategory/FNCategory/Classes/NSObject+FNSwizzle.m 的 method_exchangeImplementations nil 保护
✅ 已修复 Pods/FNCategory/FNCategory/Classes/UIViewController+FNFullScreen.m 的 method_exchangeImplementations nil 保护
Integrating client project
Pod installation complete! There are 32 dependencies from the Podfile and 35 total pods installed.

共修复 6 处,涉及 AFNetworking、DoraemonKit、FNCategory 三个 Pod。

六、总结

项目 说明
问题类型 Method Swizzling 缺少 nil 保护,被系统内部回调触发
触发条件 iOS 26 改变了 dyld 镜像加载回调时序,在类注册完成前触发 Swizzle
崩溃位置 NSMutableArray insertObject:atIndex:safe_insertObject:atIndex:
修复方式 Podfile post_install 全局注入 if (A && B) nil 保护
优点 一次修复,覆盖所有 Pod,无需逐个修改,pod update 后自动重新修复
注意 这是 Swizzling 的通用最佳实践,不局限于 iOS 26,建议所有项目都加上

除法的意义

可可已经在三年级下学期了,数学似乎还是有点问题。这个阶段考试成绩其实都不会太差,但一旦作业或考卷上的错题并非粗心大意就值得警惕。乘除法是二年级学的,三年级已经在学两位数除一位数的除法。但会计算并不难,计算只是一项机械性技能,难的是理解乘除法的意义。理解乘除比理解加减法困难的多。

我翻出几个月前的一篇 blog,发现过了 4 个月,她的问题依旧:乘除法作为一项计算技能和其背后的意义是割裂的。这导致了很多问题到底如何解决一筹莫展。固然多作练习就能开悟,毕竟几乎没有成人回头看小学数学会觉得难以理解的。但我还是想尽力搞清楚她的小脑袋里到底是哪打结了。

今晚讲一道相当简单的数学题:

有 96 个鸡蛋,8 个一盒装,可以装多少盒?

可可不知道如何解决这个问题,我一开始是很诧异的。我先反复确认她理解了题目的文本,并非语言理解的问题。真的是无法联想到应该使用除法这个工具,而 96 这个数字过大,即使不使用除法,也不知道该如何处理。我默不作声,让她仔细想想,她愣在那里不知所措,都急得掉眼泪了。

我决定一步步推演这个问题。

先问一个简单的版本:有 12 个鸡蛋,10 个一盒装,最多可以装满几盒?

我本以为她能一口答出,但可能是前面的问题受挫,她还是不知道如何下手。我想想,从桌游盒中找了一堆 token 和若干小碗,说你自己装碗试试吧。装完 12 个后,又把问题改成了 30 个,她重新摆弄了一次,这下明白了。

我说,现在要把道具收起来了,换成草稿纸,你该如何解决这个问题呢?

我教她用减法:用 30 - 10 = 20 , 20 - 10 = 10 ,10 - 10 = 0 ;数一下一共减了 3 次。可可说,我知道了,其实不用数,只要看数字是几十,那么就是几盒了。

那么,回到一开始的问题,不是 10 个一盒而是 8 个一盒就不能直接看出来了,该怎么办呢?可可说那我也会:她从 96 - 8 = 88 开始一步步的做减法计算,很耐心的减到了 0 ,数了一下是 12 ,中间居然没有算错。

我说,96 / 8 = 12 ,并不真的要花这么多时间做减法。你其实会算除法,只是不知道除法有什么用。除法就是连续计算减法的次数,就好比乘法就是连续做多次加法一样。你需要把 token 一个个放进碗里的过程抽象化成数字写到草稿纸上,打草稿就是把脑子里想的东西具象化出来。这个过程借助数学符号可以更简单。数字是符号,加减乘除也是符号,符号能帮助你思考,但先要明白这些符号代表的道理。

我再换个问题:

有 80 个鸡蛋,8 个一盒装,可以装多少盒?

可可没犹豫,马上告诉我是 8 盒。我说你别着急,拿草稿纸仔细算一下。她算完不好意思的告诉我是 10 盒。我画了张矩形图,给她讲解了一下 8 x 10 = 10 x 8 的道理:10 行 8 个与 8 行 10 个其实只是图形旋转了一下,总数是一样的。

那么,从 96 个鸡蛋里先拿出 80 个装满 10 盒后,剩下的还可以装多少盒呢?她计算了一下 96 - 80 = 16 ,16 / 8 = 2 ;然后 10 盒与 2 盒合在一起也是 12 。

再看除法的竖式草稿,其实是一样的。

今天花了一个小时讲这道数学题(她的考卷上的错题),这次似乎真的懂了。

❌