FlutterでPlatformViewを活用してアニメーションPNGを再生してみる【Android編】

FlutterにおけるAPNG対応状況

github.com
今の所は進展がなさそうです

APNGを再生する外部パッケージ

pub.dev

Dart製のAPNGパーサはあるけどFlutterでアニメーションさせるものは今のところ存在しないようです

PlatformViewを使おう

FlutterのPlatformViewは、Android/iOSのネイティブビューをFlutterアプリに埋め込むことができる仕組みです。以下の記事が非常に参考になりました。
itome.team

プロジェクトを作成

flutter create --org com.example --template=plugin --platforms=android,ios -a kotlin -i swift apngview

Android

APNGライブラリ選定

github.com

build.gradleを更新

group 'com.example.apngview'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.4.21'
    ext.coroutines_version = '1.4.2'
    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:4.0.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

rootProject.allprojects {
    repositories {
        google()
        jcenter()
    }
}

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
    compileSdkVersion 29

    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
    }
    defaultConfig {
        minSdkVersion 21
    }
    lintOptions {
        disable 'InvalidPackage'
    }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
    implementation 'com.linecorp:apng:1.9.1'
}
  • LINEのapngライブラリとkotlinコルーチンを追加しておきます
  • また、minSdkVersionも更新しておきます

PlatformViewを継承したApngViewを作る

package com.example.apngview

import android.content.Context
import android.view.View
import android.widget.ImageView
import com.linecorp.apng.ApngDrawable
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.platform.PlatformView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream

class ApngView(
        context: Context,
        messenger: BinaryMessenger,
        id: Int
) : PlatformView, MethodChannel.MethodCallHandler {
    private val imageView: ImageView = ImageView(context);
    private var drawable: ApngDrawable? = null

    init {
        MethodChannel(messenger, "plugins/apngview_$id").also {
            it.setMethodCallHandler(this)
        }
    }

    override fun getView(): View = imageView

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "setImage" -> setImage(call, result)
            "start" -> start(result)
            "stop" -> stop(result)
            else -> result.notImplemented()
        }
    }

    // FlutterからAPNGデータをバイト配列で受け取りApngDrawableにセット
    private fun setImage(call: MethodCall, result: MethodChannel.Result) {
        GlobalScope.launch(Dispatchers.Main) {
            try {
                val data = call.arguments as ByteArray
                val inputStream = ByteArrayInputStream(data)
                drawable = null
                imageView.setImageDrawable(null)
                drawable = withContext(Dispatchers.IO) {
                    ApngDrawable.decode(inputStream)
                }
                drawable?.loopCount = ApngDrawable.LOOP_FOREVER
                imageView.setImageDrawable(drawable)
                imageView.scaleType = ImageView.ScaleType.FIT_CENTER
            } catch (e: Exception) {
                result.error("-1", e.message, e)
            }
            result.success(null)
        }
    }

    // APNGを再生する
    private fun start(result: MethodChannel.Result) {
        drawable?.start()
        result.success(null)
    }

    // APNGを停止する
    private fun stop(result: MethodChannel.Result) {
        drawable?.stop()
        result.success(null)
    }

    // 後始末
    override fun dispose() {
        drawable?.stop()
        drawable?.recycle()
        drawable = null
    }
}

ApngViewのFactoryクラスを作る

package com.example.apngview

import android.content.Context
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory

class ApngViewFactory(
        private val messenger: BinaryMessenger
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
    override fun create(context: Context, id: Int, o: Any?): PlatformView {
        return ApngView(context, messenger, id)
    }
}

プラグインの登録を行う

package com.example.apngview

import androidx.annotation.NonNull

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry.Registrar

/** ApngViewPlugin */
class ApngViewPlugin: FlutterPlugin {

  override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    flutterPluginBinding.platformViewRegistry
            .registerViewFactory(
                    "plugins/apngview",
                    ApngViewFactory(flutterPluginBinding.binaryMessenger)
            )
  }

  override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
  }
}

Flutter側

ApngViewのControllerを返すコールバックを持つApngViewウィジェットを作成

import 'dart:async';
import 'dart:typed_data';

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

class ApngView extends StatefulWidget {
  ApngView({this.onApngViewCreated, Key key}) : super(key: key);

  final Function(ApngViewController) onApngViewCreated;

  @override
  _ApngViewState createState() => _ApngViewState();
}

