SharedPreferences桌面插件 flutter

flutter可以构建跨平台的多端应用, 正好开发的应用需要桌面版本, 那就尝试传说中的无缝移植.

然而刚开始就遇到了大麻烦: 移动端普遍使用的SharedPreferences在桌面端只有macOS有实现! 虽然引入 shared_preferences: ^0.5.3+4 在编译时没有问题, 但windows和linux平台在运行时会抛出 [ERROR:flutter/lib/ui/ui_dart_state.cc(148)] Unhandled Exception: MissingPluginException(No implementation found for method getAll on channel plugins.flutter.io/shared_preferences) 的异常.

这"无缝"来的太猛, 有点措手不及…等着官方出正式版本断然不行的, 必须得自行添加在平台层的实现了. 好在桌面端可以以插件的方式与 shared_preferences 对接上, 结合在macOS上的实现及提供的示例程序总算给搞出来了! 以linux为例, 写一下久违的C++.

开发环境:
之前尝试最新的flutter1.9运行集成的桌面应用,但失败了, 所以开发环境在flutter1.8, 这是确定可以运行起来的

flutterSDK: v1.8.0@stable

flutter Desktop: c183d46798b9642b8d908710de1e7d14a8573c86@master

pubspec.yaml:

dependencies:
  shared_preferences: ^0.5.3+4

运行以下命令确保可以运行起来或者参照这篇文章 (flutterSDK安装不再另行说明):

git clone https://github.com/google/flutter-desktop-embedding.git desktop
cd desktop/example
flutter run

我们就是基于example应用把SharedPreferences插件开发出来.

插件结构

所有的插件位于desktop仓库根目录下的 plugins , 其中的 flutter_plugins 特指的是flutter在其它端(android/iOS/web)也可以用的插件, 其余的表示只在桌面端(macOS/linux/windows)用到的插件, 需要实现的 SharedPreferences 就在 plugins/flutter_plugins/shared_preferences_fde 下,可以看到只有macos的目录.
所以开始新建linux平台上的插件:

创建目录及文件

借助已经有 url_launcher_fde

mkdir -p plugins/flutter_plugins/shared_preferences_fde/linux && cd plugins/flutter_plugins/shared_preferences_fde/linux
cp ../../url_launcher_fde/linux/Makefile .
cp ../../url_launcher_fde/linux/url_launcher_fde_plugin.{cc,h} .

插件命名

将Makefile中的 url_launcher_fde_plugin 改成 shared_preferences_fde_plugin , 这是编译插件所需要的Makefile, 只需改这一个名称即可.
本地cpp文件改成 shared_preferences_fde_plugin.{cc,h} , 同时类名和宏也改成相应的名称, 最好用 sed 搜索一起替换

FLUTTER_PLUGIN_EXPORT void SharedPreferencesRegisterWithRegistrar(
    FlutterDesktopPluginRegistrarRef registrar);

class SharedPreferencesPlugin : public flutter::Plugin {
  virtual ~SharedPreferencesPlugin();
private:
  SharedPreferencesPlugin();
}
...

RegisterWithRegistrar 方法里有个通道注册的名称 "plugins.flutter.io/shared_preferences" , 这和异常抛出时的名称是一致的.

void SharedPreferencesPlugin::RegisterWithRegistrar(
    flutter::PluginRegistrar *registrar) {
  auto channel = std::make_unique<flutter::MethodChannel<EncodableValue>>(
      registrar->messenger(), "plugins.flutter.io/shared_preferences",
      &flutter::StandardMethodCodec::GetInstance());
}

另外需要专门说一下 SharedPreferencesPlugin::HandleMethodCall 这个方法

void SharedPreferencesPlugin::HandleMethodCall(
    const flutter::MethodCall<EncodableValue> &method_call,
    std::unique_ptr<flutter::MethodResult<EncodableValue>> result) {
}

method_call 是方法调用结构体, 包含dart层传过来的名称参数等信息,以引用的类型传入; result 是方法结果结构体, 包含需要传回给dart层的返回值及操作结果标识(标识这个调用是否成功), 以指针类型传入.

dart和c++两种语言的数据类型完全不一样, 是怎么相互传递的? 这就用到了一个很重要的数据结构 flutter::EncodableValue , EncodableValue 在c++层抽象了dart层的数据类型, 一个实例可以作为bool, int, String, map, 与list:

