ベータコンピューティングの活動や技術、開発のこだわりなどを紹介するブログです。



Flutterでパズルを作ってみた話

弊社Android・クロスプラットフォーム担当のd-ariakeです。

弊社でもクロスプラットフォーム開発においてFlutterを採用しています。
今回はFlutter for Webでシンプルなパズルを作ってみたので、紹介させていただこうと思います。

作ったもの

このようなパズルを作ってみました。

flutter_puzzle

GitHub Pagesに置いてあります。
ぜひ遊んでみてください。
https://betacomputing.github.io/flutter_puzzle/

ソースコードはこちらに置いてあります。
https://github.com/BetaComputing/flutter_puzzle

Flutter for Webについて

3/4に Flutter 2.0 がリリースされましたが、その際にFlutterのWebサポートがstable化しました。
私もなにかFlutterでWebアプリを作ってみたいと思ったので、さっそくこのパズルを作ってみました。

特に手動で有効化設定を行わずともWebサポートが有効化されているので、

flutter create --org jp.co.betacomputing --platforms web ./flutter_puzzle

といったコマンドでWeb用のFlutterプロジェクトが作成することができます。

Webアプリ用ビルドは

flutter build web --web-renderer html

というコマンドで行うことができます。

上記ビルドコマンドにもオプションとして指定していますが、Flutter for Webには htmlcanvaskit の2通りのレンダリング方式があります。
それぞれダウンロードサイズの削減・他プラットフォームとの互換性とパフォーマンス性というメリットがあるようです。

ですが、現状はCanvasKitによるレンダリングにおいて正しく文字列が描画されないというバグがあるようなので、 --web-renderer html を指定したほうが良さそうです。

参考:

スライドアニメーションについて

このパズルを遊んでもらうとわかると思いますが、ピースを選択するとスライドアニメーションが再生されるようになっています。
これは AnimatedPadding を利用して実現させています。

パズルのピースを配置するコード は以下のとおりです。
各ピースの左と上のパディングを設定することによって任意の位置に配置するという実装アプローチを取っています。

//  パズルの複数のピースを生成する。
Widget _buildPieces(double pieceWidth, double pieceHeight) {
  return Stack(
    children: puzzle.pieces.map(
      (piece) {
        final offsetX = pieceWidth * piece.currentPos.hIndex;
        final offsetY = pieceHeight * piece.currentPos.vIndex;

        return AnimatedPadding(
          padding: EdgeInsets.only(left: offsetX, top: offsetY),
          duration: animDuration,
          child: SizedBox(
            width: pieceWidth,
            height: pieceHeight,
            child: _buildPiece(piece),
          ),
        );
      },
    ).toList(),
  );
}

あるピースがクリックされ、1つ右のマスに移動する状況を考えます。
右に移動するため、水平方向のインデックス piece.currentPos.hIndex の値が + 1 されます。
すると、 EdgeInsets.only()left には、更新前の値 offsetX よりも pieceWidth 分だけ大きな値が指定されます。
ここで AnimatedPadding を利用しているので、更新前と更新後の左パディングの値を線形補間しながら、なめらかに表示されるように描画してくれます。

更新後の値を渡すだけで、アニメーションの途中経過の面倒な座標計算を一切せずとも、Flutterが自動的にいい感じにしてくれました。 🙌

参考:

GitHub Pagesについて

最後にビルドとデプロイに関して軽く説明します。

見ての通り、このパズルはGitHub Pagesに配置しています。
タグをプッシュすると自動的にビルドが走り、ビルド内容が gh-pages ブランチにコミットされるように設定しました。

Release.yml:

  - name: Build (Web)
    run: flutter build web --web-renderer html

  - name: Deploy
    uses: peaceiris/actions-gh-pages@v3
    with:
      github_token: ${{ secrets.GITHUB_TOKEN }}
      publish_dir: ./build/web

gh-pages ブランチへの配置には peaceiris/actions-gh-pages@v3 というactionを利用しています。 GitHubのトークンとビルド先のディレクトリと指定するだけで、自動的にいい感じにしてくれます。 🙌

FlutterとGitHub Pagesを使えば、こんなに簡単にWebアプリが作れちゃいました。
今後も簡単なWebアプリであればFlutterで作ってみようと思います。 😤

おわりに

Beta Computing株式会社は石川県のスマートフォンアプリ開発に特化したソフトウェア会社です。 Kotlin・Swiftによるスマホアプリ開発も、FlutterやXamarin.Forms、Unityによるクロスプラットフォームのスマホアプリ開発も、得意としているスマホアプリ開発のプロフェッショナルです。

スマホアプリ開発をご検討されているのでしたら、是非私たちBeta Computing株式会社におまかせください! 業種・ジャンル問わず対応可能ですので、ぜひご相談下さい。

Xamarinの共有プロジェクトでも単体テストが書きたい!

弊社Android担当のd-ariakeです。

現在、Xamarinの改修案件をさせてもらっています。
そのプロジェクトで単体テストを書こうと思ったのですが、少しハマってしまいましたので、それを共有したいと思います。

このプロジェクトはXamarinネイティブの 共有プロジェクト でした。
共有プロジェクトは単なるソースコードの置き場で、AndroidとiOSのそれぞれのプロジェクトでそのソースファイルを参照するといった仕組みです。

この案件には既存のコードがあり、そのソースではがっつりそのままXamarin固有のコードが多用されていました。
ですので、そのまま .NET CorexUnit プロジェクトで共有プロジェクトの参照を追加してしまうと、Xamarin固有のコードを含むソースファイルでエラーになってしまいます。

↓ こんな感じに、Xamarin.AndroidプロジェクトとXamarin.iOSプロジェクトから見たときはビルドが通りますが、単体テストプロジェクトから見たときにエラーになります。

f:id:betacomputing3:20201124135257p:plain f:id:betacomputing3:20201124135309p:plain

