第三章:创建第一个跨平台SDK项目:C++核心代码 + 各平台包装示例

好了,理论铺垫完毕。这一章我们直接动手,搭建一个真实的跨平台SDK项目骨架。我个人习惯把这类项目叫做“三明治”结构——中间是厚厚的C++核心,两边是平台特定的包装层。你想想看,这其实是性价比最高的做法:核心逻辑一次编写,平台适配各自为战。

3.1 项目结构总览:一个三明治架构

我们先看这个项目的目录长什么样。这是我多次实践中总结出来的布局,干净,且容易维护。

CrossPlatformSDK/
├── core/                    # C++ 核心代码
│   ├── include/
│   │   └── math_engine.h
│   ├── src/
│   │   └── math_engine.cpp
│   └── CMakeLists.txt
├── platforms/
│   ├── ios/                 # iOS Framework 包装
│   │   ├── MSMathEngine.h
│   │   ├── MSMathEngine.mm
│   │   └── Info.plist
│   ├── android/             # Android AAR 包装
│   │   ├── src/main/java/
│   │   │   └── com/example/
│   │   │       └── MathEngine.java
│   │   ├── src/main/jni/
│   │   │   └── native_bridge.cpp
│   │   └── build.gradle
│   └── desktop/             # 桌面 DLL/SO 包装
│       ├── include/
│       │   └── math_engine_export.h
│       └── src/
│           └── desktop_bridge.cpp
├── tests/                   # 单元测试
│   ├── test_core.cpp
│   └── test_integration.cpp
└── scripts/                 # 构建脚本
    └── build_all.sh

这个结构的核心原则是什么?C++ 核心完全不感知它最终会运行在哪个平台上。它只管算数、处理数据。而所有平台相关的调用(比如 iOS 的 ObjC Runtime、Android 的 JNI 调用、Windows 的 DLL 导出)都被隔离在 platforms/ 目录下。我在项目中遇到过最痛苦的事情,就是核心代码里混入了 #ifdef WIN32 和 __ANDROID__ 宏,结果导致新平台移植时几乎要重写。所以,别这么干。

3.2 C++ 核心:纯计算逻辑的实现

我们以一个小巧但能说明问题的数学引擎为例。它只做一件事:浮点数混合运算(加法、乘法、取模优化)。

头文件:math_engine.h

#ifndef MATH_ENGINE_H
#define MATH_ENGINE_H

#include <vector>
#include <cstdint>

namespace mscore {

class MathEngine {
public:
    MathEngine();
    ~MathEngine();

    // 标准加法
    double add(double a, double b);

    // 带精度的乘法
    double multiply(double a, double b, int decimal_places = 2);

    // 快速取模(仅限正数)
    int32_t fast_mod(int32_t a, int32_t b);

    // 批量运算
    std::vector<double> batch_add(const std::vector<double>& inputs, double increment);

private:
    bool initialized_;
};

} // namespace mscore

#endif

实现文件:math_engine.cpp

#include "math_engine.h"
#include <cmath>
#include <algorithm>

namespace mscore {

MathEngine::MathEngine() : initialized_(false) {
    // 嗯,这里可以做些预计算或者内存池分配
    initialized_ = true;
}

MathEngine::~MathEngine() {
    // 清理工作
}

double MathEngine::add(double a, double b) {
    return a + b;
}

double MathEngine::multiply(double a, double b, int decimal_places) {
    double factor = std::pow(10.0, decimal_places);
    return std::round((a * b) * factor) / factor;
}

int32_t MathEngine::fast_mod(int32_t a, int32_t b) {
    // 对于正数,直接用位运算优化
    // 我曾经踩过一个坑:b=0 会导致除零异常
    if (b == 0) {
        return a; // 安全返回
    }
    return a % b;
}

std::vector<double> MathEngine::batch_add(const std::vector<double>& inputs, double increment) {
    std::vector<double> result;
    result.reserve(inputs.size());
    for (const auto& val : inputs) {
        result.push_back(val + increment);
    }
    return result;
}

} // namespace mscore
小提示: C++ 核心应保持“纯函数式”风格——无全局状态、无平台 API 依赖、可测试。这样后续不论是做单元测试还是移植到 WebAssembly 都极其容易。

