flutter: SharedPreferences桌面外掛

林鹿發表於2019-10-03

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_preferencesdart包, 所以實現其對應的介面就好.

名稱 用途
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了.

相關文章