LoginSignup
257
238

More than 3 years have passed since last update.

アプリ開発にgomobileを利用する(Android/iOS/Flutter)

Last updated at Posted at 2019-07-15

この記事の読み方

  • Android/iOS + Go
  • Flutter + Android/iOS ネイティブ
  • その組み合わせ

この3つから成る記事です。
複数のアプリを作りながら手順等を確認していきます。

Flutter は使わない、Go は知らない、といった方にも参考にしていただけると思います。

  • Android 開発者の方
    Flutter を使わない方は Android + Go までをご覧ください。

  • iOS 開発者の方
    iOS 開発環境がなく未検証のため、iOS の情報は少なめです。
    特に Flutter で使うために Objective-C/Swift で橋渡しする部分はほぼありません。
    それでも、Android での MethodChannel に近いと思われますので、雰囲気は掴めるはずです。
    ライブラリ作成自体や Dart/Flutter で使う部分は OS に関わらず共通です。

  • Flutter 開発者の方
    Go を使わない方は Flutter + AndroidFlutter + iOS をご覧ください。

細かなことは 付録 にまとめましたので、そちらも参考になさってください。

gomobileとは

Go をモバイルアプリ開発に活用できるという素晴らしい代物です。
Mobile · golang/go Wiki · GitHub

これを使って作れるアプリは二種類あります。

  • ネイティブアプリ
    Go だけで作るネイティブアプリ。

  • SDKアプリ
    Go で作ったライブラリを使って作るアプリ。

この記事で扱うのは SDK アプリのほうです。

SDKアプリ

gomobile によって Go のパッケージを基にバインディングが行われてライブラリ化されます。
Kotlin、Swift 等からライブラリを使えるだけでなく、逆方向に呼び出すこともできます。

ライブラリとして生成されるのは次のファイルです。

  • Android
    aar ファイル(Android Archive)

  • iOS
    framework ファイル(Framework Bundle)

Android では ARM / ARM64 / 386 / AMD64 のアーキテクチャに対応しています。
MIPS は非対応です。

Flutter を使い始めるまでは Android/iOS のロジックをこれで共通化して楽をしようと考えていました。

Goを使う理由

  • Dart でやりにくいことを Go に任せられる
    Go には有用なパッケージがあるのに、相当するものが Dart にない場合など。

  • Go が得意なことを Dart/Flutter に持ち込める
    Go は簡単に使える便利な標準ライブラリが豊富です。
    Goroutine による並行処理も得意です。
    サーバサイドで人気の Go をアプリで使えればコードを流用できます。

  • 実行速度の優位性
    Flutter では Dart のコードがネイティブのライブラリにコンパイルされる 1 ので速度に大きな差はなさそうに思えますが、試してみると違いがありました。2

  • C/C++ より扱いやすい
    C/C++ など Go 以外の言語でもライブラリは作れます。
    でも Go ならシンプルな文法、GC 等によって楽をして安全に書けます。

  • Dart より Go に慣れている人が書きやすい
    Dart を使ってみると Web でも使ってみたくなるような素敵な言語でしたが、好みの問題や人的リソースの都合があるので・・・。

  • 楽しい!
    楽しい Go と楽しい Dart/Flutter を組み合わせて使えるなんて至福(著者調べ)。

Goを使うデメリット

  • Android NDK が必要(Android のみ)3
  • アプリのサイズが大きくなる
  • 言語間のバインディングにオーバーヘッドがある 4
  • ターゲット言語側の制限により、エクスポートされた API の見た目に少し制限がある 4 5
  • 使える型が限られている
  • ライブラリ内に作った環境のパスが含まれる 6
  • gomobile 製ライブラリと Flutter を繋ぐ Java/Kotlin、Objective-C/Swift のコードも必要
  • Flutter がせっかくマルチプラットフォーム対応なのに Dart 以外も使うなんて面倒
  • ウェブアプリも作れる Flutter で Go を使うとモバイルアプリ限定になってしまう
  • ライブラリには compute() を使えず、重い処理だとメインスレッドがブロックされる

こう見ると結構ありますね。
メリットとデメリットのどちらが大きいか、ご自身で判断ください。

準備

Windows での手順になりますが、他の環境でもほぼ同じだと思います。7
Android/iOS の開発環境は既に用意されている前提です。

  1. Android NDK のインストール(Android のみ)
    Android Studio にて
    Tools > SDK Manager > 右ペインの SDK Tools タブ
    NDK にチェックが付いていなければ付けて OK または Apply

  2. gomobile のインストール
    コマンドプロンプトか PowerShell にて
    > go get golang.org/x/mobile/cmd/gomobile
    > gomobile init

これだけです。
インストール先の GOPATH/bin/ にパスが通っていない場合は設定が必要です。

-ndk /path/to/ndk という NDK のパス指定を説明しているサイトがありますが、

> gomobile init -ndk /path/to/ndk
flag provided but not defined: -ndk

のように怒られました。
NDK のパスを指定する必要はないようです。8
Windows 以外では未確認ですので、もし NDK のパスのエラーが出たら指定してみてください。

Goによるライブラリ作成

1. コード

非常にシンプルなライブラリを作ってみます。
わざわざ Go でライブラリにしたい類ではありませんが、あくまでわかりやすい例として。

  • 整数を受け取り、倍にした値を返す
  • 受け取る整数の範囲は 0 ~ 10 とする
  • 範囲外の値ならエラーを返す
  • 値を LogCat で確認できるように出力