EncodableValue b(true); // 作为bool的EncodableValue
EncodableValue v(32); //作为int的EncodableValue
EncodableValue ret(EncodableValue::Type::kMap); //作为map的EncodableValue
EncodableMap& map = ret.MapValue(); // 操作起来必须先转成EncodableMap类型
std::string key = "some_key";
map[EncodableValue(key)] = v; // EncodableMap的K/V也必须是EncodableValue

flutter引擎最后完成到dart类型的最终对应.

插件依赖shared_preference的dart包, 所以需要看 $FLUTTER_SDK/.pub-cache/hosted/$PUB_HOST/shared_preferences-0.5.3+4/lib/shared_preferences.dart 传递和需要哪些数据.
初始化用到的方法名是’getAll’, 需要返回已经存储的所有键值对, 可以先实现一个空方法来通过编译环节:

void SharedPreferencesPlugin::HandleMethodCall(
    const flutter::MethodCall<EncodableValue> &method_call,
    std::unique_ptr<flutter::MethodResult<EncodableValue>> result) {
  const auto methodName = method_call.method_name();
  if (methodName.compare("getAll") == 0) {
    result->Error("no result", "but great~!");
  } else {
    result->NotImplemented();
  }
}

关联插件

生成插件

在构建应用的时候把插件也编译进来,所以需要改造Makefile(注意是应用的Makefile, 不是插件的),如果是windows得改sln文件, 总之就是得关联上, 改造后的 example/linux/Makefile 如下:

# Executable name.
BINARY_NAME=flutter_desktop_example
# The C++ code for the embedder application.
SOURCES=flutter_embedder_example.cc

FLUTTER_PLUGIN_NAMES=shared_preferences_fde

# Default build type. For a release build, set BUILD=release.
# Currently this only sets NDEBUG, which is used to control the flags passed
# to the Flutter engine in the example shell, and not the complation settings
# (e.g., optimization level) of the C++ code.
BUILD=debug

# Configuration provided via flutter tool.
include flutter/generated_config

# Dependency locations
FLUTTER_APP_CACHE_DIR=flutter
FLUTTER_APP_DIR=$(CURDIR)/..
FLUTTER_APP_BUILD_DIR=$(FLUTTER_APP_DIR)/build
PLUGINS_DIR=$(CURDIR)/../../plugins
FLUTTER_PLUGINS_DIR=$(PLUGINS_DIR)/flutter_plugins

OUT_DIR=$(FLUTTER_APP_BUILD_DIR)/linux

# Libraries
FLUTTER_LIB_NAME=flutter_linux
FLUTTER_LIB=$(FLUTTER_APP_CACHE_DIR)/lib$(FLUTTER_LIB_NAME).so

PLUGIN_LIB_NAMES=$(foreach plugin,$(PLUGIN_NAMES) $(FLUTTER_PLUGIN_NAMES),$(plugin)_plugin)
PLUGIN_LIBS=$(foreach plugin,$(PLUGIN_LIB_NAMES),$(OUT_DIR)/lib$(plugin).so)
ALL_LIBS=$(FLUTTER_LIB) $(PLUGIN_LIBS)

# Tools
FLUTTER_BIN=$(FLUTTER_ROOT)/bin/flutter
LINUX_BUILD=$(FLUTTER_ROOT)/packages/flutter_tools/bin/linux_backend.sh

# Resources
ICU_DATA_NAME=icudtl.dat
ICU_DATA_SOURCE=$(FLUTTER_APP_CACHE_DIR)/$(ICU_DATA_NAME)
FLUTTER_ASSETS_NAME=flutter_assets
FLUTTER_ASSETS_SOURCE=$(FLUTTER_APP_BUILD_DIR)/$(FLUTTER_ASSETS_NAME)

# Bundle structure
BUNDLE_OUT_DIR=$(OUT_DIR)/$(BUILD)
BUNDLE_DATA_DIR=$(BUNDLE_OUT_DIR)/data
BUNDLE_LIB_DIR=$(BUNDLE_OUT_DIR)/lib

BIN_OUT=$(BUNDLE_OUT_DIR)/$(BINARY_NAME)
ICU_DATA_OUT=$(BUNDLE_DATA_DIR)/$(ICU_DATA_NAME)
FLUTTER_LIB_OUT=$(BUNDLE_LIB_DIR)/$(notdir $(FLUTTER_LIB))
ALL_LIBS_OUT=$(foreach lib,$(ALL_LIBS),$(BUNDLE_LIB_DIR)/$(notdir $(lib)))