class _ApngViewState extends State<ApngView> {
  @override
  Widget build(BuildContext context) {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(
        viewType: 'plugins/apngview',
        onPlatformViewCreated: _onPlatformViewCreated,
      );
    }
    return Text(
        '$defaultTargetPlatform is not yet supported by the apngview plugin');
  }

  Future _onPlatformViewCreated(int id) async {
    widget.onApngViewCreated?.call(ApngViewController(id));
  }
}

class ApngViewController {
  ApngViewController(
    int id,
  ) : _channel = MethodChannel('plugins/apngview_$id');

  final MethodChannel _channel;

  Future<void> setImage(Uint8List data) async {
    return _channel.invokeMethod('setImage', data);
  }

  Future<void> start() async {
    return _channel.invokeMethod('start');
  }

  Future<void> stop() async {
    return _channel.invokeMethod('stop');
  }
}

実際に使ってみよう!

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

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

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ApngViewController _controller;
  bool _isPlaying = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: FutureBuilder(
            future: _getImageData(),
            builder: (context, snapshot) {
              if (!snapshot.hasData) {
                return CircularProgressIndicator();
              }
              return Container(
                width: 200,
                height: 200,
                child: ApngView(
                  onApngViewCreated: (controller) {
                    _controller = controller;
                    _controller.setImage(snapshot.data);
                  },
                ),
              );
            },
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            if (_isPlaying) {
              _controller.stop();
            } else {
              _controller.start();
            }
            setState(() {
              _isPlaying = !_isPlaying;
            });
          },
          child: _isPlaying
              ? const Icon(Icons.pause)
              : const Icon(Icons.play_arrow),
          backgroundColor: Colors.blueAccent,
        ),
      ),
    );
  }

  Future<Uint8List> _getImageData() async {
    return rootBundle
        .load('assets/sample.png')
        .then((data) => data.buffer.asUint8List());
  }
}

動作確認

f:id:i53:20210125002755g:plain

スニペットから学ぶDartのnull安全 その③

スニペット9. late キーワード

クラス内のフィールドはnull非許容にしたい場合には、late キーワードを使用します。

lateキーワードはDartに以下のことを伝えます:

  • そのフィールドにすぐに値を代入するつもりはない
  • しかし、後で値を代入するつもりである
  • このキーワードが指定されたフィールドはアクセスされる前に値が代入されていることを確認しなければならない

もしも late フィールド を宣言していて、それが値を持つ前に読み込まれた場合、LateInitializationError がスローされます。

以下のコードを修正するために late キーワードを使用してみてください。

class Meal {
  String description; // ここでエラー
  
  void setDescription(String str) {
    description = str;
  }
}

void main() {
  final myMeal = Meal();
  myMeal.setDescription('Feijoada!');
  print(myMeal.description);
}

class Meal {
  late String description; // late フィールドを宣言!
  
  void setDescription(String str) {
    description = str;
  }
}

void main() {
  final myMeal = Meal();
  myMeal.setDescription('Feijoada!');
  print(myMeal.description);
}

この後にちょっとしたお楽しみとして、`description` を設定している行をコメントアウトしてみてください。

class Meal {
  late String description;
  
  void setDescription(String str) {
    // description = str; 
  }
}

void main() {
  final myMeal = Meal();
  myMeal.setDescription('Feijoada!');
  try {
    print(myMeal.description);  
  } catch(e) {
    print(e.toString()); // LateInitializationError: Field 'description' has not been initialized.
  }
}

スニペット10. late 循環参照

late キーワードは、循環参照のようなトリッキーなパターンに非常に役立ちます。
ここでは、お互いにNULLではない参照を維持する必要がある2つのオブジェクトを示します。
以下のコードを修正するために late キーワードを使ってみてください。

class Team {
  final Coach coach;
}

class Coach {
  final Team team;
}

void main() {
  final myTeam = Team();
  final myCoach = Coach();
  myTeam.coach = myCoach;
  myCoach.team = myTeam;
  
  print ('All done!');
}

class Team {
  late final Coach coach; // late !
}

class Coach {
  late final Team team; // late !
}

void main() {
  final myTeam = Team();
  final myCoach = Coach();
  myTeam.coach = myCoach;
  myCoach.team = myTeam;
  
  print ('All done!');
}

late を追加したときに final を削除する必要はありません。
late final を宣言することで一度だけ値を設定して、その後は読み取り専用にすることができます。

