在 SwiftUI 页面里复用一个 C++ 算法库,到底该直接调用,还是加一层 Objective-C++?Flutter 的 Dart FFI 又能不能调用同一个库?
这些是我在旧文《c++混编的一些简单的思考》中讨论的问题。现在把它们重新整理:保留原来的接入路线,修正不准确的表述,并结合 WWDC25《Safely mix C, C++, and Swift》,补上更重要的一层------调用成功之后,怎样避免越界、悬空指针和所有权错误。
本文分成三条主线:
- 接入:Swift 直接互操作、Objective-C++ 包装、Flutter FFI 与 Platform Channel。
- 边界契约:传输的数据有多长、借用能持续多久、谁负责释放。
- 内部防护:Swift 严格内存安全检查、C++ 标准库强化和 C 边界安全扩展。
一、先修正旧文中的几个结论
原来的问题仍然有意义,但答案需要更新:
| 旧文中的说法 | 更准确的解释 |
|---|---|
| Swift 没法直接和 C++ 混编 | Swift 5.9 起支持直接 C++ 互操作,但有支持范围和配置要求 |
| OC 天然理解 C++ | .m 使用 Objective-C 模式;.mm 才使用 Objective-C++ 模式,同时支持 Objective-C 与 C++ |
| Swift 直接调 C++ 通常仍需要桥接头 | 同一个 Xcode App Target 常用桥接头;Framework、Swift Package 则可以通过模块导入,并非处处需要桥接头 |
| Swift 为了安全,不能使用指针 | Swift 提供 UnsafePointer 等不安全接口,只是把责任显式交给调用者 |
| Flutter 在 iOS 上因为 C++ 支持有限,所以推荐统一走 Channel | Dart FFI 对接 C ABI;iOS 同样可以走 FFI。纯算法与平台服务应按职责选择 |
.a 是动态库,.framework 也是一种固定的动态库格式 |
.a 是静态归档;Framework 是打包形式,内部可以包含静态或动态二进制 |
下面逐项展开,避免只改结论、不解释怎样用。
二、为什么 .mm 能调用 C++,而 Swift 需要互操作支持?
关键是编译模式与类型映射,不能只用"语言有没有血缘关系"解释。
.c:C 编译模式。.cpp:C++ 编译模式。.m:Objective-C 编译模式。.mm:Objective-C++ 编译模式,可以在同一实现文件里使用 Objective-C 和 C++。.swift:Swift 编译器处理;导入 C/C++ 时,通过互操作机制理解相应声明,并映射到 Swift 能使用的接口。
Objective-C++ 适合给既有 C++ SDK 加一层 Cocoa 风格接口,但 ARC 并不会因此接管 C++ 的任意裸指针。
Swift 的直接互操作也不是把 C++ 源码当成 Swift 解释。它需要处理类、值语义、引用、模板实例化、所有权等差异,只有受支持的声明才能正常导入。
例如 std::vector 内部拥有元素,而 std::span 只是借看别人的内存,二者即使都能导入,生命周期责任也不同。
三、三条接入路线,先选适合的一条
| 路线 | 调用链 | 适合的场景 |
|---|---|---|
| Swift 直接互操作 | Swift → C++ 模块或头文件 | 工具链满足要求,接口在支持范围内,希望保留 C++ 类型表达 |
| Objective-C++ 包装 | Swift → Objective-C 接口 → .mm → C++ |
老项目、已有 OC 接口、需要隔离复杂模板或第三方 SDK |
| C ABI 包装 | Swift / Dart FFI → C 函数 → C++ | 跨语言复用、跨平台算法库、希望暴露稳定且简单的边界 |
包装层不代表一定低效。真正需要评估的是调用次数、跨边界复制、对象转换以及算法成本。
同样,"直接调用"也不代表零成本或自动安全。先用实际项目的类型、错误模型和工具链约束决定路线,再测量性能。
四、保留原来的例子:通过 Objective-C++ 包装两倍计算
原文用 multiplyByTwo 演示调用链。这里继续做相同的计算,同时避免简单的 int * 2 在大输入时发生有符号溢出。
1. C++ 接口
NativeMathHeader.h,这是一个可供 C++ 包含的头文件:
cpp
// Created by Hunter on 2026-10-21.
#pragma once
#include <cstdint>
namespace NativeMath {
// 先扩为 64 位,保证任意 int32_t 输入乘二均可表示。
inline int64_t multiplyByTwo(int32_t value) noexcept {
return static_cast<int64_t>(value) * 2;
}
}
这段 C++ 代码怎么读:
#pragma once避免同一个编译单元重复包含头文件。<cstdint>提供明确位宽的int32_t、int64_t,方便跨语言对应类型。namespace NativeMath用来组织名称,避免与其他库的同名函数冲突。- 输入是 32 位整数,先
static_cast<int64_t>(value)再乘二,运算在 64 位范围内完成。不能先用 32 位乘完再转换,否则溢出已经发生。 noexcept声明函数不允许异常向外传播,不是自动捕获异常;若实现违反承诺抛出异常,会触发终止。
这里使用 inline 是因为实现直接放在头文件中。如果把实现放到 .cpp,头文件只保留声明,并确认 .cpp 进入目标的编译源列表。
2. Objective-C 公开接口
CalculationWrapper.h:
objc
// Created by Hunter on 2026-10-21.
#import <Foundation/Foundation.h>
#include <stdint.h>
NS_ASSUME_NONNULL_BEGIN
@interface CalculationWrapper : NSObject
/// 将 32 位整数乘二,使用 64 位结果避免溢出。
- (int64_t)multiplyByTwo:(int32_t)value
NS_SWIFT_NAME(multiplyByTwo(_:));
@end
NS_ASSUME_NONNULL_END
这段 OC 声明怎么读:
CalculationWrapper : NSObject 声明包装类。- 表示实例方法,(int64_t) 是返回类型,(int32_t)value 是输入。Swift 通常分别映射为 Int64、Int32。NS_ASSUME_NONNULL_BEGIN/END 为区域内适用的指针声明提供默认非空约定,不会为整数增加校验。
NS_SWIFT_NAME(multiplyByTwo(_:)) 固定 Swift 名称,所以调用写成 calculator.multiplyByTwo(200);它只影响接口导入后的名字,不改变计算过程。
这份头文件只暴露 Objective-C/C 可理解的类型,不包含 C++ 类、命名空间或模板。这样使用者不需要跟着进入 C++ 编译模式。
NS_SWIFT_NAME 显式规定 Swift 调用名,不必猜测 importer 会把方法改写成什么名字。
3. Objective-C++ 实现
CalculationWrapper.mm:
objc
// Created by Hunter on 2026-10-21.
#import "CalculationWrapper.h"
#include "NativeMathHeader.h"
@implementation CalculationWrapper
- (int64_t)multiplyByTwo:(int32_t)value {
return NativeMath::multiplyByTwo(value);
}
@end
包装层实际做了什么:
@implementation 实现公开头中的方法;NativeMath::multiplyByTwo(value) 使用 C++ 的 :: 访问命名空间内函数。这里没有创建临时图片、转换数组或复制大对象,只是转发整数参数和结果。换成复杂 SDK 时,类型转换、错误处理和所有权适配才是这一层需要明确承担的职责。
关键是 .mm 后缀。不要把 C++ 实现直接塞进 .m,也不要为了方便让公共 OC 头文件到处暴露 C++ 细节。
4. 桥接头与 Swift 调用
同一个 App Target 的桥接头中添加:
objc
#import "CalculationWrapper.h"
在 Build Settings 的 Objective-C Bridging Header 中配置这份头文件路径,并确认包装类实现、C++ 源文件与链接依赖都加入正确 Target。
Swift 调用:
swift
let calculator = CalculationWrapper()
let result = calculator.multiplyByTwo(200)
// result 为 Int64,值为 400。
调用顺序是:Swift 创建 CalculationWrapper 对象 → 调用 OC 方法 → .mm 中执行 C++ 函数 → 将 int64_t 结果返回给 Swift。桥接头负责让编译器"看见声明",不负责把缺失的 .cpp 实现自动链接进来;能自动补全函数但出现链接错误时,要检查实现与库是否确实进入目标。
5. SwiftUI 页面不要承担桥接业务
原文把计算调用写在按钮闭包里,适合看懂最短调用链。项目中可以给 ViewModel 负责调用:
swift
import SwiftUI
@MainActor
final class CalculationModel: ObservableObject {
@Published private(set) var result: Int64 = 0
private let calculator = CalculationWrapper()
func calculate() {
result = calculator.multiplyByTwo(200)
}
}
struct CalculationView: View {
@StateObject private var model = CalculationModel()
var body: some View {
VStack(spacing: 16) {
Text("计算结果:\(model.result)")
Button("计算 200 × 2") {
model.calculate()
}
}
.padding()
}
}
页面代码的职责:
@MainActor约束 ViewModel 的访问,使这里的界面状态更新遵守主 actor 隔离。@Published private(set)允许 ViewModel 内部修改结果,视图读取并订阅变化。@StateObject让 SwiftUI 为页面维护 ViewModel 的身份与生命周期。- 点击按钮只调用
model.calculate();桥接对象的创建和计算调用集中在 ViewModel。 - 计算得到
400后,@Published触发相应视图更新,Text显示新结果。
这个计算很轻,可以同步执行;图片处理、编解码等耗时 C++ 调用则应在合适的执行上下文中完成,再回主线程更新 UI。async 声明本身并不保证同步的重计算自动离开主线程。
五、Swift 直接调用 C++:现在如何配置?
Xcode App Target
Swift.org 给出的设置是:
- 在使用 C++ 的 Target 中,将 C++ and Objective-C Interoperability 设置为 C++ / Objective-C++。
- 同一 App Target 通过桥接头包含
NativeMathHeader.h。 - 如果实现位于
.cpp,确保它被编译并链接。 - 保证调用方、实现和依赖使用兼容的 C++ 标准库与构建设置。
然后可直接调用:
swift
let result = NativeMath.multiplyByTwo(200)
这里的 NativeMath 是 C++ 命名空间映射,不是一个 Swift 手写包装类。
Swift Package / Framework
Framework 使用公开头文件和模块;Swift Package 的 C++ Target 可以通过 umbrella header / module map 暴露声明,Swift Target 再 import 该模块。
Swift Package 中的关键配置为:
swift
.target(
name: "InteropFeature",
dependencies: ["NativeMath"],
swiftSettings: [
.interoperabilityMode(.Cxx)
]
)
这段 Target 配置怎么读:
dependencies: ["NativeMath"] 声明当前 Swift Target 依赖哪个目标;.interoperabilityMode(.Cxx) 则让这个 Swift Target 采用 C++ 互操作模式。二者不是一回事:只声明依赖,不一定开启了 C++ 导入能力;只开启模式,也不会自动创建不存在的 C++ 模块。
这是 Package.swift 的 Target 配置节选,不是完整清单。C++ Target 的公开头目录默认是 include;例如 Sources/NativeMath/include/NativeMath.h 可以作为 umbrella header。
如果使用 std::span,还需要 C++20;不是所有 C++ 混编项目都必须升级 C++20。简单整数函数并不需要这个标准版本。
互操作模式还可能影响依赖该 Swift Target 的下游 Target,应按 Swift.org 的构建说明检查,不能只修改最底层后忽略消费者。
什么时候仍然选包装层?
第三方库使用大量不受支持的接口、复杂回调、宏,或者你希望控制 ABI、异常和所有权边界时,包装层仍然很有价值。
尤其不要假设 C++ 异常会自动变成 Swift Error。需要调用可能抛异常的 C++ 代码时,应在 C++/Objective-C++ 边界捕获,再转换为状态码、NSError 或其他明确的结果协议。
六、WWDC25 讲座的重点:能调用,不等于安全调用
官方入口:Safely mix C, C++, and Swift,Session 311。讲者 Yeoul 来自 Apple Secure Language Extension 团队。
她的示例是一个 Swift App:调用 C/C++ 图片滤镜,处理用户照片。调用点看起来很短:
swift
filterImage(&imageData, imageData.count)
但这里可能隐藏了三个问题:
| 问题 | 具体含义 | 典型后果 |
|---|---|---|
| 边界 | 指针后面到底有多少可访问元素 | 越界读写 |
| 生命周期 | 被引用内存何时失效 | use-after-free、悬空指针 |
| 所有权 | 谁持有、谁增加引用、谁释放 | 泄漏、重复释放、过早释放 |
Swift 的安全语法不会自动修复被调用的 C++ 实现。讲座的路线是:先找出不安全调用,再补充编译器缺失的契约,最后强化 C/C++ 内部。
七、Strict Memory Safety:先让不安全调用显形
Swift 6.2 增加 Strict Memory Safety 模式。讲座在 Xcode Build Settings 中把该项设置为 Yes,重新构建后,编译器为不安全构造给出诊断和原因。
命令行对应:
sh
swiftc -strict-memory-safety ...
这里的 ... 是其他编译参数的占位符,不应原样输入终端。开启后,应先根据诊断定位具体不安全调用,再决定改接口还是保留经过审计的 unsafe 边界。它不会自动重写 C++ 实现,也不会自动给旧函数增加长度参数。
需要区分:
- 这是内存安全检查,不是 Strict Concurrency 的并发隔离检查。
- 讲座中的模式不是默认开启,也不等于所有警告默认都会阻止编译。
- 使用
unsafe标记是在显式确认风险,不会让原本不安全的代码变安全。 - 没有诊断,也不意味着编译器已经证明了被调用 C++ 函数的全部实现正确。
例如指针类型在方法签名中很显眼,数组隐式转为指针则不一定容易在代码审查时发现。这个模式的价值,就是帮助定位这些边界。
八、Span 与 MutableSpan:把边界和借用联系起来
Swift 6.2 的 Span<Element> 与 MutableSpan<Element> 为连续内存提供安全的借用视图:
| 类型 | 主要用途 |
|---|---|
Span<Element> |
只读访问一段连续元素 |
MutableSpan<Element> |
可变访问一段连续元素 |
UnsafePointer<Element> |
裸指针,使用者自行保证有效范围和生命周期 |
UnsafeBufferPointer<Element> |
带数量的指针视图,但不能据此认为生命周期已自动安全 |
Span 不拥有底层元素,也不通过"自动复制整块图片"来解决生命周期问题。但取得可变视图时,原容器可能为满足写时复制(COW)和独占访问要求而复制存储,不能把所有路径都宣传成绝对零复制。它把数量信息与借用关联起来,由编译器检查生命周期依赖,并对访问提供相应的边界保护。
在安全代码中,不能把依赖局部数组内存的 Span 任意存到更长寿命的位置;如果从不安全指针构造 Span,构造者仍必须保证起始地址和长度真实有效。
Span 不能挽救一个已经无效的地址,也不能替 C++ 检查所有指针操作。
九、接受裸指针:__counted_by 和 __noescape 各负责什么?
视频 7:25 起讨论接受指针的函数。原始接口相当于:
cpp
void invertImage(uint8_t *imagePtr, size_t imageSize);
指针和数量是两个独立参数,调用方可能把只有 100 字节的缓冲区,误报成 1000 字节。
官方为它补充:
cpp
void invertImage(
uint8_t *__counted_by(imageSize) imagePtr __noescape,
size_t imageSize
);
两个注解表达的是不同契约:
__counted_by(imageSize):指针关联的元素数量来自imageSize,单位是元素。__noescape:被调用方不会保存这份借用,供调用结束后继续访问。
不能只加一个就认为另一个问题也解决了。函数如果把指针缓存到全局变量、成员或异步任务,不能为了消除警告而标 __noescape。
对 void * 这类需要按字节描述范围的接口,Swift.org 还介绍了 __sized_by:单位为字节数 ,在受支持的转换中对应 RawSpan。
安全重载是额外接口,不是把旧接口删掉
具备足够注解并启用相关特性后,importer 可以保留原指针接口,同时生成接收 Span 的安全重载,替调用者拆出正确指针与数量。
概念上:
text
C/C++:指针 + 数量 + 不逃逸契约
↓
Swift 安全重载:Span / MutableSpan
↓
包装代码提取指针与数量,再调用原实现
多个参数共用同一个数量时,安全重载还可能检查它们的长度是否一致。复杂的 rows * columns 范围无法只由元素总数还原两个维度,维度参数仍需保留并校验;"用了 Span,所有 size 参数都会消失"是错误的。
必须说明的实验特性开关
Swift.org 安全互操作文档明确写明:其所述自动安全重载能力在 Swift 6.2 release branch 上需要:
text
-enable-experimental-feature SafeInteropWrappers
在需要此开关的 Xcode 工具链中,可在 Other Swift Flags 添加这两个参数;SwiftPM/命令行也要给实际导入接口的 Swift 编译过程传入相应参数。
这是编译器实验特性,不是 iOS 的运行时开关。使用其他版本时要核对对应文档和编译输出,不能把 WWDC 演示当成所有 Swift 6.2 工程默认启用的能力。
十、一个具体的 Span 调用例子
下面是根据讲座机制整理的独立小例子。C++ 头文件需要工具链提供的 ptrcheck.h、lifetimebound.h,而不是在业务里随意定义同名空宏。
cpp
#pragma once
#include <cstddef>
#include <cstdint>
#include <ptrcheck.h>
#include <lifetimebound.h>
// 原地反转字节;只在调用期间使用,不保存 bytes。
inline void invertBytes(
uint8_t *__counted_by(count) bytes __noescape,
size_t count
) {
for (size_t index = 0; index < count; ++index) {
bytes[index] = 255 - bytes[index];
}
}
先看 C++ 实现中的四个关键点:
uint8_t *表示可修改的字节地址;count是元素数量,对uint8_t来说也恰好等于字节数。__counted_by(count)把原本独立的指针与数量建立关联;__noescape承诺不在调用后继续持有该地址。- 循环使用
index < count,不会访问位置count。若误写成<=,仍然是实现错误,注解不是修复循环的工具。 255 - bytes[index]对每个字节做反转:0 → 255、127 → 128、255 → 0。
Swift 导入这份头文件并启用安全重载后,在函数内部使用:
swift
func processBytes() {
var bytes: [UInt8] = [0, 127, 255]
var byteSpan = bytes.mutableSpan
invertBytes(&byteSpan)
// 修改通过借用视图作用于 bytes 的元素。
}
再看 Swift 调用过程:
bytes.mutableSpan 提供这段数组存储的可变借用,byteSpan 同时携带访问范围。调用安全重载时,包装代码从 Span 获取实际指针和 count,不需要调用者再手填一个长度。正常完成后,数组元素为 [255, 128, 0]。
这里无需手动 free:底层存储由 Swift 数组管理,Span 只借用它。反过来,也不能让 C++ 保存该地址,等函数结束后再异步使用。
注意两个 & 的概念不能混淆:这里是把 MutableSpan 变量作为 inout 参数传给安全重载,而不是把原数组隐式转成裸指针。
这类可变借用有作用域和独占访问约束,不能将 byteSpan 随意移到全局,也不能在它仍被使用时对原数组进行冲突访问。不要机械复制视频中的临时借用写法到不同工具链。
十一、接受 std::span:有长度,不代表有生命周期保证
视频 12:10 的 C++ 接口使用:
cpp
using CxxSpanOfByte = std::span<uint8_t>;
void applyGrayscale(CxxSpanOfByte imageView);
std::span 是 C++20 的标准类型,包含指针与元素数量,但不拥有那段内存。
两个容易混淆的点:
- 普通 C++20
std::span::operator[]不能被默认当作有运行时越界检查。 - 它不会自动保证底层对象活得足够久。
如果 applyGrayscale 把 imageView 存起来,原数组释放后,再读缓存的 span 就可能悬空。
审计确认不会逃逸后,可标记:
cpp
void applyGrayscale(CxxSpanOfByte imageView __noescape);
在工具链支持并启用安全重载时,可生成 Swift 的 MutableSpan<UInt8> 调用接口。std::span<const uint8_t> 则对应只读 Span<UInt8>。
这里的转换主要针对受支持的顶层参数/返回类型,不能推广成"任何嵌套在自定义对象中的 std::span 都自动转换"。
十二、返回 std::span:用 __lifetimebound 说明依赖谁
视频 17:13 开始讨论另一种情况:从图片中取一行,返回指向原内存的视图。
cpp
CxxSpanOfByte scanImageRow(
CxxSpanOfByte imageView __lifetimebound,
size_t width,
size_t rowIndex
);
这一行声明里,输入、输出分别是什么:
CxxSpanOfByte 是返回类型,表示选中行的视图;imageView 是整幅图像的输入视图;width 和 rowIndex 帮助实现定位某一行。返回视图通常指向输入中的一部分内存,并没有新建一张图片。
把 __lifetimebound 写在 imageView 上,是为了把"返回结果依赖输入内存"告诉 importer;它不是说函数运行时会自动延长输入对象的寿命。
__lifetimebound 加在参数上,表达:返回视图依赖这个参数所关联的内存,不能超出相应有效期。
它不会复制图片,也不会自动增加底层内存的引用计数。
如果 Swift 函数创建局部数组,再试图返回借用该数组的 Span,编译器就有机会拒绝这份会悬空的结果。确实需要长期保存时,应返回拥有数据的 Array 等容器,或者采用明确的所有权对象。
讲座的 scanImageRow 是接口示例,实际实现还必须检查:
width是否为零或超出数据范围。rowIndex是否有效。rowIndex * width是否溢出。- 行跨度是否包含 padding,单位是像素还是字节。
有生命周期注解,不等于图片坐标运算自动正确。
方法返回值依赖 this 时怎么办?
Swift.org 还说明:把 __lifetimebound 放在 C++ 方法签名后,可以描述返回值依赖隐式 this 对象。
cpp
ByteView bytes() const __lifetimebound;
这适用于对象交出内部缓冲区视图的场景。同样需要保证对象在借用期间没有进行使地址失效的重分配操作。
十三、自定义 C++ 视图:SWIFT_NONESCAPABLE
视频 20:16 开始讨论自定义类型。假设 C++ 视图同时携带像素、宽度与高度:
cpp
#include <span>
#include <cstdint>
#include <swift/bridging>
struct SWIFT_NONESCAPABLE ImageView {
std::span<uint8_t> pixelBytes;
int width;
int height;
};
结构体字段说明:
pixelBytes 是借用的像素视图,不是拥有数据的 vector;width、height 是描述图像的元数据。即使它们打包进一个结构体,像素内存也仍由其他对象拥有。标注这个结构体,是为避免 Swift 使用者把它误当作能独立长期保存的数据对象。
这里的 ImageView 是 C++ 数据视图,与 SwiftUI 的 View 协议、UIKit 的 UIView 没有继承或导航关系。
SWIFT_NONESCAPABLE 告诉 Swift:把它导入为 non-escapable 类型,即 ~Escapable。
它适合表达"这只是借来的视图,不能独立长期存在"。随后,创建或返回这个类型的函数,还需要提供正确的生命周期依赖;只给结构体加宏,并没有回答它依赖谁。
例如,返回视图的函数可以通过 __lifetimebound 参数说明依赖输入对象。如果缺少信息,importer 或类型检查可能要求补齐契约。
不要把 ~Escapable 和 ~Copyable 混为一谈:前者关注逃逸与生命周期,后者关注复制,两者解决不同问题。
C++ 一侧依然可以写出错误实现。这个宏不是把所有 C++ 类型的使用自动改造成 Swift 借用检查。
十四、自定义引用计数对象:让 Swift 知道怎样 retain / release
另一类对象自己拥有内存,例如图片缓冲区。官方使用:
cpp
// 结构声明节选,retain/release 的实现由库提供。
struct ImageBuffer;
void retain_image_buffer(ImageBuffer *_Nonnull buffer);
void release_image_buffer(ImageBuffer *_Nonnull buffer);
struct SWIFT_SHARED_REFERENCE(
retain_image_buffer,
release_image_buffer
) ImageBuffer {
// 像素容器、尺寸和引用计数等成员见实际实现。
};
这组声明怎么配合:
前置声明 struct ImageBuffer; 允许先声明接收该类型指针的 retain/release 函数。随后类型上的宏把这两个函数登记为 Swift 的所有权操作入口。它们不是"读取图片"和"删除文件"的业务方法,而是对象引用计数协议的一部分。
当 createImage() 按 +1 交出对象时,Swift 接管相应所有权;当 getCachedImage() 按 +0 返回现有对象时,缓存方仍需保证返回时对象有效,Swift 再按需要取得持有权。具体插入多少次 retain/release 由语义和优化决定,不能靠手工假定每个赋值必然调用一次。
SWIFT_SHARED_REFERENCE 让 Swift 知道如何增加和减少这个外部对象的引用计数。
它并没有替库生成引用计数实现,也不等于把任意 C++ 指针自动变成 std::shared_ptr。释放函数必须真的在引用数归零时按正确分配方式销毁对象。
返回值还需要说明是 +1,还是借用的 +0
官方函数声明节选:
cpp
ImageBuffer *_Nonnull createImage()
SWIFT_RETURNS_RETAINED;
ImageBuffer *_Nonnull getCachedImage()
SWIFT_RETURNS_UNRETAINED;
| 注解 | 所有权含义 | Swift 侧需要理解的责任 |
|---|---|---|
SWIFT_RETURNS_RETAINED |
返回一个交给调用方管理的 +1 引用 | 这份所有权使用完后需要释放 |
SWIFT_RETURNS_UNRETAINED |
返回没有转移 +1 所有权的现有对象 | 需要持有时,通过正确 retain 规则取得所有权 |
"新建对象"和"缓存对象"只是典型例子,最终以库的真实所有权协议为准。函数名不能替代契约,标错可能导致泄漏或过早释放。
引用计数正确也不自动意味着线程安全;即使计数用原子变量,像素内容的并发读写仍需独立设计。
十五、这些注解分别解决什么?
| 注解 / 类型 | 传达的信息 | 不能替代的检查 |
|---|---|---|
__counted_by |
元素数量与指针的关联 | 实现中的任意错误索引 |
__sized_by |
指针关联的字节范围 | 数据格式、对齐与布局校验 |
__noescape |
参数引用不会被保留到调用之后 | 异步任务的真实所有权管理 |
__lifetimebound |
返回值依赖哪些输入的生命周期 | 自动复制或自动 retain |
SWIFT_NONESCAPABLE |
导入类型具有受限制的逃逸能力 | 函数层面的具体生命周期依赖 |
SWIFT_SHARED_REFERENCE |
外部对象的 retain / release 入口 | 库内部引用计数和析构实现 |
SWIFT_RETURNS_RETAINED / UNRETAINED |
返回值的所有权交接规则 | 空指针、线程安全、业务有效性 |
如果无法修改第三方头文件,Swift.org 还介绍了通过 API Notes 为部分类型和函数补充边界、逃逸与生命周期信息。但它也有适用限制,例如 C++ 重载函数不能一概套用,不能声称所有 API 都可在外部无侵入修复。
十六、C++ 内部:Standard Library Hardening 与不安全缓冲区诊断
讲座 26:57 之后强调:安全导入只是第一步,纯 C/C++ 实现本身还可能出错。
C++ Standard Library Hardening
Xcode 的 C++ Standard Library Hardening 可为所支持的标准库操作增加检查。例如对 std::span、std::vector 的部分不满足前置条件的操作进行检测,具体覆盖范围取决于 libc++ 和 hardening 模式。
不要把它理解为检测所有 C++ 内存错误。裸指针运算、悬空引用、数据竞争等并不会因此全部消失。
Enforce Bounds-Safe Buffer Usage in C++
讲座介绍的 Enforce Bounds-Safe Buffer Usage in C++ 组合启用标准库强化与不安全缓冲区使用诊断,推动代码从裸指针访问迁移到带范围的容器/视图。
例如:
cpp
void fillBytes(uint8_t *bytes, size_t count) {
for (size_t index = 0; index < count; ++index) {
bytes[index] = 0;
}
}
迁移为:
cpp
void fillBytes(std::span<uint8_t> bytes) {
for (size_t index = 0; index < bytes.size(); ++index) {
bytes[index] = 0;
}
}
从裸指针改成 span 后,改善在哪里?
旧函数的 bytes 和 count 可以被调用者配错;新函数把地址与数量打包在 std::span 里,循环直接读取 bytes.size()。这减少参数不一致的机会,但调用者仍可能用错误指针和长度构造 span;所以必须检查构造位置,并结合标准库强化,而不是只看函数签名。
再结合 hardening 检查,而不是只替换类型名称。
这主要改善边界安全 ,不等于解决生命周期安全。接口传入的 span 如果本来就指向失效内存,仍然有问题。
十七、C 内部:-fbounds-safety 与 __counted_by
C 没有 std::span 这样的标准容器,讲座介绍了 Clang 的 Bounds Safety 扩展,通过范围注解、静态约束及运行时检查改善越界问题。
c
#include <stddef.h>
#include <stdint.h>
#include <ptrcheck.h>
void clearBytes(
uint8_t *__counted_by(count) bytes,
size_t count
) {
for (size_t index = 0; index < count; ++index) {
bytes[index] = 0;
}
}
这段 C 代码的保护依赖什么?
__counted_by(count) 为编译器提供范围关联,循环中的下标访问再由支持的边界安全编译模式约束。若写错下标并触发已插入的运行时检查,程序可能确定性 trap;这不是返回一个业务错误码,调用者不能把它当作可正常恢复的输入校验机制。
相应编译选项是 -fbounds-safety。讲座展示了 Xcode 的 Bounds Safety 设置;使用时需要核对具体 Apple Clang 版本、文件语言模式和依赖兼容性。
与前面"生成 Swift 安全重载"的区别是:
- 边界注解配合 Swift importer,帮助保护跨语言调用接口。
- C 文件本身也用支持的
-fbounds-safety编译,才会进一步获得对应的 C 内部保护。
它不解决所有 use-after-free,也不能把任意 malloc / free 协议自动验证正确。
还需注意:本次读取的 LLVM 上游 Bounds Safety 文档仍带有设计/实现进度提示,与 Apple 在特定 Xcode 中提供的功能不能简单视为完全同步。不要把 Xcode 演示直接推广成任意版本上游 Clang、Android NDK 或 C++ 编译模式都支持。
十八、Flutter 调 C++:先暴露 C ABI
保留原文的 Flutter 部分,但先澄清核心限制:Dart FFI 不直接理解任意 C++ 类、模板、重载或异常。通常让 C++ 库对外暴露 C ABI。
text
Dart → dart:ffi → C ABI 包装 → C++ 实现
extern "C" 解决的是所标记函数的语言链接方式等问题,不会把 C++ 类布局、std::string 或 std::vector 自动变成跨语言安全格式。
1. 公共 C 头文件
NativeBridgeAPI.h:
c
// Created by Hunter on 2026-10-21.
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#if defined(_WIN32)
#define NATIVE_EXPORT __declspec(dllexport)
#else
#define NATIVE_EXPORT __attribute__((visibility("default"))) \
__attribute__((used))
#endif
/// 输入 32 位整数,返回其两倍的 64 位结果。
NATIVE_EXPORT int64_t multiply_by_two(int32_t value);
#ifdef __cplusplus
}
#endif
公共 C 头为什么这么写:
#ifdef __cplusplus让同一头文件既能被 C 编译器包含,也能被 C++ 编译器包含;只有 C++ 编译模式使用extern "C"。extern "C"让导出函数采用 C 语言链接约定,避免 Dart 查找普通符号名时遇到 C++ 名称修饰。NATIVE_EXPORT表达导出可见性,具体库仍要由构建系统编译和链接。- 边界只传固定宽度整数,没有暴露
std::string或 C++ 对象布局,Dart 才能直接声明对应 ABI 类型。
导出策略还需匹配库的实际构建方式。used / 默认可见性不能替代 iOS 静态归档成员被链接进最终产物这一步。
NativeBridgeAPI.cpp:
cpp
// Created by Hunter on 2026-10-21.
#include "NativeBridgeAPI.h"
#include "NativeMathHeader.h"
int64_t multiply_by_two(int32_t value) {
return NativeMath::multiplyByTwo(value);
}
有潜在异常的实现要在此处 try/catch 并转换错误结果,不要让 C++ 异常穿越 Dart FFI 边界。
2. Dart 手写绑定
dart
import 'dart:ffi' as ffi;
// 原生函数类型:对应 int64_t 和 int32_t。
typedef DoubleNative = ffi.Int64 Function(ffi.Int32);
// Dart 可调用类型。
typedef DoubleDart = int Function(int);
class NativeCalculator {
NativeCalculator(ffi.DynamicLibrary library)
: _multiply = library
.lookupFunction<DoubleNative, DoubleDart>('multiply_by_two');
final DoubleDart _multiply;
int multiplyByTwo(int value) {
if (value < -2147483648 || value > 2147483647) {
throw RangeError.range(value, -2147483648, 2147483647);
}
return _multiply(value);
}
}
Dart 绑定分三步:
DoubleNative描述原生 ABI:返回Int64,接收Int32。DoubleDart描述 Dart 调用形式:参数和返回值都用 Dart 的int。lookupFunction在传入的库中查找multiply_by_two,并建立可调用绑定;_multiply保存绑定,避免业务每调用一次都重新组织查找。
RangeError 在进入 FFI 前拒绝不属于 32 位有符号整数范围的输入,避免让较大的 Dart 整数在跨边界转换时被截断成意外值。库没有加载或符号未导出时,会在初始化查找处失败,尚未执行 C++ 计算。
这些类型必须匹配 C 声明:int32_t 对 Int32、int64_t 对 Int64。不要把所有原生整数都写成 IntPtr,也不要假定 C 的 long 在所有平台都一样宽。
这段代码使用手动符号查找,适合解释调用机制;大接口优先考虑 ffigen 生成绑定,再对生成结果提供 Dart 业务封装。
十九、Flutter Android:保留旧工程的 CMake / Gradle 接入
原文把源文件放进 android/app/src/main/cpp 的方式,仍可用于手动集成的旧工程,但不是所有 Flutter 项目的唯一目录结构。
如果 CMakeLists.txt 位于 android/app,对应示意为:
cmake
cmake_minimum_required(VERSION 3.22)
project(native_math LANGUAGES CXX)
add_library(native_math SHARED
src/main/cpp/NativeBridgeAPI.cpp
)
target_include_directories(native_math PRIVATE src/main/cpp)
target_compile_features(native_math PRIVATE cxx_std_17)
CMake 每行负责什么:
add_library(... SHARED ...) 创建共享库目标,Android 上通常产出 libnative_math.so;源文件路径相对这份 CMakeLists.txt。target_include_directories 提供头文件查找位置,PRIVATE 表示用于该目标自身。target_compile_features 请求 C++17 能力,不等于替所有第三方依赖统一修改标准。
如果还有独立 .cpp 实现或其他库,要把源文件加到目标,或通过 target_link_libraries 正确链接;仅包含头文件不能补上缺失的非 inline 实现。
这里 C++17 足够完成示例;库若使用 std::span,再调整为 C++20,并核对 NDK / libc++ 支持情况。CMake 版本也要匹配本机与工程,不是照抄某个最低版本就保证成功。
Kotlin DSL,android/app/build.gradle.kts 的相关配置节选:
kotlin
android {
externalNativeBuild {
cmake {
path = file("CMakeLists.txt")
}
}
}
Groovy DSL,android/app/build.gradle 对应为:
groovy
android {
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}
看文件后缀与现有构建语法,不要根据 Flutter 版本一刀切判断是 Kotlin DSL 还是 Groovy。
手动加载库时:
dart
final library = ffi.DynamicLibrary.open('libnative_math.so');
final calculator = NativeCalculator(library);
final result = calculator.multiplyByTwo(200);
加载与调用的实际顺序是:打开 .so → 构造 NativeCalculator 查找 C 符号 → 检查输入范围 → 调用 C ABI → 返回 Dart int。这个整数示例不涉及字符串编码或数据序列化;换成大缓冲区时,是否复制需要单独设计。
确认需要的 ABI 已打包;使用 C++ 标准库共享运行时的场景,还要确保 libc++_shared.so 的打包和版本兼容,不要只看到 .so 生成就认为交付完成。
二十、Flutter iOS:FFI 可以用,不必强制改成 Channel
iOS 同样可以编译、链接 C ABI 包装后的 C++ 代码。传统集成可通过 Xcode、插件的构建配置或预编译 XCFramework 完成。
若符号已正确链接到当前进程并可被查找,手动绑定可以使用:
dart
final library = ffi.DynamicLibrary.process();
final calculator = NativeCalculator(library);
DynamicLibrary.process() 的含义是从当前进程可见的符号中查找,不会替你读取项目目录中的源代码,也不会自动加载一个还没有链接的静态库。它适用于这条"原生符号已经进入当前进程"的手动集成路线;code assets 路线应按其生成绑定使用。
但这不是"把 .cpp 拖进去就全部完成":
- 源文件或库必须进入正确 Target 和链接阶段。
- 导出的 C 符号不能被名称修饰、隐藏或链接裁剪掉。
- 静态归档是否被提取,不能仅靠
visibility判断;必要时按实际库设置链接规则,避免无差别全量加载。 - 真机与模拟器需要各自兼容的二进制,不能因为都叫 arm64 就当成同一平台。
.a是静态归档;.framework是打包形式;XCFramework 可以组织不同平台、架构的产物。
什么时候用 Platform Channel?
| 需求 | 更适合的起点 |
|---|---|
| 图像算法、压缩、数学计算等跨平台 C/C++ 内核 | C ABI + FFI |
| 相机、定位、系统权限、原生界面、生命周期回调 | Platform Channel / 平台插件 |
| 上层操作包含系统服务,下层又有 C++ 算法 | 插件组织平台能力,内部复用 C++,按需要结合 FFI |
Channel 中同样可以由 Objective-C++ 包装 C++。它的优点是容易组织平台语义,不是因为 iOS 无法使用 FFI。
二十一、Flutter 新项目:优先了解 package_ffi 与 build hooks
原文写于 2025 年,不能只保留当时逐平台改构建文件的做法。
本次读取的 Flutter 文档说明,自 Flutter 3.38 起,推荐使用:
sh
flutter create --template=package_ffi native_math
模板通过 hook/build.dart 配置原生构建,配合 ffigen、@Native() 和 code assets 管理绑定与产物,减少手写每个平台加载逻辑。
典型结构包括:
text
native_math/
├── src/ C/C++ 源文件和公开 C 头
├── hook/build.dart 构建 hook
├── ffigen.yaml 绑定生成配置
└── lib/ 生成绑定与 Dart 封装
这条新路线与上一节的手动 DynamicLibrary.open/process 是不同的集成组织方式,不应混成"全部都要配置"的步骤。
如果需要 Flutter Plugin API 或原生平台服务,仍应选择合适的插件模板。旧项目也不必为了一个整数函数立即重构全部构建链。
英文文档站在本次访问中连接超时,此节核对的是 Flutter 中文文档对应页面,页面标注适用于 Flutter 3.47.2。模板细节以项目实际 Flutter/Dart 版本生成的内容为准。
二十二、原文的 greetUser:字符串最容易隐藏所有权错误
整数例子说明不了真实项目最容易出错的部分:返回字符串、图片或对象句柄。
例如 C API 返回 char * 时,必须先确认:
- 字符串是什么编码,是否保证以
\0结尾? - 地址指向静态内存、内部缓存,还是新分配的内存?
- 如果需要释放,应该调用哪个库函数?
- 指针是否可能为空,哪种操作会让它失效?
一种清晰的协议是:
c
// 仅用于说明契约,需由库提供实际实现。
// name 是调用期间借用的 UTF-8 C 字符串。
// 返回新分配的 UTF-8 C 字符串,失败返回 NULL。
char *greet_user(const char *name);
// 与 greet_user 的分配器配套;调用者必须使用此函数释放。
void free_greeting(char *text);
Dart 侧的核心处理示意:
dart
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart';
// greetUser 与 freeGreeting 为按上述 C 签名生成/声明的绑定。
String readGreeting(String name) {
final namePtr = name.toNativeUtf8();
try {
final resultPtr = greetUser(namePtr);
if (resultPtr == ffi.nullptr) {
throw StateError('原生函数生成字符串失败');
}
try {
return resultPtr.toDartString();
} finally {
freeGreeting(resultPtr);
}
} finally {
malloc.free(namePtr);
}
}
这段字符串代码的释放顺序:
toNativeUtf8()为输入姓名生成原生 UTF-8 字符串,默认使用malloc分配器,所以外层finally用malloc.free(namePtr)释放。greetUser(namePtr)同步借用输入,返回另一块由库管理的结果内存;若原生函数要长期保留姓名,就不能直接沿用这份借用契约。- 检查返回值是否为空,再通过
toDartString()解码,并生成由 Dart 管理的字符串结果。 - 内层
finally用freeGreeting释放原生结果,即使解码抛错也执行。它和输入指针的分配器不是同一个约定。 return不会跳过finally,因此正常返回时两块原生临时内存仍会按约定释放。
toDartString() 的前提是返回地址有效、编码符合约定且有可访问的结束符;如果返回未终止缓冲区,不能指望 Dart 的字符串函数自动知道边界。
不要把指针地址直接当成文字显示,也不要假定所有返回指针都可以 malloc.free。new[] / delete[]、malloc / free、第三方自定义分配器需要匹配;返回局部变量地址则从一开始就是错误的。
FFI 本身不让 Dart 的 GC 自动理解这些原生分配。大对象需要独立的句柄与释放协议,必要时设计显式 close/dispose,finalizer 只能作为符合约束的兜底机制,而不是及时释放资源的保证。
二十三、把安全契约扩展到异步、线程和异常
讲座主要讲内存边界,但项目接入还要检查这些问题:
- 异步保留输入 :C++ 后台任务要继续读数据时,不能把参数标成
__noescape。需要复制数据、转移所有权或持有明确的 owner。 - 地址失效 :
vector扩容、容器变更或显式释放,可能使已有视图失效;"对象还活着"不代表内部指针始终不变。 - 线程安全 :生命周期正确,不代表可并发读写。Swift 的
Sendable与 C++ 线程安全契约也不能凭空推断。 - 异常边界:不要让 C++ 异常跨越不支持的 Swift/Dart 边界;错误码、错误对象和释放责任要一起设计。
- 主线程卡顿 :FFI 的同步调用会占用调用它的 Dart isolate;返回
Future的外壳不等于计算已转到后台。需要按任务设计 worker isolate 或原生异步执行。
这些不是再加一个宏就能解决的问题,而是跨语言接口本身需要写清的行为。
二十四、项目中建议这样落地
- 先列出真正需要跨语言暴露的最小接口,不要把整个 C++ 类体系全部传给 UI。
- 根据工具链和复用目标,选择直接互操作、Objective-C++ 或 C ABI。
- 标明每个指针的范围、生命周期、所有权、空值和线程约束。
- 开启 Strict Memory Safety,定位调用边界。
- 对真实满足条件的接口补注解,不通过虚假注解消除诊断。
- 使用目标工具链验证安全重载,检查实验开关与标准库 availability。
- 在支持的 C++ 构建中采用标准库强化与缓冲区诊断;C 的边界安全扩展另行核对。
- 按风险使用 Address Sanitizer、Undefined Behavior Sanitizer、Thread Sanitizer 等工具,并核对各平台支持情况。
- 覆盖空输入、长度不匹配、超大尺寸、异常、取消、重复释放和并发场景。
- UI 只调用 ViewModel / 控制器组织好的业务接口,不承担裸指针与释放协议。
二十五、验证范围与阅读建议
本文做的是旧文更新与官方技术解读,不是声称所有片段组成了一个已经运行的 Flutter/SwiftUI App。
本次使用的本机工具链为 Xcode 26.4.1,xcrun swift --version 报告 Apple Swift 6.3.1。
局部编译与运行验证结果如下(最终一轮约 13 秒,未启动模拟器):
- 通过 :Swift 直接调用整数 C++ 函数,验证
200 × 2,以及Int32最大、最小值的 64 位结果。 - 通过 :
__counted_by + __noescape生成的MutableSpan重载,将[0, 127, 255]原地转换为[255, 128, 0],启用了 Strict Memory Safety 与 SafeInteropWrappers。 - 通过:Swift → Objective-C++ → C++ 的包装调用链,验证返回值及大整数边界。
- 另一个返回
std::span<const uint8_t>的最小复现虽然编译通过,运行时返回视图长度却与预期不符。因此本文只用官方声明解释该机制,没有把 std::span 返回路径标为本机运行验证通过。原因尚未确定,不据此断言所有工具链都有同一问题;实际项目必须用自己的编译器、标准库和接口再验证。
包装调用链已经验证,但 SwiftUI 页面组合、Flutter 平台打包与自定义引用计数代码还需要放入对应工程验收。它们在本文中分别是集成示例、机制说明,不是完整 App 的运行结果。
讲座值得按以下时间点回看:
| 时间 | 主题 | 阅读本文对应内容 |
|---|---|---|
| 02:39 | 在 Swift 中发现不安全调用 | Strict Memory Safety |
| 04:55 | 安全调用 C/C++ 的思路 | Span、边界与生命周期 |
| 07:25 | 接受指针的函数 | counted_by、noescape、std::span |
| 17:13 | 返回指针/视图的函数 | lifetimebound |
| 20:16 | 自定义类型导入 | non-escapable、共享引用与返回所有权 |
| 26:57 | C/C++ 内部强化 | hardening、缓冲区诊断、C bounds safety |
更新后的主线是:先把库接进来,再把边界契约说清楚,最后验证调用方和实现方各自的安全责任。
参考资料
本次实际读取核对:
- 原文:c++混编的一些简单的思考
- WWDC25 311:Safely mix C, C++, and Swift,逐字稿与代码标签页。
- Swift.org:C++ interoperability
- Swift.org:Setting Up Mixed-Language Swift and C++ Projects
- Swift.org:Safely Mixing Swift and C/C++
- Clang:-fbounds-safety,注意其上游实现进度说明。
- Flutter 中文文档:使用 FFI 绑定原生代码
- 本机 Apple Swift / Clang 工具链提供的
swift/bridging头文件与相关编译诊断。
扩展阅读:
- WWDC23 10172:Mix Swift and C++
- Flutter 英文文档:Bind to native code,本次站点连接超时,未作为已逐页读取来源。
讲座节选的原始实现与声明以 Apple 官方资源为准;本文补充的项目组织、校验和错误处理建议属于工程化解释,不应误认为全部由视频逐字给出。