首頁 > 軟體

Flutter應用Windows平臺接入實踐詳解

2023-09-12 18:03:49

前言

Windows應用開發有著較為豐富和多樣的技術選型。C#/WPF 這種偏Native的閉源方案,目前開發人員相對比較小眾了。C++/QT 的跨平臺框架,C++對於GUI開發來說上手會更難。JavaScript/CEF/Electron 基於Chromium 的跨端框架,使用前端技術棧來構建桌面應用,效能會略低一些。總而言之各有所長,有一點可以確定的是,跨端能力成為了選型的重要考量。

Flutter從誕生之初起,其核心目標就是跨平臺,不僅僅支援Android和iOS的行動端裝置,同時包括桌面端和Web端。隨著2022年2月Flutter 2.10的推出,也帶來了首個支援Windows平臺的穩定版本。基於Flutter的跨平臺特性,行動端或Web端的Flutter應用也能夠在Windows系統上執行,Windows應用開發者能夠享受到Flutter開發帶來的便利和生產力上的提升,同時行動端開發者也能夠快速上手Windows應用開發了。

Windows平臺接入

在進一步探索和預演之後,通過Flutter的能力,可以很方便地將行動端的業務模組遷移至PC端,儘可能地實現一碼多端,降低業務維護成本,以此為出發點,進行了Windows平臺的接入。

閒魚App已經在Android和iOS平臺上有了多年的積累,並且採用了Native和Flutter混合的技術方案,Flutter和Native相輔相成,共同組成了App的完整生態。如果想要讓Flutter相關的模組在Windows平臺上執行,那就需要讓Windows平臺補齊Android和iOS平臺提供給Flutter的能力。比如通過Platform Channel提供給Flutter側相關的Native能力,通過Platform View將Native檢視嵌入到Flutter頁面中,都需要在Windows平臺上進行重新開發。

Windows平臺通過Plugin或FFI的方式提供相關能力,需要使用C++編寫相關的平臺程式碼。如果Plugin的程式碼可以自閉環,即所有C++程式碼都可以在Plugin內編寫完成,那這個Plugin可以單獨抽成一個Dart庫。但是如果Plugin的程式碼需要複用其他Plugin或者主工程的C++程式碼,粗暴一點就是拷貝程式碼,或者通過CMakeLists來控制相互之間的依賴關係,通過find_package來完成標頭檔案和庫檔案的連結。一旦依賴關係比較複雜,CMakeLists就會變得臃腫,依賴關係發生變化時,也會牽一髮而動全身。隨著系統複雜度的提升,開發人員的增加,模組之間相互耦合在一起,單一模組的修改都會影響到所有模組。

針對上述的問題,對於底層的模組化設計,梳理了需要遵循的設計原則:

  • 單一職責原則:一個模組維護一個單一的主要功能,劃清模組間的職責邊界;
  • 開閉原則:模組應該對擴充套件開放,對修改關閉。用抽象構建框架,用實現填充細節,通過擴充套件實體來實現變化,避免修改程式碼來實現擴充套件。
  • 迪米特法則:最少知道原則,對依賴的模組知道的越少越好,模組除了對外暴露的方法,其他實現細節都隱藏在內部。
  • 介面隔離原則:只依賴需要的介面,模組之間提供最小的介面實現依賴關係。
  • 依賴倒置原則:依賴抽象,不依賴具體細節,模組之間需要依賴抽象的架構,而非具體的模組細節。

首先基於上述的設計原則,制定了模組化拆解的XModule方案,依據職責來劃分模組,設計對外暴露的抽象介面,抽象介面保持最小化原則,完成介面實現,編譯出模組的動態連結庫DLL,依賴到主工程並放置到特定目錄,執行時通過外掛機制進行動態載入。

其次針對模組化帶來的依賴管理複雜的問題,引入了vcpkg的依賴管理方案,通過清單模式便捷地管理各個模組,可以自動引入間接依賴,並且版本衝突問題也不復存在了。
結合XModule和vcpkg之後,最終形成了下面的結構,後面將詳細展開。

模組化拆解XModule

上述是一個登入模組的例子,Module 作為基礎類別,定義了模組的一些生命週期方法。LoginModule是對外公開的業務介面,裡面僅包含外部會用到的和登入業務相關的方法。LoginModuleImplV1類是登入邏輯的具體實現,不對外公開,裡面的私有成員變數和方法對外部是隱藏的,同時實現了Module和LoginModule的介面。Provider用於建立和管理Module範例。

這裡採用的思路是,底層模組和模組之間,上層和底層之間只依賴介面標頭檔案,標頭檔案內包含有限的需要對外暴露的介面。通過XModule這個框架,將實現和介面進行分離。