単体テストでテストしたい部分というのはXamarinに依存しない計算ロジックや判定ロジックだったりします。
そのため、 共有プロジェクトをまるごと参照に追加するのではなく、ソースファイル単位での追加をすること で解決することができました。

↓ これです。

f:id:betacomputing3:20201124135316p:plain

既存のファイルの追加で、Xamarinに依存していないソースコードのみを単体テストプロジェクトに追加します。
もし、テストをしたい箇所でXamarin固有の機能 (Preferencesなど) を使用している場合、適切にリファクタリングをしてあげましょう。

固有の機能を必要とする箇所を IHogeHogeProviderIHugaHugaRepository などといったインタフェースで切り、テストをしたいビジネスロジックがインタフェースのみに依存するように変更します。

先ほどの画像でも Xamarin.Essentials.Preferences を使っている箇所がありましたが、 IHogePrefereces というインタフェースと HogePreferences (Impl) という具象型をつくり、単体テストプロジェクトには IHogePrefereces のみを追加しました。
これでちゃんと単体テストプロジェクトでもビルドが通るはずです。

そして、もう一箇所ハマったので、そちらも共有します。
私はテストに Moq というモックライブラリを使っているのですが、何も設定せずに使うと以下のようなエラーが発生しました。

System.ArgumentException : Cannot set up 〇〇 because it is not accessible to the proxy generator used by Moq: Can not create proxy for method 〇〇 because it or its declaring type is not accessible. Make it public, or internal and mark your assembly with [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] attribute, because assembly 〇〇 is not strong-named.

怒られている内容に従い、 AssemblyInfo.cs に以下のような属性を追加します。

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]

しかし、最初は Hoge.SharedHoge.Shared.Tests というプロジェクトがあったとき、Hoge.Shared の方に書いてしまい、エラーが消えてくれませんでした。
共有プロジェクトは単なるソースコード置き場で、それを取り込む側の単体テストプロジェクトでアセンブリが吐かれるので Hoge.Shared.Tests の方に書かないといけません。 (ここで少しハマってしまいました。)
単体テストプロジェクトの方に書き直すと、正常にMoqによるモックが動作してくれました。 🎉

共有プロジェクトの保守をする際には是非参考にしてみてください!

先日のFlutterでのサンプルアプリをAndroidネイティブアプリでも作ってみた件

Android担当のd-ariakeです。
先日 弊社もFlutter採用致します! - Beta Computingブログ という記事の中で このような サンプルアプリを作り、紹介したかと思います。

今回はこのサンプルアプリと同じようなアプリをAndroidネイティブでも作ってみたので、設計の比較やポイントをご紹介したいと思います。

作ったアプリ

仕様は先日のものとほとんど変わっていません。

https://github.com/BetaComputing/SimpleQiitaClientAndroid

image

この同じような仕様のアプリをネイティブで作った際のポイントを軽く解説していきます。

ポイント

以下が今回説明するポイントとなります。

  • UIの設計
  • ネット上の画像のロード
  • FlexboxLayoutの導入

UIの設計

Flutter版では BLoC パターンで設計していましたが、Androidネイティブ版では MVVM パターンで設計を行いました。

UIはxmlで記述し、ViewModelにUIを制御するための変更通知機構を持ったプロパティ (LiveData) をもたせ、データバインディングによってViewModelのプロパティの状態をUIに反映させています。

internal class MainViewModel(/* 省略 */) : ViewModel() {

    //  取得中かどうか
    private val isFetching: MutableLiveData<Boolean> = MutableLiveData(false)

    //  記事リスト
    private val _articleList = MutableLiveData<List<Article>>()
    val articleList: LiveData<List<Article>> = this._articleList

    //  検索キーワード
    val keyword: MutableLiveData<String> = MutableLiveData("")

    //  検索ボタンが有効かどうか
    val isSearchButtonEnabled: LiveData<Boolean> = MediatorLiveData<Boolean>().also {
        val onChanged = { it.value = this.isFetching.value == false && !this.keyword.value.isNullOrEmpty() }
        it.addSource(this.isFetching) { onChanged() }
        it.addSource(this.keyword) { onChanged() }
    }

    //  プログレスバーを表示するかどうか
    val isProgressBarVisible: LiveData<Boolean> = this.isFetching

    //  検索ボタンがクリックされたとき。
    fun onSearchButtonClicked() { /* 省略 */ }

    //  検索を行う。
    private suspend fun search(keyword: String) { /* 省略 */ }
}
<layout <!-- 省略 --> >

    <data> <!-- 省略 --> </data>

    <LinearLayout <!-- 省略 --> >

        <com.google.android.material.appbar.AppBarLayout />

        <LinearLayout <!-- 省略 --> >

            <androidx.constraintlayout.widget.ConstraintLayout <!-- 省略 -->>

                <com.google.android.material.textfield.TextInputEditText
                    android:id="@+id/keywordEditText"
                    <!-- 省略 -->
                    android:text="@={viewModel.keyword}" />

                <com.google.android.material.button.MaterialButton
                    android:id="@+id/searchButton"
                    <!-- 省略 -->
                    android:enabled="@{viewModel.isSearchButtonEnabled}"
                    android:onClick="@{() -> viewModel.onSearchButtonClicked()}" />

            </androidx.constraintlayout.widget.ConstraintLayout>

            <ProgressBar
                style="@style/Widget.AppCompat.ProgressBar.Horizontal"
                <!-- 省略 -->
                android:visibility="@{viewModel.isProgressBarVisible ? View.VISIBLE : View.INVISIBLE}" />

            <androidx.recyclerview.widget.RecyclerView <!-- 省略 --> />

        </LinearLayout>

    </LinearLayout>

</layout>

例えば、検索ボタンの活性の制御の場合です。
以下のコードで、取得処理中かどうかを表すLiveDataと検索キーワードを表すLiveDataから、検索ボタンの活性に変換しています。