simple.go
package simple

import "fmt"

func Multiply(value int32) (int32, error) {
    fmt.Println(value)

    if value < 0 || value > 10 {
        return 0, fmt.Errorf("value out of range: must be within the range of 0 to 10")
    }

    return value * 2, nil
}
  • これを GOPATH 以下のどこかに作ったフォルダの中に置く
  • パッケージ名がライブラリの名前になる(フォルダ名は関係ない)
  • Android/iOS のコードや Dart/Flutter から利用したい関数は、先頭を大文字にして export する
    → Android で使うときは先頭は小文字、先頭以外は Go で書いたまま
     [例] Go で GetHoge なら Android で使うときは getHoge(iOS では異なるようです)

関数にコメントを付けておいても、ライブラリの使用時にその情報を参照することはできませんでした。

整数型

Go の int はアーキテクチャに依存し、64 ビット実装の Go では 64 ビット の整数になります。
それに対応する Java と Objective-C の型は それぞれ LongnumberWithLong です。
IntegernumberWithInt にするには、より小さなサイズの int32 等を使いましょう。

型の対応 については付録にまとめています。

情報出力とエラーの扱い

複数の方法で動作を見てみると、かなり癖がありました。
基本的に次のように考えておけば大丈夫かと思います。
詳細は 付録 をご覧ください。

  • 情報を LogCat や Run のウィンドウに表示したい
    fmt.Println() を使う。
    fmt.Print()fmt.Printf() で第一引数の末尾に改行するのも OK。

  • Android/iOS や Dart/Flutter で例外として捕捉したい
    ライブラリで値を返すとき、二つ目の戻り値に error 型のデータを付ける。

他のポイント

長くなるので 付録 に収めました。

2. ライブラリ生成

Android では aar ファイル、iOS では framework ファイルを生成します。
次のようなパスになっているとします。

  • GOPATH
    C:\Go

  • simple.go
    C:\Go\src\hoge\gomobile_example\simple.go

生成には gomobile bind を使います。
ファイルのあるディレクトリを指定する方法と指定しない方法があります。
Android 向けに生成する場合は下のようになります。

コマンド

(a) ディレクトリへの相対パスを指定して生成する場合
※最後の引数は GOPATH/src/ からの相対パス です。
※Windows でもスラッシュ区切りにしないとエラーになりました。

> gomobile bind -target android hoge/gomobile_example

(b) ディレクトリに移動してから生成する場合

> cd C:\Go\src\hoge\gomobile_example\simple.go
> gomobile bind -target android

オプション

  • -o
    出力先を指定するには -o を使います(例: -o path/to/library.aar)。
    ここで指定するパスは カレントディレクトリからの相対パス ですのでご注意ください。
    (a) のほうでは相対パスの起点がややこしいので (b) がオススメです。

    なお、パスにはファイル名まで含める必要があります。
    また、存在しないディレクトリを指定した場合、自動的に作ってくれるわけではありません。

  • -target android/arm64
    Android ではターゲットのアーキテクチャも指定できます。
    スラッシュの後ろは armarm64386amd64 のいずれかです。
    指定しない場合、サポートする全4アーキテクチャの so ファイルを含んだ aar ファイルになります。9

  • -target ios
    iOS では -targetios を指定します。
    Android のようなアーキテクチャの指定には対応していないようです。
    そもそも幅広いバリエーションがあるわけでもないので不要ですね。


  • オプションは他にもあり、gomobile bind -h で確認できます。

ビルド時間、aarファイルのサイズ

これくらい小規模のコードを普段 Go でビルドするときと比べて長くかかります。
環境によりますが、私の PC で Android 向けにビルドしたところ 40 秒ほどでした。

また、aar 内の共有ライブラリ(.so)が一つあたり 2MB 以上、圧縮状態で 1MB 程度になりました。
aar ファイルには 4 アーキテクチャ分が入っていて計 4MB 台です。大きめですね。
ユーザが Google Play ストアからダウンロードするときにはもっと小さくなります。9

Android + Go

ライブラリ導入

Android Studio を使っていきます。
使わずに、次の 1 ~ 2 に載せた diff を参考にしてファイル追加や記述変更を手動で行っても OK です。

1. ライブラリのモジュールを追加

モジュールとは、プロジェクトを分割した機能ごとのアプリのようなものです。
ここでは、ライブラリを一つのモジュールとしてプロジェクトに追加します。

  1. 起動後のウィンドウで「Start a new Android Studio project」を選ぶ

  2. 開いたウィザードで「Empty Activity」を選び、プロジェクトが開くところまで進める

  3. New Module のダイアログを開く

  4. 「Import .JAR/.AAR Package」を選んで「Next」

  5. フォルダアイコンを押し、先ほど生成された aar ファイルを指定してから「Finish」

    Subproject name のところには自動的にサブプロジェクト(モジュール)の名前が入ります。
    自分で変えても良いでしょう。

    これで simple モジュールが追加された状態になりました。

ここまでの操作による変化は次のとおりです(Android Studio 関連ファイルは省いています)。

settings.gradle
-include ':app'
+include ':app', ':simple'
simple/build.gradle
new file mode 100644
+configurations.maybeCreate("default")
+artifacts.add("default", file('simple.aar'))
\ No newline at end of file
simple/simple.aar
new file mode 100644

