🎃

Hydro-SDK で始める TypeScript を使った Flutter 開発

2021/12/21に公開

この記事は Sansan Advent Calendar 2021 - Adventar の 21 日目の記事です 🎄
https://adventar.org/calendars/6219


こんにちは。 フロントエンドエンジニアをしている @pvcresin です。
近年、スマホアプリ開発の文脈で Flutter の人気が高まっていますね。
普段は Web フロントエンドを書いていますが、アプリ開発をきっかけにプログラミングにハマったという経緯があり、アプリ開発まわりの動向にも興味があります。
Android Beam[1] という機能を使って非接触で名刺交換を行うアプリを試作していました。)

さて今回は、 TypeScript を使って Flutter によるマルチプラットフォーム開発が行える Hydro-SDK を試していきたいと思います。

背景

https://flutter.dev/

Flutter はアプリ開発で人気のフレームワークです。
Skia という 2D グラフィックライブラリを使って独自 UI を構築するため、マルチプラットフォーム間で統一されたデザインを提供できます。
また、動作のパフォーマンスも良く、宣言的 UI や Hot Reload によって快適に開発できます。

しかし、使用する言語が Dart という点で手を出すのをためらう方もいるのではないでしょうか。
Dart 自体は様々なところで使える便利な言語ですが、文法が Java や C#っぽく、新しさを感じられないというのも事実だと思います。
(裏を返せば、馴染みのある文法のため学習コストが低いとも言えます。)
そこで別の言語で Flutter を使う方法ないかなと思い探していたところ、見つけたのが Hydro-SDK でした。

Hydro-SDK

https://hydro-sdk.io/

Hydro-SDK は Flutter を TypeScript で書けるようにする SDK です。
プロジェクトの最終的な目標は、「Flutter 版 React Native になること」とのことでした。
コード的には Flutter プロジェクト上で TypeScript で書かれたコンポーネント(Widget)を呼び出す形になります。
内部で TypeScript を Lua bytecode を経由して Dart に変換しているようで、Flutter や Dart の API をそのまま呼び出すことができます。
そのほかにもコンポーネントをアップロードして配信するための Registry が用意されているなど、野心的なプロジェクトという印象です。

https://github.com/hydro-sdk でいくつか Hydro-SDK を使った作例を見ることができます。
これらのサンプルを見ると、Hydro-SDK の可能性を感じます。

basic-appbar-app animated-list-app hotel-booking-app
basic-appbar-app animated-list-app hotel-booking-app

環境構築

ではまず環境構築から始めます。
今回は VS Code と Android Emulator でデバッグビルドを行いながら開発していきます。
ライブラリは npmpub.dev の両方で公開されており、開発時にはどちらも必要になるため、それらを使える環境を整えます。

まずは基本的な Flutter 開発環境を VS Code で構築します。
Flutter の Get started のページなどを参考に Android 開発に必要な SDK をインストールしたり、各種コマンドにパスを通したり、Emulator のデバイス(AVD)を作成したりと、初めての方にはまぁまぁ大変です。

Flutter のバージョン管理には FVM を使います。
残念ながら Hydro-SDK は Flutter の現時点での最新版(v2.8.1)だと動かないようでした 😇
現状 Flutter v2.0.6 では Hydro-SDK が動作することが確認されているので、今回はこのバージョンを使用して開発を行います。
FVM はプロジェクトごとに Flutter のバージョンを設定することもできますが、今回は説明を簡単にするために global でいきます。
fvm install 2.0.6で Flutter v2.0.6 をインストールし、fvm global 2.0.6で global の Flutter を v2.0.6 に固定します。
ここで、/Users/<you>/fvm/default/bin にパスを通し、固定したバージョンで flutter コマンドが呼び出せることを確認します。

% flutter --version
Flutter 2.0.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 1d9032c7e1 (8 months ago)2021-04-29 17:37:58 -0700
Engine • revision 05e680e202
Tools • Dart 2.12.3

また、VS Code の設定に "dart.flutterSdkPath": "/Users/<you>/fvm/default", を追記します。これにより、Flutter 拡張も FVM を参照するようになります。