val isSearchButtonEnabled: LiveData<Boolean> = MediatorLiveData<Boolean>().also {
    val onChanged = { it.value = this.isFetching.value == false && !this.keyword.value.isNullOrEmpty() }
    it.addSource(this.isFetching) { onChanged() }
    it.addSource(this.keyword) { onChanged() }
}

そして、xmlのデータバインディング構文を使ってボタンの android:enabled プロパティに反映させています。

<com.google.android.material.button.MaterialButton
    android:id="@+id/searchButton"
    android:enabled="@{viewModel.isSearchButtonEnabled}" />

Flutter版では RxDartcombineLatest2() で変換した Stream/SinkStreamBuilder で実装していましたが、本質的には同じことをやっています。
このように主流の設計パターンが両者とも似ているので、Androidアプリ開発者がFlutterを学習するのはとても敷居が低いと感じました。

ネット上の画像のロード

Flutterではネット上の画像を表示させたいときは、単に

Image.network('URL')

というように書けばOKでした。
Androidネイティブには標準でそのような機能が用意されていので、Glide を使用しました。

Glide.with(context).load("画像のURL").into(imageView)

Glideを使うとこのように書くだけで、通信を行い、画像を反映させ、自動でキャッシュまで行ってくれます。
今回はデータバインディングを使った設計をしていたので、

@BindingAdapter("imageUrl")
fun ImageView.setImageUrl(url: String?) {
    Glide.with(this.context).load(url).into(this)
}

このような BindingAdapter を書いて、

<androidx.appcompat.widget.AppCompatImageView
    app:imageUrl="@{article.authorIconUrl}" />

xml上で画像の指定をできるようにしてみました。

このような処理はよく書くので、Flutterが公式で用意してくれているのはとてもありがたいですね。

FlexboxLayoutの導入

記事のタグを表示している部分は、動的に要素数を変えたり、横幅からはみ出た際に折り返しをしなくてはなりません。

Flutterだと Wrap を使うことで自動的に折り返しを設定してくれます。
Androidネイティブだとそのような機能が標準ではありません。

そこで FlexboxLayout というライブラリを使うことにしました。
使い方はとってもかんたんで <FlexboxLayout /> の下に <RecyclerView /> を入れてやり、FlexboxLayoutManager をセットしてやるだけです。

RecyclerView recyclerView = (RecyclerView) context.findViewById(R.id.recyclerview);
FlexboxLayoutManager layoutManager = new FlexboxLayoutManager(context);
layoutManager.setFlexDirection(FlexDirection.COLUMN);
layoutManager.setJustifyContent(JustifyContent.FLEX_END);
recyclerView.setLayoutManager(layoutManager);

引用元: https://github.com/google/flexbox-layout#flexboxlayoutmanager-within-recyclerview

しかし、Androidネイティブのリスト (RecyclerView) を扱おうと思うと、Adapterに関する部分のコードを書く必要があり、多少めんどくさいです。
そこで、コードの見通しを良くするために、カスタムViewを作ることにしました。

internal class TagList(/* 省略 */) : FlexboxLayout(/* 省略 */), TagClickedListener {
    internal var listener: TagClickedListener? = null
    private val adapter = Adapter(this)

    init {
        LayoutInflater.from(this.context).inflate(R.layout.tag_list, this, true)

        val layoutManager = FlexboxLayoutManager(this.context, FlexDirection.ROW)
        this.recyclerView.layoutManager = layoutManager
        this.recyclerView.adapter = this.adapter
    }

    internal fun setTags(tags: List<String>) { /* 省略 */ }
    override fun onTagClicked(tag: String) { /* 省略 */ }

    private inner class Adapter(private val parent: TagList = this) : RecyclerView.Adapter<BindingHolder>() {
        private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) }
        var tags: List<String> = emptyList()

        override fun getItemCount(): Int = this.tags.size
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder =
            BindingHolder(TagBinding.inflate(this.inflater, parent, false))
        override fun onBindViewHolder(holder: BindingHolder, position: Int) {
            holder.binding.tag = this.tags[position]
            holder.binding.listener = this.parent
            holder.binding.executePendingBindings()
        }
    }

    private class BindingHolder(val binding: TagBinding) : RecyclerView.ViewHolder(binding.root)
}

@BindingAdapter("tags")
internal fun TagList.setTags(tags: List<String>?) {
    if (tags != null) this.setTags(tags)
}

@BindingAdapter("onTagClicked")
internal fun TagList.setListener(listener: TagClickedListener?) {
    this.listener = listener
}

TagList というカスタムViewとそのBindingAdapterを生やし、

<TagList
    <!-- 省略 -->
    app:onTagClicked="@{tagClickedListener}"
    app:tags="@{article.tags}" />

このようにxml中で使えるようにしてみました。

Flutterだと数行で実現できたことですが、Androidネイティブでやろうとすると、ものすごく大変でした。

感想

UIに関して、シンプルなUIを組むことに限って言えば、Flutterだとものすごく簡単に感じました。
Androidネイティブの場合、ActivityとFragmentの概念や、ライフサイクルの理解と画面の再生成の対応など、知っておかなくてはならないことがたくさんあります。
Flutterだとそれらを意識せずに作れてしまうので、アプリ開発初学者に対する敷居もかなり低くなっていると思います。

当然、Android固有の機能であったり、一部のUIに関してはAndroidネイティブが必要ですし、ネイティブがすぐに不要になるとは思いませんが、今後はFlutterの選択肢も増えていくと思います。

弊社もFlutter採用致します!

弊社Android担当のd-ariakeです。
この度、弊社もFlutterを採用することにしました。

ここ1週間ほど前からFlutterを入門していまして、最低限の動くものが作れるようになってきたので、ざっくりと学習メモのような感じで知見を残しておこうと思います。

サンプルアプリについて

