查看原文
其他

Flutter 利用 FFI,绕过 Android JNI 直接调用 C++ 层!

坏de牧羊人 承香墨影 2022-09-09

坏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 目录。

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

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_directoriesset_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 实现思路

  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 数据并渲染;

为什么在 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))


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 端实现:

  1. assets 中读取图片转为 Uint8List;
  2. 使用 malloc 在 C++ 中分配内存大小与上一步中 Uint8List 一样;
  3. 用 FFI 查找 opencv_blur 函数并调用;
  4. 处理返回结果,并释放指针;
  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 子类声明中的所有字段声明必须具有 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(); 
    final phone = 1000;
    
    
    final result = createMessage(msg, phone);

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

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

  1. FFI 提供了直接与 C++ 的交互能力,相对于依赖 JNI 的方式提升数据传递的效率;
  2. 使用 FFI 调用 C++ 能做可以做到 UI 统一、逻辑统一,对于写具体业务的同学而言,写一套 Flutter 逻辑和视图双端即可运行,基本相当于客户端原生开发双倍的开发效率。在后期功能维护上,投入的成本也远远小于原生开发。
  3. 编写 FFI 可以抽象成在 C++ 一端开发,掌握 C++ 基础的同学上手成本较低;
  4. 未来可以考虑将项目中通过 JNI 方式调用 .os 库的业务替换成 FFI 提升体验,减少后期维护成本;
  5. 从 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)的数据结构

Android高版本HTTPS抓包解决方案及问题分析!

Kotlin 协程,怎么开始的又是怎么结束的?原理讲解!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存