Flutter 利用 FFI,绕过 Android JNI 直接调用 C++ 层!
坏de牧羊人 | 作者
承香墨影 | 校对
https://juejin.cn/post/6976824832595853342 | 原文
大家好,这里是承香墨影!
今天和大家聊聊 Flutter 与 C/C++ 直接调用的方法 FFI。
虽然在 Android & iOS 中,哪怕不了解 Flutter FFI,也可以通过 Platform 的到各自的平台层去调用 C/C++。但是这样无意会造成一些额外的资源损耗,不如 Dart 直接调用 用C/C++ 层效率高。
在 Flutter 中,Framework 与 Engine 之间,就绑定了很多方法用于相互调用。随着 Flutter 2.0 发布的 Dart 2.12 中,已经包含了 Dart FFI 的稳定版,并提供了对应的工具 ffigen,让我们使用 Flutter 开发跨平台的方式更优雅了。
接下来进入正题,了解 Flutter 的 FFI。
一、需求背景
如在 Flutter 中调用 C/C++ 代码可以通过 method channel
的方式来调用 JNI 间接调用 C/C++ 代码 ,那么就会有较长的调用链 Flutter -> Java JNI -> C/C++, 假如我们要传递一个参数,这个参数将会在 Java & C++ 堆各拷贝一次,如果是大对象容易造成内存抖动,且效率较低。
那么 Dart 中有没有像 JNI 一样的东西,直接调用 C/C++ 代码?
引出我们今天的主角 FFI(我们是否可以叫它 DNI 呢?)。
FFI 文档:https://en.wikipedia.org/wiki/Foreign_function_interface
二、FFI 简介
FFI (Foreign function interface) 代表 外部功能接口, 类似功能的其他术语包括本地接口和语言绑定。这个叫法延续在 Rust、Python、Dart 等语言中,而 Java 将其 FFI 称为 JNI(Java 本机接口)。
2.1 Flutter 中的 FFI
在 Flutter 2.0 中的 Dart 2.12 已发布,其中包含健全的空安全和 Dart FFI 的稳定版, 并且提供了一套类型绑定生成工具 ffigen,可以自动生成 Dart Wrapper 加快开发效率。
ffigen: https://pub.dev/packages/ffigen
在目前最新的 Flutter 2.2 中,Dart 2.13 扩展了对原生互操作性的支持,现在支持在 FFI 中使用数组和封装结构体。
可见 Flutter 成为首选的多平台开发 UI 工具包之势日趋明显。
2.2 与 JNI 比较
网络上关于 FFI 的文章较少,我查阅到的是《快手 - 开眼快创 Flutter 实践》,相对于 JNI 它大大提升提升数据传输的性能。
使用 FFI 后,首次加载缩略图速度提升 2% ~ 16%,在涉及大量图片传输场景下数据提升明显,数据传输耗时占比较高,FFI 替换 Channel 后传输耗时降低。
开眼快创Flutter实践:http://w4lle.com/2021/04/12/kidea-flutter/
2.3 FFI 原理:如何找到 C++ 中的库
通过 DynamicLibrary.open()
查看源码实现。
final DynamicLibrary ffiLib =
Platform.isAndroid
? DynamicLibrary.open('lib_invoke.so')
: DynamicLibrary.process();
DynamicLibrary.open()
最终执行的逻辑如下, 源码位于 ffi_dynamic_library.cc:
// https://github.com/dart-lang/sdk/blob/master/runtime/lib/ffi_dynamic_library.cc
static void* LoadExtensionLibrary(const char* library_file) {
if defined(HOST_OS_LINUX) || defined(HOST_OS_MACOS) ||
defined(HOST_OS_ANDROID) || defined(HOST_OS_FUCHSIA)
void* handle = dlopen(library_file, RTLD_LAZY);
if (handle == nullptr) {
char* error = dlerror();
const String& msg = String::Handle(
String::NewFormatted("Failed to load dynamic library (%s)", error));
Exceptions::ThrowArgumentError(msg);
}
return handle;
可以看到最终使用 dlopen 加载动态链接库,并返回句柄。
拿到对应的动态链接库的句柄之后,就能使用相关方法进行操作了。
句柄主要包含以下两个方法:
external Pointer<T> lookup<T extends NativeType>(String symbolName);
external F lookupFunction<T extends Function, F extends Function>(String symbolName);
其中lookup()
的最终实现主要使用了 dlsym。
static void* ResolveSymbol(void* handle, const char* symbol) {
if defined(HOST_OS_LINUX) || defined(HOST_OS_MACOS) ||
defined(HOST_OS_ANDROID) || defined(HOST_OS_FUCHSIA)
dlerror();
void* pointer = dlsym(handle, symbol);
if (pointer == nullptr) {
char* error = dlerror();
const String& msg = String::Handle(
String::NewFormatted("Failed to lookup symbol (%s)", error));
Exceptions::ThrowArgumentError(msg);
}
return pointer;
dlopen:
该函数将打开一个新库,并把它装入内存。该函数主要用来加载库中的符号,这些符号在编译的时候是不知道的。这种机制使得在系统中,添加或者删除一个模块时,都不需要重新进行编译。
dlsym
: 是一个计算机函数,功能是根据动态链接库操作句柄与符号,返回符号对应的地址,不但可以获取函数地址,也可以获取变量地址,返回符号对应的地址。
句柄与普通指针的区别:指针包含的是引用对象的内存地址,而句柄则是由系统所管理的引用标识,该标识可以被系统重新定位到一个内存地址上。这种间接访问对象的模式增强了系统对引用对象的控制。
句柄就是个数字,一般和当前系统下的整数的位数一样,比如32bit
系统下就是4
个字节。这个数字是一个对象的唯一标示,和对象一一对应。
三、FFI 实战 - OpenCv 高斯模糊
我们使用成熟的开源库 Android OpenCV SDK 在 Fluter 上实践 FFI 并实现高斯模糊。
下图是原图,
这是高斯模糊后的结果
3.1 环境搭建
使用 FFI 之前,必须首先确保本地代码已加载,并且其符号对 Dart 可见,然后才能在库或程序使用 FFI 库绑定本地代码。
3.1.1 下载 SDK
Open Cv 下载地址:https://opencv.org/releases/
解压后包含以下内容:
3.1.2 导入文件
创建一个 Flutter 插件名为 opencv_plugin
,在 main
目录下新建 cpp
目录。
创建 native-lib.cpp
文件在cpp
目录下;复制 OpenCv SDK 的 sdk -> native -> jni -> include
文件到 cpp 目录下;新建 jniLibs
文件,并复制sdk -> native -> libs
到jniLibs
;
3.1.3 配置 CmakeLists.txt
新建 CmakeLists.txt
到 android 目录下:
cmake_minimum_required(VERSION 3.4.1)
include_directories(${CMAKE_SOURCE_DIR}/src/main/cpp/include)
add_library(libopencv_java4 SHARED IMPORTED)
set_target_properties(libopencv_java4 PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/src/main/jniLibs/libs/${ANDROID_ABI}/libopencv_java4.so)
add_library( # Sets the name of the library.
native-lib # Sets the library as a shared library.
SHARED # Provides a relative path to your source file(s).
src/main/cpp/native-lib.cpp )
find_library( # Sets the name of the path variable.
log-lib # Specifies the name of the NDK library that
# you want CMake to locate.
log )
target_link_libraries( # Specifies the target library.
native-lib libopencv_java4 # Links the target library to the log library
android
# included in the NDK.
${log-lib} )
注: include_directories
与 set_target_properties
一定要使用${CMAKE_SOURCE_DIR}
配置绝对路径,不然编译过程中会报找不到 .so
的问题。
3.1.4 Gradle
将 opencv_plugin
下的 Gradle
文件添加以下内容
引入 c++_shared.so
库配置 CmakeLists.txt
路径
android {
compileSdkVersion 30
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdkVersion 16
### 引入 c++_shared.so 库
externalNativeBuild {
cmake {
cppFlags ""
arguments "-DANDROID_STL=c++_shared"
}
}
}
### 配置 CmakeLists.txt 路径
externalNativeBuild {
cmake {
path file('CMakeLists.txt')
}
}
}
3.1.4 检查是否配置成功
在 Flutter 调用 DynamicLibrary.open("libnative-lib.so")
观察日志是否报错。
3.1.5 引入 FFI
为了方便 FFI 的操作,开始之前先 pubspec.yaml
引入 FFI 1.1.2。这个库作用是,在 Dart 字符串和使用 UTF-8 和 UTF-16 编码的 C 字符串之间进行转换。
dependencies:
ffi: ^1.1.2
3.2 实现思路
在 Dart 端读取图片,并转换成 Uint8List 并展示图片; 在 C++ 层分配内存,长度为 Uint8List 的长度,并深拷贝一份 Uint8List; 将 Dart 端创建的指针(Pointer) 对象,当做参数传入 C++; C++ 层先图片 decode
后转换为Mat
结构体,调用cv::GaussianBlur()
实现高斯模糊并encode
成.PNG
(其他格式也可以),最后将指针传回 Dart 端;将 C++ 传回的 Uint8List 转化成 Dart Uint8List 数据并渲染;
为什么在 Dart 读取图片?
在 Dart 端读取图片是为了展示原图用,可以直接传文件路径在 C 层处理,可以减少一次拷贝。
为什么要深拷贝一次?
Uint8List 存在于 Dart 堆中,该堆是垃圾收集器,对象可能会被垃圾收集器移动。因此,您必须将其转换为指向 C 堆的指针。
图片压缩格式
jpg 格式:即为 jpeg 格式,是通过压缩改变画质和文件尺寸的格式; png 格式:png 可以对图像进行无损压缩,并且压缩体积比 jpg 格式要小得多; bmp格式:Windows 中使用的标准图像格式;
3.3 源码
C++ 端的实现分为三步:
decode
图片转化为Mat
对象;将 Mat
对象高斯模糊处理;encode
图片为.png
(其他格式也可以);
#define ATTRIBUTES extern "C" __attribute__((visibility("default"))) __attribute__((used))
ATTRIBUTES Mat *opencv_decodeImage(
unsigned char *img,
int32_t *imgLengthBytes) {
Mat *src = new Mat();
std::vector<unsigned char> m;
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"opencv_decodeImage() --- start imgLengthBytes:%d ",
*imgLengthBytes);
for (int32_t a = *imgLengthBytes; a >= 0; a--) m.push_back(*(img++));
*src = imdecode(m, cv::IMREAD_COLOR);
if (src->data == nullptr)
return nullptr;
if (DEBUG_NATIVE)
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"opencv_decodeImage() --- len before:%d len after:%d width:%d height:%d",
*imgLengthBytes, src->step[0] * src->rows,
src->cols, src->rows);
*imgLengthBytes = src->step[0] * src->rows;
return src;
}
ATTRIBUTES
unsigned char *opencv_blur(
uint8_t *imgMat,
int32_t *imgLengthBytes,
int32_t kernelSize) {
Mat *src = opencv_decodeImage(imgMat, imgLengthBytes);
if (src == nullptr || src->data == nullptr)
return nullptr;
if (DEBUG_NATIVE) {
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"opencv_blur() --- width:%d height:%d",
src->cols, src->rows);
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"opencv_blur() --- len:%d ",
src->step[0] * src->rows);
}
GaussianBlur(*src, *src, Size(kernelSize, kernelSize), 15, 0, 4);
std::vector<uchar> buf(1);
imencode(".png", *src, buf);
if (DEBUG_NATIVE) {
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"opencv_blur() resulting image length:%d %d x %d", buf.size(),
src->cols, src->rows);
}
*imgLengthBytes = buf.size();
return buf.data();
}
补充:GaussianBlur(src, src, Size(kernelSize, kernelSize), 15, 0, 4);:这里 sigmaX、sigmaY、borderType 数值写死了,最好的做法应当做参数传过来。
在 C++ 所有函数上面加 ATTRIBUTES extern "C" __attribute__((visibility("default")))
。FFI 库只能与 C 符号绑定,因此在 C++ 中,这些符号添加 extern C
标记。还应该添加属性来表明符号是需要被 Dart 引用的,以防止链接器在优化链接时会丢弃符号。
Dart 端实现:
从 assets
中读取图片转为 Uint8List;使用 malloc
在 C++ 中分配内存大小与上一步中 Uint8List 一样;用 FFI 查找 opencv_blur
函数并调用;处理返回结果,并释放指针;
Uint8List? uint8list;
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addPostFrameCallback((_) async {
final bytes = await rootBundle.load('assets/image_lonely.jpeg');
uint8list = bytes.buffer.asUint8List();
setState(() {});
});
}
static Uint8List? blur(Uint8List list) {
Pointer<Uint8> bytes = malloc.allocate<Uint8>(list.length);
for (int i = 0; i < list.length; i++) {
bytes.elementAt(i).value = list[i];
}
final imgLengthBytes = malloc.allocate<Int32>(1)..value = list.length;
final DynamicLibrary _opencvLib =
Platform.isAndroid ? DynamicLibrary.open("libnative-lib.so") : DynamicLibrary.process();
final Pointer<Uint8> Function(
Pointer<Uint8> bytes, Pointer<Int32> imgLengthBytes, int kernelSize) blur =
_opencvLib
.lookup<
NativeFunction<
Pointer<Uint8> Function(Pointer<Uint8> bytes, Pointer<Int32> imgLengthBytes,
Int32 kernelSize)>>("opencv_blur")
.asFunction();
final newBytes = blur(bytes, imgLengthBytes, 15);
if (newBytes == nullptr) {
print('高斯模糊失败');
return null;
}
var newList = newBytes.asTypedList(imgLengthBytes.value);
malloc.free(bytes);
malloc.free(imgLengthBytes);
return newList;
}
四、FFI 拓展
4.1 FFI 中的指针与 C++ 中的指针
上述高斯模糊的案例中,使用了指针的概念,让我想到以下问题:
那么 FFI 中指针的地址是否与 C++ 中指针的地址相同?
例如:C++ 计 32 位数的乘法。
ATTRIBUTES
int32_t *multiply(int32_t *a, int32_t b)
{
int32_t *mult = (int *)malloc(sizeof(int));
*mult = *a * b;
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"multiply() --- address:%d value:%d",
mult, *mult);
return mult;
}
Dart 声明一个 Pointer<Int32>
类型的指针,分配内存后初始化 value。
import 'package:ffi/ffi.dart';
static int multiply(int a, int b) {
final DynamicLibrary _opencvLib =
Platform.isAndroid ? DynamicLibrary.open("libnative-lib.so") : DynamicLibrary.process();
final Pointer<Int32> Function(Pointer<Int32> a, int b) multiply =
_opencvLib.lookup<NativeFunction<Pointer<Int32> Function(Pointer<Int32> a, Int32 b)>>("multiply").asFunction();
Pointer<Int32> pa = malloc.allocate<Int32>(1);
pa.value = a;
final result = multiply(pa, b);
final value = result.value;
print('dart --> multiply() address=${result.address} value=${result.value}');
malloc.free(result);
malloc.free(pa);
return value;
}
例如: 我们传入 a = 10, b = 100
如下结果:。
10 * 100 = 1000
返回的结果符合我们的预期。
同时我们可以观察到 C 指针的地址,与 Dart 中指针对象指向的地址不一样。这是因为 Dart 与 C 在不同堆栈中分配内存导致的。
还需要注意,上述案例中在 C 通过 malloc
分配了内存,如不使用还需要在 C 层调用 free()
, 在 C 声明的指针,只能在 C 层释放.
同理在 Dart 端也要释放指针。
4.2 FFI 接收 C++ 中的结构体
处理复杂的对象通常使用结构体,如何传递 C++ 结构体与 Dart 交互?
4.2.1 结构体定义:
例如: C++ 定义如下结构体
struct Message {
char *msg;
uint32_t phone;
};
那么对应 Dart 中需要如下定义
class Message extends Struct {
external Pointer<Utf8> msg;
@Uint32()
external int phone;
}
Struct 子类声明中的所有字段声明必须具有 int
或 float
类型并使用表示本机类型的 NativeType 进行注释,或者必须是 Pointer 类型。
4.2.2 Dart 端接收结构体
C++ 定义如下函数:
ATTRIBUTES
Message createMessage(const char *msg, int32_t phone) {
__android_log_print(ANDROID_LOG_VERBOSE, "NATIVE",
"createMessage() ---msg:%s phone:%d",
msg, phone);
Message message = Message();
message.msg = "C++";
message.phone = 99999;
return message;
}
Dart 定义如下函数:
static Message createMessage() {
final DynamicLibrary _opencvLib =
Platform.isAndroid ? DynamicLibrary.open("libnative-lib.so") : DynamicLibrary.process();
final Message Function(Pointer<Utf8> msg, int phone) createMessage =
_opencvLib.lookup<NativeFunction<Message Function(Pointer<Utf8> msg, Uint32 phone)>>("createMessage").asFunction();
final msg = 'Dart'.toNativeUtf8();
final phone = 1000;
final result = createMessage(msg, phone);
print('result msg = ${result.msg.toDartString()} phone = ${result.phone}');
return result;
}
在 C++ 层打印 "Dart"
字符, 在 Dart 层打印 "C++"
字符
FFI 提供了直接与 C++ 的交互能力,相对于依赖 JNI 的方式提升数据传递的效率; 使用 FFI 调用 C++ 能做可以做到 UI 统一、逻辑统一,对于写具体业务的同学而言,写一套 Flutter 逻辑和视图双端即可运行,基本相当于客户端原生开发双倍的开发效率。在后期功能维护上,投入的成本也远远小于原生开发。 编写 FFI 可以抽象成在 C++ 一端开发,掌握 C++ 基础的同学上手成本较低; 未来可以考虑将项目中通过 JNI 方式调用 .os
库的业务替换成 FFI 提升体验,减少后期维护成本;从 Flutter 2.0 发布 FFI 稳定版,到 Flutter 2.2 的 FFI 支持数组结构体, 可见 Flutter 成为首选的多平台开发 UI 工具包之势日趋明显;
references:
C interop using dart:ffi:https://dart.dev/guides/libraries/c-interop Flutter FFI 实践:https://julis.wang/2021/04/18/FlutterFFI%E5%AE%9E%E8%B7%B5/
-- End --
本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!
推荐阅读:
面试官:设计一个基于索引,setAll()时间复杂度为O(1)的数据结构