為了將介面和實現分離,用到了 pimpl (Pointer to Implementation) 的理念,將物件的實現細節隱藏在指標背後。LoginModule介面負責定義對外公開的API,LoginModuleImplV1類負責定義LoginModule的具體實現,也就是呼叫的指標實際指向的物件。呼叫方只能知道LoginModule中公開的API,而無法知道LoginModuleImplV1的實現細節,可以降低呼叫方的使用門檻,也可以降低錯誤使用的可能性。pimpl不僅解除了介面和實現之間的耦合關係,還可以降低檔案間的編譯依賴關係,起到“編譯防火牆”的作用,可以提高一定的編譯效率。

// LoginModuleProvider 通過宏自動生成
X_MODULE_PROVIDER_DEFINE_SINGLE(LoginModule, MIN_VERSION, MAX_VERSION);
// LoginModuleImplV1Provider 通過宏自動生成
X_MODULE_DEFINE_SECONDARY_PROVIDER(LoginModuleImplV1, LoginModule);

XModule的模版開發方式,會增加很多類檔案,為了方便,通過宏來控制Provider類的自動生成。其中MIN_VERSION和MAX_VERSION是該Module介面能支援的最小和最大的版本範圍,可以限制後期dll外掛化載入時,不載入在版本之外的dll,避免產生衝突和錯誤,目前Provider的GetVersion使用的是MAX_VERSION。

// 由 X_MODULE_DEFINE_SECONDARY_PROVIDER 宏自動生成
class DLLEXPORT LoginModuleImplV1Provider : public LoginModuleProvider {
   public:
    LoginModule* Create() const {
      LoginModuleImplV1* p = new LoginModuleImplV1();
      ((Module*)p)->OnCreate();
      return p;
    } 
  };

LoginModuleImplV1Provider可以通過呼叫Create方法拿到對應的LoginModuleImplV1範例。

x_module::ModuleCenter* module_center = x_module::ModuleCenter::GetInstance();
module_center->AcceptProviderType<LoginModuleProvider>();

ModuleCenter是所有Module的管理類,先通過x_module::ModuleCenter::GetInstance()拿到ModuleCenter的範例,它是一個跨dll的單例。然後要用之前的LoginModuleProvider去註冊一個Module型別到ModuleCenter中。LoginModuleProvider中定義了支援的Module型別,以及最小版本和最大版本,如果後續掃描到的dll中提供的對應型別的Provider中GetVersion返回的值不在最大版本和最小版本之間,那麼就不會被允許載入進來。

module_center->AddProvider(new LoginModuleImplV1Provider());

通過這種方式,可以將LoginModuleImplV1Provider註冊到ModuleCenter中,然後建立並管理LoginModuleImplV1的範例。但是這樣就顯式地依賴了LoginModuleImplV1Provider,違反了前面說過的依賴倒置原則,對開閉原則也不友好,因為這樣就只能通過修改程式碼來實現擴充套件了。

#include <x_module/connector.h>
#include "login_module/login_module_impl.h"
X_MODULE_CONNECTOR
bool XModuleConnect(x_module::Owner& owner) {
    owner.add(new LoginModuleImplV1Provider());
    return true;
}

為了在載入dll時,來註冊Provider,增加了一個connector.cc,新增一個XModuleConnect方法,讓dll被載入之後,能夠找到XModuleConnect這個符號方法,並進行呼叫,在XModuleConnect被呼叫的時候,會呼叫AddProvider將Provider進行註冊。

std::string path = GetProgramDir();
module_center->Install(path, "login_module");

由於目前login_module.dll是直接放在exe同目錄的,所以這裡直接獲取了一下exe絕對路徑,然後呼叫Install方法,將路徑和dll名login_module傳入進去,這樣就完成了註冊。

auto* p_login_module = module_center->ModuleFromProtocol<LoginModule, LoginModuleProvider>();
if (p_login_module == nullptr) {
    (*move_result)->Error("-100", "login module 為空");
    return;
}
bool islogin = p_login_module->IsLogin();

在使用時,只需要LoginModule和LoginModuleProvider這兩個抽象,就能獲取真實的LoginModuleImplV1這個範例,呼叫方僅需關心LoginModule所公開的API,完全螢幕蔽了對實現的依賴。後續底層擴充套件成了LoginModuleImplV2,只要LoginModule的公開API不變,對上層是無感知的。這種方式完全遵循了前面提到的設計原則,對團隊內的多人維護以及後續的更新迭代都帶來了穩定的保障。

基於vcpkg的C++依賴管理

