[Flutter翻译]Discreet日志#9:带有原生Go库的Flutter

727 阅读16分钟

原文作者:openprivacy.ca/discreet-lo…

原文地址:

发布时间:2021年6月9日

欢迎来到Discreet Log! 这是一个两周一次的技术开发博客,深入介绍我们在Open Privacy从事的研究、项目和工具。在我们的第九篇文章中,Dan Ballard谈到了在跨平台的移动和桌面Flutter应用中使用非Dart代码的工作,其形式是预先存在的Go代码。

为什么

语言、编程环境和框架对不同的事情都有好处。当我们开始Cwtch的时候,我们评估了Go是建立一个系统库的好候选者。在我们正在进行的研究中,我们也发现Rust在制作非常安全的关键应用程序方面有很好的特性。然而,这两种语言都没有配备用于快速、轻松、无缝地构建跨平台UI的框架。如果你发现自己在开始的时候也有类似的情况,或者在有一个成熟的解决方案的情况下,而该语言又不太适合UI建设,那么你可能会发现我们将一个强大的Go网络库Cwtch与Flutter结合起来,为Windows、Android和Linux建立一个单一的UI和代码库的工作概述。

至于为什么是Flutter?Cwtch最重要的优先事项之一是维护用户的隐私和匿名性。出于这个原因,我们在评估UI框架时,首先排除了所有的HTML框架(Electron、React Native等),因为在一个充满用户内容的应用程序中,一个未转义的HTML位被渲染出来的风险可能会刺穿并破坏用户的匿名性。这给我们留下的选择并不多,但有一个比我们上次看的时候还要新的选择,那就是Flutter,它现在拥有实验性的、即将稳定的桌面支持和背后的大牌支持。

还有一点:我们仍在积极开发Cwtch Flutter用户界面,所以我们的代码库在未来可能会改变我们今天在这里展示的内容,特别是我们正在添加Android服务支持。我将记下哪些地方可能会进一步改变。

库API设计和构建

大多数语言的API提供了一个丰富的接口,使用了全部的语言特性和数据类型。当制作一个跨语言的API时,通常,特别是在这种情况下,会有很多限制。在dart:fi和gomobile之间(我们将在下面的章节中讨论),我们剩下的数据类型和表达能力大大减少,我们可以使用。我们的第一步是建立一个新的包装库,libcwtch-go,以提供一个新的、更简单的、跨语言的API。通常,当我们仍然需要更复杂的数据类型时,我们会在Go端将其序列化为JSON字符串,然后传递简单的字符串,并在Dart或Kotlin端进行反序列化。如果你打算在这样的接口上移动大量的数据,这可能是性能方面的考虑。

桌面和FFI

ffi的 "外国函数接口 "是许多语言中提供的功能,可以从其他语言的动态库中调用代码,通常使用C共享对象库作为标准。Dart在dart:ffi模块中内置了ffi功能,所以这显然是未来的方向,至少在桌面上是这样。

这方面的诀窍是,我们必须将Go代码编译成C风格的库(.so和.dll),以便于访问,这一点我们稍后会看到,手机也是如此。这有一点新奇,因为Go的一个重要设计原则是没有动态链接,在Go中所有的东西都被编译成一个静态的二进制文件,没有任何依赖性。因此,将Go代码打包成一个动态库以便在另一种语言中使用,这有点讽刺。值得庆幸的是,虽然这看起来与Go有点对立,但它实际上是以cgo的形式被植入了Go的核心工具。

go build -buildmode c-shared -o libCwtch.so

为windows制作DLL需要mingw,有了它,Go已经内置了对Windows DLL的支持,甚至可以交叉编译到它们。我们使用

GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc-win32 go build -buildmode c-shared -o libCwtch.dll

还有一个问题。Dart的FFI希望有一个具有C风格的接口和数据类型的库,所以我们还必须为我们的函数编写一个符合这个要求的API。Go支持C语言包,提供全套的C语言数据类型封装器和转换器。