3.3 iOS Framework 包装:ObjC 与 C++ 的混编技巧

iOS 端需要将 C++ 类封装成一个 ObjC 类,暴露给 Swift 和 ObjC 调用。这层是关键:不要直接用 C++ 头文件暴露给上层应用,因为 Swift 无法直接处理 C++ 类。

头文件:MSMathEngine.h

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface MSMathEngine : NSObject

- (instancetype)init;
- (double)add:(double)a to:(double)b;
- (double)multiply:(double)a with:(double)b decimalPlaces:(int)places;
- (int32_t)fastMod:(int32_t)a by:(int32_t)b;

@end

NS_ASSUME_NONNULL_END

实现文件:MSMathEngine.mm

注意后缀 .mm,这是 ObjC++ 文件,允许你在 ObjC 中直接包含 C++ 头文件。

#import "MSMathEngine.h"
#include "math_engine.h"

@interface MSMathEngine ()
@property (nonatomic, assign) mscore::MathEngine* engine;
@end

@implementation MSMathEngine

- (instancetype)init {
    self = [super init];
    if (self) {
        // 在堆上创建 C++ 对象
        _engine = new mscore::MathEngine();
    }
    return self;
}

- (void)dealloc {
    // 手动管理生命周期
    delete _engine;
    // 不需要调用 [super dealloc](ARC 环境)
}

- (double)add:(double)a to:(double)b {
    return _engine->add(a, b);
}

- (double)multiply:(double)a with:(double)b decimalPlaces:(int)places {
    return _engine->multiply(a, b, places);
}

- (int32_t)fastMod:(int32_t)a by:(int32_t)b {
    return _engine->fast_mod(a, b);
}

@end

这里有个关键的避坑指南:我曾经在项目中忘记写 dealloc 里的 delete _engine,结果运行几天后应用突然闪退——内存泄漏了。ObjC++ 编译器不会替你管理 C++ 堆对象,你必须手动处理。

3.4 Android AAR 包装:JNI 桥接的“脏活累活”

Android 端通过 JNI(Java Native Interface)调用 C++。这层包装通常是最繁琐的,因为你要处理 JNI 类型转换和函数注册。

Java 类:MathEngine.java

package com.example;

public class MathEngine {
    static {
        // 加载原生库,库名对应 CMake 构建的产物
        System.loadLibrary("math_engine_jni");
    }

    // 声明 JNI 方法
    public native double nativeAdd(double a, double b);
    public native double nativeMultiply(double a, double b, int decimalPlaces);
    public native int nativeFastMod(int a, int b);

    private long nativeHandle; // 指向 C++ 对象的指针(存储为 Long)

    public MathEngine() {
        nativeHandle = nativeCreate();
    }

    // 在 finalize 中释放 C++ 对象
    @Override
    protected void finalize() throws Throwable {
        if (nativeHandle != 0) {
            nativeDestroy(nativeHandle);
        }
        super.finalize();
    }

    // 包装方法,供应用层调用
    public double add(double a, double b) {
        return nativeAdd(a, b);
    }

    public double multiply(double a, double b, int decimalPlaces) {
        return nativeMultiply(a, b, decimalPlaces);
    }

    public int fastMod(int a, int b) {
        return nativeFastMod(a, b);
    }

    // JNI 辅助方法
    private static native long nativeCreate();
    private static native void nativeDestroy(long handle);
}

JNI 桥接层:native_bridge.cpp

#include <jni.h>
#include "math_engine.h"

extern "C" {

JNIEXPORT jlong JNICALL
Java_com_example_MathEngine_nativeCreate(JNIEnv *env, jclass clazz) {
    return reinterpret_cast<jlong>(new mscore::MathEngine());
}

JNIEXPORT void JNICALL
Java_com_example_MathEngine_nativeDestroy(JNIEnv *env, jclass clazz, jlong handle) {
    delete reinterpret_cast<mscore::MathEngine*>(handle);
}

JNIEXPORT jdouble JNICALL
Java_com_example_MathEngine_nativeAdd(JNIEnv *env, jobject thiz, jdouble a, jdouble b) {
    mscore::MathEngine* engine = reinterpret_cast<mscore::MathEngine*>(/* 需要从实例获取 handle */);
    // 实际项目中,应从 Java 对象的 long 字段中读取 handle
    return engine->add(a, b);
}

} // extern "C"
警告: JNI 中 reinterpret_cast 的使用非常危险。务必保证 Java 层传过来的 long 值确实指向一个合法对象,且在销毁后及时置零。我曾经遇到过一个 bug:由于 Java 垃圾回收顺序不确定,导致 JNI 侧访问了已经被 delete 的内存。

