iOS 引用 C/C++ 项目:交叉编译与 Objective-C++
最近终于有幸参与公司的 iOS 项目,其中有个 C/C++ 写的库需要调用;
之前对 Android JNI/NDK 调用 C/C++ 还算熟悉,但 iOS 混编 C/C++ 却是初次接触,各种被虐..
上个周末基本都在解决库的编译问题,爱人 Amble 也耐心帮我查资料、作分析,感动之余,决定把中途遇到的问题记录下来。
静态库 or 动态库?
iOS 不支持调用第三方动态库(.dylib),应该是出于安全考虑,比如各种越狱工具就大量使用动态库;
一开始在这里耽误很久,.dylib 库编译没问题,但是 Xcode 项目引用后一直报 ‘library not load’、’symbols not found for architexture’ 之类的错误..
C++ 头文件去掉动态链接代码
C++ 动态库接口对应的头文件,需要将 __declspec(dllexport)
、__stdcall
等关键字去掉;
CMake 脚本改为静态链接
add_library(${PROJECT_NAME}) SHARED ${SOURCE_FILES})
这里 SHARED
改为 STATIC
即可。
交叉编译
Mac 上直接跑 CMake 编译出来的是 X86 架构的,iOS 上无法运行,需要一个调用 xcodebuild 的 CMake 脚本做交叉编译;
Google Code 上的这个 iOS-CMake 项目年久失修,需要自己做一些改动。
修改 Xcode 路径
exec_program(/usr/bin/xcode-select ARGS -print-path OUTPUT_VARIABLE CMAKE_XCODE_DEVELOPER_DIR)
set (XCODE_POST_43_ROOT "${CMAKE_XCODE_DEVELOPER_DIR}/Platforms/${IOS_PLATFORM_LOCATION}/Developer")
这里自动检测安装路径并设置,不需要写死,也不需要编译时指定编译选项;
编译器设置和检查
CMakeForceCompiler 已经过时,直接指定编译器为 Clang 就好:
set (CMAKE_C_COMPILER /usr/bin/clang clang)
set (CMAKE_CXX_COMPILER /usr/bin/clang++ clang)
后面需要跳过编译器检查,否则编译会报错中断:
set (CMAKE_CXX_COMPILER_WORKS TRUE)
set (CMAKE_C_COMPILER_WORKS TRUE)
修改 Architecture
if (IOS_PLATFORM STREQUAL "OS")
set (IOS_ARCH "armv7;armv7s;arm64")
elseif (IOS_PLATFORM STREQUAL "SIMULATOR")
set (IOS_ARCH "i386;x86_64")
elseif (IOS_PLATFORM STREQUAL "WATCHOS")
set (IOS_ARCH "armv7k")
endif ()
这里可以根据实际情况做取舍,比如最新 iOS 11 就宣布不支持非 64 位架构,真机和模拟器可以分别只保留 arm64、x86_64;
另外,还新增了 WatchOS 的支持。
禁用 CMake 可执行编译选项
一般 C/C++ 项目每个模块下面都会添加包含 main()
的测试代码,需要在 CMakeLists.txt 文件中注释掉 #add_executable
相关代码,否则后面编译会报以下错误:
target specifies product type ‘com.apple.product-type.tool’, but there’s no such product type for the ‘iphoneos’ platform
调用 CMake 创建并编译 Xcode 项目
CMake 添加 -DCMAKE_TOOLCHAIN_FILE
选项指定 iOS 交叉编译脚本,-GXcode
选项指定生成项目类型为 Xcode:
cmake -DCMAKE_TOOLCHAIN_FILE=./ios.cmake -DIOS_PLATFORM=SIM -H. -Bbuild.sim -GXcode
cmake --build build.sim/ --config Release
上面是模拟器的库编译,真机类似。
使用 xcodebuild 自动编译项目
后面发现,如果存在库的依赖关系,即 CMakeLists.txt
中设置了 target_link_libraries
,上面生成的 Xcode 工程并不会自动添加库依赖,需要自己手动设在 Build Phases 的 Link Binary with Libraries 中设置;
设置完后,只要不增删 C/C++ 代码,就不用重新生成 Xcode 项目,可以通过 xcodebuild 自动编译:
xcodebuild clean
xcodebuild -target xxx
这个工具在自动打包时经常用到。
符号表相关编译设置
我们知道,符号表(symbols) 包含 C/C++ 程序中的变量和函数信息,虽然可方便调试,但是会显著增加包大小;
Xcode 的 Build Settings 包含一些符号表相关设置,这里介绍两个常用的、和 Clang 编译选项有关的:
Generate Debug Symbols: 开启后,编译每个源文件时会自动带上编译选项
-g
和-gmodules
,生成完整的调试信息,如果 C/C++ 层代码 crash 后会自动跳转到源代码行数;关闭后,crash 时会直接跳到汇编代码;一般建议至少调试阶段应该开启;Debug Info Level: 如果设置为
Line Tables Only
,会自动带上编译选项-gline-tables-only
,调试信息就只会包含函数名、文件名、行号,不包含变量;
也可以先不管这些设置,最后直接通过 strip -x -S old.a -o new.a
命令去掉符号表信息;
更多内容可参考:
库的合并与检查
macOS 自带的 lipo 命令可用于将真机和模拟器的库文件合并:
lipo -create "lib/ios/libXXX.a" "lib/sim/libXXX.a" -output "lib/generic/libXXX.a"
最后,再介绍两个有用的命令行工具:
file xxx.a
:可以查看库文件的架构信息;lipo -info xxx.a
:也可以查看库文件的架构信息;nm -a xxx.a
:可以查看库的符号表信息;
详细编译脚本参考这里。
Objective-C 混编 C/C++
C++ 头文件编译问题
C++ 工程的 .h 文件直接拷进 Xcode 是可能编译不过的,如果在头文件使用了 C++ 语法的话;
因为 Xcode 默认当做 C 头文件编译,解决方式有两个:
扩展名改为 .hpp 或 .hxx,让编译器知道这是 C++ 头文件;
在使用 C++ 语法的代码块前后分别添加
#ifdef __cplusplus
和#endif
宏;
除了上面的问题,如果头文件定义了 const
常量,并且被多次 include
的话,Xcode 编译会报 ‘duplicate symbol’ 的异常;
原因是被认为重复定义,解决方式是在前面添加 static
关键字。
Objective-C 和 C/C++ 数据类型转换
主要是 NSString
/NSData
、std::string
、char*
这三者之前的相互转换,这里我定义了一些宏方便调用:
#define STDStr2Int(str) std::atoi(str.c_str())
#define STDStr2Float(str) std::atof(str.c_str())
#define STDStr2Long(str) std::atol(str.c_str())
#define STDStr2LongLong(str) std::atoll(str.c_str())
#define NSStr2CharP(nsStr) (char*)[nsStr cStringUsingEncoding:NSUTF8StringEncoding]
#define NSStr2STDStr(nsStr) std::string([nsStr cStringUsingEncoding:NSUTF8StringEncoding])
#define STDStr2NSStr(stdStr) [NSString stringWithCString:stdStr.c_str() encoding:NSUTF8StringEncoding]
#define STDStr2NSData(stdStr) [NSData dataWithBytes:stdStr.data() length:stdStr.size()]]
#define charP2NSStr(charP) [NSString stringWithCString:charP encoding:NSUTF8StringEncoding]
#define charP2NSData(charP) [[NSData alloc] initWithBytes:charP length:strlen(charP)]
#define charV2STDStr(charV) std::string(charV.begin(), charV.end())
#define NSStr2NSData(nsStr) [nsStr dataUsingEncoding:NSUTF8StringEncoding]
#define NSData2NSStr(nsData) [[NSString alloc] initWithData:nsData encoding:NSUTF8StringEncoding]
还有一点要注意,对于包含汉字等宽字节的 NSString
,如果要传字节长度到 C/C++ 层,不能直接调 [nsString length]
,因为它返回的是字符数而不是字节数;
可以先将 NSString*
转为 char*
,然后调用 C/C++ 的 std::strlen()
得到字节数:
char *charP = NSString2CharP(nsStr);
int charLength = std::strlen(charP);
内存管理
我们知道,Objective-C 的 init
和 dealloc
方法分别对应 C++ 的构造函数和析构函数,混编时他们的对象内存也是各自管理的;
Objective-C 中如果创建了全局 C/C++ 对象,需要在 dealloc
方法中释放;
同样,如果 C++ 创建了 Objective-C 对象,也需要在析构函数中释放;
二进制数据传递问题
通常 Android/iOS 调用底层 C/C++ 库最多的场景就是图像和音视频处理了,这就涉及到二进制数据的传递;
具体到我参与的项目,传递的数据是一个 std::map<std::string,std::string>
类型参数;
一开始看到 std::string
很自然联想到 Objective-C 中对应的 NSString
,所以做了个“画蛇添足”的转换:先把 NSData
转换为 NSString
,再将 NSString
转为 std::string
;
为什么要先转换为 NSString
呢?因为一开始打算在 Objective-C++ 的 wrapper 类屏蔽掉 C++ 的类型,外部调用全部传入 Objective-C 的类型;
但既然要转为 NSString
就涉及到 encoding 的问题,但很快发现无论 NSUTF8StringEncoding
还是 NSASCIIStringEncoding
都有问题;
最后改为直接 NSData
和 std::string
之间转换,而且考虑到上面提到的内存管理问题,避免 EXC_BAD_ACCESS
,每次传递时都做下数据拷贝:
template<typename T>
inline T MALLOC(long length) {
return reinterpret_cast<T>(std::malloc(length * sizeof(T)));
}
template<typename T>
inline T COPY(T src, long length) {
T data = MALLOC<T>(length);
std::memcpy(data, src, length);
return data;
}
inline std::string copySTDStrFromNSData(NSData* data) {
long size = [data length];
Byte* bytes = COPY<Byte*>((Byte*)[data bytes], size);
std::string str(reinterpret_cast<char const*>(bytes), size);
return str;
}
inline NSData* copyNSDataFromSTDStr(std::string str) {
long size = str.length();
Byte* bytes = COPY<Byte*>((Byte*)str.c_str(), size);
NSData *data = [NSData dataWithBytes:bytes length:size];
return data;
}
注意 std::string
构造函数要带上 length
参数,否则二进制数据很可能被截断。
C/C++ 回调 Objective-C
Objective-C 中的 C 回调函数是无法直接访问类的成员和方法的,甚至会报 EXC_BAD_ACCESS
错误;
那么如何解决这个问题呢?
熟悉 JNI 的都知道,它的每个 JNI 函数第一个参数都是 JNIEnv*
,是访问 JVM 相关接口的桥梁;
同样,这里我们也可以在回调函数中增加 Objective-C 对象的指针参数,用来间接访问类成员和方法;
但是,这个 Objective-C 对象指针在 C/C++ 环境下是无法直接使用的,还得借助一个双方都能识别的桥梁 — void*
;
而 Objective-C 对象指针和 void*
的相互转换,则需要依赖 Bridged Cast:
__bridge
可用于类型互转,但是不改变对象的所有权(ownership);__bridge_retained
用于将 Objective-C 对象指针转换为 C/C++ 对象指针,同时获得所有权;__beidge_transfer
用户将 C/C++ 对象指针转换为 Objective-C 对象指针,同时放弃所有权;
其实系统的 Core Foundation 很多地方 也使用到这种类型转换。
最后看个 C++ 调用 Objective-C 的示例:
首先定义接口函数指针类型:
typedef void (*Callback)(void* obj, int x);
Objective-C 类的回调函数中,通过 __bridge_transfer
将 void*
还原为对象指针:
void funcObjc(void* obj, int x) {
[(__bridge_transfer Objc*)obj doInObjc:x];
}
@implementation Objc
-(id)init {
if (self = [super init]) {
self.callback = funcObjc;
}
return self;
}
-(void)doInObjc:(int)x {
NSLog(@"doInObjc: %d", x);
}
@end
C++ 构造函数接收 Objective-C 传过来的 void*
和函数指针,用于执行该回调函数:
Cxx::Cxx(void* objc, Callback callback) {
this->objc = objc;
this->callback = callback;
}
void Cxx::doInCxx(int x) {
std::cout << "doInCxx: " << x << std::endl;
this->callback(this->objc, x);
}
最终调用时,将 Objective-C 对象通过 __bridge_retained
转为 void*
传给 C++ 对象:
Objc* objc = [[Objc alloc]init];
void* vp = (__bridge_retained void*)objc;
Cxx* cxx = new Cxx(vp, objc.callback);
cxx->doInCxx(9);
也可以将上面的 __bridge_transfer
和 __bridge_retained
全部改为 __bridge
。
[完整代码]