Flutterの学習を進めつつ、シンプルなアプリを作ってみました。
リポジトリとアプリの動作イメージは以下の通りとなります。

FlutterQiitaClient - GitHub
image

Qiitaの記事検索APIを使用し、技術記事をキーワードで検索してリストで表示するといった動作となります。
(このイメージでは1つ記事しか表示されていませんが、実際は検索キーワードに応じた記事がちゃんと表示されるようになっています。)

ポイント

Flutterの入門をする上でポイントであると思ったのは以下の点です。

  • BLoCパターン
  • 静的解析
  • CI

これらについて軽く知見を共有していきます。

BLoCパターン

BLoC (Business Logic Component) パターンはFlutterでよく使われる設計パターンです。
実装をする際には以下のルールに従います。

  1. Inputs and outputs are simple Streams/Sinks only
  2. Dependencies must be injectable and platform agnostic
  3. No platform branching allowed
  4. Implementation can be whatever you want if you follow the previous rules

引用元: Flutter / AngularDart – Code sharing, better together (DartConf 2018)

これらのルールに従って実装したBLoCは以下のようになります。

class SearchPageBloc {
  SearchPageBloc(ArticleRepository repository) : this._repository = repository {
    this._searchEventSubject.listen((_) => this._search());
  }

  final ArticleRepository _repository;

  final _articleListSubject = BehaviorSubject.seeded(List<Article>.empty());
  final _isFetchingSubject = BehaviorSubject.seeded(false);
  final _keywordSubject = BehaviorSubject.seeded('');
  final _searchEventSubject = PublishSubject<void>();

  //  記事リストを通知するStream
  Stream<List<Article>> get articleList => this._articleListSubject.stream;

  //  取得中かどうかを通知するStream
  Stream<bool> get isFetching => this._isFetchingSubject.stream;

  //  検索キーワードを流すSink
  Sink<String> get keywordSink => this._keywordSubject.sink;

  //  検索ボタンが有効かどうかを通知するStream
  Stream<bool> get isSearchButtonEnabled => Rx.combineLatest2(
        this._keywordSubject,
        this.isFetching,
        (String keyword, bool isFetching) => keyword.isNotEmpty && !isFetching,
      );

  //  検索が要求されたことを流すSink
  Sink<void> get searchEvent => this._searchEventSubject.sink;

  void dispose() { /* 省略 */ }

  //  検索を行う。
  Future<void> _search() async { /* 省略 */ }
}

ルール 1. の通りに、BLoCからWidgetへの変更通知を行うもの (表示データや活性フラグなど) は Stream<T> で公開し、WidgetからBloCへの通知を行うもの (入力イベントなど) は Sink<T> で公開しています。

また、BLoC内部でのストリームの変換には RxDart を利用しています。
例えば以下の部分です。

//  検索ボタンが有効かどうかを通知するStream
Stream<bool> get isSearchButtonEnabled => Rx.combineLatest2(
      this._keywordSubject,
      this.isFetching,
      (String keyword, bool isFetching) => keyword.isNotEmpty && !isFetching,
    );

Rxの combineLatest2() を使い、

  1. 検索キーワードのストリーム (_keywordSubject)
  2. データの取得中フラグのストリーム (isFetching)

の2つのストリームから検索ボタンの活性状態に変換するようなストリーム (isSearchButtonEnabled) を作っています。

また、ルール 2. の通り、このBLoCは依存しているリポジトリ (ArticleRepository) を constructor injection で注入可能な仕組みになっています。
もしテストをしたければ、コンストラクタに Mockito などで作ったモックを渡してやることでかんたんに通信部分の挙動を変えることができます。

そして、このBLoCをWidgetに持たせる部分ですが、私は provider を使って実装しました。

//  検索ページ
class SearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Provider<SearchPageBloc>(
        create: (context) => SearchPageBloc(Dependency.resolve()),
        dispose: (context, bloc) => bloc.dispose(),
        child: _SearchPageContent(),
      );
}

create でBLoCのインスタンスを生成する処理を書きます。
コンストラクタに引数が必要なので Dependency.resolve<T>() という get_itラッパ を介して、リポジトリのインスタンスをサービスロケートしています。