2. 追加したモジュールを使う設定

追加しただけでは使えません。
メインのモジュールである app から simple を利用できるように依存関係の設定を行います。

  1. Project Structure のダイアログを開く

  2. Dependencies > app を選び、右ペインで「+」を押して「Module Dependency」を選ぶ

  3. 「simple」にチェックをつけて「OK」を押す

  4. 右ペインに「simple」が追加されているのを確認して「OK」を押す

使うための設定はこれで完了です。
この操作による変化は次のとおりです。

app/build.gradle
 dependencies {
     testImplementation 'junit:junit:4.12'
     androidTestImplementation 'com.android.support.test:runner:1.0.2'
     androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
+    implementation project(path: ':simple')
 }

ライブラリを使う

Simple ライブラリを実際に使ったアプリを作ります。

gomobile_android.gif

app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">GomobileAndroid</string>
    <string name="button">Tap here!</string>
</resources>
app/src/main/res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:textSize="32sp"/>
    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/button"/>

</LinearLayout>
app/src/main/java/com/example/gomobile/gomobileandroid/MainActivity.kt
import simple.Simple  // これ以外のインポートは割愛

class MainActivity : AppCompatActivity() {
    private lateinit var textView: TextView
    private var value = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        textView = findViewById(R.id.textView)
        updateText()

        findViewById<Button>(R.id.button).setOnClickListener {
            value++
            updateText()
        }
    }

    private fun updateText() {
        try {
            textView.text = Simple.multiply(value).toString()
        } catch (e: Exception) {
            Log.e("MainActivity", e.message)
        }
    }
}

とても簡単ですね。
ポイントは下記箇所のみです。

import simple.Simple

....

try {
    textView.text = Simple.multiply(value).toString()
} catch (e: Exception) {
    Log.e("MainActivity", e.message)
}

ライブラリのメソッドを使っているだけです。
そのメソッドではエラー時に例外を発生させるようにしているため trycatch を使っています。

ボタンを 12 回押したときの LogCat の出力は下のようになります(途中省略)。
端末画面上の表示は 10 回目の「20」で止まります。

07-14 13:58:37.951 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 1
07-14 13:58:38.246 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 2
...
07-14 13:58:41.244 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 10
07-14 13:58:41.630 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 11
07-14 13:58:41.635 1622-1622/com.example.gomobile.gomobileandroid E/MainActivity: value out of range: must be within the range of 0 to 10
07-14 13:59:00.962 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 12
07-14 13:59:00.966 1622-1622/com.example.gomobile.gomobileandroid E/MainActivity: value out of range: must be within the range of 0 to 10

ライブラリを使う記述をする際のコード補完等については 付録 をご覧ください。

iOS + Go

iOS 開発環境がないため動作は未確認です。

ライブラリ導入

生成した framework ファイルを Xcode でプロジェクトに導入する方法は Wiki に書かれています。
参考にしながら導入してみてください。

ライブラリを使う

Wiki には挨拶のテキストを出力するサンプルコードのスクリーンショットがあります。
ライブラリを利用するためのメソッド使用箇所は次のようになっています。

bind/ViewController.m(スクショより)
textLabel.text = GoHelloGreetings(@"iOS and Gopher");

しかし こちらのサンプル ではメソッド名が異なります。

bind/ViewController.m(サンプルより)
textLabel.text = HelloGreetings(@"iOS and Gopher");

いずれかの情報がアップデートされていなくて古いのかもしれません。

なお、Go で書いた関数 は下記のとおりです。
上記の二つのメソッド名はどちらも、この元の関数名と異なります。
iOS で使うときにはその点の注意が必要です。

