1. 程式人生 > >Flutter 外掛編寫必知必會

Flutter 外掛編寫必知必會

本文目的

  • 介紹包和外掛的概念
  • 介紹 flutter 呼叫平臺特定程式碼的機制:Platform Channels,和相關類的常用方法
  • 介紹外掛開發流程和示例
  • 介紹優化外掛的方法:新增文件,合理設定版本號,新增單元測試,新增持續整合
  • 介紹釋出外掛的流程和常見問題

目錄結構

  • 編寫之前
  • Platform Channels
  • 外掛開發
  • 優化外掛
  • 釋出外掛
  • 總結

編寫之前

包(packages)的概念

packages 將程式碼內聚到一個模組中,可以用來分享程式碼。一個 package 最少要包括:

  • 一個 pubspec.yaml
    檔案:它定義了包的很多元資料,比如包名,版本,作者等
  • 一個 lib 資料夾,包含了包中的 public 程式碼,一個包裡至少會有一個 <package-name>.dart 檔案

packages 根據內容和作用大致分為2類:

  • Dart packages :程式碼都是用 Dart 寫的
  • Plugin packages :一種特殊的 Dart package,它包括 Dart 編寫的 API ,加上平臺特定程式碼,如 Android (用Java/Kotlin), iOS (用ObjC/Swift)

編寫平臺特定程式碼可以寫在一個 App 裡,也可以寫在 package

裡,也就是本文的主題 plugin 。變成 plugin 的好處是便於分享和複用(通過 pubspec.yml 中新增依賴)。

Platform Channels

Flutter提供了一套靈活的訊息傳遞機制來實現 Dart 和 platform-specific code 之間的通訊。這個通訊機制叫做 Platform Channels

  • Native Platform 是 host ,Flutter 部分是 client
  • hostclient 都可以監聽這個 platform channels 來收發訊息

Platofrm Channel架構圖

Architectural overview: platform channels

常用類和主要方法

Flutter 側

MethodChannel

Future invokeMethod (String method, [dynamic arguments]); // 呼叫方法
void setMethodCallHandler (Future handler(MethodCall call)); //給當前channel設定一個method call的處理器,它會替換之前設定的handler
void setMockMethodCallHandler (Future handler(MethodCall call)); // 用於mock,功能類似上面的方法
複製程式碼

Android 側

MethodChannel

void invokeMethod(String method, Object arguments) // 同dart
void invokeMethod(String method, Object arguments, MethodChannel.Result callback) // callback用來處理Flutter側的結果,可以為null,
void setMethodCallHandler(MethodChannel.MethodCallHandler handler) // 同dart
複製程式碼

MethodChannel.Result

void error(String errorCode, String errorMessage, Object errorDetails) // 異常回調方法
void notImplemented() // 未實現的回撥
void success(Object result) // 成功的回撥
複製程式碼

PluginRegistry

Context	context() // 獲取Application的Context
Activity activity() // 返回外掛註冊所在的Activity
PluginRegistry.Registrar addActivityResultListener(PluginRegistry.ActivityResultListener listener) // 新增Activityresult監聽
PluginRegistry.Registrar addRequestPermissionsResultListener(PluginRegistry.RequestPermissionsResultListener listener) // 新增RequestPermissionResult監聽
BinaryMessenger	messenger() // 返回一個BinaryMessenger,用於外掛與Dart側通訊
複製程式碼

iOS 側

FlutterMethodChannel

- (void)invokeMethod:(nonnull NSString *)method arguments:(id _Nullable)arguments;

// result:一個回撥,如果Dart側失敗,則回撥引數為FlutterError型別;
// 如果Dart側沒有實現此方法,則回撥引數為FlutterMethodNotImplemented型別;
// 如果回撥引數為nil獲取其它型別,表示Dart執行成功
- (void)invokeMethod:(nonnull NSString *)method arguments:(id _Nullable)arguments result:(FlutterResult _Nullable)callback; 

- (void)setMethodCallHandler:(FlutterMethodCallHandler _Nullable)handler;
複製程式碼

Platform Channel 所支援的型別

標準的 Platform Channels 使用StandardMessageCodec,將一些簡單的資料型別,高效地序列化成二進位制和反序列化。序列化和反序列化在收/發資料時自動完成,呼叫者無需關心。

type support

外掛開發

建立 package

在命令列輸入以下命令,從 plugin 模板中建立新包

flutter create --org com.example --template=plugin hello # 預設Android用Java,iOS用Object-C
flutter create --org com.example --template=plugin -i swift -a kotlin hello
 # 指定Android用Kotlin,iOS用Swift
複製程式碼

實現 package

