likes
comments
collection
share

如何在 Flutter 中调用 C++ 代码

作者站长头像
站长
· 阅读数 17

目录

  • 需求背景
  • FFI 简介
  • FFI 实战 - OpenCv 高斯模糊
  • FFI 中的指针与 C++ 中的指针
  • 拓展:FFI 接收 C++ 中的结构体
  • 总结

一、需求背景

如在 flutter 中调用 C/C++ 代码可以通过 method channel 的方式来调用 JNI 间接调用 C/C++ 代码 ,那么就会有较长的调用链 flutter -> java jni -> C/C++, 假如我们要传递一个参数,这个参数将会在 java、C++ 各拷贝一次,如果是大对象容易造成内存抖动,且效率较低。

那么 Dart 中有没有像 JNI 一样的东西,直接调用 C/C++ 代码?

引出我们今天的主角 FFI (我们是否可以叫它 DNI 呢?)

二、FFI 简介

FFI (Foreign function interface) 代表 外部功能接口,类似功能的其他术语包括本地接口和语言绑定。这个叫法延续在 Rust、Python、Dart等语言中,而 Java 将其 FFI 称为 JNI(Java 本机接口)。

2.1 Flutter 中的 FFI

Flutter2.0 中的 Dart 2.12 已发布,其中包含健全的空安全和 Dart FFI 的稳定版, 并且提供了一套类型绑定生成工具 ffigen,可以自动生成 Dart Wrapper 加快开发效率。

在目前最新的 Flutter 2.2 中,Dart 2.13 扩展了对原生互操作性的支持,现在支持在 FFI 中使用数组和封装结构体

可见 flutter 成为首选的多平台开发 UI 工具包之势日趋明显.

2.2 与 JNI 比较

网络上关于 FFI 的文章较少,我查阅到的是快手-开眼快创 Flutter 实践,相对于 JNI 它大大提升提升数据传输的性能。

使用 FFI 后,首次加载缩略图速度提升2% ~ 16%,在涉及大量图片传输场景下数据提升明显,数据传输耗时占比较高,FFI替换 Channel 后传输耗时降低。

2.3 FFI 原理:如何找到 C++ 中的库

通过 DynamicLibrary.open() 查看源码实现

final DynamicLibrary ffiLib = Platform.isAndroid ? DynamicLibrary.open('lib_invoke.so') : DynamicLibrary.process();

DynamicLibrary.open() 最终执行的逻辑如下, 源码位于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 加载动态链接库,并返回句柄。

拿到对应的动态链接库的句柄之后,就能使用相关方法进行操作了。

句柄主要包含以下两个方法:

// 在内存中查找对应符号名的地址,与dlsym()功能相同
external Pointer<T> lookup<T extends NativeType>(String symbolName);

// 1、去动态库中查找对应名称的函数 
// 2、将 Native 类型的 C/C++ 函数转化为 Dart 的 Function 类型
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();  // Clear any errors.
  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 SDKFluter 上实践 FFI 并实现高斯模糊。

左边是原图,右边是高斯模糊后的结果

如何在 Flutter 中调用 C++ 代码 如何在 Flutter 中调用 C++ 代码

3.1 环境搭建

使用 FFI 之前,必须首先确保本地代码已加载,并且其符号对 Dart 可见,然后才能在库或程序使用 FFI 库绑定本地代码。

3.1.1 下载 SDK

Open Cv 下载地址

解压后包含以下内容:

如何在 Flutter 中调用 C++ 代码

3.1.2 导入文件

创建一个 flutter 插件名为 opencv_plugin,在 main 目录下新建 cpp 目录。

  1. 创建 native-lib.cpp 文件在 cpp 目录下。
  2. 复制 OpenCv SDKsdk -> native -> jni -> include 文件到 cpp目录下。
  3. 新建 jniLibs 文件,并复制 sdk -> native -> libsjniLibs

如何在 Flutter 中调用 C++ 代码

3.1.3 配置 CmakeLists.txt

新建 CmakeLists.txtandroid 目录下:

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_directoriesset_target_properties 一定要使用${CMAKE_SOURCE_DIR}配置绝对路径,不然编译过程中会报找不到 .so 的问题。

配置 CmakeLists 是为了编译 native-lib.cpp 文件生成 native-lib.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-8UTF-16 编码的 C 字符串之间进行转换。

dependencies:
  ffi: ^1.1.2

3.2 实现思路

  1. Dart 端读取图片,并转换成 Uint8List 并展示图片。
  2. C++ 层分配内存,长度为 Uint8List 的长度,并深拷贝一份 Uint8List
  3. Dart 端创建的指针(Pointer) 对象,当做参数传入 C++
  4. C++ 层先图片 decode 后转换为 Mat 结构体,调用 cv::GaussianBlur() 实现高斯模糊encode.PNG(其他格式也可以),最后将指针传回 Dart 端。
  5. C++ 传回的 Uint8List 转化成 Dart Uint8List 数据并渲染。

如何在 Flutter 中调用 C++ 代码

为什么在 Dart 读取图片?