あとは flutter doctor コマンドで全部にチェックが付くまで頑張りましょう。
また、npm も使うため、Node.js の環境も準備しておきましょう。

私は使っている PC に Android SDK Command-line Tools がインストールされていなかったり、各種ツールのバージョンによってコマンドやパスが異なったりで構築に手間取りました。

最終的な開発環境は以下の通りです。

  • macOS Big Sur v11.6.1 (Intel Processor)
  • VS Code + Flutter extension
  • Node.js v16.13.0, npm v8.1.0
  • Flutter v2.0.6 , Dart v2.12.3
  • Pixel 5 API 28 (android-x86 emulator)

Flutter のサンプルアプリをビルドする

まずは、Flutter のビルド環境ができていることを確認していきます。
VS Code のコマンドパレットで Flutter: New Project から Application を選択してプロジェクト名を設定すると、雛形となるのコードが生成されます
VS Code の右上の Start Debugging からビルドを行います。
ビルドが成功するとボタンをタップした回数をカウントするアプリが起動します。

雛形となる counter-app のコード
lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

ひとまず Flutter がビルドできることが確認できました。

Hydro-SDK を動かしてみる

ここでは先程と同じカウントアプリの Hydro-SDK 版を動かしてみます。
最終的な成果物は https://github.com/pvcresin/hydro_sdk_demo に置いておきます。

ライブラリの準備

まずは Hydro-SDK の Tutorial にある通り、ライブラリのインストールを行います。

  1. npm init で package.json を作成し、package.json と pubspec.yaml の dependencies に Hydro-SDK の開発中の最新のバージョン(0.0.1-nightly.414)を書く
  2. npm installfvm flutter pub get でライブラリをインストール
  3. .gitignore に node_modules などを追記

ちなみに package.json に直接書くのではなく npm i -E @hydro-sdk/hydro-sdk 経由でインストールすると0.0.1-nightly.1 が入ってしまうので、npm i -E @hydro-sdk/hydro-sdk@nightly としましょう。

コードの追加

まず、Dart 側のコードを書き換えます。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:hydro_sdk/runComponent/runComponent.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const RunComponent(
    project: "example-project",
    component: "counter-example",
  ));
}

RunComponent() で TypeScript で書かれたコンポーネントを配置しています。

次に、TypeScript のコードを追加します。

ota/index.ts
// Make sure to import from /index specifically if using barell imports.
// The compiler won't resolve /index by itself
import {
  StatelessWidget,
  Text,
  Center,
  StatefulWidget,
  State,
  Column,
  MainAxisAlignment,
  Icon
} from "@hydro-sdk/hydro-sdk/runtime/flutter/widgets/index";
import { AppBar, FloatingActionButton, Icons, MaterialApp, Scaffold, Theme } from "@hydro-sdk/hydro-sdk/runtime/flutter/material/index";
import { Widget } from "@hydro-sdk/hydro-sdk/runtime/flutter/widget";
import { BuildContext } from "@hydro-sdk/hydro-sdk/runtime/flutter/buildContext";
import { Key } from "@hydro-sdk/hydro-sdk/runtime/flutter/foundation/key";
import { runApp } from "@hydro-sdk/hydro-sdk/runtime/flutter/runApp";

export class CounterApp extends StatelessWidget {
  public constructor() {
    super();
  }

  public build(): Widget {
    return new MaterialApp({
      title: "Counter App",
      initialRoute: "/",
      home: new MyHomePage("Counter App Home Page"),
    });
  }
}

class MyHomePage extends StatefulWidget {
  public title: string;
  public constructor(title: string) {
    super();
    this.title = title;
  }
  public createState(): MyHomePageState {
    return new MyHomePageState(this.title);
  }
}

class MyHomePageState extends State<MyHomePage> {
  private counter = 0;
  public title: string;
  public constructor(title: string) {
    super();
    this.title = title;
  }

  private incrementCounter = (): void => {
    this.setState(() => {
      this.counter++;
    });
  };

  public dispose() {}

  public initState() {}