hello/hello.goの一部
func Greetings(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

他にも異なる部分があるかもしれませんので、サンプル全体を一度ご確認ください。

Flutter + Android

Go 製ライブラリを Flutter で使う前に、Android 側に書いた機能を Flutter で使う方法を見てみます。
Flutter と Android/iOS の間で連携できるようにする Platform Channel というものを使います。

この図は上記リンク先より拝借したものです。
Platform Channel というのはこの全体の仕組みのことだと思われます。
使うのは MethodChannel(iOS 側だけは FlutterMethodChannel)というものです。

1. Android側(使う機能の作成)

Go で作ったライブラリと同様の機能にしてみます。
まず Flutter の新しいプロジェクトを作りますが、今回も simple という名前にしておきます。

Kotlin をサポートするプロジェクトにするには、Android Studio のウィザードで「Include Kotlin support for Android code」にチェックを付けるか、flutter create コマンドで -a kotlin を付けます。

private fun multiply(value: Int): Int? {
  return if (value in 0..10) value * 2 else null
}

受け取る値が 0 ~ 10 の範囲なら倍数、範囲外なら null を返します。

2. Android側(連携処理)

作ったメソッドを Flutter から使えるように Android 側に連携処理を書きます。
そのために用意されている MethodChannel を使います。

android/app/src/main/kotlin/com/example/simple/MainActivity.kt
class MainActivity: FlutterActivity() {
...
    val methodChannel = MethodChannel(flutterView, "example.com/simple")
    methodChannel.setMethodCallHandler { call, result ->
      when {
        call.method == "simple_multiply" -> {
          val v = call.argument<Int>("value") ?: 0
          val r = multiply(v)
          result.success(r)
        }
        else -> result.notImplemented()
      }
    }
...
}
  • MethodChannel(flutterView, "example.com/simple")
    第二引数の example.com/simple はチャンネルの名前です。
    Flutter のほうでも同じ名前を使うことでやり取りできるようになります。

  • call.method == "simple_multiply"
    simple_multiply は Flutter から機能を呼び出すときの名前です。
    使いたいメソッドが multiply() なので、それを使うことがわかる名前にしました。
    そのようなわかりやすい名前であれば何でも大丈夫です。

  • call.argument("value")
    Flutter 側から渡された引数を取り出す部分です。
    value という引数名を Android と Flutter で共通使用する必要があります。
    受け取った値は null の場合もあるため、そのことを考慮しておく必要があります。

  • result.success(~)
    成功したときに結果を返す処理です。
    ( ) 内に指定した値を Flutter 側で受け取ることができます。

  • result.error("エラーコード", "エラーメッセージ", "エラー詳細")
    上のコードにはありませんが、これを使うと Flutter 側で PlatformException になります。
    各引数に指定する情報を Flutter で取得できます。
    第3引数は Object 型なので String に限りません(使わないなら null で OK)。

  • result.notImplemented()
    存在しない名前で機能を呼び出された場合にこれを使っています。
    このとき Flutter 側で MissingPluginException として捕捉することができます。

MainActivity 全体のコードは次のようになります。

android/app/src/main/kotlin/com/example/simple/MainActivity.kt
package com.example.simple

import android.os.Bundle

import io.flutter.app.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

class MainActivity: FlutterActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)

    val methodChannel = MethodChannel(flutterView, "example.com/simple")
    methodChannel.setMethodCallHandler { call, result ->
      when {
        call.method == "simple_multiply" -> {
          val v = call.argument<Int>("value") ?: 0
          val r = multiply(v)

          if (r == null) {
            result.error("Out of range", "value must be within the range of 0 to 10", v)
          } else {
            result.success(r)
          }
        }
        else -> result.notImplemented()
      }
    }
  }

  private fun multiply(value: Int): Int? {
    return if (value in 0..10) value * 2 else null
  }
}

multiply() の結果が null のときは result.error() でエラーにしています。

3. Flutter側(ヘルパークラス)

Flutter 側でも MethodChannel を使います。
使うためには package:flutter/services.dart のインポートが必要です。

UI のコードにロジックが混じらないように、Simple というヘルパークラスを作ることにしました。

lib/simple.dart
import 'package:flutter/services.dart';

class Simple {
  static const _platform = MethodChannel('example.com/simple');

  static Future<int> multiply(int count) async {
    final arguments = {'value': count};

    try {
      return await _platform.invokeMethod<int>('simple_multiply', arguments);
    } on PlatformException catch (e) {
      print(e);
    } catch (e) {
      print(e);
    }

    return null;
  }
}
  • MethodChannel('example.com/simple')
    Android 側で設定したのと同じチャンネル名を指定します。

  • Future<int> multiply(int count) async
    Android 側から返ってくるのは Future です。

  • final arguments = {'value': count};
    Android 側に値を渡すには、このように Map にする必要があります。
    キーは Android 側で設定した名前に合わせます。

  • return await _platform.invokeMethod('simple_multiply', arguments);
    第一引数は Android 側で設定した呼び出し名です。
    第二引数には渡したい引数の Map を指定します。
    await はここでしないと例外を補足できません。
    「invoke」で始まるメソッドは他に invokeListMethod()invokeMapMethod() があります。

  • on PlatformException catch (e)
    Android 側で result.error() に指定した情報をここで得ることができます。

    • e.code エラーコード
    • e.message エラーメッセージ
    • e.details エラー詳細

4. Flutter側(完成)

ヘルパークラスを使うメインのファイルは次のようにしました(一部省略)。

lib/main.dart
import 'simple.dart';
...
class _MyAppState extends State<MyApp> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              FutureBuilder<int>(
                future: Simple.multiply(_count),
                initialData: 0,
                builder: (_, snapshot) {
                  return Text(snapshot.hasData ? snapshot.data.toString() : '--');
                },
              ),
              RaisedButton(
                onPressed: () {
                  setState(() => ++_count);
                },
                child: const Text('Tap Here!'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

カウンターの値を Dart で持ち、ボタンが押されたときにインクリメントします。
その際に setState() しているため全体がリビルドされます。
都度 Simple.multiply(_count) が実行され、返ってきた FutureFutureBuilder で処理しています。

multiply() に渡す値が 11 以上だとエラーになり、Flutter 側では例外が発生します。
アプリを起動してボタンを 11 回押すと、Run ウィンドウに次のように出力されました。

I/flutter ( 3664): PlatformException(Out of range, value must be within the range of 0 to 10, 11)

例外については、付録 にもう少し細かく書いています。

Hot Reload/Restart

Hot Reload/Restart は Flutter の機能です。
Android 側の処理を変えたときには当然 Hot Restart しても反映されません。

しかし Android 側はしっかりと書いてしまえばその後はあまり変えることはないはずです。
さほど不便ではないと思います。

Flutter + iOS

flutter.dev のドキュメント を参考にしてみてください。
iOS ホスト側でバッテリーの情報を取得して Flutter で利用する方法が解説されています。

Android の MethodChannel に相当するものは iOS では FlutterMethodChannel です。
環境の都合で未検証ですが、コードを見ると MethodChannel の使い方に近いです。
チャンネルや呼び出しの名前を設定する点や、エラー時にコード等3種類の情報を返せる点が同じです。
OS によって Flutter 側の書き方を変えなくていいように共通化されているようです。

Flutter + Android + Go

Go で書いた処理を Flutter で使うのはもうここまでのことを組み合わせるだけです。
操作に関して少しだけ違いがあります。

Flutter のプロジェクトで Android の MainActivity.kt を開くと、ライブラリが認識されません。
上のスクリーンショットでは Nudity が赤くなっています。
右上に出るリンクで Android のプロジェクトを開き、ライブラリの導入等の操作はそちらで行いましょう。

Simple カウンター

Go で作った Simple ライブラリを Flutter で使ってみます。

Flutter + Android のコードと違うのは下記の trycatch の部分だけです。
ライブラリのエラーによって発生した例外を Android 側 で catch し、result.error() を使って Flutter 側でも catch できるようにしました。

MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/simple")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "simple_multiply" -> {
      val v = call.argument<Int>("value") ?: 0
      try {
        result.success(Simple.multiply(v))
      } catch (e: Exception) {
        result.error("Go Simple", e.message, null)
      }
    }
    else -> result.notImplemented()
  }
}

