Dart/Flutter連載 の3本目はFlutter Webを紹介します。
Flutter 2になって、Web向けに出力する機能もStableになりました。
Flutter for Webは標準のHTMLにするHTMLレンダラーと、CanvasKitレンダラーと2種類あります。後者はSkiaという2DグラフィックスのライブラリをWebAssembly化したものを使います。Skiaはウェブ向けではないFlutterでも使っているため、モバイルとの互換性の高さが期待されます。
現状では明示的に指定しなければauto(モバイルはHTMLレンダラー、PCはCanvasKitレンダラー)になりますが、明示的に指定もできます。これらの違いはまた後で触れますが、せっかくウェブが出せるようになったので、ウェブフロントエンドをFlutterで作ってみるための色々調査をしてみました。React/Vue/Angularを一通り業務で使ってみましたし、フロントエンド開発周りもここ5-6年ぐらい、書き方が違うぐらいでやっていることはあんまり変わらなくて個人的に飽きてきたこともあります。
ウェブアプリといえばRouter SPAで管理画面を作っていく上で、最低限必要なことはRouterと呼ばれる機能です。VueやAngularだと標準で用意されています。Reactは標準はないですが、使うときはだいたい何かしら入れるでしょう。
FlutterはデフォルトでNavigotorというクラスがあります。以下のページがめちゃくちゃまとまっていますので、詳細はこちらをご覧ください。
https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade
ウェブアプリケーションユーザー目線で、いくつか知っておくべきポイントがあります。
1.0と2.0と大きく2種類に分かれる(ここでは2を扱います)ので、ウェブを検索して出てきた内容を参考にするには利用バージョンと同じかどうか注意が必要
サンプルの一番シンプルな書き方だと、URLのパスを決めるのではなく、その場でウィジェットを上書きする(pushする)モードで、ウェブのよくある挙動とは違う動きになる
named navigator routesという、ウェブのRouterに近い、パスのルールとその時の表示するウィジェットのマッピングを定義するモードもある(ネストもできる)
named navigator routesでデフォルトはハッシュを挟んだパスになる(AngularでいうところのHashLocationStrategy)が、PathLocationStrategyも設定可能
パスの一部をパラメータとして利用しようとすると面倒
あとは次のあたりも僕がFlutterを学び始めたときにちょっと悩んだポイントです。
statefulとstatelessでウィジェットを作り分ける必要がある
buildメソッドはReactのrender
builderという言葉はVueのslot的な、特定のライフサイクルで呼ばれてビューの一部を返す何か←某握力王の人に教えてもらいました
debug()関数でconsole.logに出力できる
最小のRouter 次のコードが↑に書いてあるnamed navigator routesを使った最小のコードです。2つの画面の間の遷移をします。まず、ルートのMaterialAppに、routesの引数でURLとページのマップを定義します。あとは、Navigatorクラスを使って、pushNamed()メソッドや、pop()メソッドを使ってページ遷移ができます。よくあるSPAと変わらないですね。
lib/main.dart 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import 'package:flutter/material.dart' ;void main() { configureApp(); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo' , theme: ThemeData( primarySwatch: Colors.blue, ), routes: { '/' : (context) => HomeScreen(), '/details' : (context) => DetailScreen(), }, ); } } class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: TextButton( child: Text('View Details' ), onPressed: () { Navigator.pushNamed(context, '/details' ); }, ) ), ); } } class DetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: TextButton( child: Text('Pop!' ), onPressed: () { Navigator.pop(context); } ) ), ); } }
こちらができあがりです。Android Studioで作った環境でウェブで表示してみたものになります。
なお、URLの一部がエンティティのIDとしてパスパラメータとして使いたい場合は、RouteInformationParserを継承したクラスを作ってアプリに渡す必要があります。上記のmediumのページの中でRouteInformationParserで検索して見てみれば書き方がわかりますが、面倒です。ここはそのうち改善されるのでは、ということを期待しています。
ハッシュがURLに入ってしまうのをやめる PathLocationStrategy相当への切り替え方法については次のページで説明されています。
https://flutter.dev/docs/development/ui/navigation/url-strategies
まず、依存パッケージにflutter_web_pluginsを追加します。
pubspec.yaml 1 2 3 dependencies: flutter_web_plugins: sdk: flutter
次に、main関数の中で、URLのルールを変更します。↑のページには、Web向けとそれ以外向けでルールを切り替える方法も紹介されていますが、ここではウェブでしか使わない前提でシンプルにmainに書いてしまっています。
main.dart 1 2 3 4 5 6 7 import 'package:flutter_web_plugins/flutter_web_plugins.dart' ;void main() { setUrlStrategy(PathUrlStrategy()); runApp(MyApp()); }
これでパスにハッシュが入ることがなくなりました。
Goのアプリケーションに組み込む Goで作ったサーバーの管理画面をFlutterで作る前提で、go:embedでアプリにバンドルしてみます。以前、本技術ブログでVueで行ったことをFlutterでもやってみます。
https://future-architect.github.io/articles/20210408/
まずビルドします。CanvasKitのほうが描画性能は高いとのこと ですが、たぶん、レンダラーはHTMLが良いかと思います。
1 $ flutter build web --web-renderer=html --source-maps
ビルドオプションには–releaseをつけることができます。つけるとビルドは遅くなります(M1 MacBook Proで20秒ほど。つけないと0.3秒)。
ビルド結果はbuild/web
フォルダに出力されます。
一見、CanvasKitもHTMLもファイルサイズがほとんど変わらない(3.4MBと3.5MB)のですが、CanvasKitでビルドすると、CanvasKitの本体のwasmのビルド済みのファイルをネット越しにダウンロードしているようです。これが2MBぐらいあるみたいですし、もしかしたらプロキシが必要なイントラネットで利用とか考えると、外部依存はないに越したことはありません。
main.dart.js 1 2 14151 :$2 :function (a,b ){return "https://unpkg.com/canvaskit-wasm@0.25.1/bin/" +a},41865 :s ($,"ae9" ,"a2x" ,function ( ){return "https://unpkg.com/canvaskit-wasm@0.25.1/bin/canvaskit.js" })
Goのファイルをいくつか作成します。go:embedが、今いるフォルダよりも子供のフォルダしか読み込めないので、Flutterのルートのフォルダでgo mod init flutter_with_goを叩いて、go.modを作成します。
ファイルを参照するgo:embedは次のように書きます。
asest.go 1 2 3 4 5 6 7 8 package flutter_with_goimport ( "embed" ) var assets embed.FS
NotFoundHandlerハンドラーは前回の記事のファイルの配信のハンドラー で紹介したコードとほぼ同じです。ファイルの置き場をプロジェクトルートにしてみたのと、パスがbuild/webになったぐらいです。main関数もほぼ以前と同じです。
無事、GoでもFlutter Webのビルド結果をホストできました。
今回のフォルダ構成は次の通りです。Goのコードはserverみたいなサブパッケージを作って入れてもよかったかも。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ├── README.md ├── android ├── asset.go ├── cmd │ └── flutter_with_go │ └── main.go ├── flutter_with_go.iml ├── go.mod ├── go.sum ├── ios ├── lib │ └── main.dart ├── notfound.go ├── pubspec.lock ├── pubspec.yaml └── web ├── favicon.png ├── icons │ ├── Icon-192.png │ └── Icon-512.png ├── index.html └── manifest.json
サーバーへのHTTPアクセス 静的HTMLを表示するだけでは管理画面にはなりませんので、HTTPアクセスを行ってみます。より高度なサービスになると、昨日のエントリーのSwaggerを使ったサーバーアクセス や、GraphQLやgRPCを使いたくなるかもしれません。今時なプロトコルはどれでも利用できるのも、Flutterの良いところですが、今回はシンプルなHTTPアクセスをします。
題材としては今話題沸騰のイケてるWeb APIであるケンオール にアクセスしてみます。
ケンオールはアカウント登録するとAPIキーが発行され、これを使ってアクセスします。サンプルと言えど、APIキーはフロントエンドに置きたくないので、サーバー側で中継することとします。
サーバー側の実装 /api/postal/{code}
にアクセスしたら、住所情報を返すAPIをGoで実装しました。APIキーは環境変数で渡します。Vue.jsのときのサンプルの差分だけ表示します。
cmd/flutter_with_go/main.go 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 type Env struct { Port uint16 `envconfig:"PORT" default:"8000"` KenAllAPIKey string `envconfig:"KENALL_API_KEY" required:"true"` } func newHandler (apiKey string ) http.Handler { router := chi.NewRouter() router.Route("/api" , func (r chi.Router) { r.Get("/postal/{code}" , func (w http.ResponseWriter, r *http.Request) { code := chi.URLParam(r, "code" ) req, _ := http.NewRequestWithContext(r.Context(), "GET" , "https://api.kenall.jp/v1/postalcode/" +code, nil ) req.Header.Set("Authorization" , "Token " +apiKey) res, err := http.DefaultClient.Do(req) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } defer res.Body.Close() io.Copy(w, res.Body) }) }) router.NotFound(flutter_with_go.NotFoundHandler) return router } func main () { server := &http.Server{ Addr: ":" + strconv.FormatUint(uint64 (env.Port), 10 ), Handler: newHandler(env.KenAllAPIKey), } }
ビルドしたら試しにcurlでこのサーバーAPIを叩いてみます。バッチリですね(長いのでレスポンスは短くしてます)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 % curl http://localhost:8000/api/postal/1410032 { "version" : "2021-04-30" , "data" : [ { "postal_code" : "1410032" , "prefecture_kana" : "トウキョウト" , "city_kana" : "シナガワク" , "town_kana" : "オオサキ" , "prefecture" : "東京都" , "city" : "品川区" , "town" : "大崎" } ] }
フロント側の実装 フロント側からはサーバーアクセスをさせたいと思います。状態をもつのでstatefulなウィジェットとします。
lib/main.dart 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo' , theme: ThemeData( primarySwatch: Colors.blue, ), home: Scaffold( appBar: AppBar( title: Text('KenAll Sample' ), ), body: Center( child: KenAll(), ), ), ); } } class KenAll extends StatefulWidget { @override _KenAllState createState() => _KenAllState(); }
実サーバーアクセスと表示を行う部分はこちらです。フィールドの入力が7文字になったらサーバーアクセスを行い、取得してきた情報をStateに入れています。
lib/main.dart 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 class _KenAllState extends State <KenAll > { final _formKey = GlobalKey<FormState>(); String prefecture = '' ; String city = '' ; String town = '' ; String koaza = '' ; String kyoto_street = '' ; String building = '' ; String floor = '' ; @override Widget build(BuildContext context) { return Form( key: _formKey, child: Column( children: [ TextFormField( decoration: InputDecoration( filled: true , hintText: 'Enter a postal code...' , labelText: 'Postal Code' , ), onChanged: (value) async { if (value.length == 7 ) { final response = await http.get (Uri .parse('/api/postal/${value} ' )); debugPrint(response.body); if (response.statusCode == 200 ) { final json = jsonDecode(response.body); final body = json['data' ][0 ]; print (body); setState(() { prefecture = body['prefecture' ]; city = body['city' ]; town = body['town' ]; koaza = body['koaza' ]; kyoto_street = body['kyoto_street' ]; building = body['building' ]; floor = body['floor' ]; }); return ; } } setState(() { prefecture = '' ; city = '' ; town = '' ; koaza = '' ; kyoto_street = '' ; building = '' ; floor = '' ; }); }, ), Expanded( child: ListView( children: [ ListTile( leading: Text('Prefecture' ), title: Text(prefecture), ), ListTile( leading: Text('City' ), title: Text(city), ), ListTile( leading: Text('Town' ), title: Text(town), ), ListTile( leading: Text('Koaza' ), title: Text(koaza), ), ListTile( leading: Text('Kyoto Street' ), title: Text(kyoto_street), ), ListTile( leading: Text('Building' ), title: Text(building), ), ListTile( leading: Text('Floor' ), title: Text(floor), ), ], ), ), ] ), ); } }
このHTTPアクセスには外部パッケージが必要なため、pubspec.yamlとHTTPリクエストを送っているコードへのimportの追加を行いま。
1 2 dependencies: http: ^0.13.3
1 import 'package:http/http.dart' as http;
無事動いたようです。
まとめ そろそろReact/Vue/Angularに飽きてきたかも?な人の新たなおもちゃとしてFlutter Webの紹介をしました。機能的には以下の3つを紹介しました
Router周り
ビルドした成果物がどうなっていて他の言語(Go)のサーバーにどう組み込めばいいのか
サーバーへのHTTPアクセス
モバイルアプリ開発案件じゃなくてもFlutterができてしまうので、スカンクワークスにぴったりですね。用途が広くていつの間にかシェアを広げていた黎明期のGoと同じように、上司に内緒でこっそり導入に最適です。
Dart/Flutter連載 の3記事目でした。次回は鶴巻さんのFlutterレイアウト入門 です。