Flutter外掛(Plugin)開發 - Android視角

ad6623發表於2018-07-19

前言

上篇文章 Flutter如何和Native通訊-Android視角 講了Flutter app和Native通訊的機制。文末提到如果你把某個Native功能(比如藍芽,GPS什麼的)用Platform Channels包裝成了完美的Flutter API。那麼你可以用外掛(Plugin)的形式把你的API開放給Flutter開發者們使用。

Flutter裡的包分為外掛包(Plugin packages)和Dart包(Dart packages)的區別。

-- 外掛包(Plugin packages)是當你需要暴露Native API給別人的時候使用的形式,內部需要使用Platform Channels幷包含Androiod/iOS原生邏輯。

-- Dart包(Dart packages)是當你需要開發一個純Dart元件(比如一個自定義的Weidget)的時候使用的形式,內部沒有Native程式碼。

本文會簡單介紹一下怎麼從零開始開發一個包裝了Android MediaPlayer的Flutter外掛。相關程式碼可以從Github獲取。

注意,此外掛不包含iOS相關程式碼,並且只有有限的功能,僅供學習使用,切勿用於正式App開發。

需求

先上一張圖說明一下使用場景。

外掛使用例子

使用這個外掛的Flutter App可以實現一個有以下功能的低配版音樂播放器。

  • 開啟手機上的本地音樂檔案並自動開始播放。
  • 播放/暫停按鈕可以暫停或恢復播放。
  • 介面顯示當前已播放時長/總時長。
  • 介面顯示播放器狀態:就緒/播放中/已暫停/播放結束。

有了以上需求,那我們來考慮外掛需要給Flutter App提供哪些介面:

  • 開啟本地音樂:"open"
  • 播放:"start"
  • 暫停:"pause"
  • 獲取總時長:"getDuration"

上述介面都由Flutter app發起呼叫,需要MethodChannel實現。此外,外掛還需要上報播放器狀態和播放時長,上報這類事件由EventChannel實現。

需求搞清楚了,那我們就開始開發這個外掛吧。

開發

首先在Android Studio裡新建一個Flutter Plugin工程: File > New > New Flutter Project... 在彈出的對話方塊裡選擇 "Flutter Plugin"

選擇
然後一路 "Next"下去。完成後的工程結構如下:
外掛工程結構
整個工程包含4個主目錄,android和ios目錄下是對應Native程式碼。lib目錄下是外掛的Flutter端程式碼。example目錄下是個完整的Flutter App。這個App示範怎麼使用你開發的Flutter外掛。在本例中,example在手機上跑起來就是上面那個播放器的樣子。

外掛Native端

照例我們先來看看Native端怎麼做,在android目錄下,IDE會為你生成一個XXXPlugin.java的檔案。開啟開啟以後可以看到下面這樣的示例程式碼:

/** FlutterMusicPlugin */
public class FlutterMusicPlugin implements MethodCallHandler {
  /** Plugin registration. */
  public static void registerWith(Registrar registrar) {
  final FlutterMusicPlugin plugin = new FlutterMusicPlugin();
    final MethodChannel channel = new MethodChannel(registrar.messenger(), "flutter_music_plugin");
    channel.setMethodCallHandler(plugin);
  }

  @Override
  public void onMethodCall(MethodCall call, Result result) {
     // TODO implement method call handler
  }
}
複製程式碼

裡面有一個實現了MethodCallHandler的類FlutterPlugin和一個靜態函式registerWith。在這個靜態函式裡,new了一個MethodChannel,然後把FlutterPlugin的例項設定給了這個MehodChannel。換句話說,你的外掛裡的那些個MethodChannelEventChannel都是通過這個函式註冊到Host App的。這樣Flutter端在呼叫的時候才能找到對應的channel。接下來我們要做的就是重寫onMethodCall這個函式,把之前定義好的媒體播放的API在這裡做路由:

@Override
    public void onMethodCall(MethodCall call, Result result) {
        switch (call.method) {
            case "pause":
                // 暫停
                mMediaPlayer.pause();
                break;
            case "start":
                // 開始播放
                mMediaPlayer.start();
                break;
            case "open":
                //TODO 開啟本地音訊檔案
                break;
            case "getDuration":
                // 獲取音訊時長
                if (mMediaPlayer != null) {
                    result.success(mMediaPlayer.getDuration());
                } else {
                    result.error("ERROR", "no valid media player", null);
                }
                break;
            default:
                result.notImplemented();
                break;
        }
    }
複製程式碼

具體本地MediaPlayer的操作就不細說了,大家可以去看原始碼。MethodChannel就新增完了。此外我們還需要上報播放器的狀態和播放時的進度,這就需要在registerWith裡再註冊兩個EventChannel了

public static void registerWith(Registrar registrar) {
     ...
     // 上報播放器的狀態的EventChannel
    EventChannel status_channel = new EventChannel(registrar.messenger(), "flutter_music_plugin.event.status");
        status_channel.setStreamHandler(new EventChannel.StreamHandler() {
            @Override
            public void onListen(Object o, EventChannel.EventSink eventSink) {
                // 把eventSink存起來
                plugin.setStateSink(eventSink);
            }

            @Override
            public void onCancel(Object o) {

            }
        });
        //上報播放進度的EventChannel
        EventChannel position_channel = new EventChannel(registrar.messenger(), "flutter_music_plugin.event.position");
        position_channel.setStreamHandler(new EventChannel.StreamHandler() {
            @Override
            public void onListen(Object o, EventChannel.EventSink eventSink) {
                // 把eventSink存起來
                plugin.setPositionSink(eventSink);
            }

            @Override
            public void onCancel(Object o) {

            }
        });
  }
複製程式碼

註冊完以後我們就拿到了兩個EventSink,當需要的時候就可以用需要的EventSink給Flutter App上報事件了。

Native這邊還有一環是開啟本地音訊檔案的操作,這裡我偷個懶,用傳送Intent的方式來讓使用者在第三方app中選擇音訊檔案。如果是在Activity中我會用startActivityForResultonActivityResult來獲取音訊檔案,可是我們現在開發的是一個外掛,不是Activity怎麼辦?

回想一下我們用來註冊外掛的靜態函式registerWith,入參的型別是Registrar。看看它裡面都有啥?

public interface Registrar {
        //返回 Host app的Activity
        Activity activity();
        //返回 Application Context.
        Context context();
        //返回 活動Context
        Context activeContext();
        //返回 BinaryMessenger 主要用來註冊Platform channels
        BinaryMessenger messenger();
        //返回 TextureRegistry,從裡面可以拿到SurfaceTexture 
        TextureRegistry textures();
        //返回 當前Host app建立的FlutterView
        FlutterView view();
        //返回Asset對應的檔案路徑
        String lookupKeyForAsset(String var1);
        //返回Asset對應的檔案路徑
        String lookupKeyForAsset(String var1, String var2);
        //外掛對外發布的一個"值"
        PluginRegistry.Registrar publish(Object var1);
        //註冊許可權相關的回撥
        PluginRegistry.Registrar addRequestPermissionsResultListener(PluginRegistry.RequestPermissionsResultListener var1);
        //註冊ActivityResult回撥
        PluginRegistry.Registrar addActivityResultListener(PluginRegistry.ActivityResultListener var1);
        //註冊NewIntent回撥
        PluginRegistry.Registrar addNewIntentListener(PluginRegistry.NewIntentListener var1);
        //註冊UserLeaveHint回撥
        PluginRegistry.Registrar addUserLeaveHintListener(PluginRegistry.UserLeaveHintListener var1);
        //註冊View銷燬回撥
        PluginRegistry.Registrar addViewDestroyListener(PluginRegistry.ViewDestroyListener var1);
    }