このように、本当にここまでの技術の組み合わせるだけでできてしまいます。

ヌード写真判定

Awesome Gogo-nude という Go のパッケージを見つけました。
nude.js というライブラリを Go に移植したものだそうです。

JavaScript でできるなら Dart でもできそうですが、まだ存在しないようです。
使いたくてもまだ無いケースとして Go の利用が適していると考えました。

flutter_nudiry.gif

※このスクリーンキャスト内では go-nude の example/images/ にある画像を使いました。

※実用性は低そうです。
 判定が厳しすぎるかと思ったら、逆に景色の写真がヌードと判定されることもあったりします…。10

nudity.go
package nudity

import (
    "github.com/koyachi/go-nude"
)

const (
    Unknown int = iota
    IsNotNude
    IsNude
)

func Check(path string) (int, error) {
    isNude, err := nude.IsNude(path)
    if err != nil {
        return Unknown, err
    }

    if isNude {
        return IsNude, nil
    }

    return IsNotNude, nil
}
MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/nudity")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "nudity_check" -> {
      val imagePath = call.argument<String>("imagePath")
      result.success(Nudity.check(imagePath))
    }
    else -> result.notImplemented()
  }
}
nudity.dart
import 'package:flutter/services.dart';

class Nudity {
  static const _platform = MethodChannel('example.com/nudity');

  static const unknown = 0;
  static const isNotNude = 1;
  static const isNude = 2;

  static Future<int> check(String path) async {
    final arguments = {'imagePath': path};
    return await _platform.invokeMethod<int>('nudity_check', arguments);
  }
}

画像選択は Flutter で行い、画像パスをライブラリに渡して判定結果の数値を受け取っています。
ここまでに見てきたことと大差なく、特筆することはありません。
例外処理は省きました(以下同様)。

画像変換

画像変換は時間がかかることがあります。
でもグレースケール変換くらいは一瞬でできてほしいところです。

ところが、Dart で変換してみると待たされてしまいました(画像サイズ等にもよります)。
こういったものは Go でやれば速くなるのではないかと考えました。

flutter_grayscale.gif

grayscale.dart
package grayscale

import (
    "bytes"
    "fmt"
    "github.com/anthonynsimon/bild/effect"
    "github.com/anthonynsimon/bild/imgio"
    "image/jpeg"
)

func Convert(path string) ([]byte, error) {
    img, err := imgio.Open(path)
    if err != nil {
        return nil, fmt.Errorf("failed to open image: %v", err)
    }

    img = effect.Grayscale(img)

    buf := new(bytes.Buffer)
    err = jpeg.Encode(buf, img, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to save image: %v", err)
    }

    return buf.Bytes(), nil
}
MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/grayscale")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "grayscale_convert" -> {
      val path = call.argument<String>("imagePath")
      result.success(Grayscale.convert(path))
    }
    else -> result.notImplemented()
  }
}
grayscale.dart
import 'dart:typed_data';
import 'package:flutter/services.dart';

class GrayScale {
  static const _platform = MethodChannel('example.com/grayscale');

  static Future<Uint8List> convert(String path) async {
    final arguments = {'imagePath': path};
    return await _platform.invokeMethod<Uint8List>('grayscale_convert', arguments);
  }
}

MainActivity は先ほどとほとんど同じです。
Go では変換後の画像を []byte 型にして返します。
それを Kotlin では ByteArray、Dart では Uint8List として受け取っています。
Uint8List のデータは Flutter で Image.memory() にそのまま渡して画像表示できます。

リリースビルドして大きめの画像を変換したところ、所要時間に差が出ました。

Dart Go
1 回目 11.96 秒 1.55 秒
2 回目 11.92 秒 1.41 秒
3 回目 11.89 秒 1.44 秒
4 回目 11.96 秒 1.46 秒
5 回目 11.94 秒 1.49 秒
  • 利用したパッケージ

メインスレッドのブロッキング

画像変換の間に CircularProgressIndicator を表示すると、クルクル回るアニメーションが止まりました。
そこで compute() を使ってバックグラウンドの isolate で処理させたかったのですが、ライブラリのほうに使うと例外が発生しました。
isolate で扱うのがプリミティブな値ではなく Future なのがたぶん原因です。
これが解決できないと辛い場合があるかもしれません。