3.5 桌面 DLL/SO 包装:C 接口导出

桌面端(Windows/Linux/macOS)相对简单。我们只需要用 extern "C" 和 __declspec(dllexport) 导出 C 风格函数。

导出头文件:math_engine_export.h

#ifdef _WIN32
    #define MATH_API __declspec(dllexport)
#else
    #define MATH_API __attribute__((visibility("default")))
#endif

extern "C" {

MATH_API void* math_engine_create();
MATH_API void  math_engine_destroy(void* engine);
MATH_API double math_engine_add(void* engine, double a, double b);
MATH_API double math_engine_multiply(void* engine, double a, double b, int decimal_places);
MATH_API int32_t math_engine_fast_mod(void* engine, int32_t a, int32_t b);

}

实现文件:desktop_bridge.cpp

#include "math_engine_export.h"
#include "math_engine.h"

void* math_engine_create() {
    return new mscore::MathEngine();
}

void math_engine_destroy(void* engine) {
    delete static_cast<mscore::MathEngine*>(engine);
}

double math_engine_add(void* engine, double a, double b) {
    return static_cast<mscore::MathEngine*>(engine)->add(a, b);
}

// ... 其余函数实现类似

桌面端包装的优点是简单直接。任何支持 C ABI 的语言(Python Ctypes、C# P/Invoke、Go Cgo)都可以调用。我个人习惯在导出时统一使用 void* 句柄,避免暴露 C++ 类型信息。

3.6 核心流程一览:从源码到平台产物

下面这张图总结了整个构建与包装的流程。说实话,这个流程我调试了无数次才定型。

跨平台SDK构建流程:从C++核心到各平台产物 跨平台SDK构建流程 C++ 核心库 (math_engine) iOS Framework ObjC++ (.mm) 包装 Android AAR JNI 桥接 (C++/Java) 桌面 DLL/SO C 接口导出 MSMathEngine.framework math-engine-release.aar math_engine.dll/.so 核心不感知平台,包装层各司其职,一次编写,多处包装

3.7 构建配置与对比

不同平台的构建配置差异很大。下表总结了关键差异,方便你快速对比。

维度 iOS Framework Android AAR 桌面 DLL/SO
编译器 Apple Clang (Xcode) NDK Clang / GCC MSVC / GCC / Clang
接口形式 ObjC Header (.h) JNI 方法注册 extern "C" 导出
静态/动态 .framework (动态库包) .aar (包含 .so) .dll / .so / .dylib
内存管理 ARC + 手动 delete JVM GC + 手动释放 完全手动管理
调试难度 中等(可直接断点 .mm) 高(需要混合调试JNI) 低(标准 native 调试)
关键认知: 三个平台的包装层本质都是“胶水代码”。胶水代码越多,后期维护越痛苦。所以 C++ 核心的接口设计要尽可能简洁(比如只暴露 int、double、std::vector 等基础类型),从而减少各平台的转换逻辑。

3.8 常见踩坑与应对

  • C++ 异常跨平台边界:C++ 异常不要抛出到 ObjC 或 Java 层。我曾经在 iOS 端忘了 catch,结果 ObjC 直接调用 std::terminate(),应用闪退无日志。所有平台包装层都应该用 try-catch 把 C++ 异常转为平台错误码或 NSError。
  • 符号可见性:Android NDK 默认隐藏所有符号,需要显式 __attribute__((visibility("default")))。Windows DLL 则要用 __declspec(dllexport)。这个细节很容易忘。
  • JNI 线程模型:JNI 回调可能发生在任意线程。C++ 核心如果假定单线程,一定要在桥接层加锁或调度。