複製程式碼

。。。簡直就是個寶庫啊。裡面的中文註釋我是照官方英文文件翻譯的,有些方法的用途也不太明確,有待大家的發掘。本例中目前只需要兩個方法,呼叫activity()就拿到Host App的Activity。addActivityResultListener設定處理返回結果的回撥。程式碼如下:

// 實現 PluginRegistry.ActivityResultListener
public class FlutterMusicPlugin implements MethodCallHandler, PluginRegistry.ActivityResultListener {
    ...
    private Activity mActivity;
    // 加個建構函式,入參是Activity
    private FlutterMusicPlugin(Activity activity) {
        // 存起來
        mActivity = activity;
    }
    
    public static void registerWith(Registrar registrar) {
        //傳入Activity
        final FlutterMusicPlugin plugin = new FlutterMusicPlugin(registrar.activity());
        ...
        // 註冊ActivityResult回撥
        registrar.addActivityResultListener(plugin);
    }
    
    @Override
    public void onMethodCall(MethodCall call, Result result) {
        switch (call.method) {
            ...
            case "open":
                Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
                intent.addCategory(Intent.CATEGORY_OPENABLE);
                intent.setType("audio/*");
                mActivity.startActivityForResult(intent, REQUEST_CODE_OPEN);
                break;
           ...
        }
    }
    
    @Override
    public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == REQUEST_CODE_OPEN && resultCode == RESULT_OK) {
            Uri uri = data.getData();
            if (uri != null) {
                // 拿到音訊檔案uri,開始播放。
                play(uri);
            } else {
                mStateSink.error("ERROR", "invalid media file", null);
            }
            return true;
        }
        return false;
    }
}
複製程式碼

我們改造一下FlutterMusicPlugin, 增加以Activity為入參的建構函式,在靜態函式registerWith裡例項化的時候傳入Host app的Activity。同時註冊自身來處理onActivityResult回撥。 在onMethodCall方法內"open"下啟動第三方選擇音訊檔案的頁面。當使用者選好了某首歌返回的時候,外掛這邊就會拿到音訊檔案uri,並開始播放。

至此,Native端的邏輯就完成了,我們再來看看外掛的Flutter端怎麼做。

外掛Flutter端

IDE在lib目錄下會幫你自動生成flutter_music_plugin.dart檔案,這個就是外掛的Flutter程式碼所在了,內容比較簡單,就是對我們定義好的Platform channels的包裝。直接上程式碼:

typedef void EventHandler(Object event);

class FlutterMusicPlugin {
  static const MethodChannel _channel = const MethodChannel('flutter_music_plugin');
  static const EventChannel _status_channel = const EventChannel('flutter_music_plugin.event.status');
  static const EventChannel _position_channel = const EventChannel('flutter_music_plugin.event.position');

  static Future<void> open() async {
    await _channel.invokeMethod('open');
  }

  static Future<void> pause() async {
    await _channel.invokeMethod('pause');
  }

  static Future<void> start() async {
    await _channel.invokeMethod('start');
  }

  static Future<Duration> getDuration() async {
    int duration = await _channel.invokeMethod('getDuration');
    return Duration(milliseconds: duration);
  }

  static listenStatus(EventHandler onEvent, EventHandler onError) {
    _status_channel.receiveBroadcastStream().listen(onEvent, onError: onError);
  }

  static listenPosition(EventHandler onEvent, EventHandler onError) {
  _position_channel.receiveBroadcastStream().listen(onEvent, onError: onError);
  }
}

複製程式碼

外掛Example App