# Add relevant code from the wrapper library, which is intended to be statically
# built into the client.
WRAPPER_ROOT=$(FLUTTER_APP_CACHE_DIR)/cpp_client_wrapper
WRAPPER_SOURCES= \
    $(WRAPPER_ROOT)/flutter_window_controller.cc \
    $(WRAPPER_ROOT)/plugin_registrar.cc \
    $(WRAPPER_ROOT)/engine_method_result.cc
SOURCES+=$(WRAPPER_SOURCES)

# Headers
WRAPPER_INCLUDE_DIR=$(WRAPPER_ROOT)/include
PLUGIN_INCLUDE_DIRS=$(OUT_DIR)/include
INCLUDE_DIRS=$(FLUTTER_APP_CACHE_DIR) $(WRAPPER_INCLUDE_DIR) $(PLUGIN_INCLUDE_DIRS)

# Build settings
CXX=clang++
CXXFLAGS.release=-DNDEBUG
CXXFLAGS=-std=c++14 -Wall -Werror $(CXXFLAGS.$(BUILD))
CPPFLAGS=$(patsubst %,-I%,$(INCLUDE_DIRS))
LDFLAGS=-L$(BUNDLE_LIB_DIR) \
    -l$(FLUTTER_LIB_NAME) \
    $(patsubst %,-l%,$(PLUGIN_LIB_NAMES)) \
    -ljsoncpp \
    -Wl,-rpath=\$ORIGIN/lib

# Targets

.PHONY: all
all: $(BIN_OUT) bundle

# This is a phony target because the flutter tool cannot describe
# its inputs and outputs yet.
.PHONY: sync
sync: flutter/generated_config
    $(FLUTTER_ROOT)/packages/flutter_tools/bin/tool_backend.sh linux-x64 $(BUILD)

.PHONY: bundle
bundle: $(ICU_DATA_OUT) $(ALL_LIBS_OUT) bundleflutterassets

$(BIN_OUT): $(SOURCES) $(ALL_LIBS_OUT)
    mkdir -p $(@D)
    $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(SOURCES) $(LDFLAGS) -o $@

$(WRAPPER_SOURCES) $(FLUTTER_LIB) $(ICU_DATA_SOURCE) $(FLUTTER_ASSETS_SOURCE): \
    | sync

$(OUT_DIR)/libshared_preferences_fde_plugin.so: | shared_preferences_fde

.PHONY: $(FLUTTER_PLUGIN_NAMES)
$(FLUTTER_PLUGIN_NAMES):
    make -C $(FLUTTER_PLUGINS_DIR)/$@/linux \
        OUT_DIR=$(OUT_DIR) FLUTTER_ROOT=$(FLUTTER_ROOT)

# Plugin library bundling pattern.
$(BUNDLE_LIB_DIR)/%: $(OUT_DIR)/%
    mkdir -p $(BUNDLE_LIB_DIR)
    cp $< $@

$(FLUTTER_LIB_OUT): $(FLUTTER_LIB)
    mkdir -p $(BUNDLE_LIB_DIR)
    cp $(FLUTTER_LIB) $(BUNDLE_LIB_DIR)

$(ICU_DATA_OUT): $(ICU_DATA_SOURCE)
    mkdir -p $(dir $(ICU_DATA_OUT))
    cp $(ICU_DATA_SOURCE) $(ICU_DATA_OUT)

# Fully re-copy the assets directory on each build to avoid having to keep a
# comprehensive list of all asset files here, which would be fragile to changes
# in the Flutter example (e.g., adding a new font to pubspec.yaml would require
# changes here).
.PHONY: bundleflutterassets
bundleflutterassets: $(FLUTTER_ASSETS_SOURCE)
    mkdir -p $(BUNDLE_DATA_DIR)
    rsync -rpu --delete $(FLUTTER_ASSETS_SOURCE) $(BUNDLE_DATA_DIR)

.PHONY: clean
clean:
    rm -rf $(OUT_DIR); \
    cd $(FLUTTER_APP_DIR); \
    $(FLUTTER_BIN) clean

diff一下容易看出来, 本质是构建应用的时候增加一个依赖( ALL_LIBS_OUT ), 这个依赖是一些.so文件, 这些.so文件根据我们给定的插件目录( FLUTTER_PLUGINS_DIR )下的插件名称( FLUTTER_PLUGIN_NAMES )在指定目录( OUT_DIR )生成.