(単体テストはドメイン層からBLoCやViewModelまでの部分に対して書くのが最も費用対効果が高いと思っているので、今のところ provider#create でのサービスロケータで問題はないと思っています。UIの自動テストをやろうと思った場合は別のアプローチが必要になるかもしれません。)

Stream<T> の変更に応じて、Widgetをリビルドする部分は StreamBuilder<T> を使います。
監視対象の Stream<T> と、その流れてきた値からWidgetをビルドする関数を渡してやります。

以下はプログレスインジケータを構築する部分のコードとなります。

//  プログレスインジケータを構築する。
Widget _buildProgressIndicator(SearchPageBloc bloc) => StreamBuilder<bool>(
      initialData: false,
      stream: bloc.isFetching,
      builder: (context, snapshot) =>
          snapshot.data ? const LinearProgressIndicator() : Container(),
    );

SearchPageBloc#isFetching: Stream<bool> を監視して、取得中ならば LinearProgressIndicator を返し、取得中ではなければ空っぽの Container を返すことで、プログレスインジケータの表示・非表示を切り替えています。

(Androidでは visibility = View.INVISIBLE というようにプロパティで制御をしていましたが、Flutterではまるごと再構築を行うことで制御するんですね。UIパーツが状態を持たないという考え方はちょっと新鮮です。)

というのが、BLoCパターンによる設計のポイントです。
他にもReduxやFluxなどもありますが、ひとまず標準的だと思われるBLoCパターンで実装を行っていました。
必要ならばこれらのアーキテクチャについても学んでおこうと思います。

静的解析

Dart & Flutterには標準で強力なフォーマッタ・Linterがついています。

analysis_options.yaml という名前で設定ファイルを置いておくと、その設定に応じた静的解析を行ってくれます。

このサンプルアプリでも設定をしています。
https://github.com/BetaComputing/FlutterQiitaClient/blob/master/analysis_options.yaml

Dartのフォーマッタを走らせるには以下のコマンドを実行し、

dart format --fix 

FlutterのLinterを走らせるには以下のコマンドを実行します。

flutter analyze

私は結構コードのスタイルを気にするタイプなのですが、これらを走らせることで誰か書いても良いコードスタイルになります。 そして、Dangerと組み合わせてCIで実行させることで、本質的でないコードレビューを避けることもできてとても良いですね。
みなさんもぜひ使いましょう!

CI

弊社では普段から GitHub を使っているので GitHub Actions でのCIも設定してみました。
設定ファイルは以下のような感じです。

CI.yml:

name: CI
# (※一部省略しています。)
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: 'stable'
          flutter-version: '1.22.2'

      - name: Restore dotenv
        run: echo ${{ secrets.DOT_ENV }} | base64 -d > .env

      - name: Restore dependencies
        run: flutter pub get

      - name: Build (Android)
        run: flutter build apk --debug

      - name: Test
        run: flutter test

      - name: Format and Report
        run: dart format --fix ./ > dart_format_report.txt

      - name: Analyze and Report
        continue-on-error: true
        run: flutter analyze > flutter_analyze_report.txt

      - name: Run Danger
        uses: danger/danger-js@9.1.8
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

flutter-action という便利なactionが公開されていたので、これを利用させてもらいました。
全体の処理の流れは以下のようになります。

  • Flutterのセットアップ
  • 環境変数 (flutter_dotenv) の復元
  • 依存関係の復元
  • ビルド (Android)
  • 単体テスト
  • フォーマット
  • Lint
  • Danger

フォーマッタとLinterを走らせて、その実行結果を Danger JS で報告させています。
(少し雑ですが dangerfile.ts も自分で書いてみました。)

もし、フォーマッタとLintで引っかかると以下のような怒られが生じてCIがコケます。
これで本質的なコードレビューに集中できると思います。

f:id:betacomputing3:20201020153915p:plain

このように、開発ツール・CIまわりについても基本的な使い方を学ぶことはできたかと思います。

入門してみた感想

1週間程度の駆け足で入門してみましたが、Flutterはそこそこ使えると感じました。
Dartの機能不足感を感じたり、一部だけ実現が難しいUIがあったりしましたが、基本的なマテリアルデザインのアプリならば十分だと思います。
開発ツールも公式が必要なものを提供してくれていますし、ホットリロードも爆速で開発体験も非常に良かったです。

ということで、Flutterでの開発もバリバリ対応できますので、ネイティブ (Kotlin・Swift)・クロスプラットフォーム (Flutter・Xamarin.Forms) 問わず、ぜひ我々にお任せください!
お仕事お待ちしております!

AndroidでQRコードとJANコードを扱ってみる

Beta ComputingのAndroid担当のd-ariakeです。
Android案件でバーコードを扱う機会があったので、実装方法を残したいと思います。

行うこと

今回は以下の2点を共有したいと思います。

  • ZXing Android Embedded を使い、QRコードとJANコードを生成する。
  • Glide のカスタムModel Loaderで生成・キャッシュさせる。

ZXing Android Embeddedによるバーコードの生成

ZXing Android EmbeddedZxing をAndroid向けに使いやすくしたライブラリです。
このライブラリを使うと以下のようなコードを書くだけで、簡単にバーコードを生成することができます。

//  QRコードを生成するコード
val hints = mapOf(
    EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H,
    EncodeHintType.CHARACTER_SET to "Shift_JIS",
    EncodeHintType.QR_VERSION to 12
)
val bitmap: android.graphics.Bitmap =
    BarcodeEncoder().encodeBitmap(contents, BarcodeFormat.QR, width, height, hints)
//  JAN (EAN) コードを生成するコード
val bitmap: android.graphics.Bitmap =
    BarcodeEncoder().encodeBitmap(contents, BarcodeFormat.EAN_13, width, height)

本当に簡単に生成できちゃいます!

余談:
JANコードを生成する際には12桁か13桁の数値文字列を渡す必要があります。
13桁目はチェックサムとなっていて、誤り検出に使われるみたいです。
EAN13Writer.javaUPCEANReader.java のソースを読んだ感じ、12桁のコードを渡すと自動的にチェックサムを計算して13桁として生成してくれるようです。

GlideのカスタムModel Loaderの実装

Androidで画像を扱う際には何かしらの画像管理ライブラリを使うかと思います。
今回は私が一番慣れているGlideを使用して、QRコード・JANコードを生成・キャッシュし、ImageView に表示させるといったことを行いました。

ネット上の画像の場合はそのままURL文字列を渡してしまえばすぐに実現できてしまいます。

Glide.with(this.applicationContext)
    .load("https://画像のリンク.png")
    .placeholder(R.drawable.プレースホルダー)
    .into(this.imageView)

しかし、今回のようにQR・JANコードを生成する場合は自分で ModelLoader を実装する必要があります。
以下に実装手順を載せていきます。

(公式ドキュメント: Glide v4 : Writing a custom ModelLoader)

まず、以下のようにライブラリを追加します。
(必要に応じて apply plugin: 'kotlin-kapt' を追記してください。)

//  https://mvnrepository.com/artifact/com.journeyapps/zxing-android-embedded
implementation("com.journeyapps:zxing-android-embedded:4.1.0")

//  https://mvnrepository.com/artifact/com.github.bumptech.glide/glide
implementation("com.github.bumptech.glide:glide:4.11.0")

//  https://mvnrepository.com/artifact/com.github.bumptech.glide/compiler
kapt("com.github.bumptech.glide:compiler:4.11.0")

次に、Glideの Model に相当する BarcodeRequest を定義します。
sealed class で定義し、Qr 型と Jan 型で表現しました。

package jp.co.betacomputing.barcodeapp

import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel

sealed class BarcodeRequest {

    abstract val content: String

    data class Qr(override val content: String, val level: ErrorCorrectionLevel) : BarcodeRequest()
    data class Jan(override val content: String) : BarcodeRequest()
}

次に ModelLoader<Model (= BarcodeRequest,) Data (= Bitmap)>DataFetcher<Data (= Bitmap)> の実装です。
BarcodeRequestwidthheight とともに Fetcher に渡し、そこで Bitmap を生成しています。

今回は特にネット経由で何かをダウンロードするといったことはないので、getDataSource()DataSource.LOCAL を指定しており、リソースの解放処理も不要なので clearnup() では何もしていません。
(公式のドキュメントのBase64の例でも同じような感じでしたね。)

loadData() で実際のバーコードの生成処理を行います。
オプションを指定して BarcodeEncoder().encodeBitmap() を叩くだけです。
生成に成功したら DataCallback#onDataReady() を叩いて完了を通知します。

package jp.co.betacomputing.barcodeapp

import android.graphics.Bitmap
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.signature.ObjectKey
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.journeyapps.barcodescanner.BarcodeEncoder
import jp.co.betacomputing.barcodeapp.BarcodeRequest as Req

internal class BarcodeModelLoader : ModelLoader<Req, Bitmap> {

    override fun buildLoadData(model: Req, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap>? =
        ModelLoader.LoadData(ObjectKey(model), Fetcher(model, width, height))

    override fun handles(model: Req): Boolean = true

    private class Fetcher(
        private val request: Req,
        private val width: Int,
        private val height: Int
    ) : DataFetcher<Bitmap> {

        override fun getDataClass(): Class<Bitmap> = Bitmap::class.java

        override fun cleanup() = Unit

        override fun getDataSource(): DataSource = DataSource.LOCAL

        override fun cancel() = Unit

        override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
            when (this.request) {
                is Req.Qr -> this.generateQr(this.request, callback)
                is Req.Jan -> this.generateJan(this.request, callback)
            }
        }

        private fun generateQr(request: Req.Qr, callback: DataFetcher.DataCallback<in Bitmap>) {
            try {
                val hints = mapOf(EncodeHintType.ERROR_CORRECTION to request.level)
                val bitmap = BarcodeEncoder()
                    .encodeBitmap(this.request.content, BarcodeFormat.QR_CODE, this.width, this.height, hints)

                callback.onDataReady(bitmap)
            } catch (e: Exception) {
                callback.onLoadFailed(e)
            }
        }

        private fun generateJan(request: Req.Jan, callback: DataFetcher.DataCallback<in Bitmap>) {
            try {
                val bitmap = BarcodeEncoder()
                    .encodeBitmap(request.content, BarcodeFormat.EAN_13, this.width, this.height)

                callback.onDataReady(bitmap)
            } catch (e: Exception) {
                callback.onLoadFailed(e)
            }
        }
    }
}

そして、この BarcodeModelLoader をGlideに登録する部分です。
これはほとんどドキュメント通りです。

package jp.co.betacomputing.barcodeapp

import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.AppGlideModule

@GlideModule
internal class BarcodeGlideModule : AppGlideModule() {

    override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
        registry.prepend(BarcodeRequest::class.java, Bitmap::class.java, Factory())
    }

    private class Factory : ModelLoaderFactory<BarcodeRequest, Bitmap> {

        override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<BarcodeRequest, Bitmap> =
            BarcodeModelLoader()

        override fun teardown() = Unit
    }
}