除了自身的邏輯之外,一個外掛還要有示例應用來演示其API怎麼使用,同時,示例應用也是我們開發,除錯,驗證外掛的必備工具。本例中的示例可參考example目錄下的main.dart檔案。使用外掛API的主要邏輯都在State中。簡要程式碼如下

  @override
  void initState() {
    super.initState();
    // 在這裡註冊EventChannles,引數傳入響應的回撥
    FlutterMusicPlugin.listenStatus(_onPlayerStatus, _onPlayerStatusError);
    FlutterMusicPlugin.listenPosition(_onPosition, _onPlayerStatusError);
  }
  ...
  // 根據播放狀態呼叫pause或start
  void _playPause() {
    switch (_status) {
      case "started":
        FlutterMusicPlugin.pause();
        break;
      case "paused":
      case "completed":
        FlutterMusicPlugin.start();
        break;
    }
  }
  // 開啟媒體檔案
  void _open() {
    FlutterMusicPlugin.open();
  }
  // MediaPlayer出錯事件處理
  void _onPlayerStatusError(Object event) {
    print(event);
  }
  // MediaPlayer狀態改變事件處理
  void _onPlayerStatus(Object event) {
    setState(() {
      _status = event;
    });
    if (_status == "started") {
      _getDuration();
    }
  }
  // 獲取音訊時長
  void _getDuration() async {
    Duration duration = await FlutterMusicPlugin.getDuration();
    setState(() {
      _duration = duration;
    });
  }
  // 播放進度事件處理
  void _onPosition(Object event) {
    Duration position = Duration(milliseconds: event);
    setState(() {
      _position = position;
    });
  }
複製程式碼

釋出

當你的外掛開發測試完成以後,你就可以把你的外掛釋出出去了。 釋出之前,先檢查pubspec.yaml, README.mdCHANGELOG.md這幾個檔案的內容是否完整正確。然後執行下面這個命令檢查外掛是否可以釋出。

$ flutter packages pub publish --dry-run

如果有問題存在的話,會在終端輸出相關資訊,你需要據此做出修改直到返回成功。具體遇到的問題可以參考官方文件

最後去掉--dry-run以後再執行以上命令。

$ flutter packages pub publish

恭喜你,你的外掛終於釋出出去了。

外掛註冊

從前文開發外掛的過程中我們知道了在外掛Android程式碼裡有一個靜態函式registerWith,這個函式可以把外掛註冊到Host App。那麼問題來了,外掛是什麼時候註冊的呢?這個靜態函式是被誰呼叫的呢? 答案就在example app的MainActivity裡:

public class MainActivity extends FlutterActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // 外掛在這裡註冊
    GeneratedPluginRegistrant.registerWith(this);
  }
}
複製程式碼

onCreate函式裡,有這麼一行程式碼GeneratedPluginRegistrant.registerWith(this)。外掛就是在這裡註冊的。再看看GeneratedPluginRegistrant的內容就明白了:

public final class GeneratedPluginRegistrant {
  public static void registerWith(PluginRegistry registry) {
    if (alreadyRegisteredWith(registry)) {
      return;
    }
    //那個註冊的靜態函式是在這裡被呼叫的
    FlutterMusicPlugin.registerWith(registry.registrarFor("io.github.zhangjianli.fluttermusicplugin.FlutterMusicPlugin"));
  }

  private static boolean alreadyRegisteredWith(PluginRegistry registry) {
    final String key = GeneratedPluginRegistrant.class.getCanonicalName();
    if (registry.hasPlugin(key)) {
      return true;
    }
    registry.registrarFor(key);
    return false;
  }
}
複製程式碼

在第一個靜態函式裡就找到了呼叫外掛的registerWith函式的地方。這個類是IDE幫我們自動生成的。也就是說,外掛的註冊完全不需要開發者去幹預。

總結

本文通過開發一個音樂播放功能的外掛簡要介紹了Flutter外掛包的開發過程。總體來講,外掛的開發過程並不是很複雜,關鍵的問題還是在能否抹平Android和iOS平臺差異上面。另外,Flutter官方維護了一批Flutter外掛包,而且是開源的。大家感興趣的話可以學習一下官方是如何開發外掛的。

相關文章