加载插件

生成完成后需要加载, 这个加载是静态的, 也就是编译时显式的通过代码调用, 直接上 example/linux/flutter_embedder_example.cc 的diff文件

index d87734f..bbc203d 100644
@@ -21,6 +21,8 @@
 
 #include <flutter/flutter_window_controller.h>
 
+#include <shared_preferences_fde_plugin.h>
+
 namespace {
 
 // Returns the path of the directory containing this executable, or an empty
@@ -65,6 +67,9 @@ int main(int argc, char **argv) {
     return EXIT_FAILURE;
   }
 
+  SharedPreferencesRegisterWithRegistrar(
+      flutter_controller.GetRegistrarForPlugin("SharedPreferences"));
+
   // Run until the window is closed.
   flutter_controller.RunEventLoop();
   return EXIT_SUCCESS;

这样就可以运行 flutter run 了, 结果虽然还是有异常, 但错误信息应该是我们写死的’“no result”, “but great~!” '表明方法成功调用了~
注意 这期间编译的时候最好是先把example/build目录删除, 这样生成的是最新的中间文件, 否则由于缓存旧的生成文件可能导致一些运行时异常退出的诡异问题.

插件实现

最后一步自然是我们要如何在平台层实现 SharedPreferences 的key/value存储功能. 因为已经有了 shared_preferences dart包, 所以实现其对应的接口就好.

名称 用途
getAll 初始化时返回所有k/v
commit 将改动保存
clear 清除所有k/v
remove 移除某项
setBool 存bool
setInt 存int

搜索了一圈发现linux竟然没有广泛使用的K/V存储库! 大概桌面的应用长久以来数据一般都直接存文件.

后来看到flutter-go项目实现 SharedPreferences 用的是levelDB, 于是也就欣然用之, 结果发现很不好用! levelDB的Key/Value可以是任意长度的字节数组, 强大是强大, 可用在这里却不合适, 因为取数据的时候丢失了类型信息, 无法知道key对应的这个value到底是int还是bool, 除非在存数据时候再设计出类型存储的格式. 这太麻烦了.

想到shared_preference在android底层也是个xml文件, 而且需要知道类型, 同时当前也不用太考虑性能问题, 那直接以json存不就完了吗?! 于是很快找到jsoncpp这个库, 容易上手,直接操作文件, 而且读取之后能够知道数据的类型信息, 完美!
'getAll’方法如下:

  if (methodName.compare("getAll") == 0) {
    std::ifstream infile;
    infile.open(kSavedFileName, std::ios::in);

    try {
      infile >> _root;
    } catch (std::exception& e) {
      _root = Json::objectValue;
    }
    infile.close();

    EncodableValue ret(EncodableValue::Type::kMap);
    EncodableMap& map = ret.MapValue();

    for (auto i = _root.begin(); i != _root.end(); i++) {
      Json::Value& obj = *i;
      const std::string key = i.name();
      map[EncodableValue(key)] = adaptJsonValue(obj);
    }

    result->Success(&ret);
  } else if (methodName.find("remove") == 0) {

adaptJsonValue 方法只是把jsoncpp的类型转成flutter对应的类型
更多 Json::Value 用法见之前写的指南

static EncodableValue adaptJsonValue(const Json::Value& value) {
  switch (value.type()) {
    case Json::nullValue: {
      return EncodableValue(EncodableValue::Type::kNull);
    }
    case Json::booleanValue: {
      bool v = value.asBool();
      return EncodableValue(v);
    }
    case Json::uintValue:
    case Json::intValue: {
      int v = value.asInt();
      return EncodableValue(v);
    }
    case Json::realValue: {
      double v = value.asDouble();
      return EncodableValue(v);
    }
    case Json::arrayValue: {
      EncodableValue ev(EncodableValue::Type::kList);
      flutter::EncodableList& v = ev.ListValue();
      Json::Value def;
      for (Json::ArrayIndex i = 0; i < value.size(); ++i) {
        v.push_back(adaptJsonValue(value.get(i, def)));
      }
      return ev;
    }
    case Json::objectValue: {
      return EncodableValue();
    }
    case Json::stringValue:
    default: {
      const char* v = value.asCString();
      return EncodableValue(v);
    }
  }
}

最终在flutter项目中dart层的代码再验证一下就OK了.