ojichat

これで最後です。

Go のパッケージに ojichat というものがあります。
おじさんがLINEやメールで送ってきそうな文を生成してくれる楽しいパッケージです。

これを使ったチャット風アプリが簡単にできました。
コードは省略します。

flutter_ojichat.gif

付録

ここまでに書いた以外に知っておくと良いことをまとめました。

gomobileのポイント

型の対応

Go からエクスポートするものは全てサポートされている型である必要があります。
Type restrictions(gobind - GoDoc)

  • 符号付きの整数型・浮動小数点型

  • 文字列型、論理型

  • byte スライス型
    参照渡しとなり、渡した先での変更は元のスライスに反映されます。

  • 関数型
    仮引数や戻り値はサポート対象の型にすること。
    戻り値を二つにする場合、二つ目は error 型に限られます。

  • インタフェース型
    export されるメソッドはサポート対象の関数型にすること。

  • 構造体型
    export されるメソッドはサポート対象の関数型にすること。
    export されるフィールドはサポート対象の型にすること。

サポートされている型は以上です。

スライスとマップが含まれていないのが気になるので試すと、やはりどちらもダメでした。
Go ではよく使うものなので、これらが使えないのはちょっと不便かもしれません。

また、byte スライス型の「参照渡し」も試しました。
Kotlin から受け取って中身を Go で変えると Kotlin 側でも変わっていました。
しかし、逆だと変化がありませんでした。
(Kotlin に不慣れで、扱い方を間違えた可能性もあります。)

情報出力、エラー

★印が付いている二つのどちらかを用途に合わせて使いましょう。

  • fmt.Print("message") fmt.Printf("%s", "message")
    意外なことに、何も起こりませんでした。

  • fmt.Println("message") fmt.Printf("message\n")
    LogCat や Flutter の Run ウィンドウに Info レベルの情報として出てきます。
    タグは「GoLog」です。
    Printf() でも Println() と同じ意味になるように末尾に改行を置けば OK のようです。

  • fmt.Print("message\nhoge") fmt.Printf("%s\nhoge", "message")
    なんと!
    メッセージの途中に改行があると、順序が逆転して「hoge message」になりました…。

  • 関数の二つ目の戻り値としてエラーを返す
    Android 側や Flutter 側で例外として補足できます。
    例外処理をしない場合、Flutter ではないネイティブの Android アプリは異常終了します。

    一方 Flutter のアプリは、Android 側で例外処理をし忘れていても生き続けます。
    アプリが丸ごと落ちないように対策されているようで、例外の情報が出力されるだけです。
    その場合、Flutter 側では MissingPluginException となります。

    Android 側で例外を無視して Flutter でハンドルすることもできますが、微妙です。
    ライブラリの異常は全て上記例外となり、種類をメッセージで判別するしかなくなります。
    それよりもきちんと Android 側で対応したほうが良さそうです。

  • log.Fatal("message") log.Fatalf("message") log.Fatalln("message")
    os.Exit(1) を呼ぶものなのでアプリごと終了します。
    その際、指定したメッセージが Info レベルの情報として出力されます。

    改行の有無は関係なく情報が出力されました。
    また、途中に改行があっても出力順序は逆転せず、改行は改行として出ました。

  • panic("message")
    Android 側を巻き込んで異常終了してしまいます。
    その際、指定したメッセージが Error レベルの情報として出力されます。
    タグは「GoLog」ではなく「Go」です。

    当然ですが、次のように recover() で回復させれば異常終了は防げます。

defer func() {
    if r := recover(); r != nil {
        fmt.Println(r)
    }
}()

構造体、レシーバー

Passing Go objects to target languages(gobind - GoDoc)

この記事で見てきた例では、値を保持するのは Flutter 側や Android 側でした。
実験的に Go のライブラリ内で状態保持させてみたいと思います。
ライブラリのパッケージ内の変数に持たせれば簡単ですが、あえて構造体を使ってみます。

counter.go
package counter

type GoCounter struct {
    value int32
}

func NewGoCounter() *GoCounter {
    c := new(GoCounter)
    c.value = 0
    return c
}

func (c *GoCounter) Increment() int32 {
    c.value++
    return c.value
}

NewGoCounter()GoCounter という構造体を初期化してそのポインタを返します。11 12
それをレシーバーとする Increment() では、構造体が持つ value の値を 1 増やして結果を返します。

Java や Dart にレシーバーはありませんが、どうなるのでしょうか。
上記コードで作ったライブラリを Android のプロジェクトに導入し、デコンパイルした情報を見てみます。
見方については バインディングの中身 を参照してください。

Counter.class(デコンパイル)
package counter;

public abstract class Counter {
    private Counter() { /* compiled code */ }

    public static void touch() { /* compiled code */ }

    private static native void _init();

    public static native counter.GoCounter newGoCounter();
}

Counter というクラスが作られ、newGoCounter() をメソッドとして持っているのがわかります。
newGoCounter() が返すのは Go 側では GoCounter のポインタ型ですが、Java/Kotlin 側はポインタではありません。

GoCounter のクラスも作られているので見てみましょう。

GoCounter.class(デコンパイル)
package counter;

public final class GoCounter implements go.Seq.Proxy {
    private final int refnum;

    public final int incRefnum() { /* compiled code */ }