下面以install_plugin為例,介紹開發流程

1.定義包的 API(.dart)

class InstallPlugin {
  static const MethodChannel _channel = const MethodChannel('install_plugin');

  static Future<String> installApk(String filePath, String appId) async {
    Map<String, String> params = {'filePath': filePath, 'appId': appId};
    return await _channel.invokeMethod('installApk', params);
  }

  static Future<String> gotoAppStore(String urlString) async {
    Map<String, String> params = {'urlString': urlString};
    return await _channel.invokeMethod('gotoAppStore', params);
  }
}
複製程式碼

2.新增 Android 平臺程式碼(.java/.kt)

  • 首先確保包中 example 的 Android 專案能夠 build 通過
cd hello/example
flutter build apk
複製程式碼
  • 在 AndroidStudio 中選擇選單欄 File > New > Import Project… , 並選擇 hello/example/android/build.gradle 匯入
  • 等待 Gradle sync
  • 執行 example app
  • 找到 Android 平臺程式碼待實現類
    • java:./android/src/main/java/com/hello/hello/InstallPlugin.java
    • kotlin:./android/src/main/kotlin/com/zaihui/hello/InstallPlugin.kt
    class InstallPlugin(private val registrar: Registrar) : MethodCallHandler {
    
        companion object {
        
            @JvmStatic
            fun registerWith(registrar: Registrar): Unit { 
                val channel = MethodChannel(registrar.messenger(), "install_plugin")
                val installPlugin = InstallPlugin(registrar)
                channel.setMethodCallHandler(installPlugin)
                // registrar 裡定義了addActivityResultListener,能獲取到Acitvity結束後的返回值
                registrar.addActivityResultListener { requestCode, resultCode, intent ->
                    ...
                }
            }
        }
    
        override fun onMethodCall(call: MethodCall, result: Result) {
            when (call.method) {
                "installApk" -> {
                    // 獲取引數
                    val filePath = call.argument<String>("filePath")
                    val appId = call.argument<String>("appId")
                    try {
                        installApk(filePath, appId)
                        result.success("Success")
                    } catch (e: Throwable) {
                        result.error(e.javaClass.simpleName, e.message, null)
                    }
                }
                else -> result.notImplemented()
            }
        }
    
        private fun installApk(filePath: String?, appId: String?) {...}
    }
    複製程式碼

3.新增iOS平臺程式碼(.h+.m/.swift)

  • 首先確保包中 example 的 iOS 專案能夠 build 通過