最後に、この BarcodeModelLoader を実際に使った簡単なアプリを作りました。
ボタンを押すと、このModel Loader経由でバーコードが生成され、ImageView に設定されます。

f:id:betacomputing3:20200824180053p:plain:w300

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/qrImageView"
        android:layout_width="256dp"
        android:layout_height="256dp"
        android:layout_margin="10dp"
        android:background="#ef5350"
        android:contentDescription="@null" />

    <ImageView
        android:id="@+id/janImageView"
        android:layout_width="256dp"
        android:layout_height="102dp"
        android:layout_margin="10dp"
        android:background="#42a5f5"
        android:contentDescription="@null" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="10dp"
        android:text="PUSH ME!"
        tools:ignore="HardcodedText" />

</LinearLayout>
package jp.co.betacomputing.barcodeapp

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.bumptech.glide.Glide
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        this.setContentView(R.layout.activity_main)
        this.button.setOnClickListener {

            //  QRコード
            Glide.with(this)
                .load(BarcodeRequest.Qr(content = "あいうえお", level = ErrorCorrectionLevel.H))
                .into(this.qrImageView)

            //  JANコード
            Glide.with(this)
                .load(BarcodeRequest.Jan(content = "020000100001"))
                .into(this.janImageView)
        }
    }
}

以上、GlideのカスタムModel Loaderを作って、バーコードを表示させる方法でした。
Glideのキャッシュやロード機能を使いたいけれども、画像の取得部分は自分で実装したいというときは是非やってみてください。

RxJavaとKotlin CoroutinesでAndroidのBLE制御をした話

はじめまして、新入社員のd-ariakeと申します。
主にAndroidアプリの開発を担当しています。
宜しくお願い致します。

今回扱う内容

私の初めてのお仕事は業務用Androidアプリの開発案件でした。
ものすごく簡単に言うと、組み込み機器とAndroid端末をOOBでペアリング・接続し、その機器から送られてくる商品のデータをAndroidアプリで受信・管理するといった内容です。

Bluetooth通信を行っているので、当然処理は非同期で行う必要が出てきます。
標準のBLEライブラリも非同期コールバックで書くようになっているのですが、なかなか取り扱いづらいです。
(触ったことがある人にはわかってもらえるかと思います。)