    public GoCounter() { /* compiled code */ }

    private static native int __NewGoCounter();

    GoCounter(int i) { /* compiled code */ }

    public native int increment();

    public boolean equals(java.lang.Object o) { /* compiled code */ }

    public int hashCode() { /* compiled code */ }

    public java.lang.String toString() { /* compiled code */ }
}

こちらには自分で書かなかったプロパティやメソッドも含まれています。
それよりも注目すべきはコンストラクタです。
NewGoCounter という名前から判断して勝手にコンストラクタを用意してくれています。

また、もし構造体のフィールドを export していた場合にはゲッターとセッターが自動的に用意されます。
今回は value を export していないので含まれていません。

次は Android 側です。

MainActivity.kt
import counter.GoCounter
...
val methodChannel = MethodChannel(flutterView, "example.com/counter")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "counter_init" -> {
      goCounter = GoCounter()
    }
    call.method == "counter_increment" -> result.success(goCounter.increment())
    else -> result.notImplemented()
  }
}

GoCounter の初期化は Counter.newGoCounter() でもできますが、先ほどのコンストラクタを使って GoCounter() としました。
これにより、Counter は使わずに済みました。

GoCounter のインスタンスを Flutter に渡せると良いのですが、無理でした。
Java/Kotlin のオブジェクトを渡す何らかの方法ができるかもしれません。
代わりに Android 側で保持しておくことにしました。

Flutter 側のヘルパークラスは次のようにしました。

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

class Counter {
  static const _platform = MethodChannel('example.com/counter');

  static Future init() async {
    await _platform.invokeMethod('counter_init');
  }

  static Future<int> increment() async {
    return _platform.invokeMethod<int>('counter_increment');
  }
}

ライブラリの increment() を呼び出し、結果を UI に反映するだけで済みます。
Flutter側で状態管理しないでカウンターのアプリが実現しました。
残りのコードは割愛します。

インタフェース

gobind のドキュメント のコード例がわかりやすいので抜粋します。

Goのインタフェース
package myfmt

type Printer interface {
    Print(s string)
}

func PrintHello(p Printer) {
    p.Print("Hello, World!")
}
bindによって自動生成されるJavaのインタフェース
public abstract class Myfmt {
    public static void printHello(Printer p0);
}

public interface Printer {
    public void print(String s);
}
Javaでインタフェースを実装して利用
public class SysPrint implements Printer {
    public void print(String s) {
        System.out.println(s);
    }
}

Printer printer = new SysPrint();
Myfmt.printHello(printer);
  1. Go で書いたライブラリに Printer というインタフェースがある
  2. そのインタフェースを Java で実装して SysPrint クラスとする
  3. インタフェースが持つメソッドである print() を SysPrint 内で具象化する
  4. SysPrint のインスタンスを生成し、それをライブラリの PrintHello() に渡す
  5. PrintHello() の結果が SysPrint.print() を使って出力される

Go と Java/Kotlin ではインタフェースの書き方が大きく異なります。
それにもかかわらず、違和感なく使えるようにうまくできていますね。

先ほどの 構造体、レシーバー のところでもそうでしたが、言語間の差異がうまく緩衝されているのがわかると思います。

Goからアプリ側ネイティブAPIへのアクセス

Reverse bindings(gobind - GoDoc)

ここまでとは逆に Go から Java や Objective-C で用意された API にアクセスできる旨が書かれています。
上記ページには次のような例が掲載されています。

java.lang.SystemをGoで読み込んでcurrentTimeMillisメソッドを利用
import "Java/java/lang/System"

t := System.CurrentTimeMillis()

実際にやってみると確かにできました。
ただし、GoLand 等の IDE では存在しないメソッドのように扱われ、利用しにくかったです。

NSDateをGoで読み込んでdateメソッドを利用
import "ObjC/Foundation/NSDate"

d := NSDate.Date()

これだけに留まらず、例えば Android なら次のように Go で Activity を継承することもできるようです。
面白いですね。

GoでAndroidのActivityを継承してMainActivityを作る
import "Java/android/app/Activity"

type MainActivity struct {
    app.Activity
}

メモリリークの危険性

Avoid reference cycles(gobind - GoDoc)

今見たように、Go とターゲットの間で双方向にデータをやり取りできます。
片方が他方のオブジェクトへの参照を持っている場合、そのオブジェクトへのアクセスがなくなると、オブジェクトの実体を持っているほうの言語で GC によって適切に参照が破棄されるようです。13

しかし、もし参照を相互に持っているとオブジェクトを回収できなくなり、メモリリークが発生します。
そんなことはあまりしないと思いますが、ちょっと注意が必要なところだと思います。

Flutter側の例外処理

Flutter + Android で扱ったコードを使って見ていきます。

simple.dart の on PlatformException catch (e) のブロックを変えてみましょう。
次のように変えると、Android 側の result.error() で指定した情報がちゃんと出力されます。

lib/simple.dartの一部を改変
on PlatformException catch (e) {
  print(e.code);
  print(e.message);
  print(e.details);
}
I/flutter ( 3664): Out of range
I/flutter ( 3664): value must be within the range of 0 to 10
I/flutter ( 3664): 11

今後はチャンネル名を変えてみます。
「simple」を「hoge」に変えると MissingPluginException が出ました。
括弧内を訳すと「example.com/hoge チャンネルには simple_multiply メソッドの実装が見つからない」です。

