Edited at

Flutterをスマートウォッチで動かす


なぜこの記事を書いたか

Flutterに関する日本語記事がここ数ヶ月でかなり増えてきました。これもFlutter人気の兆しなのかとわくわくしています。このビッグウェーブに乗ろうと思い、今回Flutterをネタに記事を投稿しようと思いましたが、すでにWebに溢れているテーマでは面白くありません。

ということで、記事がまったくみつからなかったネタ、Flutter×スマートウォッチをテーマにします。スマートウォッチ上で、Flutterは動くか、これを検証します。

今後、同じことを考えた物珍しい方への一助となればうれしいです。

※ちなみに今回は、Apple Watchは対象ではありません。もし試した方がいらっしゃれば、ぜひどういう結果になったか教えていただきたいです。


バージョン


  • Flutter:v0.11.10

  • Java:1.8.0_102

  • エミュレータのイメージ:android-wear Wear OS (API level 28)


開発環境の構築方法

本記事では、Flutterの開発環境の構築方法は扱いません。環境構築から行いたい方は、公式ドキュメントを参照してください。


エミュレータ上での動作検証

まずは、スマートウォッチのエミュレータ上でFlutterアプリが動くのか検証します。


スマートウォッチのAVD準備

AVD Managerを開き、「Create Virtual Device...」を押下します。

Category「Wear」から「Wear OS Round」を選択し、「Next」を押下します。

OSイメージとして、「Android API 28(Wear OS)」を選択し、「Next」を押下します。

Downloadと表示されている場合、イメージをダウンロードしてください。

任意のAVD Nameを入力し、Finishを押下します。


Flutterアプリの実行

ここからはVisual Studio Code(以下、VSCodeと記述)上で実行します。私のVSCodeには「Dart」と「Flutter」の拡張機能をインストールしています。そのため、コマンドパレットを開き(ショートカットキー:Ctrl+Shift+p)、Flutterを入力すると、簡単にFlutterコマンドを選択・実行できます。

その中から「Flutter:New Project」を選択し、任意のディレクトリでFlutterプロジェクトを作成します。今回、私はプロジェクト名を「flutter_smartwatch」としました。

再度コマンドパレットを開き、「Flutter:Launch Emulator」で先ほど作成したAVDを選択し、デバッグモードを実行する(ショートカットキー:F5)と、スマートウォッチをエミュレートした環境でFlutterが実行されます。



おお、動いた...

ちなみに、四角型のAVDを作成し、Flutterアプリを実行すると、以下の結果が得られます。

どうやら、Flutterアプリはスマートウォッチ上でも動かせそうです。


画面の形に応じたレイアウトの実現

スマートウォッチの画面は、大きく「丸型」と「四角型」があります。アプリを実行してみわかりましたが、スマートウォッチの画面は小さいため、それぞれの形に適したUIを表示しなければ、すぐユーザビリティが落ちます。

したがって、スマートウォッチの形を判定し、画面を出し分ける処理を追加したいと思います。しかし、スマートウォッチの形を検出するFlutterプラグインを見つけられなかったので、独自に実装します。

今回はAndroid側にJavaで定義したスマートウォッチ画面の形の判定メソッドを、MethodChannelという仕組みを用いてFlutter側で呼び出す処理を実装します。


MethodChannelとは

FlutterでAndroidやiOSといったプラットフォーム側のコードを呼び出すために、MethodChannelというクラスが用意されています。以下の図のように、MethodChannelとは、Flutter⇔Android・iOSのやり取りを非同期で行う通り道であり、お互いにバイナリ形式でデータを送受信できます。



【引用】https://flutter.io/docs/development/platform-integration/platform-channels

ちなみに、やりとりできるデータの型は決まっているので注意してください。図の引用元に詳細があります。

実装方法としては、以下の通りです。

1. Fllutter側でMethodChannelを定義し、そのMethodChannel経由でAndroid側で定義したメソッドを呼び出す。

2. Android側でMethodChannelを定義し、そのMethodChannelにFlutter側で呼び出したいメソッドを登録する。

さっそく実装してみましょう。


Flutter側の実装

