Desktop Embedding for Flutterについて

2019年12月12日のFlutter Interactで、macOS向けがFlutterがTechnical previewからAlphaになったという発表がありました。これに伴い今までの[Desktop shells](https://github.com/flutter/flutter/wiki/Desktop-shells)に加えて[Desktop用(現在macOS専用)のページ](https://flutter.dev/desktop)が追加されました。
今後のmacOSの進捗はflutter/projectsを確認することで分かります。
> https://github.com/flutter/flutter/projects/53
> https://github.com/flutter/flutter/projects/36

ちなみにですが、Flutterに近いクロスプラットフォームのフレームワークとの比較として、12月11日にGithub starの推移の調査したところFlutterのstarの数がelectronを越えたようです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">GithubのスターのFlutterのStartの伸び方エグい <a href="https://t.co/Wb6Iu7ELop">pic.twitter.com/Wb6Iu7ELop</a></p>&mdash; mirock0606 (@mirock0606) <a href="https://twitter.com/mirock0606/status/1204592577648852993?ref_src=twsrc%5Etfw">December 11, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

#### 追記（2020/07/10）
2020/07/09にUbuntuディストリビューションなどの開発元であるCanonicalとGoogleが共同でWindows向けFlutterのAlpha版を発表しました。
[Canonical enables Linux desktop app support with Flutter](https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9)

## 概要
この記事では
- Desktop向けFlutterの現状
- Desktop向けFlutterを使う方法
- Desktopアプリに必要と思われる機能をFlutterのサンプルコードとともに紹介(※機能紹介部分はmacOS向けのみとなります)
を書きます。


## Desktop向けFlutterの現状
Desktop向けFlutterはFlutterのSDKに含まれており、Windonws/macOS/Linux上で動作するアプリケーションの開発を行うことができます。
しかし、
- macOSとLinuxがAlpha版、WindowsがTechnical Previewなため開発としては初期段階にいること
- 上記のためそもそもDesktop対応のプラグインが少ない
という強大な障壁があるため製品向けの開発に扱うのは難しいのが現状です。

実際に[flutter-desktop-embedding公式リポジトリ](https://github.com/google/flutter-desktop-embedding)を確認してみると(4ヶ月前の更新ですが)
> As explained in the link above, desktop libraries are still in early stages. The code here is not stable, nor intended for production use.
> （意訳)Desktop向けの機能はまだ開発初期段階だから、コードは安定してないし、本番環境での使用も意図してませんよ。


とあります。

また、[Destop shell](https://github.com/flutter/flutter/wiki/Desktop-shells)では、Windowsの項目で
> The Windows shell is in early stages. It is Win32-based, but we plan to explore UWP support in the future.
> (意訳)Win32 APIを使っているけど[UWP](https://docs.microsoft.com/ja-jp/windows/uwp/get-started/universal-application-platform-guide)に変更する予定だよ

Linuxの項目では(一部省略)

> The current Linux shell is a GLFW placeholder, to allow early experimentation.(....)Our current plan is to support GTK+ out of the box, in a way where adding support for other toolkits is straightforward.Expect the APIs for the final shell to be radically different from the current implementation.
> (意訳)現状はGLFW使っているけど、GTK+に変更する予定だよ。完成品は今と比べてあり得ないくらいの変更があると思うよ

一方、macOSの項目では
> This is the most mature of the desktop platforms (for various reasons, including that it's quite close to iOS, which we already support). The Objective-C API layer is largely stable at this point, so breaking changes there should be rare.
> (意訳)Objective-C APIは既にサポートしているiOSに近く、安定しているため破壊的な変更は稀だと思うよ

とあります。

別に貶しているわけではないのですが、macOS以外のDesktop版は初期段階というのが現状かと思います。

上記のページと、[flutter/engine](https://github.com/flutter/engine/tree/master/shell/platform/)のソースを元に、今のDesktop版の簡単なAPI対応を図にしました。
<img width="483" alt="スクリーンショット 2019-12-15 19.12.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/3bcaaeba-e362-6403-8bd6-9a8f7b13ceec.png">

## Desktop向けFlutterを使う方法

Flutterのバージョンは以下で行いました。

```bash
Flutter 1.13.3-pre.23 • channel master • https://github.com/flutter/flutter.git
Framework • revision c06bf6503a (2 days ago) • 2019-12-13 17:42:35 -0500
Engine • revision e0e0ac0a68
Tools • Dart 2.8.0 (build 2.8.0-dev.0.0 45db297095)
```

#### Desktop向けFlutterの設定
Desktop版のアプリを開発するには、Flutterのmaster channelに変更した上でDesktopのサポートをenableにする必要があります。Flutterのchannelの説明は[このページ](https://github.com/flutter/flutter/wiki/Flutter-build-release-channels)にあります。ちなみにmasterは、master/dev/beta/stableとあるFlutterのチャンネルの中で最新のコミットが集っています。

```bash
 flutter channel master
 flutter upgrade
 flutter config --enable-macos-desktop
 flutter config --enable-windows-desktop
 flutter config --enable-linux-desktop
```
サポートがenableになっているかは以下のコマンドで確認できます。

```bash
flutter devices
-> macOS      • macOS      • darwin-x64     • Mac OS X ... (macOSの場合)
```

#### サンプルの実行
公式ではいつものカウントアプリを作っているので、同様にやってみます。

```bash
mkdir countapp
cd countapp
flutter create .
flutter run -d macOS
```

なお、WindowsとLinuxではターゲットプラットフォームのオーバーライドが必要です。

```dart
void _setTargetPlatformForDesktop() {
  if (Platform.isLinux || Platform.isWindows) {
    debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia;
  }
}
void main() {
  _setTargetPlatformForDesktop();
  runApp.....
}
```

以下のようにDesktop上でカウントアプリを動作させることができました。flutter runコマンドでは何も設定していないのでdebug modeで動いておりますが、release modeやprofile mode(共にmacOSのみ)で動かすことも可能です。
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/051935be-193d-1e1f-0866-0d48bd227cfb.gif)

![download.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/3e8f17b5-d247-13c4-71f1-608835559628.gif)

#### Plugins
macOSであればAndroidやiOSと同様にpubspec.yamlにパッケージ名を追加するだけ大丈夫です。公式では以下のPlugin Packagesをサポートしています。
- [url_launcher](https://pub.dev/packages/url_launcher)
- [shared_preferences](https://pub.dev/packages/shared_preferences)
- [connectivity](https://pub.dev/packages/connectivity)
- [path_provider](https://pub.dev/packages/path_provider)

[url_launcher](https://pub.dev/packages/url_launcher)と[shared_preferences](https://pub.dev/packages/shared_preferences)については、pub.devにある公式のパッケージでサポートされています。[connectivity](https://pub.dev/packages/connectivity)と[path_provider](https://pub.dev/packages/path_provider)については、以下のように``_macos``という文字列をパッケージ名の最後に付けたのもを追加する必要があります。

```bash 
connectivity: ^0.4.6
connectivity_macos: ^0.0.1
```
#### Dart packages
各プラットフォームに依存していないDart packagesであればDesktop版で実行できる可能性があります。実際に次の項目でDart packagesを1つ動かしております。ですが、Desktop版自身がAlphaやTechnical previewなためエラーやクラッシュが起きる可能性は大いに考えられます。

## 今のDesktop向けFlutterで実現出来る機能
ここからはほぼmacOS向けの説明となります。Desktopアプリなら欲しい(と思う)機能とその実装を紹介します。各機能について深く紹介はしません。
- キーボード入力
- メニューバー
- ファイル選択パネル
- 画面サイズ取得と変更
- ネットワーク接続
- データ永続化

#### キーボード入力
文字列の入力させるWidgetなら
- TextField
生のキーボード入力を取得するWidgetなら
- RawKeyboardListener
があります。

##### TextField

```dart
Center(
  child: Padding(
    padding: const EdgeInsets.all(24.0),
    child: TextField(),
  ),
),
```
実際にはこんな感じになります。日本語入力はできますが、方向キーの動きに癖があったりするので今後の開発に期待しております。
[[macos] Full IME support #30660](https://github.com/flutter/flutter/issues/30660)

![input.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/45a7d11d-0180-daf6-088d-56c19794923a.gif)


##### RawKeyboardListener
```bash
  FocusNode _focusNode = new FocusNode();
  String keyLabel = "";
  _handleKey(RawKeyEventDataMacOs key) {
    setState(() {
      keyLabel = "${key.logicalKey.debugName}";
      log(key.toString());
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
        child: RawKeyboardListener(
          focusNode: _focusNode,
          autofocus: true,
          onKey: (key) => _handleKey(key.data),
          child: Center(
            child: Text(keyLabel),
          ),
        ),
      ),
  }
```
実際にはこんな感じになります。
![key.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/d0815a22-7e4e-9109-ca4c-f8a0b0f3370c.gif)


コールバックでは以下のようなデータが取得できます。

```bash
RawKeyEventDataMacOs(keyLabel: d, keyCode: 2, characters: d, unmodifiedCharacters: d, modifiers: 256, modifiers down: {})
```

keyCodeと実際のキーの対応に関してはの対応に関しては、ここ[presentation](https://github.com/tomaszpolanski/presentation/blob/master/lib/src/presentation_controller.dart)に参考にできるものがあると思います。ちなみに上記のDart packageはFlutterでプレゼン資料を作れるパッケージです。

#### メニューバー
メニューバーを表示させるなら[flutter-desktop-embedding公式リポジトリ]で管理されているPluginである[menubar](https://github.com/google/flutter-desktop-embedding/tree/master/plugins/menubar)の関数
- setApplicationMenu
が使えます。

##### pubspec.yamlへの追加

```yaml
....
  menubar:
    git:
      url: https://github.com/google/flutter-desktop-embedding.git
      path: plugins/menubar
```

##### setApplicationMenu
このサンプルでは、メニューバーに動的にメニューを追加しています。

```dart
  String menuLabel = "";
  Future setMenu() async {
    // メニューアイテムの作成
    List<MenuItem> items = [];
    for (int i = 0; i < 3; i++) {
      MenuItem menuItem = MenuItem(
          label: "sample${i}",
          onClicked: () => setState(() => menuLabel = "sample${i}をクリックしました！"));
      items.add(menuItem);
    }
    // サブメニューに作成したメニューをセット
    Submenu itemAll = Submenu(label: "HelloWorld", children: items);
    // 作成したサブメニューを実際のメニューバーにセット
    setApplicationMenu([itemAll]);
  }

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              menuLabel,
            ),
            const SizedBox(
              height: 32,
            ),
            FlatButton(
              color: Colors.blueGrey,
              child: Text(
                "メニューをセット",
              ),
              onPressed: () => setMenu(),
            ),
          ],
        ),
      ),
  }

```
このように既存のメニューに項目を追加することができます。
実際にはこんな感じになります。

![menu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/7a224f65-dfd9-252e-2937-7acb858f2305.gif)


#### ファイル選択パネル
ファイル選択/保存パネルを表示させるなら[flutter-desktop-embedding公式リポジトリ]で管理されているPluginである[file_chooser](https://github.com/google/flutter-desktop-embedding/tree/master/plugins/file_chooser)の関数
- showOpenPanel
- showSavePanel
が使えます。

##### pubspec.yamlへの追加
```yaml
.....
  file_chooser:
    git:
      url: https://github.com/google/flutter-desktop-embedding.git
      path: plugins/file_chooser
```

##### DebugProfile.entitlements
macOSのセキュリティの関係上以下をDebugProfile.entitlementsに追加しないとファイルにアクセスできません。
詳細は[macOS Signing and Security](https://github.com/google/flutter-desktop-embedding/blob/master/macOS-Security.md)にあります。

```bash
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
```

##### showOpenPanel/showSavePanel
このサンプルではファイル選択パネルを通して端末からファイルを選択しアプリ上に表示→ファイル保存パネルからファイルを端末に保存しています。

```dart
  var _imageWidget;
  String _completeText;
  var _imageFile;

  Future<void> _openPanel() async {
    var result = await showOpenPanel(
      initialDirectory: "/Users/mirock/Desktop", // パネルを開いた時に表示されるディレクトリ
      allowsMultipleSelection: true, // ファイルの複数選択を許可するか
      allowedFileTypes: ["png"], // 許可するファイル拡張子
      confirmButtonText: "オーケー!", // パネルのボタンテキスト変更
    ); //
    // ファイル選択に成功した時の処理
    if (!result.canceled) {
      _imageFile = File(result.paths[0]);
      setState(() => _imageWidget =
          SizedBox(height: 600, width: 600, child: Image.file(_imageFile)));
    }
  }

  Future<void> _savePanel() async {
    var result = await showSavePanel(
      suggestedFileName: "rev12_final_新しいファイル.png", // パネルを開いた時に表示される保存するファイル名
      initialDirectory: "/Users/mirock/Desktop", // パネルを開いた時に表示されるディレクトリ
      allowedFileTypes: ["png"], // 許可するファイル拡張子
      confirmButtonText: "保存!", // パネルのボタンテキスト変更
    );
    if (!result.canceled && _imageFile != null) {
      _imageFile.copy(result.paths[0]);
    } else {
      setState(() => _completeText = "なんかダメです");
    }
  }
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  FlatButton(
                    color: Colors.blueGrey,
                    child: Text(
                      "開く",
                    ),
                    onPressed: () async => _openPanel(),
                  ),
                  const SizedBox(
                    width: 32,
                  ),
                  FlatButton(
                    color: Colors.blueGrey,
                    child: Text(
                      "保存",
                    ),
                    onPressed: () async => _savePanel(),
                  ),
                ],
              ),
              SizedBox(
                height: 32,
              ),
              _imageWidget ??
                  Text(
                    "何にもないよ!",
                  ),
              SizedBox(
                height: 32,
              ),
              Text(
                _completeText ?? "",
              ),
            ],
          ),
        ),
      ),
    );

```

実際にはこんな感じになります。ちなみに[Unsplashのサンプル](https://github.com/flutter/samples/tree/master/experimental/desktop_photo_search)ではネットワークからダウンロードしてきたファイルを保存するコードがあります。

![panel.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/b29b7f15-e2c3-dd7e-2da9-6a8e3ceaa7b5.gif)


#### 画面サイズ取得と変更
ファイル選択/保存パネルを表示させるなら[flutter-desktop-embedding公式リポジトリ]で管理されているPluginである[window_size](https://github.com/google/flutter-desktop-embedding/tree/master/plugins/window_size)の関数
- getWindowInfo
- setWindowFrame
が使えます。

##### pubspec.yamlへの追加
```yaml
.....
  window_size:
    git:
      url: https://github.com/google/flutter-desktop-embedding.git
      path: plugins/window_size
```

##### getWindowInfo/setWindowFrame
このサンプルでは画面サイズを取得して、強制的に画面サイズを変更しています。

```dart
  PlatformWindow _windowInfo;
  Future<void> _window() async {
    getWindowInfo().then((windowInfo) {
      setState(() => _windowInfo = windowInfo);
      setWindowFrame(Rect.fromCenter(
        center: windowInfo.frame.center,
        width: 300,
        height: 800,
      ));
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              FlatButton(
                color: Colors.blueGrey,
                child: Text(
                  "前の画面サイズ",
                ),
                onPressed: () async => _window(),
              ),
              const SizedBox(
                height: 32,
              ),
              Text(_windowInfo != null
                  ? "width: ${_windowInfo.frame.width}, height: ${_windowInfo.frame.height}"
                  : "")
            ],
          ),
        ),
      ),
    );
  }
```
実際にはこんな感じになります。ちなみに起動時に変更するには1つ処理を入れなければいけないようです。
> [Flutter for Desktopで起動時のウインドウサイズを変える](https://qiita.com/YusukeIwaki/items/b80adec4b7c6d7ee8e76)


![size.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/97c3eddb-a31b-fb62-5f39-f50ad80e3b93.gif)


#### ネットワーク接続
ネットワーク接続は様々な手法があります。今回は[dio](https://github.com/flutterchina/dio)を使ってみました。
##### pubspec.yamlへの追加

```yaml
.....
  dio:
```

##### DebugProfile.entitlements
ファイルアクセス同様にネットワークに接続するためにもDebugProfile.entitlementsに設定を追加する必要があります。
```bash
   <key>com.apple.security.network.client</key>
   <true/>
```

##### Getリクエスト
このサンプルでは[json placeholder](https://jsonplaceholder.typicode.com/)からjsonを取得し画像とタイトルを表示しています。

```dart
　var posts;
  Future<void> _fetch() async {
    try {
      Response response = await Dio().get(
          "https://jsonplaceholder.typicode.com/albums/1/photos?_limit=10");
      posts = SizedBox(
        height: 700,
        width: 700,
        child: GridView.builder(
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2,
            ),
            itemBuilder: (BuildContext context, int index) {
              return _item(response.data[index]["title"],
                  response.data[index]["thumbnailUrl"]);
            },
            itemCount: response.data.length),
      );
      setState(() {});
    } catch (e) {
      log(e);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              FlatButton(
                color: Colors.blueGrey,
                child: Text(
                  "ダウンロード!",
                ),
                onPressed: () => _fetch(),
              ),
              const SizedBox(
                height: 32,
              ),
              posts ??
                  Text(
                    "何にもないよ!",
                  )
            ],
          ),
        ),
      ),
    );
  }

  Widget _item(String title, String url) {
    return SizedBox(
      height: 600,
      width: 400,
      child: Column(
        children: <Widget>[
          Text(
            title,
          ),
          Image.network(
            url,
            fit: BoxFit.contain,
          )
        ],
      ),
    );
  }
```

実際にはこんな感じになります。
![download.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283229/202b122a-a74a-d77c-acab-e55d598fea11.gif)


#### データ永続化
[公式のドキュメント](https://flutter.dev/docs/cookbook/persistence)によるとFlutterでデータを永続化方法には
- sqfliteを使う
- ファイルに書き出す
- shared preferenceを使う

があると書かれています。

shared_preferenceは現在macOSのみの対応ですが利用できます。使い方については、[Store key-value data on disk](https://flutter.dev/docs/cookbook/persistence/key-value)や[Flutterで値を保存したり取り出したりする](https://qiita.com/yukiyamadajp/items/16ec45a7d5de947a93e3)を参考にできます。

今回、sqfliteはまだmacOSをサポートしていないので大規模データの保存には別の手段が必要と書こうとしたのですが、なんと2019/12/14にsqfliteがmacOSを実験的にサポートし始めたのでsqfliteも**一応**使えるようになりました。公式のサンプルをmacOSのアプリで試したのですが、Android/iOSと同様に扱うことができました。
sqfliteの使い方については[公式](https://flutter.dev/docs/cookbook/persistence/sqlite)や[FlutterでローカルDBを扱う方法](https://iganin.hatenablog.com/entry/2019/01/09/010804)、[SQLiteでのデータ永続化](https://flutter.ctrnost.com/logic/sqlite/)を参考にできます。
sqfliteのmacOS対応前は[sembast](https://github.com/tekartik/sembast.dart)を使ってデータの保存をしようとしていたのでそちらを紹介しようと思います。

##### sembast
sembastはドキュメントベースのNoSQLでDartVMでもFlutterでも動きます。100%Dartで書かれておりプラットフォームにも依存関係が無いためアプリ内に埋め込むことができます。Webで使われているIndexedDBやnedb等に似ており、それらにインスパイアされて作られたそうです。

こちらは[sembastのドキュメント](https://github.com/tekartik/sembast.dart/blob/master/sembast/README.md)を元にデータベースの読み込み、データ書き込み、データ読み込みの例を紹介します。

```dart
//データベース読み込み
String dbPath = 'sample.db';
DatabaseFactory dbFactory = databaseFactoryIo;
_db = await dbFactory.openDatabase(dbPath);
_store = StoreRef<String, String>.main();

//キーを使ったデータ書き込み
await _store.record('text').put(_db, text);

//データの読み込み
_editedText = await _store.record('text').get(_db);
```
他にも、トランザクションの利用やデータベースの暗号化をすることができます。

## まとめ
Flutter for Desktopは、macOS版がAlphaでWindows/LinuxがTechnical Previewと開発の途中です。ですが、macOSに関してはいろいろ試していても安定していました。プラグインも記事を書いている途中に追加されていたりするので今後のmacOS周りの発展に期待が持てそうです。
2019年末でもDesktop向けのフレームワークとしてはElectronがまだ強いですが、Desktop向けFlutterが同一がそれ以上の存在になる日を期待しています。

記事内で間違いなどあればご指摘いただけますと幸いです。


以下余談
本記事ではDesktop向けFlutterと呼んでますが公式ページやその他をみるとFlutter for DesktopやDesktop Embedding for Flutter、Desktop Support for Flutterのような複数の呼び名があります。今はどう呼んだら良いんでしょうね。。