そのため、私は RxJavaKotlin Coroutines を使って非同期な処理を実装することにしました。
その結果、より直感的で分かりやすい実装を行うことができました。
今回はその知見を共有したいと思います。

RxJava2・RxAndroidBleの導入

AndroidのBluetoothまわりのAPIはとても扱いづらいです。
普通に書いているだけでコールバック地獄になり、とてもじゃないですが、メンテナンスできない状態になってしまいます。
そこで、本プロジェクトでは RxAndroidBle というライブラリを導入することにしました。
このライブラリはBLEの接続やread/wriet処理などをRxJavaの Observable<T>Single<T> のストリームとして提供してくれます。

以下、ライブラリの README.md からの引用です。

BLEのスキャン処理の例:

Disposable scanSubscription = rxBleClient.scanBleDevices(
        new ScanSettings.Builder()
            // .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // change if needed
            // .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) // change if needed
            .build()
        // add filters if needed
)
    .subscribe(
        scanResult -> {
            // Process scan result here.
        },
        throwable -> {
            // Handle an error here.
        }
    );

// When done, just dispose.
scanSubscription.dispose();

BLEのwrite処理の例:

device.establishConnection(false)
    .flatMapSingle(rxBleConnection -> rxBleConnection.writeCharacteristic(characteristicUUID, bytesToWrite))
    .subscribe(
        characteristicValue -> {
            // Characteristic value confirmed.
        },
        throwable -> {
            // Handle an error here.
        }
    );

flatMapSingle()Disposablesubscirbe() など、Rxらしさのあるコードになっていますね。

本プロジェクトでも、タイムアウトやリトライ処理などをRxのオペレータを使って記述しています。
例えば、BLEのread/write処理に対して、遅延付きリトライを行う (疑似) オペレータは以下のようになります。

//  ワンショット系のBLE処理をリトライ付きで実行する。
//  リトライ時には遅延を掛けるようにする。
//  ただし、BLE接続が切断された場合はこれ以上リトライしない。
fun Single<ByteArray>.retryBleOperation(
    retryTimes: Long,
    delay: Long,
    unit: TimeUnit,
    scheduler: Scheduler = Schedulers.computation(),
    onRetry: (Throwable) -> Unit = {}
): Single<ByteArray> = this.retryWhen { errorStream: Flowable<Throwable> ->
    errorStream.zipWith(IntRange(0, Int.MAX_VALUE).asIterable()) { error, index ->
        //  指定回数までリトライを掛ける。
        //  ただし切断エラーの場合はどうあがいても絶対にコマンドは失敗するため、リトライを中止する。
        if (index < retryTimes && error !is BleDisconnectedException) error
        else throw error
    }.flatMap { error -> Flowable.timer(delay, unit, scheduler).doOnNext { onRetry(error) } }
}

Rxを使わずにリトライのような処理を実装しようとすると、コールバックのネストになったり、一時的な状態を保持するためのフィールドが乱立したりしてしまうと思います。
また、時間が絡んでくるような処理はテストが非常にやりづらいです。

そこで、上記のようにRxを使って実装することで、(呼び出し元に時間の管理やリトライ回数の処理などを意識させる必要のない) 非常にスッキリとしたコードにすることができたと思います。
さらに TestScheduler を使用することによって、時間を扱うような処理にも関わらず、簡単に単体テストを書くことができます。

以下がそのテストコードの例となります。

    @Test
    fun BLE接続が切断されていればリトライせずにエラーを通知する() {
        val testScheduler = TestScheduler()

        var counter = 0
        val source: Single<ByteArray> = Single.create { emitter ->
            when (++counter) {
                
                //  1回目・2回目の発火時は通常のエラー
                1, 2 -> emitter.onError(Exception())
                
                //  3回目の発火時は切断エラー
                3 -> emitter.onError(mockk<BleDisconnectedException>(relaxed = true))
                
                else -> emitter.onSuccess(byteArrayOf())
            }
        }
        val testObserver = source.retryBleOperation(
            retryTimes = 5L,
            delay = 100L,
            unit = TimeUnit.MILLISECONDS,
            scheduler = testScheduler
        ).test()

        //  1回目の発火
        //  はじめは失敗しているが、まだエラーは通知されていない。
        testObserver.assertNoValues()
        testObserver.assertNoErrors()

        //  2回目の発火
        //  最初のリトライを掛けて失敗する。
        testScheduler.advanceTimeBy(100L, TimeUnit.MILLISECONDS)
        testScheduler.triggerActions()
        testObserver.assertNoValues()
        testObserver.assertNoErrors()

        //  3回目の発火
        //  再びリトライを掛けて失敗する。
        //  BleDisconnectedExceptionによって失敗するため、これ以上リトライは行わない。
        testScheduler.advanceTimeBy(100L, TimeUnit.MILLISECONDS)
        testScheduler.triggerActions()
        testObserver.assertNoValues()

        //  切断エラーによってストリームが異常終了しているはず。
        testObserver.assertError(BleDisconnectedException::class.java)
    }

Rxの経験が十分にあり、AndroidでBLEを扱う機会がある際には、ぜひ導入を検討してみることをおすすめいたします。
(Rxの購読やHot/Coldに関する知識がないとちょっと厳しいかもしれません。)

kotlinx-coroutines-rx2の導入

本プロジェクトの非同期処理では全般的にKotlin Coroutinesを利用しています。
非同期の部分をsuspend関数にすることによって、非同期処理を同期処理のような呼び出しで扱うことができます。
特に非同期処理を連続して呼び出す際に、通常ならばコールバックのネストが必要な部分を通常の同期処理のような呼び出しとして記述できるため、とても直感的で分かりやすいコードにすることができます。

通常のコールバック形式で記述した際はこのようなコードになりますが、