lib.go

import "C"

...

//export c_GetMessages
func c_GetMessages(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, start C.int, end C.int) *C.char {
	profile := C.GoStringN(profile_ptr, profile_len)

//export注释是必须的,被cgo用来命名共享对象中的函数。

Gomobile

由于打包和应用程序的部署方式的原因,在Android这样的移动设备上的动态库管理和使用已经比在桌面上更难。但这并不是唯一的挑战,Android应用程序还需要同时支持arm7 32bit和arm64-v8 CPU,所以你需要同时生成这两种程序(更多的交叉编译),并对其进行适当的打包。我们也许可以通过时间来解决这个问题,如果你没有使用Go,那么你可能不得不这样做,但是Go为移动开发提供了一个工具,其中gomobile已经解决了这个问题。在这种情况下,它为两种架构交叉编译了两个库,还为它们生成了一个与Java Runtime Environment(JRE)兼容的API,以便在Android上访问(我相信还有一个Obj C版本用于iOs)。

gomobile bind -target android

这将生成一个cwtch.ar,里面有arm7 32bit和arm64-v8库,以及JRE接口。

把库放在一起

gomobile和dart:ffi都需要稍微不同的接口。Gomobile处理数据类型的转换,从Go原生到JRE可以使用的数据类型,另一方面,Dart的ffi期望有C风格的函数和数据,如上所述。为了管理这一切,我们所有的函数都有两个定义,其形式如下图所示

lib.go

//export c_GetMessages
func c_GetMessages(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, start C.int, end C.int) *C.char {
	profile := C.GoStringN(profile_ptr, profile_len)
	handle := C.GoStringN(handle_ptr, handle_len)
	return C.CString(GetMessages(profile, handle, int(start), int(end)))
}

func GetMessages(profile, handle string, start, end int) string {
	messages := application.GetPeer(profile).GetContact(handle).Timeline.Messages[start:end]
	bytes, _ := json.Marshal(messages)
	return string(bytes)
}

我们所有可以从DFI接口中调用的函数都以c_开头,并且有C数据类型的参数。然后,我们使用cgo将它们转换为Go原生数据,并调用Go原生函数,Gomobile将通过Kotlin和生成的JRE接口 "直接 "调用。最后,当我们得到一个结果时,c_函数将其转换为C原生类型并返回。

然而,我们还没有完全完成。Gomobile和go build都希望有一个稍微不同的文件格式,所以为了方便地在两者之间使用一个主文件,我们还有最后一点锅炉板要放进去。

Go需要一个主函数,即使是在编译成一个库的时候,而gomobile则不需要。我们把

lib.go

// Leave as is, needed by ffi
func main() {}

在文件的底部。Go也需要一个main包,但Gomobile希望包的名字与lib的名字相匹配,在这种情况下是'cwtch'。我们把

lib.go

//package cwtch
package main

在文件的顶部。为了切换这些小细节,我们有两个辅助脚本:switch-ffi.shswitch-gomobile.sh

这些命令注释或取消注释func main ()行,并注释或取消注释相应的包调用。然后,我们的Makefile中的每个脚本都在上述编译命令之前被调用。

作为库设计的最后说明,为了简单起见,我们有一个顶级的go文件,lib.go,带有cgo和gomobile接口。封装库的所有其他功能都在子包中,所以它们不会被gomobile或cgo转换,都可以使用本地go数据类型。

库在Flutter中的使用

Flutter从它的主线程或 "隔离 "中操作UI,我们永远不希望阻塞,否则UI会变得无响应性。大部分阻塞和网络的复杂性都埋藏在Cwtch中,但即使是调用库中的数据也会花费大量时间,所以我们希望所有这些都发生在主隔离区之外。对于每个平台,我们将采取不同的方法,但它们都能实现这一点。

首先,在/lib下,我们做了一个/lib/cwtch模块来捕获我们的接口和实现。我们创建了一个可以使用的接口,如下所示

lib/cwtch/cwtch.dart

abstract class Cwtch {
  // ignore: non_constant_identifier_names
  Future<void> Start();

  // ignore: non_constant_identifier_names
  void SelectProfile(String onion);

	...

对于我们的接口,我们可能在不知不觉中遵循了Go-ism中的Capital命名的公共函数,Dart linter不喜欢这样,因此有//忽略的注释。这一点将在某个时候被清理掉。

FFI的实现

我们在Dart中对Cwtch接口的第一个实现是用FFI的方法来访问该库,我们在桌面上使用了这种方法。

lib/cwtch/ffi.dart

import 'dart:ffi';

...


//func GetMessages(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, start C.int, end C.int) *C.char {
typedef get_json_blob_from_str_str_int_int_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Pointer<Utf8>, Int32, Int32, Int32);
typedef GetJsonBlobFromStrStrIntIntFn = Pointer<Utf8> Function(Pointer<Utf8>, int, Pointer<Utf8>, int, int, int);

...

class CwtchFfi implements Cwtch {
  late DynamicLibrary library;

...

  CwtchFfi() {
    if (Platform.isWindows) {
      library = DynamicLibrary.open("libCwtch.dll");
    } else if (Platform.isLinux) {
      library = DynamicLibrary.open("libCwtch.so");
    } else {
      print("OS ${Platform.operatingSystem} not supported by cwtch/ffi");
      // emergency, ideally the app stays on splash and just posts the error till user closes
      exit(0);
    }
  }

...

// ignore: non_constant_identifier_names
  Future<String> GetMessages(String profile, String handle, int start, int end) async {
    var getMessagesC = library.lookup<NativeFunction<get_json_blob_from_str_str_int_int_function>>("c_GetMessages");
    // ignore: non_constant_identifier_names
    final GetMessages = getMessagesC.asFunction<GetJsonBlobFromStrStrIntIntFn>();
    final utf8profile = profile.toNativeUtf8();
    final utf8handle = handle.toNativeUtf8();
    Pointer<Utf8> jsonMessagesBytes = GetMessages(utf8profile, utf8profile.length, utf8handle, utf8handle.length, start, end);
    String jsonMessages = jsonMessagesBytes.toDartString();
    return jsonMessages;
  }

文件的顶部充满了一组我们想要调用的函数定义,通过void_from_string_string_function等描述参数和返回类型的类型进行概括,以便重新使用。

构造函数试图打开适当的桌面操作系统库或错误退出,我们的演示函数从库中获取函数(如上定义),然后从Dart值初始化C风格的参数并进行调用。最后,它获取C风格的返回数据,将其转换为预期的Dart类型并返回。

这有点繁琐,但相对容易复制,以填补整个界面的空白。我们还没有时间去做的一个优化是把所有的函数查找拉到构造函数中,并把它们存储为类变量。你可以这样开始,为自己节省一些重构的时间。

Gomobile的实现

Gomobile的实现一开始显得有点复杂,但这后来得到了回报,所以请继续关注。

可用的libcwtch-go接口是JRE兼容格式,所以必须从Java或Kotlin中调用。我们选择了Kotlin。为了让Dart能够调用Kotlin,必须使用Dart MethodChannel,所以Dart中的gomobile实现看起来像。

lib/cwtch/gomobile.dart

class CwtchGomobile implements Cwtch {
  static const cwtchPlatform = const MethodChannel('cwtch');

  CwtchGomobile() {
    ...
  }

  // ignore: non_constant_identifier_names
  Future<dynamic> GetMessages(String profile, String handle, int start, int end) {
    return cwtchPlatform.invokeMethod("GetMessage", {"profile": profile, "contact": handle, "start": start, "end": end});
  }

到目前为止,这是很直接的做法。由于我们使用的是Method Channel,并调用到另一个线程,所以响应类型必须是Future,这样Dart就可以暂停这一行的执行,在响应准备好之前可以自由地做其他事情(合作线程)。接下来我们看一下Kotlin方面的实现。

android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt

package im.cwtch.flwtch

...

import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import cwtch.Cwtch

...

class MainActivity: FlutterActivity() {

    // Channel to get cwtch api calls on
    private val CHANNEL_CWTCH = "cwtch"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_CWTCH).setMethodCallHandler { call, result -> handleCwtch(call, result) }
    }

...

    private fun handleCwtch(@NonNull call: MethodCall, @NonNull result: Result) {
        when (call.method) {
	...
	"GetMessages" -> {
                val profile = (call.argument("profile") as? String) ?: "";
                val handle = (call.argument("contact") as? String) ?: "";
                val start = (call.argument("start") as? Long) ?: 0;
                val end = (call.argument("end") as? Long) ?: 0;
                result.success(Cwtch.getMessages(profile, handle, start, end))
            }
	...
	else -> result.notImplemented()
        }
    }

Flutter亲切地提供了我们需要的Kotlin导入,使之变得简单。在自动调用的configureFlutterEngine函数中,我们在Kotlin端设置了Method通道,以转发到我们的处理函数,我们的处理函数在调用libCwtch-go接口之前做了一些基本参数解析和验证。Kotlin中的MethodChannel函数都是由一个处理程序在可能的子方法调用上运行一个when语句来处理。

要使JRE的libCwtch-go接口对Koltlin(或Java)可用,只需按照通常的步骤导入你由gomobile产生的.ar文件。

双向通信

cwtch库为配置文件管理网络连接,而这些配置文件又与许多联系人和群组相连,并不断获得消息。就像任何提供网络连接管理的库一样,我们需要的不仅仅是一种将Flutter/Dart UI驱动的事件推送到Cwtch库中的方法,我们还需要一种方法,当事件发生时,它能做出回应,并将这些事件推送到应用程序的Dart和Flutter层。

正如Go的多线程编程一样,我们使用了大量的goroutines并使用通道来传递信息。我们在Cwtch的事件总线子系统中对此进行了编纂。有了这个系统,我们已经有了一个之前用来推送许多事件到用户界面的系统(旧的QT/go ui),它的结构简单而灵活,可以处理许多事件,这很好,因为它允许我们写一个回调系统来处理所有事情。

对于我们的API,我们对库的很多调用都在libcwtch-go中定义了方法,但由于我们使用cwtch事件系统生成的消息,我们将它们作为JSON传递给Dart,以简化管理我们目前处理的20多个具有不同相关参数的事件。

在libcwtch-go中,钩子是这样的

lib.go

func (eh *EventHandler) GetNextEvent() string {
	appChan := eh.appBusQueue.OutChan()

	select {
	case e := <-appChan:
		return eh.handleAppBusEvent(&e)
	case ev := <-eh.profileEvents:
		return eh.handleProfileEvent(&ev)
	}
}

...

//export c_GetAppBusEvent
func c_GetAppBusEvent() *C.char {
	return C.CString(GetAppBusEvent())
}

// GetAppBusEvent blocks until an event
func GetAppBusEvent() string {
	var json = ""
	for json == "" {
		json = eventHandler.GetNextEvent()
	}
	return json
}

handle*Event函数切换不同的事件类型,采取相应的行动或用更多的数据丰富有限的事件总线消息,并返回JSON字符串或空字符串,如果用户界面不需要通知。该库的公共API是GetAppBusEvent()函数,它是对库的阻塞调用,在Go通道上阻塞等待消息。如果消息需要传递给UI,那么它就会把它传递上去,等待下一层的召回,但是如果事件可以在整个包装层中处理,那么它就会回到阻塞状态,等待UI需要被通知的下一个事件。

从库中冒出事件信息的最后一个Flutter方面是如何根据事件通知Flutter的UI。为此,我们使用了一个Notifier,cwtchNotifier,它是各种UI Notifiers的集合,还有一个消息处理函数,它根据从libCwtch-go得到的事件调用相应的子notifier。

FFI

由于GetAppBusEvents是一个阻塞式调用,我们需要在Dart主线程之外进行这些调用。对于FFI,我们采用了一个隔离器。

lib/cwtch/ffi.dart

class CwtchFfi implements Cwtch {
  ...
  late CwtchNotifier cwtchNotifier;
  late Isolate cwtchIsolate;

  CwtchFfi(CwtchNotifier _cwtchNotifier) {
    ...
    cwtchNotifier = _cwtchNotifier;
  }

  // Called on object being disposed to (presumably on app close) to close the isolate that's listening to libcwtch-go events
  @override
  void dispose() {
    if (cwtchIsolate != null) {
      cwtchIsolate.kill(priority: Isolate.immediate);
    }
  }

  // ignore: non_constant_identifier_names
  Future<void> Start() async {
    ...
    // Spawn an isolate to listen to events from libcwtch-go and then dispatch them when received on main thread to cwtchNotifier
    var _receivePort = ReceivePort();
    cwtchIsolate = await Isolate.spawn(_checkAppbusEvents, _receivePort.sendPort);
    _receivePort.listen((message) {
      var env = jsonDecode(message);
      cwtchNotifier.handleMessage(env["EventType"], env["Data"]);
    });
  }
 
  // Entry point for an isolate to listen to a stream of events pulled from libcwtch-go and return them on the sendPort
  static void _checkAppbusEvents(SendPort sendPort) async {
    var stream = pollAppbusEvents();
    await for (var value in stream) {
      sendPort.send(value);
    }
  }

  // Steam of appbus events. Call blocks in libcwtch-go GetAppbusEvent.  Static so the isolate can use it
  static Stream<String> pollAppbusEvents() async* {
    late DynamicLibrary library;
    if (Platform.isWindows) {
      library = DynamicLibrary.open("libCwtch.dll");
    } else if (Platform.isLinux) {
      library = DynamicLibrary.open("libCwtch.so");
    }

    var getAppbusEventC = library.lookup<NativeFunction<acn_events_function>>("c_GetAppBusEvent");
    // ignore: non_constant_identifier_names
    final GetAppbusEvent = getAppbusEventC.asFunction<ACNEventsFn>();

    while (true) {
      Pointer<Utf8> result = GetAppbusEvent();
      String event = result.toDartString();
      yield event;
    }
  }

在我们的构造函数中,我们现在也采取了cwtchNotifier并存储它。在Start()函数中,我们创建了一个隔离区,并告诉它在隔离区的coroutine上运行_checkAppbusEvents,任何从隔离区返回的数据(JSON字符串)都被分派给Dart主线程上的cwtchNotifier。

_checkAppbusEvents简单地设置了一个Stream<String>,并开始等待其中的值,以便通过它的sendPort(隔离的IPC机制)发送。pollAppbusEvents需要获得它自己的动态库句柄,因为它不能使用类值,因为它们在不同的coroutine中,而且Dart阻止这种不安全的访问。完成后,它循环进行阻塞的getAppbusEvents调用,将其转换为Dart字符串,并将结果交给_checkAppbusEvents。我们已经连接了一个处置函数来清理隔离物,但是Flutter的生命周期管理目前存在一些问题,无法正常工作。稍后再查看这个问题和代码,看看我们可能的修复方法。

有了这一切,我们现在有一个隔离器在我们的libcwtch-go库中轮询一个阻塞调用,等待一个go通道。当go通道的事件发生时,该函数返回到隔离器,隔离器将消息IPC到Dart主线程,主线程使用一个通知器来分配消息。

Gomobile

Gomobile方面的情况要简单一些,因为正如我在上一节中提到的,早期为Kotlin设置MethodChannels的工作现在会得到回报,因为MainActivity已经在一个独立的线程中运行,所以不需要在Flutter/Dart方面设置一个隔离器。

android/app/src/main/kotlin/im/cwtch/flwtch/MainActivity.kt

    // Channel to send eventbus events on
    private val CWTCH_EVENTBUS = "test.flutter.dev/eventBus"

    ...

    private fun handleCwtch(@NonNull call: MethodCall, @NonNull result: Result) { 
	when (call.method) {
	    "Start" -> {
                ...
                // seperate coroutine to poll event bus and send to dart
                val eventbus_chan = MethodChannel(flutterEngine?.dartExecutor?.binaryMessenger, CWTCH_EVENTBUS)
                Log.i("MainActivity.kt", "got event chan: " + eventbus_chan + " launching corouting...")
                GlobalScope.launch(Dispatchers.IO)  {
                    while(true) {
                        val evt = AppbusEvent(Cwtch.getAppBusEvent())
                        launch(Dispatchers.Main) {
                            //todo: this elides evt.EventID which may be needed at some point?
                            eventbus_chan.invokeMethod(evt.EventType, evt.Data)
                       }
                    }
                }
            }

Kotlin得到了对 "Start "的调用,并在Dispatchers.IO范围上启动了一个新的coroutine。这个coroutine轮询了getAppbussEvent的阻塞函数,然后将结果直接放到MethodChannel上。这是一个新的MethodChannel(可以命名为任何东西,从我们早期的测试名称persisting可以看出),Dart将监听它。

lib/cwtch/gomobile.dart

class CwtchGomobile implements Cwtch {
  ...
  final appbusEventChannelName = 'test.flutter.dev/eventBus';

  CwtchGomobile(CwtchNotifier _cwtchNotifier) {
    cwtchNotifier = _cwtchNotifier;
    ...
    // Method channel to receive libcwtch-go events via Kotlin and dispatch them to _handleAppbusEvent (sends to cwtchNotifier)
    final appbusEventChannel = MethodChannel(appbusEventChannelName);
    appbusEventChannel.setMethodCallHandler(this._handleAppbusEvent);
  }

  // Handle libcwtch-go events (received via kotlin) and dispatch to the cwtchNotifier
  Future<void> _handleAppbusEvent(MethodCall call) async {
    final String json = call.arguments;
    var obj = jsonDecode(json);
    cwtchNotifier.handleMessage(call.method, obj);
  }

由于在MethodChanel API调用工作中建立了预先存在的结构,Dart方面的实现比较简单。构造函数建立了一个EventChannel来监听,并将其与我们的_handleMessage挂钩,后者获得调用,并将其直接传递给CwtchNotifier。

在我们完成制作Android服务来运行libcwtch-go接口以提高稳定性和正确性的工作后,这一领域会有变化。我们将在Kotlin中把消息轮询转移到服务中,并在那里进行一些过滤,即如果应用程序当前没有被关注,服务可以在收到消息时创建一个通知,不需要提醒UI,直到它再次活动。请查看代码库中的最新进展。

结束

至此,我们结束了从Flutter/Dart到网络处理Go库的跨平台移动/桌面双向API调用和事件处理之旅。每次你想增加一个新的API调用时,都会有一些锅炉板,但与多个应用程序或多个库的成本相比,这算不了什么。我们在Android、Windows和Linux上有一个非常统一的代码库。我们已经能够利用我们丰富而成熟的三年前的Cwtch Go库来完成所有的工作,并让Flutter在它上面快速构建UI。这种方式似乎很适合任何一种非琐碎的Flutter应用,将工作分给更适合它的语言,而让Flutter和Dart只管理UI,这正是它们最擅长的。

像往常一样,这是一项复杂的工作,有时感觉像是在推动工作(比如当我们遇到平台错误或限制时),而在过去的6个月里,我们能够建立和整个新的用户界面的速度既说明了Flutter的好,也说明了Cwtch作为一个支持应用程序的库越来越成熟。我们的工作很复杂,也在挑战许多极限,而且一如既往地得到了你们这些捐赠者和社区的支持,所以如果你们喜欢其中的任何一部分,请考虑捐赠以继续支持这项工作。我们还远远没有完成。


www.deepl.com 翻译