模組拆分之後,帶來的副作用就是依賴管理會變得更加複雜,到C++這邊就是CMakeLists的膨脹。從行動端的角度來看這個問題,Android可以通過Gradle來管理依賴,依賴庫構建成aar之後上傳到Maven倉庫,implementation 'androidx.recyclerview:recyclerview:1.1.0'像這樣通過包名、庫名和版本號來依賴具體的庫。iOS有CocoaPods,通過新增pod 'AFNetworking', '~> 2.6'到Podfile來完成依賴的新增。前端也有NPM這樣的包管理器,所有依賴都在package.json這個檔案中宣告和管理。Flutter側也可以通過pubspec來管理各個依賴庫。為了獲得一致的體驗,解決C++側依賴管理的痛點,我們引入了微軟官方推出的vcpkg,vcpkg的清單模式可以得到類似的體驗。

依賴庫設定

這裡以fish-ffi-module模組為例子,檔案結構如下,其中include檔案裡面是對外公開的標頭檔案,src檔案包含當前庫內部使用的程式碼,cmake檔案下的config.cmake.in模版檔案用於生成xxx-config.cmake的檔案,用於被find_package找到。

.
├── CMakeLists.txt
├── LICENSE
├── cmake
│   └── config.cmake.in
├── include
│   └── fish_ffi_module.h
├── src
│   ├── connector.cc
│   ├── fish_ffi_module_impl_v1.cc
│   └── fish_ffi_module_impl_v1.h
├── vcpkg-configuration.json
└── vcpkg.json

vcpkg-configuration.json設定了私有源,後面會講到。vcpkg.json檔案,宣告了當前庫所依賴的其他庫,即vcpkg的依賴清單,其中"dependencies"欄位宣告了所使用的依賴名稱。

{
	"name": "fish-ffi-module",
	"version": "1.0.0",
	"description": "A fish-ffi module based on fish-ffi-sdk.",
	"homepage": "",
  "dependencies": [
    "fish-ffi-sdk",
    "x-module",
    "flutter-sdk"
  ]
}

CMake工程最重要的就是CMakeLists檔案了,裡面設定了編譯相關的設定,新增了相關的註釋來幫助理解。

cmake_minimum_required(VERSION 3.15)
# 倉庫版本常數,升級時修改
set(FISH_FFI_MODULE_VERSION "1.0.0")
project(fish-ffi-module
    VERSION ${FISH_FFI_MODULE_VERSION}
    DESCRIPTION "A fish-ffi module based on fish-ffi-sdk."
		HOMEPAGE_URL ""
    LANGUAGES CXX)
option(BUILD_SHARED_LIBS "Build using shared libraries" ON)
# vcpkg清單中新增依賴之後,通過find_package就能找到
find_package(fish-ffi-sdk CONFIG REQUIRED)
find_package(flutter-sdk CONFIG REQUIRED)
find_package(x-module CONFIG REQUIRED)
# configure_package_config_file 生成config要用到
include(CMakePackageConfigHelpers)
# install 安裝要用到
include(GNUInstallDirs)
# 當前庫的標頭檔案和原始檔
aux_source_directory(include HEADER_LIST)
aux_source_directory(src SRC_LIST)
add_library(fish-ffi-module SHARED
    ${HEADER_LIST}
    ${SRC_LIST}
)
# 設定別名
add_library(fish-ffi-module::fish-ffi-module ALIAS fish-ffi-module)
# 設定動態庫匯出宏,PRIVATE為編譯時,INTERFACE為執行時
if (BUILD_SHARED_LIBS AND WIN32)
    target_compile_definitions(fish-ffi-module
        PRIVATE "FISH_FFI_MODULE_EXPORT=__declspec(dllexport)"
        INTERFACE "FISH_FFI_MODULE_EXPORT=__declspec(dllimport)")