Dart 端读取图片是为了展示原图用,可以直接传文件路径在 C 层处理,可以减少一次拷贝。

为什么要深拷贝一次?

Uint8List 存在于 Dart 堆中,该堆是垃圾收集器,对象可能会被垃圾收集器移动。 因此,您必须将其转换为指向 C 堆的指针。

图片压缩格式

1、jpg格式:即为jpeg格式,是通过压缩改变画质和文件尺寸的格式。

2、png格式:png可以对图像进行无损压缩,并且压缩体积比jpg格式要小得多。

3、bmp格式:Windows中使用的标准图像格式。

3.3 源码

C++ 端的实现分为三步:

  • 1、decode 图片转化为 Mat 对象
  • 2、将 Mat 对象高斯模糊处理
  • 3、encode 图片为 .png (其他格式也可以)
#define ATTRIBUTES extern "C" __attribute__((visibility("default"))) __attribute__((used))

// decode 图片
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) {
    // 1. decode 图片
    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);
    }

    // 2. 高斯模糊
    GaussianBlur(*src, *src, Size(kernelSize, kernelSize), 15, 0, 4);
    std::vector<uchar> buf(1); // imencode() will resize it
//    Encoding with b       mp : 20-40ms
//    Encoding with jpg : 50-70 ms
//    Encoding with png: 200-250ms
    // 3. encode 图片
    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();

    // the return value may be freed by GC before dart receive it??
    // Sometimes in Dart, ImgProc.computeSync() receives all zeros while here buf.data() is filled correctly
    // Returning a new allocated memory.
    // Note: remember to free() the Pointer<> in Dart!
    
    // 3. 返回data
    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 端实现:

  • 1、从 assets 中读取图片转为 Uint8List
  • 2、使用 mallocC++ 中分配内存大小与上一步中 Uint8List 一样
  • 3、用 FFI 查找 opencv_blur 函数并调用。
  • 4、处理返回结果,并释放指针。
  Uint8List? uint8list;
  
  @override
  void initState() {
    super.initState();
    /// 读取图片,转换成 Uint8List
    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;
    
    // 查找 C++ 中的 opencv_blur() 函数
   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)); // 在 C 中分配内存
    *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();

  // 查找 C 函数 multiply()
    final Pointer<Int32> Function(Pointer<Int32> a, int b) multiply =
    _opencvLib.lookup<NativeFunction<Pointer<Int32> Function(Pointer<Int32> a, Int32 b)>>("multiply").asFunction();

  // 调用 C 函数 multiply()
  // malloc 是 import 'package:ffi/ffi.dart'; 中的对象
    Pointer<Int32> pa = malloc.allocate<Int32>(1);// 为指针分配内存
    pa.value = a;// pa.value 是指针指向的值
    final result = multiply(pa, b);
    final value = result.value;

    print('dart --> multiply() address=${result.address} value=${result.value}');
   
   // 注:这里省略了 C 层 free() 函数。
   // 这里还需要将 result 指针传回 C 层,再 C 层调用 free() 不然会内存泄漏
   // free(result)
    malloc.free(result);
    malloc.free(pa);
    return value;
  }

例如: 我们传入 a = 10, b = 100 如下结果:。

10 * 100 = 1000 返回的结果符合我们的预期。

如何在 Flutter 中调用 C++ 代码

同时我们可以观察到 C 指针的地址,与 Dart 中指针对象指向的地址不一样。这是因为 DartC 在>不同堆栈中分配内存导致的。

还需要注意,上述案例中在 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() // NativeType 类型注释
  external int phone;
}

Struct 子类声明中的所有字段声明必须具有 intfloat 类型并使用表示本机类型的 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(); /// 转换成 C 能识别的 Utf8 类型 
    final phone = 1000;
    
    /// 调用 C 函数
    final result = createMessage(msg, phone);

    print('result msg = ${result.msg.toDartString()} phone = ${result.phone}');
    return result;
  }

C++ 层打印 "Dart" 字符, 在 Dart 层打印 "C++" 字符

如何在 Flutter 中调用 C++ 代码

总结

  1. FFI 提供了直接与 C++ 的交互能力,相对于依赖 JNI 的方式提升数据传递的效率
  2. 使用 FFI 调用 C++ 能做可以做到 UI 统一、逻辑统一,对于写具体业务的同学而言,写一套 Flutter 逻辑和视图双端即可运行,基本相当于客户端原生开发双倍的开发效率。在后期功能维护上,投入的成本也远远小于原生开发。
  3. 编写 FFI 可以抽象成在 C++ 一端开发,掌握 C++ 基础的同学上手成本较低。
  4. 未来可以考虑将项目中通过 JNI 方式调用 .so 库的业务替换成 FFI 提升体验,减少后期维护成本。
  5. Flutter 2.0 发布 FFI 稳定版,到 Flutter 2.2 FFI 支持数组结构体, 可见 Flutter 成为首选的多平台开发 UI 工具包之势日趋明显.

参考

C interop using dart:ffi

Flutter FFI实践