//  通常のコールバック形式でのコード
fun doSomethingAsync() {
    doAsync1(
        onSuccess =  { result1 ->
            doAsync2(
                result1,
                onSuccess = { /* 処理1と処理2が完了した際の処理 */ },
                onFailure = { /* 処理2のエラー処理 */ }
            )
        },
        onFailure = { /* 処理1のエラー処理 */ }
    )
}

Kotlin Coroutinesを使うことにより、このような同期処理のようなコードになります。

//  Kotlin Coroutinesを利用したときのコード
suspend fun doSomethingAsync() {

    //  try-catchを使用した例
    val result1 = try {
        doAsync1()
    } catch (e: SomethingThrowable) {
        //  処理1のエラー処理
    }

    val result2 = try {
        doAsync2(result1)
    } catch (e: SomethingThrowable) {
        //  処理2のエラー処理
    }


    //  runCatchingを使用した例
    runCatching {
        doAsync1()
    }.onFailure {
        //  処理1のエラー処理
    }.mapCatching { result1 ->
        doAsync2(result1)
    }.onFailure {
        //  処理2のエラー処理
    }
}

本プロジェクトで、BLEの制御に関しては非同期をRxで扱っていましたが、ワンショットなストリームに関してはsuspend関数への変換を行い、同期処理的な書き方をできるようにしました。

そのために kotlinx-coroutines-rx2 という公式のサポートライブラリを使います。
このライブラリではRx (Observable<T>, Single<T>, ...) とKotlin Coroutines (suspend関数, Flow<T>) の相互変換用の関数を提供してくれています。

とあるBLEのwrite処理をsuspend関数化すると以下のようになります。

//  商品データの送信をリクエストする。
//  (といったような処理が実際に扱った機器にはありました。)
//  内部でタイムアウトとリトライの処理を掛ける。
suspend fun RxBleConnection.request(): ByteArray {
    val command = byteArrayOf(/* ... */)
    val uuid = UUID.fromString("12345678-1234-1234-1234-1234567890AB")

    return this.writeCharacteristic(uuid, command)
        .timeout(REQUEST_COMMAND_TIMEOUT_IN_MS, TimeUnit.MILLISECONDS)
        .retryBleOperation(
            retryTimes = REQUEST_COMMAND_RETRY_TIMES,
            delay = REQUEST_COMMAND_RETRY_DELAY_IN_MS,
            unit = TimeUnit.MILLISECONDS,
        )
        .await()
}

このようなsuspend関数を作っておき、処理の呼び出し側で通常の同期処理のように呼び出すだけです。
エラーハンドリングも普通にtry-catch (runCatching) で行うことができます。

private suspend fun executeRequestCommand() {
    val handle = this.connectionHandle ?: return

    //  BLEのコマンドを実行して結果を待機する。
    val isSuccess = runCatching { 
        handle.request()
    }.isSuccess

    if (isSuccess) { /* 成功時の処理 */ }
    else { /* 失敗時の処理 */ }
}

このようにRxのオペレータでリトライとタイムアウトの処理を行い、待機をKotlin Coroutinesで行うという連携ができました。
RxもKotlin Coroutinesも非常に便利ですね。
AndroidでBLEを扱うことがあれば、ぜひ参考にしてみてください。

ちはやふるのキャラクターをゲームに落とし込む

iOS/Android版アプリ「競技かるた ONLINE」において、アニメ「ちはやふる3」とのコラボレーションイベントを2020年1月15日~2月15日の期間で実施いたしました。
期間中にGETした「ちはやふる」のキャラクター達と競技かるたで対戦することができます。

 

f:id:betacomputing3:20200214185943p:plain

 

対戦している間にキャラクターのカットインが発生し、対戦の臨場感を演出します。

試合状況に合わせてキャラクターの感情が変化するので、プレイヤーの実力に応じてキャラクターの反応も変化します。

 

今回はキャラクターの感情変化について、どのようなことをしているかご紹介いたします。

f:id:betacomputing3:20200214191917j:plain f:id:betacomputing3:20200214191816j:plain

 

キャラクターの感情

キャラクターの感情は、リラックス/幸せ/興奮/落ち着き/通常/緊張/落ち込み/動揺/ナーバスの9種類があります。

札を取った/取られた、お手つきした、から札・・・など対戦の展開からキャラクターの感情パラメータが増減します。

札が読まれる前にキャラクターの感情パラメータを判定し、感情が変化している場合にカットインが出現します。

感情マップ

感情のパラメータの大小により、キャラクターの感情を決めています。
その際に用いるのが「感情マップ」です。
感情マップとは、X軸に「勝利への期待」、Y軸に「敗北への不安」を持つ2次元マップになります。

      f:id:betacomputing3:20200214193905p:plain 

※参考 

automaton-media.com

 

例えば、プレイヤーが札を取りつづけ、キャラクターが追い込まれている場合は、キャラクターの「勝利への期待」は低く「敗北への不安」が高くなります。この時キャラクターの感情はマップ上では、「動揺」や「ナーバス」に位置しています。
逆に、キャラクターがプレイヤーを圧倒している時は、キャラクターの「勝利への期待」は高く「敗北への不安」が低いため、キャラクターの感情は「幸せ」「リラックス」に位置することになります。

このように感情マップを取り入れることで、試合状況の応じて、キャラクターの多彩な感情を扱うことができます。

ちなみに、感情パラメータの変化率はキャラクターごとに異なり、キャラクターの性格が表れるようにしています。

 

また、キャラクターの感情は取る速さやお手つき率にも影響します。

そのため、試合展開に合わせて流動的にキャラクターの強さが変化します。

ここでもキャラクターごとに変化率が異なります。

 

「カットイン」+「キャラの強さ変化」の組み合わせによって対戦に臨場感を持たせ、プレイヤーが「相手」を感じられるようにしています。


少しでも興味をもっていただけましたら、「競技かるた ONLINE」をプレイしてみて下さい!

 

karuta.betacomputing.co.jp