endif ()
target_compile_features(fish-ffi-module PUBLIC cxx_std_17)
# 新增標頭檔案
target_include_directories(fish-ffi-module PUBLIC 
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include/>
    $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
# 連結庫檔案
target_link_libraries(fish-ffi-module PRIVATE fish-ffi-sdk::fish-ffi-sdk)
target_link_libraries(fish-ffi-module PRIVATE flutter-sdk::flutter-sdk)
target_link_libraries(fish-ffi-module PRIVATE x-module::x-module)
# 基於config.cmake.in的模板生成xxx-config.cmake的檔案
configure_package_config_file(
    cmake/config.cmake.in
    ${CMAKE_CURRENT_BINARY_DIR}/fish-ffi-module-config.cmake
    INSTALL_DESTINATION ${CMAKE_INSTALL_DATADIR}/fish-ffi-module
    NO_SET_AND_CHECK_MACRO)
# 生成xx-config-version.cmake檔案
write_basic_package_version_file(
    ${CMAKE_CURRENT_BINARY_DIR}/fish-ffi-module-config-version.cmake
    VERSION ${FISH_FFI_MODULE_VERSION}
    COMPATIBILITY SameMajorVersion)
# 將上面生成的兩個config檔案,安裝到share/fish-ffi-module下
install(
    FILES
        ${CMAKE_CURRENT_BINARY_DIR}/fish-ffi-module-config.cmake
        ${CMAKE_CURRENT_BINARY_DIR}/fish-ffi-module-config-version.cmake
    DESTINATION
        ${CMAKE_INSTALL_DATADIR}/fish-ffi-module)
# 安裝標頭檔案
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
# install target
install(TARGETS fish-ffi-module
    EXPORT fish-ffi-module-targets
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
# 匯出
install(EXPORT fish-ffi-module-targets
    NAMESPACE fish-ffi-module::
    DESTINATION ${CMAKE_INSTALL_DATADIR}/fish-ffi-module)

這裡面最重要的一點是設定xx-config.cmake和xx-config-version.cmake的生成,vcpkg會在原始碼首次拉下來的時候進行編譯,編譯完在相應庫的share目錄生成上述兩個檔案,並且在CMake設定階段執行,這樣在使用find_package的時候就能獲取到這個庫以及對應版本號。總結一下就是,vcpkg幫助完成了程式碼的下載、編譯和設定,然後就可以方便的連結三方庫了。

自定義私有源

私有源的自定義非常簡單,其實就是個Git倉庫,push到私有的git託管服務上即可。只需要將依賴庫的最新commit資訊記錄到這個倉庫裡面,通過模版化的設定就能完成依賴庫的釋出。

.
├── ports
│		├── fish-ffi-module
│   │   ├── portfile.cmake
│   │   └── vcpkg.json
│   └── x-module
│       ├── portfile.cmake
│       └── vcpkg.json
├── versions
│   ├── f-
│		│		└── fish-ffi-module.json
│   └── x-
│		│		└── x-module.json
│   └──baseline.json
└── LICENSE

vcpkg裡面對依賴庫的定義叫port,這裡定義了兩個port,分別是fish-ffi-module和x-module。其中的檔案說明如下:

  • portfile.cmake中定義了這個庫的git地址、分支、commitId、編譯設定等資訊
  • vcpkg.json定義了這個port的依賴以及版本資訊,如果有依賴,則會在編譯這個庫之前優先編譯依賴。
  • versions下的檔案按首字母分類,裡面定義了version和git-tree的對應關係。在port新增或更新之後,git-tree需要重新生成,通過git rev-parse HEAD:ports/x-module來生成git-tree,然後通過git commit --amend追加提交到剛剛的commit中。

在需要使用私有源的CMake工程根目錄,新增vcpkg-configuration.json,裡面內容如下。default-registry為預設源,指向官方的地址即可。registries下新增自定義的私有源,再通過指定packages,表示裡面的庫需要在這個私有源查詢。這樣就完成了私有源的設定。

{
    "default-registry": {
      "kind": "git",
      "repository": "https://github.com/microsoft/vcpkg",
      "baseline": "f4b262b259145adb2ab0116a390b08642489d32b"
    },
    "registries": [
      {
        "kind": "git",
        "repository": "xxx.git",
        "baseline": "1ad54586a5a2fadb8c44d3f8f47754e849fc5a38",
        "packages": [ "x-module",  "fish-ffi-sdk", "fish-ffi-module"]
      }
    ]
  }

在versions資料夾下還有一個baseline.json的檔案,這個檔案主要是設定基線用的,不像其他的依賴管理工具,vcpkg主要是通過這個基線來設定當前所使用的版本號的。

vcpkg可以勝任依賴管理的相關工作,綜上所述只是一個簡單使用,相比其他平臺的依賴管理工具略顯繁瑣,除此之外還有很多其他能力,需要到vcpkg.io的官方檔案裡面探索了。

總結

Flutter應用接入Windows平臺,主要遇到的問題就是Windows側的一些能力的提供,需要對齊Android和iOS的已有能力。因為使用的是C++的開發語言,對於行動端開發者並不是那麼友好,學習曲線相對會比較抖。不過一旦平臺側的能力完善之後,又可以迴歸到Flutter這個熟悉的領域了,享受Flutter開發帶來的便捷。此外Windows應用的開發不僅僅只是螢幕加大版的行動端開發,還包括不同的輸入裝置(鍵盤滑鼠)、互動習慣、樣式風格、作業系統特性等,為了更好的平臺體驗,會帶來一定的適配成本,這一塊後續也將持續投入。

以上就是Flutter應用Windows平臺接入實踐詳解的詳細內容,更多關於Flutter接入Windows平臺的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com