スニペット11. late による遅延初期化

late が役立つパターンに、非nullフィールドの遅延初期化があります。
以下のコードをそのまま実行してみてください。

int _computeValue() {
  print('Computing value...');
  return 3;
}

class CachedValueProvider {
  final _cache = _computeValue();
  int get value => _cache;
}

void main() {
  print('Calling constructor...');
  var provider = CachedValueProvider();
  print('Getting value...');
  print('The value is ${provider.value}!');
}

出力:

Calling constructor...
Computing value...
Getting value...
The value is 3!

CachedValueProviderクラスのコンストラクタが呼び出された時点で _cache の値が計算されてしまっています。
ここで _cachelate フィールドにすると何が変わると思いますか?

int _computeValue() {
  print('Computing value...');
  return 3;
}

class CachedValueProvider {
  late final _cache = _computeValue();
  int get value => _cache;
}

void main() {
  print('Calling constructor...');
  var provider = CachedValueProvider();
  print('Getting value...');
  print('The value is ${provider.value}!');
}

出力:

Calling constructor...
Getting value...
Computing value...
The value is 3!

late 宣言により値がアクセスされるまでは初期化されないため、CachedValueProviderクラスのコンストラクタが呼び出された時点では _cache の値は計算されません。
値にアクセスしようとしたタイミングで初めて計算されます。

面白いことに、_cache の宣言に late を追加すると、関数 _computeValueCachedValueProvider クラスに移動してもコードは動作します。

class CachedValueProvider {
  late final _cache = _computeValue();
  int get value => _cache;

  int _computeValue() {
    print('Computing value...');
    return 3;
  }
}

このように、lateフィールドの初期化式では、初期化にインスタンスメソッドを使用することができます。

スニペットから学ぶDartのnull安全 その②

スニペット5. 条件付きアクセス

条件付きアクセスは、nullになる可能性のあるプロパティを読み込むコードをタイトにする便利な方法です。

a?.b;

上の式は、a がnullでない限り b の値として評価されます。
a が null の場合、式は null と評価されます。

以下のコードを修正するのに、条件付きアクセスを使ってみてください。

class BigThing {
  LittleThing little = LittleThing();
}

class LittleThing {
  int fetchInt() => 12;
}

void main() {
  final BigThing? big = BigThing();
  
  print('The value is:');
  print(big.little.fetchInt()); // ここでエラー
}

class BigThing {
  LittleThing little = LittleThing();
}

class LittleThing {
  int fetchInt() => 12;
}

void main() {
  final BigThing? big = BigThing();
  
  print('The value is:');
  print(big?.little.fetchInt());
}

Dartがnull安全でなかった時は、このコードを動作させるためには2つの条件付きアクセス演算子が必要でした。

big?.little?.fetchInt()

null安全になった今、条件付きアクセスは短絡することができるようになり、この式で必要なのは1つの条件付きアクセス演算子だけとなります。

big?.little.fetchInt()

スニペット6. プロモーション

null安全では、Dartはnullチェックの結果を考慮に入れます。
nullチェックの結果、nullが含まれていないことが分かった null許容変数は、null非許容変数として扱われます。

この動作は「プロモーション」と呼ばれます。
以下のスニペットに、 strがnullの場合にゼロを返すif-thenをgetLengthの先頭に追加してみましょう。

int getLength(String? str) {
  // null チェックをここに追加してみよう!
  
  return str.length;  // このままだとエラー
}

void main() {
  print(getLength('This is a string!'));
}

int getLength(String? str) {
  if (str == null) {
    return 0;
  }
  
  // str がこのフローではnullになることがなくなったため null非許容変数とみなされる!
  return str.length; 
}

void main() {
  print(getLength('This is a string!'));
}

スニペット7. 例外とプロモーション

プロモーションは、return文と同様に例外でも機能します。
ゼロを返す代わりに Exception をスローするnullチェックを試してみてください。

int getLength(String? str) {
  // str が null なら 例外を投げてみよう!
  
  return str.length;  // このままだとエラー
}

void main() {
  print(getLength(null));
}

int getLength(String? str) {
  if (str == null) {
    throw Exception();
  }

  // str がこのフローではnullになることがなくなったため null非許容変数とみなされる!
  return str.length;  
}

void main() {
  print(getLength(null));
}