MethodChannelにユニークな名前を付与し、インスタンスを生成する。MethodChannelの持つinvokeMethodメソッドで、Android側で定義した処理を呼び出すことができます。


main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

MethodChannelに関係ないコードは省略~

class _MyHomePageState extends State<MyHomePage> {
// MethodChannleの定義
// 文字列を指定し、一意に特定する。
static const platform = MethodChannel("com.example/shape");

Future<Shape> detectWatchShape() async {
try {
// invokeMethodでプラットフォーム側(Android・iOS)で定義したメソッドを呼び出す。
final int result = await platform.invokeMethod("detectWatchShape");
return result == 0 ? Shape.round : Shape.square;
} on PlatformException catch (e) {
print(e);
// デフォルトを四角に設定
return Shape.square;
}
}

buildメソッドは省略~
}

// 画面の形が丸型(Shape.round)か四角型(Shape.square)かを表すenum型の定義。
enum Shape { round, square }



Android側の実装

Android側でもMethodChannelで定義します。このMethodChannelにスマートウォッチの形を判定するメソッドを登録します。


MainActivity.java

package com.example.fluttersmartwatch;

import android.os.Bundle;
import io.flutter.app.FlutterActivity;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.*;
import io.flutter.plugins.GeneratedPluginRegistrant;

public class MainActivity extends FlutterActivity {
// MethodChannel名の指定。
// Flutter側で呼び出す際に指定する文字列と合わせる。
private static final String CHANNEL = "com.example/shape";

private static final int CIRCLE = 0;
private static final int SQUARE = 1;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// MethodChannelの定義
new MethodChannel(getFlutterView(), CHANNEL).setMethodCallHandler(new MethodCallHandler() {
// Flutter側でinvokeMethodが呼ばれたときに実行される。
@Override
public void onMethodCall(MethodCall call, MethodChannel.Result result) {
// MethodCallのmethodプロパティ内に、Flutter側でinvokeMethodメソッドに
// 引数として渡した文字列が格納されているので、文字列比較で実行する処理を判定します。
if(call.method.equals("detectWatchShape")) {
// ここでスマートウォッチの形を判定する。
// 丸型なら0を返し、四角型なら1を返す。
if(getFlutterView().getResources().getConfiguration().isScreenRound()) {
// Flutter側に返したい値を引数に渡す。
result.success(CIRCLE);
} else {
// Flutter側に返したい値を引数に渡す。
result.success(SQUARE);
}
} else {
result.notImplemented();
}
}
});
}
}



動作検証

上記コードで実際にスマートウォッチの形を判別できているか試します。それぞれ異なる画面を表示するよう、_MyHomePageStateクラスにbuildメソッドを実装します。

丸型画面のスマートウォッチなら黄色、四角画面のスマートウォッチなら青色の背景を表示します。


main.dart

class _MyHomePageState extends State<MyHomePage> {

// detectWatchShapeメソッドは省略

@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder(
// 上記で定義した画面の形を判定するメソッドを設定する。
future: detectWatchShape(),
builder: (BuildContext context, AsyncSnapshot<Shape> snapshot) {
double height = MediaQuery.of(context).size.height;
double width = MediaQuery.of(context).size.width;

switch(snapshot.data) {
case Shape.round:
return Container(
height: height,
width: width,
child: Center(child: Text("ROUND")),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.yellow,
),
);
case Shape.square:
return Container(
height: height,
width: width,
child: Center(child: Text("SQUARE")),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.blue,
),
);
default:
return Container(color: Colors.red);
}
},
)
);
}
}


丸型と四角型の2つのエミュレータでFlutterアプリを起動します。

ちゃんとできていましたね。


実機検証

気になりますよね。タイトルが『Flutterをスマートウォッチで動かす』なのにエミュレータでしか動かしてないと各方面から色々と言われそうです。

ということで、



買いましたよ...!Tickwatch Eです。OSはWear OS by Google 2.1です。

スペック

OS
Wear OS by Google 2.1

メモリ
512MB

ストレージ
4G

さっそく検証しましょう。


PCからWi-Fi経由でデバッグする


