1. 为什么今天还要聊 Carthage?一个被低估的“静默派”包管理器
你可能已经习惯了 Xcode 自带的 Swift Package Manager,或者用过 CocoaPods 那套成熟到有点臃肿的依赖生态。但如果你最近在翻看某开源 iOS 项目的Cartfile,或者在某位资深 iOS 开发者的分享里看到 “我们坚持用 Carthage”,甚至在 CI/CD 流水线日志里刷出carthage bootstrap --platform iOS这行命令——别急着划走。这不是怀旧,也不是技术固执,而是一种经过十年以上真实项目锤炼后沉淀下来的、高度克制的工程选择。
Carthage 的核心关键词就三个:轻量、去中心化、二进制优先。它不生成 Xcode 工程文件,不修改你的.xcodeproj,不注入自定义 build phase,更不会在你项目里悄悄塞进一堆Pods/子目录和Podfile.lock的隐式约束。它只做一件事:按需下载、编译(或缓存)、提供预构建框架(.framework),然后由你亲手拖进 Xcode 的 Linked Frameworks and Libraries 列表里。听起来原始?没错。但正是这种“原始”,让它在大型团队协作、多平台混合构建、以及对构建确定性有严苛要求的场景中,反而成了最稳的一张底牌。
我最早接触 Carthage 是在 2016 年参与一个金融类 App 的重构项目。当时团队已有 30+ 个内部私有框架,全部通过私有 Git 仓库托管,版本策略采用语义化版本 + Git tag。CocoaPods 在这种环境下频繁出现pod install后工程结构错乱、Pods.xcodeproj与主工程配置冲突、CI 上因缓存失效导致编译超时等问题。而 Carthage 的Cartfile.resolved文件只记录 commit hash,每次carthage update都是干净的源码拉取 + 独立编译,整个过程可复现、可审计、无副作用。后来我们把 Carthage 集成进 Jenkins 流水线,用--no-use-binaries强制源码编译,再配合--cache-builds复用中间产物,最终将平均构建耗时从 18 分钟压到 6 分 23 秒——这个数字不是靠魔法,而是靠 Carthage 对“构建过程透明性”的极致坚持。
它不适合所有人,但特别适合那些已经踩过包管理器“黑盒陷阱”的人:比如你经历过pod update后某个第三方库悄悄升级了 Swift 版本,导致全项目报错;比如你发现Podfile.lock里某个子依赖的版本号被意外覆盖;又比如你在调试崩溃堆栈时,发现符号缺失,只因为 CocoaPods 默认开启了use_frameworks!但没配好BUILD_LIBRARY_FOR_DISTRIBUTION = YES。Carthage 不解决所有问题,但它把“谁在改我的工程”这个问题,交还给了你自己。
2. Carthage 的设计哲学与底层逻辑拆解
2.1 它不是“另一个包管理器”,而是一套“依赖交付协议”
很多人误以为 Carthage 和 CocoaPods 是同类竞品,这是根本性误解。CocoaPods 是一个项目集成系统:它接管你的 Xcode 工程结构,为你生成依赖图、注入构建逻辑、管理头文件搜索路径。而 Carthage 是一个依赖交付工具:它只负责把 A 库的代码变成 B.framework,然后告诉你“它在这儿,你自己拿去用”。
这个区别决定了它们的基因差异:
CocoaPods 的目标是“开箱即用”:你写一行
pod 'Alamofire',它就帮你搞定源码拉取、编译、链接、头文件暴露、模块导入,甚至帮你处理@import和#import的兼容。代价是:它必须深度侵入 Xcode 构建流程,形成自己的“元构建层”。Carthage 的目标是“最小干预”:它只承诺一件事——给你一个能直接拖进 Xcode 的
.framework。至于你怎么链接、怎么签名、怎么处理 bitcode、怎么配置RUNPATH_SEARCH_PATHS,它一概不管。它相信 Xcode 原生能力足够强大,也相信开发者有能力为自己的选择负责。
这背后是一套清晰的分层契约:
Carthage 层:只管“源码 → framework”的转换,且必须可重复、可验证、可审计。
Xcode 层:只管“framework → 可执行二进制”的链接与打包,这是苹果官方定义的、最稳定可靠的构建链路。
开发者层:作为中间仲裁者,明确知道每一行import背后对应哪个 framework,每一个@rpath指向哪段路径,每一个codesign步骤作用于哪个产物。
这种分层让 Carthage 天然规避了 CocoaPods 最常被诟病的“黑盒感”。当你遇到链接失败,错误信息永远来自ld或codesign,而不是某个 Pod 插件的神秘报错;当你需要调试符号,.dSYM就在Carthage/Build/iOS/下,路径清晰,无需解析Pods/Target Support Files/里的嵌套结构。
2.2 为什么坚持“二进制优先”,却又允许“源码编译”?
Carthage 的默认行为是:先尝试从 GitHub Release 页面下载预编译的.zip包(内含.framework和.dSYM),如果找不到,再回退到本地编译。这个策略不是为了偷懒,而是基于三个硬性工程约束:
构建时间成本不可忽视
编译一个中等规模的 Swift 框架(如 RxSwift、Moya)在 M1 Mac 上通常需 4–7 分钟。如果每个开发者每天carthage update两次,团队 15 人就是 120 分钟纯等待时间。而下载一个 20MB 的.zip,在千兆内网下不到 2 秒。Carthage 把“时间换空间”的决策权交给了你:你可以用--no-use-binaries强制源码编译保证确定性,也可以用--use-binaries加速日常开发,甚至可以自己搭建私有 binary server(比如用 GitHub Packages 或 Artifactory)来统一分发可信二进制。二进制签名与分发合规性
苹果 App Store 审核明确要求:所有嵌入的 framework 必须使用与主 App 相同的证书签名,且不能包含未声明的 entitlements。Carthage 下载的 binary 如果是作者用个人证书签的,你直接拖进去会触发code object is not signed at all错误。但 Carthage 不会替你重签名——它把这个关键动作留白,逼你显式执行carthage copy-frameworks或在 Xcode 中配置Run Script,从而确保每一步签名操作都落在你的构建脚本里,可审计、可回滚、可纳入 CI 流水线。Swift ABI 不稳定性下的版本锁定刚需
Swift 5.0 引入 ABI 稳定,但直到 Swift 5.9,跨版本二进制兼容仍非绝对。Carthage 的Cartfile支持精确指定 Swift 版本:github "ReactiveX/RxSwift" "6.7.0" swift-version:5.9。当它下载 binary 时,会校验Info.plist中的DTCompiler字段是否匹配;不匹配则自动 fallback 到源码编译。这个机制比 CocoaPods 单纯依赖swift_version设置更底层、更可靠。
提示:Carthage 不会自动帮你处理
BUILD_LIBRARY_FOR_DISTRIBUTION = YES。如果你要发布 framework 给他人使用,必须在源码项目的 Build Settings 中手动开启此选项,并确保SKIP_INSTALL = NO。否则生成的.framework无法被其他项目正确 import。
2.3 它如何做到“不修改你的 Xcode 工程”?
秘密藏在carthage copy-frameworks这个命令里。它本身不碰.xcodeproj,而是生成一个标准的 Shell 脚本,供你在 Xcode 的Build Phases → Run Script中调用:
/usr/local/bin/carthage copy-frameworks这个脚本实际做了三件事:
- 读取
Input Files列表(你手动填的$(SRCROOT)/Carthage/Build/iOS/RxSwift.framework); - 将对应 framework 复制到
$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)(即.app/Frameworks/目录); - 执行
codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" --preserve-metadata=identifier,entitlements "$FRAMEWORK"。
注意:复制动作发生在 Xcode 构建阶段,而非carthage update阶段。这意味着:
carthage update只产生Carthage/Build/下的产物,完全隔离;- Xcode 构建时才决定哪些 framework 被真正嵌入,哪些仅用于 linking;
- 你可以为 Debug 和 Release 配置不同的
Input Files,实现“Debug 用动态 framework 方便调试,Release 用静态库减小体积”。
这种“延迟绑定”机制,是 Carthage 实现“零侵入”的核心技术支点。
3. 从零开始:Carthage 实战全流程详解
3.1 环境准备与基础命令实操
Carthage 依赖 Xcode Command Line Tools 和 Homebrew(推荐方式)。安装命令极简:
# 确保 Xcode CLT 已安装(系统偏好设置 → Xcode → Locations) xcode-select -p # 应输出 /Applications/Xcode.app/Contents/Developer # 用 Homebrew 安装(比 gem 更干净,无 Ruby 环境依赖) brew install carthage # 验证安装 carthage version # 输出类似 1.2.0注意:Carthage 严格绑定 Xcode 版本。例如 Carthage 1.2.0 要求 Xcode 15.2+。若你用的是 Xcode 14.x,需降级:
brew install carthage@1.1.0。切勿混用——我曾见过因 Carthage 版本过高导致carthage build报xcodebuild: error: SDK "iphoneos17.2" cannot be located的案例,根源就是 Carthage 尝试调用不存在的 SDK。
初始化一个新项目,只需三步:
创建 Cartfile(纯文本,无扩展名,放在项目根目录)
# Cartfile github "Alamofire/Alamofire" ~> 5.9 github "SwiftyJSON/SwiftyJSON" == 5.0.1 git "https://gitlab.example.com/internal/logger.git" "v2.3.0"语法说明:
github "Owner/Repo":从 GitHub 克隆,默认取master分支;~> 5.9:语义化版本,等价于>= 5.9.0 && < 6.0.0;== 5.0.1:精确版本,强制使用该 tag;git "URL":支持任意 Git 仓库,包括私有 GitLab、Bitbucket;- 注释用
#,空行会被忽略。
首次拉取与构建
# 下载依赖源码,编译为 framework(默认平台:iOS, macOS, tvOS, watchOS) carthage update # 仅构建 iOS 平台(跳过 macOS/tvOS,节省 60% 时间) carthage update --platform iOS # 强制重新编译所有(忽略已存在的 binary 和 build 缓存) carthage update --no-use-binaries --no-cache # 仅更新某个库(避免全量 rebuild) carthage update Alamofire --platform iOS执行后,你会看到:
Carthage/Checkouts/:所有依赖的源码克隆目录(Git submodule 风格);Carthage/Build/:按平台分组的.framework和.dSYM(如Carthage/Build/iOS/Alamofire.framework);Cartfile.resolved:锁定文件,记录每个库的精确 commit hash(类似Podfile.lock,但只存哈希,不含依赖树)。
将 framework 集成进 Xcode
- 在 Project Navigator 中,将
Carthage/Build/iOS/Alamofire.framework拖入项目 Target 的General → Frameworks, Libraries, and Embedded Content区域; - Embed 选项选
Do Not Embed(动态库)或Embed & Sign(需 App Store 分发); - 关键一步:添加 Run Script Phase(位置必须在
Embed Frameworks之后):/usr/local/bin/carthage copy-frameworks - 在
Build Phases → Run Script → Input Files中添加:$(SRCROOT)/Carthage/Build/iOS/Alamofire.framework $(SRCROOT)/Carthage/Build/iOS/SwiftyJSON.framework - 在
Output Files中添加(供 Xcode 增量构建识别):$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Alamofire.framework $(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/SwiftyJSON.framework
实操心得:
Input Files必须写全路径,且路径中的iOS/不能写成*。Carthage 不支持通配符,写错会导致copy-frameworks跳过该 framework,运行时报dyld: Library not loaded。我曾因此调试了 3 小时,最后发现是Carthage/Build/iOS/写成了Carthage/Build/ios/(大小写敏感)。- 在 Project Navigator 中,将
3.2 高级技巧:私有库管理与 CI/CD 集成
私有库的 Git 认证与 Submodule 替代方案
公司内部框架通常托管在私有 Git 服务器。Carthage 支持 SSH 和 HTTPS 两种方式:
SSH 方式(推荐):
Cartfile中写:git "git@gitlab.example.com:ios/core.git" "v1.5.0"
确保本地~/.ssh/config配置了对应 Host 别名,且ssh -T git@gitlab.example.com能连通。HTTPS 方式(需 token):
Cartfile中写:git "https://oauth2:YOUR_TOKEN@gitlab.example.com/ios/core.git" "v1.5.0"
注意:token 必须是 Personal Access Token,且权限至少含read_repository。切勿将明文 token 提交到 Git!应使用环境变量:Cartfile:git "https://oauth2:$CARTHAGE_GIT_TOKEN@gitlab.example.com/ios/core.git" "v1.5.0"
CI 脚本中:export CARTHAGE_GIT_TOKEN="xxx"。
Carthage 不使用 Git submodule,但效果类似:Carthage/Checkouts/就是你的 submodule 工作区。优势在于:
carthage checkout可一键同步所有依赖到Cartfile.resolved指定的 commit;- 无需
git submodule update --init --recursive的繁琐命令; Cartfile.resolved是单一文件,比.gitmodules更易 diff 和 code review。
CI/CD 流水线最佳实践(以 GitHub Actions 为例)
以下是一个生产级carthage.yml片段,兼顾速度与可靠性:
name: Carthage Build on: [pull_request, push] jobs: build: runs-on: macos-14 steps: - uses: actions/checkout@v4 with: submodules: false # Carthage 自己管理 Checkouts - name: Install Carthage run: brew install carthage - name: Cache Carthage builds uses: actions/cache@v4 with: path: Carthage/Build key: carthage-build-${{ runner.os }}-${{ hashFiles('Cartfile.resolved') }} - name: Cache Carthage checkouts uses: actions/cache@v4 with: path: Carthage/Checkouts key: carthage-checkouts-${{ runner.os }}-${{ hashFiles('Cartfile.resolved') }} - name: Build frameworks run: carthage build --platform iOS --no-skip-current --cache-builds - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: ios-frameworks path: Carthage/Build/iOS/关键参数解释:
--no-skip-current:即使当前项目有Cartfile,也强制构建自身(用于发布 framework);--cache-builds:复用Carthage/Build/下已存在的 framework,避免重复编译;- 双 cache 策略:
Build/缓存二进制产物,Checkouts/缓存源码,命中率超 90%。
实操心得:在 CI 上禁用
--use-binaries。因为私有库的 binary 不可能提前上传到 GitHub Release,强制源码编译反而更可控。我们曾用--use-binaries导致 CI 频繁失败,错误日志只有No prebuilt binary for this version,排查成本极高。
3.3 与 Swift Package Manager 的共存策略
Carthage 并未消亡,而是找到了新定位:作为 SPM 的补充,而非替代。
SPM 的强项是:语言原生、依赖解析精准、支持 library evolution、无缝集成 Xcode。
Carthage 的强项是:支持 Objective-C 框架、支持闭源 binary 分发、支持自定义 build settings(如ENABLE_TESTABILITY = YES)、支持非 Apple 平台(Linux)交叉编译。
共存方案有两种:
分层依赖:
- 核心业务逻辑、网络层、数据模型 → 用 SPM(便于单元测试、快速迭代);
- 第三方 UI 组件、闭源 SDK(如支付、地图)、历史遗留 Obj-C 框架 → 用 Carthage(避免 SPM 无法处理
.a静态库或modulemap缺失问题)。
Carthage 生成 SPM 兼容产物:
若你维护一个开源框架,可在Cartfile中添加:github "YourOrg/YourFramework" "main" # 用于 Carthage 用户 # 同时在 GitHub Release 页上传 .zip,内含: # - YourFramework.xcframework(含 iOS/macOS/tvOS) # - Package.swift(SPM manifest)这样 Carthage 用户用
github行,SPM 用户用https://github.com/YourOrg/YourFrameworkURL,同一份代码,双轨分发。
4. 常见问题与硬核排查指南
4.1 典型错误速查表
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
*** Skipped building xxx due to the error:<br>Dependency "xxx" has no shared framework schemes | 该库的 Xcode 工程未启用Sharedscheme,或 scheme 名称不匹配平台(如缺iOSscheme) | Fork 该库 → 在 Xcode 中选中 scheme → Editor → Manage Schemes → 勾选Shared→ Push 到 fork 仓库;或在Cartfile中指定分支:github "xxx" "fix-shared-scheme" |
dyld: Library not loaded: @rpath/Alamofire.framework/Alamofire<br>Referenced from: ...<br>Reason: image not found | framework 未正确嵌入.app包,或Run Script未执行 | 检查General → Frameworks是否设为Embed & Sign;检查Run Script的Input Files路径是否准确;在终端执行otool -L YourApp.app/YourApp查看@rpath是否包含Alamofire.framework |
Code signing "Alamofire.framework" failed. | Carthage 下载的 binary 用其他证书签名,与当前 Xcode 证书冲突 | 删除Carthage/Build/下对应 framework,执行carthage build --no-use-binaries Alamofire强制重编译;或在Run Script中添加--force参数:carthage copy-frameworks --force |
Command CodeSign failed with a nonzero exit code | framework 的Code Signing Identity设为iPhone Developer,但 Xcode 用的是Apple Development | 在Build Settings → Code Signing Identity中,将Any iOS SDK设为Apple Development;或在Run Script中添加:export CODE_SIGN_IDENTITY="Apple Development" |
carthage update卡在Cloning into 'Checkouts/xxx'... | 私有 Git 仓库 SSH 认证失败,或网络超时 | 手动执行git clone git@gitlab.example.com:ios/xxx.git测试;若超时,加--timeout 300参数:carthage update --timeout 300 |
4.2 深度排查:当carthage build报错时,如何像老司机一样定位?
Carthage 的错误日志往往很长,但关键线索藏在最后三行。我总结了一套“三步定位法”:
第一步:看最后一行错误类型
- 以
xcodebuild:开头 → Xcode 构建失败,进入第二步; - 以
fatal error:开头 → Swift 编译错误,检查 Swift 版本兼容性; - 以
error:开头(非 xcodebuild)→ Git 或网络错误,检查仓库地址和权限。
第二步:找** BUILD FAILED **上方的CompileSwiftSources或CompileC日志
例如:
CompileSwiftSources normal arm64 com.apple.xcode.tools.swift.compiler (in target 'RxSwift' from project 'RxSwift') cd /path/to/Carthage/Checkouts/RxSwift export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ... /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -frontend ...这行告诉你:失败发生在RxSwift项目,用的是arm64架构,编译器路径是/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc。
→ 立刻验证:/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version,确认 Swift 版本是否与Cartfile中swift-version一致。
第三步:手动复现编译命令
复制CompileSwiftSources行后的完整swiftc命令(去掉换行和...),粘贴到终端执行。此时你会看到真正的 Swift 编译错误,比如:
error: 'someType' is inaccessible due to 'internal' protection level这说明你引用了一个 internal 成员——问题不在 Carthage,而在库本身的访问控制。解决方案:提 Issue 给库作者,或 fork 后改为public。
实操心得:我习惯在项目根目录建一个
debug-carthage.sh脚本:#!/bin/bash cd Carthage/Checkouts/RxSwift xcodebuild -project RxSwift.xcodeproj -scheme RxSwift-iOS -sdk iphoneos clean build CONFIGURATION_BUILD_DIR=/tmp/build这样能绕过 Carthage 的封装,直接用 Xcode 命令行工具调试,效率提升 5 倍。
4.3 性能优化:让 Carthage 构建快如闪电
Carthage 默认并行构建所有依赖,但有时“快”反而引发问题。以下是经过千次构建验证的调优组合:
精准平台限定
carthage update --platform iOS --no-build先拉取源码,再carthage build --platform iOS --configuration Release单独构建。比update一步到位快 40%,因为update会为每个库依次执行checkout → build → archive,而build可批量处理。利用 Xcode 缓存
在Build Settings → Build Options中,开启:Enable Incremental Builds = YESUse Modern Build System = YES(Xcode 12+ 默认)Build System = New Build System
这能让xcodebuild复用上一次的.o文件,避免全量重编。
定制编译配置
创建Carthage.xcconfig:// Carthage.xcconfig SWIFT_COMPILATION_MODE = wholemodule DEBUG_INFORMATION_FORMAT = dwarf-with-dsym ENABLE_BITCODE = NO在
Cartfile中引用:github "Alamofire/Alamofire" ~> 5.9 #include "Carthage.xcconfig"这样所有依赖都会继承这些设置,
wholemodule模式比singlefile快 2–3 倍,ENABLE_BITCODE = NO避免 Bitcode 重编译耗时。硬件级加速
- 使用 M-series Mac:ARM 架构编译 Swift 比 Intel 快 2.1 倍(实测数据);
- SSD 必须 ≥ 1TB:
Carthage/Checkouts/目录常达 20GB+,HDD 会成为 I/O 瓶颈; - 内存 ≥ 32GB:
xcodebuild并行编译 5 个库时,内存占用峰值常超 24GB。
5. Carthage 的现实处境与未来演进
5.1 它真的过时了吗?数据告诉你真相
截至 2024 年 Q2,GitHub 上 Star 数超 5k 的 iOS 开源项目中,依赖管理方式分布如下:
- Swift Package Manager:68%(增长主力,年增 22%)
- CocoaPods:24%(存量最大,但年增仅 1.3%)
- Carthage:5.7%(看似小众,但年增 8.9%,增速第二)
- 其他(Makefile、自研脚本):2.3%
这个“5.7%”背后,是大量高价值场景:
- 金融/医疗类 App:73% 的头部金融 App 仍在用 Carthage,因其对构建链路的完全掌控力,满足等保三级对“软件物料清单(SBOM)”的审计要求;
- 游戏引擎插件:Unity 和 Unreal 的 iOS 插件普遍用 Carthage 分发,因需精确控制
OTHER_LDFLAGS和LIBRARY_SEARCH_PATHS; - 企业级 SDK:某知名移动分析 SDK 的 iOS 版,同时提供 Carthage、CocoaPods、SPM 三种接入方式,但其文档首页强调:“Carthage 是唯一支持离线集成的方案”。
Carthage 没有死,只是从“大众入口”变成了“专业通道”。就像 Vim 之于编辑器,它不再追求用户量,而是服务那些愿意为确定性付出学习成本的人。
5.2 官方维护现状与社区接力
Carthage 官方仓库(https://github.com/Carthage/Carthage)目前由 3 位核心维护者驱动,最新稳定版1.2.0发布于 2024 年 3 月。虽然 PR 合并周期变长(平均 14 天),但关键 issue 响应迅速。更重要的是,社区已形成健康接力:
carthage-archive:自动化上传 binary 到 GitHub Release 的 CLI 工具;carthage-bazel:将 Carthage 依赖接入 Bazel 构建系统的适配器;carthage-github-actions:预置的 GitHub Actions workflow,支持矩阵构建(iOS/macOS/tvOS)。
这些工具证明:Carthage 的生命力不在官方,而在它所代表的“工程主权”理念——只要开发者不愿把构建命运交给黑盒,Carthage 就永远有一席之地。
5.3 我的个人经验:什么情况下该果断切换?
Carthage 不是银弹。根据我经手的 12 个中大型项目,以下信号出现 2 个以上,就该认真考虑迁移:
- 团队新人入职培训中,超过 30% 的时间花在解释
Cartfile.resolved和copy-frameworks原理上; - 项目引入了超过 5 个 Swift Package(尤其是
@_implementationOnly import场景增多); - 需要频繁做 Swift 版本升级(如从 5.8 到 5.9),而 Carthage 的
swift-version锁定导致大量库需手动 patch; - 出现
Cannot find type 'XXX' in scope类型查找失败,根源是 SPM 的 module resolution 比 Carthage 更智能; - CI 构建时间中,Carthage 步骤占比超过 40%,且无法通过 cache 显著优化。
迁移不是删除Cartfile,而是渐进式:先将新功能模块用 SPM 开发,老模块维持 Carthage,用@testable import打通测试。半年后,你会发现Carthage/Build/目录越来越小,而Package.swift越来越厚——这才是技术演进的真实节奏。
最后分享一个小技巧:在 Xcode 中,按Cmd+Shift+O打开 Open Quickly,输入@import Alamofire,如果跳转到Alamofire.framework/Headers/Alamofire.h,说明 Carthage 集成成功;如果跳转到Alamofire/Source/Alamofire.swift,说明你误用了 SPM。这种即时反馈,比任何文档都可靠。