Swift、Objective-C++ 与 Flutter 如何调用 C++:从混编接入到内存安全

在 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 给出的设置是:

  1. 在使用 C++ 的 Target 中,将 C++ and Objective-C Interoperability 设置为 C++ / Objective-C++。
  2. 同一 App Target 通过桥接头包含 NativeMathHeader.h。
  3. 如果实现位于 .cpp,确保它被编译并链接。
  4. 保证调用方、实现和依赖使用兼容的 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++ 实现中的四个关键点:

  1. uint8_t * 表示可修改的字节地址;count 是元素数量,对 uint8_t 来说也恰好等于字节数。
  2. __counted_by(count) 把原本独立的指针与数量建立关联;__noescape 承诺不在调用后继续持有该地址。
  3. 循环使用 index < count,不会访问位置 count。若误写成 <=,仍然是实现错误,注解不是修复循环的工具。
  4. 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 的标准类型,包含指针与元素数量,但不拥有那段内存。

两个容易混淆的点:

  1. 普通 C++20 std::span::operator[] 不能被默认当作有运行时越界检查。
  2. 它不会自动保证底层对象活得足够久。

如果 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 绑定分三步:

  1. DoubleNative 描述原生 ABI:返回 Int64,接收 Int32。
  2. DoubleDart 描述 Dart 调用形式:参数和返回值都用 Dart 的 int。
  3. 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 * 时,必须先确认:

  1. 字符串是什么编码,是否保证以 \0 结尾?
  2. 地址指向静态内存、内部缓存,还是新分配的内存?
  3. 如果需要释放,应该调用哪个库函数?
  4. 指针是否可能为空,哪种操作会让它失效?

一种清晰的协议是:

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);
  }
}

这段字符串代码的释放顺序:

  1. toNativeUtf8() 为输入姓名生成原生 UTF-8 字符串,默认使用 malloc 分配器,所以外层 finally 用 malloc.free(namePtr) 释放。
  2. greetUser(namePtr) 同步借用输入,返回另一块由库管理的结果内存;若原生函数要长期保留姓名,就不能直接沿用这份借用契约。
  3. 检查返回值是否为空,再通过 toDartString() 解码,并生成由 Dart 管理的字符串结果。
  4. 内层 finally 用 freeGreeting 释放原生结果,即使解码抛错也执行。它和输入指针的分配器不是同一个约定。
  5. 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 或原生异步执行。

这些不是再加一个宏就能解决的问题,而是跨语言接口本身需要写清的行为。

二十四、项目中建议这样落地

  1. 先列出真正需要跨语言暴露的最小接口,不要把整个 C++ 类体系全部传给 UI。
  2. 根据工具链和复用目标,选择直接互操作、Objective-C++ 或 C ABI。
  3. 标明每个指针的范围、生命周期、所有权、空值和线程约束。
  4. 开启 Strict Memory Safety,定位调用边界。
  5. 对真实满足条件的接口补注解,不通过虚假注解消除诊断。
  6. 使用目标工具链验证安全重载,检查实验开关与标准库 availability。
  7. 在支持的 C++ 构建中采用标准库强化与缓冲区诊断;C 的边界安全扩展另行核对。
  8. 按风险使用 Address Sanitizer、Undefined Behavior Sanitizer、Thread Sanitizer 等工具,并核对各平台支持情况。
  9. 覆盖空输入、长度不匹配、超大尺寸、异常、取消、重复释放和并发场景。
  10. 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

更新后的主线是:先把库接进来,再把边界契约说清楚,最后验证调用方和实现方各自的安全责任。

参考资料

本次实际读取核对:

扩展阅读:

讲座节选的原始实现与声明以 Apple 官方资源为准;本文补充的项目组织、校验和错误处理建议属于工程化解释,不应误认为全部由视频逐字给出。

相关推荐
Java后端的Ai之路1 小时前
前端技术Vue-13-安装webpack与vuejs
前端·vue.js·webpack
倚窗听花_1 小时前
IIS部署前端Vue项目
前端·javascript·vue.js
yixun_gdas1 小时前
政企网站错敏巡查:错敏都藏在哪些位置?
开发语言·前端·javascript·内容运营
哇嘎呀1 小时前
RHCSA题目
java·服务器·前端
周厉2 小时前
ERP系统全景解析:核心模块与业务逻辑框架
java·服务器·前端
LEE2 小时前
为什么你的 Claude Code 还在转圈等,别人的已经干完三件事了?
前端·后端·ai编程
java1234_小锋2 小时前
【技术专题】HTML5 网页技术 - HTML5 SVG
前端·html·html5
飞飞鱼在摸鱼2 小时前
前端转Agent开发 | JavaScript 从 0 到 1 完全指南
前端·javascript
开开心心就好2 小时前
课堂随机点名工具,绿色版免安装双击即用
java·服务器·前端·ffmpeg·音视频·perl·symfony