スニペット8. アサーション演算子

null許容な式をnull非許容な変数に代入したい場合にはアサーション演算子: !を使用することができます。
式の直後に ! を追加することで、その値がnullにならず、null許容変数に代入しても問題がないということをDartに伝えます。

以下のコードの3つの不正な代入を修正するためにアサーション演算子を追加してみてください。

int? couldReturnNullButDoesnt() => -3;

void main() {
  int? couldBeNullButIsnt = 1;
  List<int?> listThatCouldHoldNulls = [2, null, 4];
  
  int a = couldBeNullButIsnt;
  int b = listThatCouldHoldNulls.first;
  int c = couldReturnNullButDoesnt().abs();
  
  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

int? couldReturnNullButDoesnt() => -3;

void main() {
  int? couldBeNullButIsnt = 1;
  List<int?> listThatCouldHoldNulls = [2, null, 4];
  
  // スコープ内で '1' が代入された時点でnullでないことが評価されるため
  // アサーション演算子は不要
  int a = couldBeNullButIsnt;

  // '2' が返ることが分かっているから
  // アサーション演算子で null にはならないことを伝える
  int b = listThatCouldHoldNulls.first!; 

  // '-3' が返ることが分かっているから
  // アサーション演算子で null にはならないことを伝える
  int c = couldReturnNullButDoesnt()!.abs();
  
  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

注意: 間違って null が代入された場合は、実行時に例外が発生します。
試しに、null非許容変数d にアサーション演算子を使ってnullを代入して、print で出力を試みます。

int? couldReturnNullButDoesnt() => -3;

void main() {
  int? couldBeNullButIsnt = 1;
  int? couldBeNull = null;
  List<int?> listThatCouldHoldNulls = [2, null, 4];
  
  int a = couldBeNullButIsnt;
  int b = listThatCouldHoldNulls.first!;
  int c = couldReturnNullButDoesnt()!.abs();
  int d = couldBeNull!;

  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
  print('d is $d.'); // ここで例外が発生
}
Uncaught TypeError: Cannot read property 'toString' of nullError: TypeError: Cannot read property 'toString' of null

やむを得ない理由でアサーション演算子を使用する場合は、その値が決してnullにならないことにあなた自身が責任を持つ必要があります。

スニペットから学ぶDartのnull安全 その①

本記事はDartPad with null safety! の Learn with Snippets! を元にして作成されています。
nullsafety.dartpad.dev

Dartのnull安全バージョンにようこそ!

Dartのnull安全のための新しい構文とコーディングパターンを一連のコードスニペットを通して説明していきます。各スニペットは壊れた状態で始まり、そのまま実行しようとすると、Dartアナライザから警告が表示されたりコードがコンパイルされない可能性があります。

ただし、指示に従ってコードを編集することで、各スニペットを動作可能な状態に更新できます。 その過程で、Dartのnull安全について学びます!

スニペット1. Null非許容型の導入

void main() {
  int a;
  a = null;
  print('a is $a.');
}

この最初の例は、基本的なものです。
変数 aintとして宣言されています。

試しに上記のコードを実行すると以下のエラーがコンソールに出力されます。

Error: The value 'null' can't be assigned to a variable of type 'int' because 'int' is not nullable.
  a = null;
      ^
Error: Compilation failed.

int は null非許容型 であるから null を代入することはできません!

では、変数aに代入する値を3に変更してみてください。

void main() {
  int a;
  a = 3; // null以外の任意の数値を代入しよう!
  print('a is $a.');
}

すると、コードを実行した時に以下のようなが出力が得られます。

a is 3.

ちなみに、Dartプロジェクトでnull安全が有効になっている場合、すべての通常の型がデフォルトでnullになりません。

void main() {
  int a;
  print('a is $a.');
}

上記のコードを実行すると以下のエラーが出力されます。

Error: Non-nullable variable 'a' must be assigned before it can be used.
  print('a is $a.');
               ^
Error: Compilation failed.

null非許容型の変数は参照される前にnull以外の何かしらの値が代入されていなければならないのです。

void main() {
  int a; // NG🙅
  int b = null; // NG🙅
  int c= 3; // OK🙆
}

スニペット2. Null許容型

void main() {
  int a; 
  a = null;
  print('a is $a.');
}

では、null値を保持できる変数が必要な場合はどうすればよいでしょうか?
上記のスニペットではエラーになってしまうため、以下のように書き換えましょう。

void main() {
  int? a; // 後ろに?を付けてnull許容型として宣言する 
  a = null;
  print('a is $a.');
}

型の最後にクエスチョンマーク '?' を追加することで、null許容型として宣言することができます。
null許容型として宣言すればnullの代入が可能となり、コードが実行されて以下が出力されるようになります。

a is null.

ちなみに、null安全が有効になっている場合、null許容型の変数にはデフォルトではnullが代入されます。
以下を実行してみても先のスニペットと同じ結果が出力されるはずです。

void main() {
  int? a;
  print('a is $a.');
}

スニペット3. いろいろなNull許容型

ジェネリクス(総称型)のパラメータも、null許容またはnull非許容にすることができます。

void main() {
  List<String> a = ['one', 'two', 'three'];
  List<String> b;
  List<String> c = ['one', null, 'three'];
  
  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

そのまま実行すると以下のエラーが出力されます。

main.dart:4:28:
Error: The value 'null' can't be assigned to a variable of type 'String' because 'String' is not nullable.
  List<String> c = ['one', null, 'three'];
                           ^
main.dart:7:16:
Error: Non-nullable variable 'b' must be assigned before it can be used.
  print('b is $b.');
               ^
Error: Compilation failed.

変数 b は null非許容であるから、b には何か値を代入しない状態で参照することはできません。
変数 c の 型パラメータ: String は null非許容であるから、このListの中に null を格納することはできません。

では、クエスチョンマーク '?' を使って、型宣言を以下のように修正してみましょう。

void main() {
  List<String> a = ['one', 'two', 'three'];
  List<String>? b;
  List<String?> c = ['one', null, 'three'];
  
  print('a is $a.');
  print('b is $b.');
  print('c is $c.');
}

Dartアナライザのエラーが消えて、実行すると以下が出力されるようになります。

a is [one, two, three].
b is null.
c is [one, null, three].

変数 a は String型の値のみ格納することができるがnullを代入することのできないListです。
変数 b は String型の値のみ格納することができるがnullを代入することができるListです。
変数 c は String型か、null値のみ格納することができる、nullを代入することのできないListです。

変数cの例のように、ジェネリクスの型に対してもnullを許容するかどうかを宣言できるようになっていることが分かればokです。

スニペット4. 明確な代入

Dartの型システムは、変数がどこに代入され、どこでその値が読み込まれるかを追跡するのには十分です。
この追跡により、コードが実行されるよりも前に null許容型ではないフィールドに値が与えられているかどうか確認できます。

このような追跡のプロセスは明確な代入 "definite assignment" と呼ばれています。
以下のコードでは、Null許容型ではないString変数のtextに値が与えられないまま使用されていることがエラーとして表示されています。

void main() {
  String text;
  
//   if (DateTime.now().hour < 12) {
//     text = "It's morning! Let's make aloo paratha!";
//   } else {
//     text = "It's afternoon! Let's make biryani!";
//   }

  print(text); // エラー
  print(text.length); // エラー
}

if-else文のコメントを解除するとDartアナライザのエラーが消えるのを確認してください。

void main() {
  String text;
  
  if (DateTime.now().hour < 12) {
    text = "It's morning! Let's make aloo paratha!";
  } else {
    text = "It's afternoon! Let's make biryani!";
  }

  print(text);
  print(text.length);
}

また、以下のように else文を削除するとDartアナライザのエラーが復活するのを確認してください。

void main() {
  String text;
  
  if (DateTime.now().hour < 12) {
    text = "It's morning! Let's make aloo paratha!";
  }

  print(text); // if文を通らないとnullになる可能性があるためエラーとなる
  print(text.length); // 同上
}

明確な代入のチェックにより変数に確実に値が割り当てられることを確認することができ、誤ってnull参照による例外が発生してしまうことを未然に防ぐことができるようになるのです。

JRで乗り間違えた際の対処法

この記事は5年以上前に書かれたものです。
「旅客営業規則 第6款 誤乗及び誤購入」の大きな改定はありませんが、当時と対応状況が変わっている可能性はあります。予めご了承ください。
また、当時貧乏学生だったため追加料金を払わずに済む方法を記載していますが、普通に考えて追加料金を支払ってでも、早く到着する方法をお聞きしたほうが良いのではないかと、今では思います。

続きを読む