cd hello/exmaple
flutter build ios --no-codesign
複製程式碼
  • 開啟Xcode,選擇 File > Open, 並選擇 hello/example/ios/Runner.xcworkspace
  • 找到 iOS 平臺程式碼待實現類
    • Object-C:/ios/Classes/HelloPlugin.m
    • Swift:/ios/Classes/SwiftInstallPlugin.swift
    import Flutter
    import UIKit
        
    public class SwiftInstallPlugin: NSObject, FlutterPlugin {
        public static func register(with registrar: FlutterPluginRegistrar) {
            let channel = FlutterMethodChannel(name: "install_plugin", binaryMessenger: registrar.messenger())
            let instance = SwiftInstallPlugin()
            registrar.addMethodCallDelegate(instance, channel: channel)
        }
    
        public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
            switch call.method {
            case "gotoAppStore":
                guard let urlString = (call.arguments as? Dictionary<String, Any>)?["urlString"] as? String else {
                    result(FlutterError(code: "引數異常", message: "引數url不能為空", details: nil))
                    return
                }
                gotoAppStore(urlString: urlString)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
        func gotoAppStore(urlString: String) {...}
    }
    複製程式碼

4. 在 example 中呼叫包裡的 dart API

5. 執行 example 並測試平臺功能

優化外掛

外掛的意義在於複用和分享,開源的意義在於分享和迭代。外掛的開發者都希望自己的外掛能變得popular。外掛釋出到pub.dartlang後,會根據 Popularity ,Health, Maintenance 進行打分,其中 Maintenance 就會看 README, CHANGELOG, 和 example 是否添加了內容。

新增文件

1. README.md

2. CHANGELOG.md

  • 關於寫 ChangeLog 的意義和規則:推薦一個網站keepachangelog,和它的專案的[changelog]((github.com/olivierlaca…)作為範本。
    keepachangelog principle and types
  • 如何高效的寫 ChangeLog ?github 上有不少工具能減少寫 changeLog 工作量,推薦一個github-changelog-generator,目前僅對 github 平臺有效,能夠基於 tags, issues, merged pull requests,自動生成changelog 檔案。

3. LICENSE

比如 MIT License,要把[yyyy] [name of copyright owner]替換為年份+所有者,多個所有者就寫多行。

license-ownner-year

4. 給所有public的API新增 documentation

合理設定版本號

在姊妹篇Flutter 外掛使用必知必會中已經提到了語義化版本的概念,作為外掛開發者也要遵守

版本格式:主版本號.次版本號.修訂號,版本號遞增規則如下:

  • 主版本號:當你做了不相容的 API 修改,
  • 次版本號:當你做了向下相容的功能性新增,
  • 修訂號:當你做了向下相容的問題修正。

編寫單元測試

plugin的單元測試主要是測試 dart 中程式碼的邏輯,也可以用來檢查函式名稱,引數名稱與 API定義的是否一致。如果想測試 platform-specify 程式碼,更多依賴於 example 的用例,或者寫平臺的測試程式碼。

因為InstallPlugin.dart的邏輯很簡單,所以這裡只驗證驗證方法名和引數名。用setMockMethodCallHandler mock 並獲取 MethodCall,在 test 中用isMethodCall驗證方法名和引數名是否正確。

void main() {
  const MethodChannel channel = MethodChannel('install_plugin');
  final List<MethodCall> log = <MethodCall>[];
  String response; // 返回值

  // 設定mock的方法處理器
  channel.setMockMethodCallHandler((MethodCall methodCall) async {
    log.add(methodCall);
    return response; // mock返回值
  });

  tearDown(() {
    log.clear();
  });


  test('installApk test', () async {
    response = 'Success';
    final fakePath = 'fake.apk';
    final fakeAppId = 'com.example.install';
    final String result = await InstallPlugin.installApk(fakePath, fakeAppId);
    expect(
      log,
      <Matcher>[isMethodCall('installApk', arguments: {'filePath': fakePath, 'appId': fakeAppId})],
    );
    expect(result, response);
  });
}
複製程式碼

新增CI

持續整合(Continuous integration,縮寫CI),通過自動化和指令碼來驗證新的變動是否會產生不利影響,比如導致建構失敗,單元測試break,因此能幫助開發者儘早發現問題,減少維護成本。對於開源社群來說 CI 尤為重要,因為開源專案一般不會有直接收入,來自 contributor 的程式碼質量也良莠不齊。

我這裡用 Travis 來做CI,入門請看這裡travis get stated

在專案根目錄新增 .travis.yml 檔案

os:
  - linux
sudo: false
addons:
  apt:
    sources:
      - ubuntu-toolchain-r-test # if we don't specify this, the libstdc++6 we get is the wrong version
    packages:
      - libstdc++6
      - fonts-droid
before_script:
  - git clone https://github.com/flutter/flutter.git -b stable --depth 1
  - ./flutter/bin/flutter doctor
script:
  - ./flutter/bin/flutter test # 跑專案根目錄下的test資料夾中的測試程式碼
cache:
  directories:
    - $HOME/.pub-cache
複製程式碼

這樣當你要提 PR 或者對分支做了改動,就會觸發 travis 中的任務。還可以把 build 的小綠標新增到 README.md 中哦,注意替換路徑和分支。

[![Build Status](https://travis-ci.org/hui-z/flutter_install_plugin.svg?branch=master)](https://travis-ci.org/hui-z/flutter_install_plugin#)

複製程式碼

travis ci

釋出外掛

1. 檢查程式碼

$ flutter packages pub publish --dry-run
複製程式碼

會提示你專案作者(格式為authar_name <[email protected]>,保留尖括號),主頁,版本等資訊是否補全,程式碼是否存在 warnning(會檢測說 test 裡有多餘的 import,實際不是多餘的,可以不理會)等。

2. 釋出

$ flutter packages pub publish
複製程式碼

如果釋出失敗,可以在上面命令後加-v,會列出詳細釋出過程,確定失敗在哪個步驟,也可以看看issue上的解決辦法。

常見問題

  • Flutter 安裝路徑缺少許可權,導致釋出失敗,參考
sudo flutter packages pub publish -v
複製程式碼
  • 如何新增多個 uploader?參考
 pub uploader add [email protected]
 pub uploader remove [email protected] # 如果只有一個uploader,將無法移除
複製程式碼

去掉官方指引裡面對PUB_HOSTED_URL、FLUTTER_STORAGE_BASE_URL的修改,這些修改會導致上傳pub失敗。

總結

本文介紹了一下外掛編寫必知的概念和編寫的基本流程,並配了個簡單的例子(原始碼)。希望大家以後不再為Flutter缺少native功能而頭疼,可以自己動手豐衣足食,順便還能為開源做一點微薄的貢獻!

參考