lib/simple.dartの一部を改変
static const _platform = MethodChannel('example.com/hoge');
I/flutter ( 3664): MissingPluginException(No implementation found for method simple_multiply on channel example.com/hoge)

最後に trycatch を使わないようにしてみます。

lib/simple.dartの一部を改変
final arguments = {'value': count};
return await _platform.invokeMethod<int>('simple_multiply', arguments);

ボタンを 11 回以上押してもチャンネル名を変えても、何も出力されませんでした。
意図的に無視することもできるようになっているようです。
しかし、異常に気づいて対応できるように trycatch しておくのが良いと思います。

Docker

go4droid/Dockerfile at master · mpl/go4droid · GitHub
https://github.com/mpl/go4droid/blob/master/Dockerfile

gomobile の Wiki からリンクされている Dockerfile です。
既存環境を汚したくない方にはおすすめです。
また、Go で作ったライブラリに環境の情報(パス)が含まれるのを気にする方は対策に使えます。

ただし、ファイルの中身を見ると対象の環境が古いです。
Android や Go のバージョンを書き換えて使う必要があると思います。

Android Studioについて

バインディングの中身

自作ライブラリであっても、Android Studio は使い方がわかるように補助してくれます。14

MainActivity のコードの中でライブラリのクラス名にカーソルの上で
右クリック > Go To > Declaration
と操作すると、ライブラリの class ファイルをデコンパイルしたものが表示されます。

Simple.class(デコンパイル)
package simple;

public abstract class Simple {
    private Simple() { /* compiled code */ }

    public static void touch() { /* compiled code */ }

    private static native void _init();

    public static native int multiply(int i) throws java.lang.Exception;
}

最初に見たサンプル(Simple ライブラリ)だと次のようになります。
作ったライブラリを Java/Kotlin でどう使えばいいのかわかりやすくて助かりますね。

  • int multiply(int i)
    仮引数も戻り値も int になっています。
    これは Go で Multiply(value int32) int32 のように int32 を使ったためです。
    64 ビットの Go で Multiply(value int) int とすると long multiply(long l) になります。

  • throws java.lang.Exception
    multiply() で二つ目の戻り値によってエラーを返さない場合、例外はスローされません。

デコンパイルで得られたクラス/メソッド等の定義の情報は
View > Quick Definition
の操作でも表示されます。

コード補完やクラス・メソッド等の情報表示もしてくれて助かります。

ライブラリの更新方法

ライブラリの中身を変えた場合、aar ファイルを上書きするだけで変更が適用されます。
ただし、Android Studio はその変更をすぐに認識しません。
変更をコード補完などにも反映するには、プロジェクトを開き直す必要があります。

その方法で反映されないときは、app の build.gradle から

implementation project(path: ':simple')

を消してから再追加し、プロジェクトの sync をしたところ、ようやく反映されました。
少し手間ですが、そこまですると確実です。

Android App Bundle

Go で作ったライブラリはサイズが大きめになりがちです。
特に複数のアーキテクチャ向けのファイルが含まれていると大きくなります。

少しでもユーザにやさしいサイズになるよう、ストアには APK ではなく App Bundle にしましょう。
そうすれば、必要なアーキテクチャの APKs にしてくれたり、モジュール単位のダウンロードが可能になったりします。


  1. Flutter の FAQ の中で説明されています。 

  2. 必要に応じて CGO を使って C も組み合わせれば速度の違いは更に大きくなるかもしれません。 

  3. C/C++ で作る場合も NDK は必要で、Go だからではありません。 

  4. https://github.com/golang/go/wiki/Mobile#sdk-applications-and-generating-bindings 

  5. "The equivalent of calling newCounter in Go is GoMypkgNewCounter in Objective-C. The returned GoMypkgCounter* holds a reference to an underlying Go *Counter." 見た目の制限とはこのあたりのことかなと思います。https://godoc.org/golang.org/x/mobile/cmd/gobind#hdr-Passing_Go_objects_to_target_languages 

  6. gomobile に限らず Go 自体がそういうものです 

  7. 記事執筆時の調査等には Go 1.12.7 (windows/amd64)、Flutter 1.7.8+hotfix.3 (channel stable)、Dart 2.4.0、Android Studio 3.4.2 を使用しました。 

  8. NDK のパスを環境変数の Path に設定する必要もありませんでした。数年前に使っていたときには設定した記憶があるのですが、不要になったのかもしれません。 

  9. サポートしたいアーキテクチャ分をすべて含んだ App Bundle をストアにアップロードすると、ユーザの利用端末に合わせて自動的に最適化した APK を配信してくれるため、複数を含んでいることを気にする必要はないと思います。32/64 ビット両方を対象に含めた App Bundle の生成は、先日リリースされたばかりの Flutter 1.7 で可能になりました。 

  10. 研究論文に基づいて実装されたものだそうです。また、nude.js の作者の ブログ には "I wouldn’t recommend using the library in production mode right now because the detection rate is about 60%" と書かれています。 

  11. 型を初期化する関数(コンストラクタのようなもの)の名前の先頭に「New」を付けるのは Go の慣習です。 

  12. ポインタにしないとこの関数のバインディング自体が行われませんでした。 

  13. ちょっと理解があやふやです。間違っていればご指摘ください。 

  14. Visual Studio Code はこの点は不十分なようです。 

257
238
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
257
238