スマートウォッチの設定


  1. 自分のスマートフォンとスマートウォッチがbluetoothでペアリングされていることを確認する。

  2. 設定⇒システム⇒端末情報⇒ビルド情報を7回タップ⇒設定画面に開発者向けオプションの項目が表示される。

  3. 設定⇒接続⇒Wi-Fi⇒接続するWi-Fiを選択する。

  4. 設定⇒開発者向けオプション⇒Wi-Fi経由でデバッグをオン。


    • Wi-Fiがちゃんと接続されていれば、IPアドレスが表示される。このIPアドレスをメモしておく。




PCの設定


  1. スマートウォッチと同一ネットワークにPCが接続しているか確認する。

  2. 任意のディレクトリで以下のコマンドを実行する。


    • adb connect 【上記でメモしたスマートウォッチのIPアドレス】

    • 接続できたら以下が表示される。

    • connected to :5555



  3. 今度はVSCodeでスマートウォッチが接続されているか確認する。

  4. デバッグ(ショートカットキー:F5)を開始する

  5. スマートウォッチにapkがインストールされるので確認する。

【参考】https://developer.android.com/training/wearables/apps/debugging


動作検証

動く、動くぞ...!

先ほどの画面と少し違うのは、文字列だけ出すのは、あまり面白くないと思ったので、加速度センサーの値を表示しているからです。動けば動くほど値が変わります。


加速度センサーの値を取得する実装

Flutterのsensorsプラグインを利用しています。

参考までにコードをあげます。


main.dart

~省略~

return StreamBuilder(
// accelerometerEventsは、Sensorsプラグインの提供するメソッドです。
// Stream<AccelerometerEvent>を返します。
// StreamBuilderを使うことで、Streamから新たなAccelerometerEventを受け取るたびに、
// 画面を再描画します。
stream: accelerometerEvents,
builder: (context, AsyncSnapshot<AccelerometerEvent> eventSnapshot) {
// 加速度の値が取得できなかった場合、ローディング画面を表示する。
if(!eventSnapshot.hasData) return Center(child: CircularProgressIndicator(),);

// AccelerometerEventは、加速度センサーの値を保持するオブジェクトです。
// このオブジェクト経由でx軸、y軸、z軸の加速度を取得できます。
AccelerometerEvent event = eventSnapshot.data;

// shapeは、Shapeというenum型のインスタンス。
// 画面の形が丸型(Shape.round)か四角型(Shape.square)かを格納している。
// 画面が四角だった場合のコードは省略する。
switch(shape) {
case Shape.round:
return Container(
height: height,
width: width,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text("ROUND"),
Text("x: ${event.x}"),
Text("y: ${event.y}"),
Text("z: ${event.z}")
],
),
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.yellow,
),
);
~省略~
}
},
);



アプリサイズの比較

基本的に、Flutterアプリは大きくなる傾向があります。他のスマートウォッチアプリと比較します。

アプリ
アプリサイズ

Wear OS
26.33MB

Flutterアプリ
25.76MB

Google Playストア
17.65MB

Google Play Music
10MB

Google MAP
1.74MB

画面表示+加速度センサーの値の取得表示だけのアプリなのですが、やはり大きいですね。限られたリソースしか持たないスマートウォッチよりも、やはりFlutterは「モバイル」のためのフレームワークなんですよ。


まとめ

今回、興味本位でFlutterをスマートウォッチで動かしてみました。

Flutterは、Wear OSでも動きました。通常のモバイルアプリのように開発できます。しかし、Flutterはあくまでもモバイルアプリ用のフレームワークなので、スマートウォッチ用のプラグインがほとんどありません。つまり、こだわればこだわるほど、独自にAndroid・iOSのコードを書く必要があります。

さらに、Flutterアプリは、ちょっとしたものでもアプリサイズが大きくなる傾向があります。モバイルより限られたリソースのスマートウォッチのアプリとしては、適切ではなさそうです。

これはFlutterが悪いとかではなく、単純に向き不向きの問題です。なんていったって、Flutterはモバイルアプリ用のフレームワークなのですから、スマートウォッチに最適化されていなくて当然です。Flutter自体は最高です。はやくバージョン1でないかな。