  public build(context: BuildContext): Widget {
    return new Scaffold({
      appBar: new AppBar({
        title: new Text(this.title),
      }),
      body: new Center({
        child: new Column({
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            new Text("You have pushed the button this many times"),
            new Text(this.counter.toString(), {
              key: new Key("counter"),
              style: Theme.of(context).textTheme.display1,
            }),
          ],
        }),
      }),
      floatingActionButton: new FloatingActionButton({
        key: new Key("increment"),
        child: new Icon(Icons.add),
        onPressed: this.incrementCounter,
      }),
    });
  }
}

runApp(() => new CounterApp());

import 部分がやや気になりますが、Dart のコードとほぼほぼ同じ感じです。

そして、hydro.json という設定ファイルをプロジェクトのルートに追加します。

hydro.json
{
  "project": "example-project",
  "components": [
    {
      "name": "counter-example",
      "chunks": [
        {
          "type": "mountable",
          "baseUrl": "ota",
          "entryPoint": "ota/index.ts"
        }
      ]
    }
  ]
}

ビルドを行う

npx hydroc run で TypeScript 側(ota/index.ts)の開発ビルドを行います。
npx hydroc xxxx の初回実行時には .hydroc フォルダが作成され、中にダウンロードした Hydro-SDK tools が格納されます。
結構時間かかるので気長に待ちましょう。
ちなみにビルドで謎のエラーが起きた時に、.hydroc フォルダを消してもう一度ダウンロードしたらビルドが通ることがありました。
ビルドが無事に通ると以下のような表示になります。

% npx hydroc run
Watching for changes in ota
Building project example-project
Building component counter-example
Lowering files
  [████████████████████████████████████████] 205/205 ota/index.ts
Assembling manifest...                 0.0s
Assembling package...                  0.7s

このとき、ota/index.ts の変更を検知して Hot Reload のために裏で常にビルドをおこなっています。

HTTP 接続を許可する

lib/main.dart で Debug ビルドを実行すると、Hydro-SDK の serviceAware.dart

StateError (Bad state: Insecure HTTP is not allowed by platform)

と怒られてしまいます。
調べたところ、Flutter 公式ドキュメントの以下のページがヒットしました。
https://docs.flutter.dev/release/breaking-changes/network-policy-ios-android

どうやら、Android API 28 や iOS 9 からは HTTP 接続がデフォルトで無効になったようです。
今回私が使っていた Android Emulator は API 28 だったので、対応が必要です。
Hydro-SDK は Hot Reload に HTTP 接続を使っているので、上記のページに従ってエラーにならないように設定します。
最低限 Debug ビルドを動かすためには、Android の debug 用のマニフェストファイルに usesCleartextTraffic の追記が必要でした。

android/app/src/debug/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.hydro_sdk_demo">
    <uses-permission android:name="android.permission.INTERNET"/>
    <application android:usesCleartextTraffic="true"/>
</manifest>

Debug ビルドを行う

これで長かった準備は終わりです。

npx hydroc run を実行して ota/index.ts の変更の監視をはじめてから、lib/main.dart の Debug ビルドを開始すると、Hot Reload におよる開発が可能です。
Hot Reload の 反映は数秒待つといった感じで、そこまで早くなかったです。

counter-app
(動作は https://github.com/hydro-sdk/hydro-sdk の GIF を参照 🙏)

制約

現時点で、Hydro-SDK には以下のような制約があります。

  1. tree-shaking 周りが微妙
  2. async / await が使えない
  3. 内部で使っている TypeScriptToLua の制約

詳しくはドキュメントの Limitations に載っています。
開発が進んで、少しずつ制約がなくなっていくといいですね。

まとめ

今回は TypeScript を使って Flutter 開発が行える Hydro-SDK を試してみました。
本当はオリジナルのアプリケーション作るところまで行きたかったですが、力尽きました 😇
現時点では Dart で書いていたクラスをそのまま TypeScript に置き換えた感じですが、Flutter 本体の Issue でも議論されているように、JSX 記法が使えるといいなと思いました。
まだまだ実験段階という印象はありますが、アクティブに開発されていて今後が楽しみです。

脚注
  1. NFC と Bluetooth を使って端末同士をかざすだけでデータを送受信できる機能です。Android 4.0(Ice Cream Sandwich)で登場しましたが、Android 10(Q) で廃止になりました。😇 ↩︎

Discussion