Flutter製チャットアプリを支える技術

Posted by kwmt27 on Thu, Mar 26, 2020

はじめに

今年はGoogle I/Oに行ってFlutterを知って6月ぐらいからFlutterを触りだし、いろいろ勉強会に行ったりFlutter温泉に行ったり、今年の後半はFlutter三昧でした。

そんな中チャットアプリを作ったので、それぞれ機能をどのように実装したかコードを交えつつ少しずつご紹介したいと思います。

開発環境は下記のとおりです。

% flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, v1.0.0, on Mac OS X 10.14.1 18B75, locale en-JP)
[✓] Android toolchain - develop for Android devices (Android SDK 28.0.3)
[✓] iOS toolchain - develop for iOS devices (Xcode 10.1)
[✓] Android Studio (version 3.2)
[✓] VS Code (version 1.29.1)
[✓] Connected device (2 available)

まずどんな機能を作ったか?

大きく分けると次のようになるかなと思います。

  • 会員登録・ログイン
  • チャットルーム(以下、ルーム)作成できる
  • ルーム一覧を見ることができる
  • ルームでチャット(テキスト、画像の送信)ができる
  • 既存のルームにユーザーが参加できる
  • 自分のプロフィールを見ることができる
  • アプリ内課金で定期購読できる

それぞれ見ていきます。

会員登録・ログイン

チャットアプリなので、だれが投稿したか知りたいので、会員登録・ログインをできるようにしました。 これは、firebase_authプラグインを使っていて、現在はGoogleログインと、Facebookログインに対応しています。

まずこんな感じで表示されるGoogleログインボタンのUIの実装を見てみます。

signInButtonというWidgetを作成し、

使う側は例えば次のように使います。

MaterialButton(
  child: signInButton(Strings.of(context).signInWithGoogle,
      'assets/images/google.png'),
  onPressed: _isAgree ? _handleSignInWithGoogle : _showError,
  color: Colors.white,
),

ログインとは直接関係ないですが_isAgreeはプライバシーポリシーと利用規約に同意したかどうかで、同意していれば_handleSignInWithGoogleを呼び、同意してなければエラーにしています。

また、Strings.of(context).signInWithGoogleの部分は多言語対応していて、Android のstringsリソースをイメージしています(こちらが参考になりました)。

ボタンができたので、Googleログインの処理は次のような感じにしています。

_googleSignInを準備して、

final GoogleSignIn _googleSignIn = GoogleSignIn(
    scopes: <String>['email'],
);

ログインボタンが押されたタイミングで、下記のようにします。

Future<FirebaseUser> signInWithGoogle() async {
  GoogleSignInAccount googleUser = await _googleSignIn.signIn();
  if (googleUser == null) {
    return null;
  }
  GoogleSignInAuthentication googleAuth = await googleUser.authentication;
  final AuthCredential credential = GoogleAuthProvider.getCredential(
    accessToken: googleAuth.accessToken,
    idToken: googleAuth.idToken,
  );
  FirebaseUser user = (await _auth.signInWithCredential(credential)).user;
  return user;
}

プラグインがPlatformExceptionを投げることがあるので、try catchして、PlatformExceptionにキャストするとcodeが取得できるので、それでエラー判定するとよいかと思います。ただし、プラグインによっては、違うエラーだけどcodeが全部一緒だったり、iOSとAndroidでcodeが異なっていたりする場合があるので、プラグインによって対応を変えなければいけないことがあります。

try {
  // ↑のsignIn処理のため省略
} catch (error) {
  if (error is PlatformException) {
    switch (error.code) {
      case GoogleSignIn.kSignInFailedError:
        // something
      case GoogleSignIn.kSignInCanceledError:
        // something
      case GoogleSignIn.kSignInRequiredError:
        // something
      case GoogleSignInAccount.kFailedToRecoverAuthError:
        // something
      case GoogleSignInAccount.kUserRecoverableAuthError:
        // something
      default:
        // something
    }
  }
  return null;
}

あと、flutter_firebase_uiを使うと少しだけ楽に作れますが、ボタンを押したイベントが取れずプログレスを出すことができないため、それぞれgoogle_sign_influtter_facebook_loginをそれぞれ使っています。

ルーム作成機能

ルームを作成するのに、下図のようにルーム画像とルーム名を入力してもらうようにしました。

黄色の円をタップすると画像を選択するようにし、TextFieldにはルーム名を入力できます。

これをコードで見ると次のようにしています。

Widget roomImageWidget = GestureDetector(
  child: Stack(
    alignment: const Alignment(0.6, 0.6),
    children: [
      ImageUtils.circleImageProvider(_previewImage(_imageFile),
          radius: 100.0, text: _inputtingRoomName),
      Icon(Icons.camera_alt),
    ],
  ),
  onTap: () => _onTapRoomImage(context),
);

円のWidgetとカメラアイコンのWidgetを重ねていますが、このように2つ以上のWidgetを重ねるには、Stack(こちらが参考になるかと思います)を使います。 また、全体をタップできるようにしたいため、StackをGestureDetectorでWrapしています。

画像を円形に表示するには、circleImageProviderの自作メソッドの中でやっているのですが、次のようにCircleAvatarを使うと便利かと思います。

CircleAvatar(
  child: child,
  backgroundColor: Colors.yellow,
  backgroundImage: imageProvider,
  radius: radius,
);

BottomSheetを使って選択

ルーム画像を設定するために先程の黄色の画像をタップすると、「ギャラリー」か「カメラ」をBtoomSheetを使って選択できるようにしています。1

これを実装するには、次のようにshowModalBottomSheet(こちらが参考になるかと思います)を使います。

Future<void> showCameraSelector(BuildContext context,
        VoidCallback onTapGallery, VoidCallback onTapCamera) =>
    showModalBottomSheet<void>(
        context: context,
        builder: (BuildContext context) => Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                ListTile(
                  leading: Icon(Icons.photo_library),
                  title: Text(Strings.of(context).gallery),
                  onTap: onTapGallery,
                ),
                ListTile(
                  leading: Icon(Icons.camera_alt),
                  title: Text(Strings.of(context).camera),
                  onTap: onTapCamera,
                ),
              ],
            ));

onTapGalleryonTapCameraでは、好みの写真プラグインを使えばよいかと思います。今回はimage_pickerを使いました。

画像の圧縮

画像のアップロード前に圧縮しているのですが、プラグインを調べると最初に目についたのがImageプラグインだったのでこちらを使っていました。が、Image.decodeが4,5分かかって使い物にならないので、flutter_native_imageを使っています。こちらはだいたい100ms以内で終わってくれるので早いです!

実装例は次のとおりです。

import 'dart:io';

import 'package:flutter_native_image/flutter_native_image.dart';

class Image {
  /// 画像を圧縮する
  /// https://stackoverflow.com/a/50998865
  /// ↓に変更
  /// https://github.com/flutter/flutter/issues/19383#issuecomment-405130684
  /// https://github.com/btastic/flutter_native_image
  /// @param file オリジナルファイル
  /// @return 圧縮されたfile
  static Future<File> compress(File file) async {
    ImageProperties properties =
        await FlutterNativeImage.getImageProperties(file.path);
    return await FlutterNativeImage.compressImage(file.path,
        quality: 80,
        targetWidth: 600,
        targetHeight: (properties.height * 600 / properties.width).round());
  }
}

画像のアップロード

画像のアップロード先はCloud Storageにしていますので、firebase_storageプラグインを使用しています。 画像をアップロードして、アップロード先のURLを知りたいのでコードは次のようにしています。

Future<String> uploadImage(File file) async {
  int timestamp = DateTime.now().millisecondsSinceEpoch;
  String subDirectoryName = 'images';
  final StorageReference ref = storage
      .ref()
      .child(subDirectoryName)
      .child('${timestamp}');
  final StorageUploadTask uploadTask = ref.putFile(
      file,
      StorageMetadata(
        contentType: "image/jpeg",
      ));
  StorageTaskSnapshot snapshot = await uploadTask.onComplete;
  if (snapshot.error == null) {
    return await snapshot.ref.getDownloadURL();
  }
  switch (snapshot.error) {
    case StorageError.unknown:
      // something
    case StorageError.objectNotFound:
      // something
    case StorageError.bucketNotFound:
      // something
    case StorageError.projectNotFound:
      // something
    case StorageError.quotaExceeded:
      // something
    case StorageError.notAuthenticated:
      // something
    case StorageError.notAuthorized:
      // something
    case StorageError.retryLimitExceeded:
      // something
    case StorageError.invalidChecksum:
      // something
    case StorageError.canceled:
      // something
  }
  return null;
}

画像のアップロードはルーム作成時だけでなく、チャットのメッセージとしてアップロードできるようにしていますし、プロフィール画像を変更できるようにもしているので、実際のコードはアップロード先を変更する処理とか入ったり、戻りの型が違ったりしてますが、だいたいこのような感じです。2

Firestoreに格納する

ルームを保存する場所はCloud Firestoreを使っていますので、cloud_firestoreプラグインを使います。

Future<void> createNewRoom(Room room) async {
  return _firestore
      .collection('rooms')
      .document(room.id)
      .setData(<String, dynamic>{'id': room.id, 'name': room.name});
}

ルーム一覧

ルーム一覧画面でfirestoreのroomsコレクションを監視していて、roomsコレクションにドキュメントが変更されたら更新されるようにしています。

_firestore
    .collection('rooms')
    .where(/** 条件 */) 
    .snapshots()
    .listen((querySnapshot) async {
        // 'rooms'に変化があったら通知される
    });

ルームでチャット(テキスト、画像の送信)ができる

ルームのイメージです。

詳細を書くとここだけで1記事になりそうなのであまり細かくは書きませんが、簡単に。

チャットメッセージの表示部分

チャットメッセージの表示部分はListを逆順で表示しています。これはListView.builderreversetrueにすることで逆順にできます。

Widget _buildListView() => ListView.builder(
      controller: _scrollController,
      reverse: true,
      itemCount: messages != null ? messages.length : 0,
      itemBuilder: (BuildContext context, int position) =>
          _buildMessageRow(context, position),
    );

画像を横幅いっぱいかつ角丸

Widget _buildImageWidget(
    BuildContext context, Message message, int position) {
  return SizedBox(
      height: 180.0,
      child: Container(
          padding: EdgeInsets.only(top: 8.0, right: 8.0, bottom: 8.0),
          child: GestureDetector(
            onTap: () {
              _onTapImage(position);
            },
            child: message.downloadImageUrl != null
                ? ImageUtils.roundedImage(message.downloadImageUrl.toString())
                : Text(Strings.of(context).uploading),
          )));
}

ImageUtils.roundedImageの部分は以下です。

static Widget roundedImage(String imageUrl) {
  return ClipRRect(
      borderRadius: BorderRadius.circular(8.0),
      child: cachedImage(imageUrl, fit: BoxFit.cover));
}

画像のキャッシュ

画像のキャッシュにはcached_network_imageプラグインを使用しています。

static CachedNetworkImage cachedImage(String imageUrl, {fit}) {
  return CachedNetworkImage(
    imageUrl: imageUrl,
    errorWidget: Icon(Icons.error),
    fit: fit,
  );
}

メッセージ入力部分

送信タイミングでテキストをクリアするには

送信ボタンを押すタイミングで入力テキストをクリアしたいと思ったとき、次のような実装がパッと思いつくかと思います。

_texEditingController.crear();

これだと日本語などの文字を入力中(確定前)に、クリアするとクラッシュしてしまいます。仕様など見たりいろいろ調べたところ、次のようにするとクラッシュしなくなりました。

_texEditingController
  ..clearComposing()
  ..clear();

キーボードを閉じるには

TextField部分をタップするとキーボードが表示されますが、iOSの場合はなにもしないとキーボードを閉じることができません。たとえば、チャットメッセージの表示部分をタップしてキーボードを閉じるには、チャットメッセージリスト部分をGestureDetectorでWrapして、onTapでフォーカスを変えることで閉じることができます。

GestureDetector(
    onTap: () => FocusScope.of(context).requestFocus(FocusNode()),
    child: _buildListView(),
)

focusまわりはこちらが参考になるかと思います。

既存のルームにユーザーが参加できる

既存のルームに他のユーザーが参加するためには、そのルームのQRコードを読み取って参加します。

QRコードを生成するには

ルームから下図のような画面を出して読み取ってもらうことにしました。

QRコード生成にはqr_flutterプラグインを使用していて、この画面の全体のコードは下記のように実装しました。

/// QRコードを表示するための画面
class DisplayQrCodeScreen extends StatelessWidget implements Screen {
  final String _qrCodeData;

  const DisplayQrCodeScreen(this._qrCodeData);

  @override
  Widget build(BuildContext context) {
    final bodyHeight = MediaQuery.of(context).size.height -
        MediaQuery.of(context).viewInsets.bottom;

    return Scaffold(
      appBar: AppBar(
        title: Text(Strings.of(context).qrCode),
        backgroundColor: Colors.black,
      ),
      backgroundColor: Colors.black,
      body: Container(
        color: Colors.white,
        child: Center(
          child: QrImage(
            version: 10,
            data: _qrCodeData,
            size: 0.2 * bodyHeight,
          ),
        ),
      ),
    );
  }

  @override
  String get name => "/qr";
}

QRコードのサイズを画面の高さの20%になるように設定しました。デバイス画面サイズを取得するために、MediaQueryを使っています。

QRコード読み取り

QRコードの表示ができたので、今度は読み取りです。本アプリでルームに参加するためには、ルームを表すQRコードを読み取る必要があります。QR読み取る方法として2種類用意しました。

  • QRコードをカメラで読み取る
  • QRコード画像を開いて読み取る

の2種類です。

QRコードをカメラで読み取る

使用しているプラグインはbarcode_scanです。(QRコードリーダーに関して、以前ブログを書きましたのでご興味あればぜひ!)

/// カメラを使ってQRコードをスキャンしてRoomに参加する
void _joinRoomWithCamera(BuildContext context) async {
  try {
    _roomId = await BarcodeScanner.scan();
  } catch (error) {
    if (error is PlatformException &&
        error.code == BarcodeScanner.CameraAccessDenied) {
       // something
    }
  }
}

ポイントはBarcodeScanner.scan()だけでQRコード読み取り画面を呼び出せます。カメラアクセス許可されてない場合にエラーコードがBarcodeScanner.CameraAccessDeniedで来るので、メッセージを出すとかするとよいかと思います。

QRコード画像を開いて読み取る

こちらは最近実装したのですが、firebase_ml_visionプラグインを使って実現しました。学習モデルをダウンロードするためアプリ容量が増えてしまうのが難点なのですが(20MBぐらい増えました)。

image_pickerプラグインでギャラリーからQRコード画像を開けるようにしておき、選択した画像をMLKitのBarcodeDetectorを使って検出しするという流れです。

Future<void> _onImageButtonPressed(ImageSource source) async {
  File selectedImageFile = await ImagePicker.pickImage(source: source);
  if (selectedImageFile == null) {
    return;
  }
  FirebaseVisionImage visionImage =
      FirebaseVisionImage.fromFile(selectedImageFile);
    final BarcodeDetectorOptions options = BarcodeDetectorOptions()
  final BarcodeDetector barcodeDetector =
      FirebaseVision.instance.barcodeDetector();
  final List<Barcode> barcodes =
      await barcodeDetector.detectInImage(visionImage);
  for (Barcode barcode in barcodes) {
    final BarcodeValueType valueType = barcode.valueType;
    switch (valueType) {
      case BarcodeValueType.text:
        final String value = barcode.displayValue;
        _roomId = value;
        break;
      default:
        break;
    }
  }
}

実装としてはこれで可能ですが、Androidでアプリ起動時に次のような例外が出るようになってしまいました。

2-06 17:02:11.288  9895  9895 E AndroidRuntime: java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[[zip file "/system/framework/org.apache.http.legacy.boot.jar", zip file "/data/app/com.instantonnection.app-zIN5KpTkwZQRngy5QLP6ig==/base.apk"],nativeLibraryDirectories=[/data/app/com.instantonnection.app-zIN5KpTkwZQRngy5QLP6ig==/lib/arm64, /data/app/com.instantonnection.app-zIN5KpTkwZQRngy5QLP6ig==/base.apk!/lib/arm64-v8a, /system/lib64]]] couldn't find "libflutter.so"
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at java.lang.Runtime.loadLibrary0(Runtime.java:1012)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at java.lang.System.loadLibrary(System.java:1669)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at io.flutter.view.FlutterMain.startInitialization(Unknown Source:32)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at io.flutter.view.FlutterMain.startInitialization(Unknown Source:5)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at io.flutter.app.FlutterApplication.onCreate(Unknown Source:3)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1154)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.app.ActivityThread.handleBindApplication(ActivityThread.java:5871)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.app.ActivityThread.access$1100(ActivityThread.java:199)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1650)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.os.Handler.dispatchMessage(Handler.java:106)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.os.Looper.loop(Looper.java:193)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at android.app.ActivityThread.main(ActivityThread.java:6669)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at java.lang.reflect.Method.invoke(Native Method)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
12-06 17:02:11.288  9895  9895 E AndroidRuntime: 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)

この対策としては、

image.png

と設定し、ビルド時に以下のオプションをつけることで解決しました。(参考)

--target-platform android-arm

自分のプロフィールを見ることができる

下のように、スクロールするとヘッダーが変化するWidgetを使っています。3

profile.gif

コードはこんな感じです。

Widget screen = Scaffold(
  body: CustomScrollView(slivers: <Widget>[
    SliverAppBar(
      expandedHeight: _appBarHeight,
      pinned: true,
      elevation: 4.0,
      actions: actions,
      flexibleSpace: FlexibleSpaceBar(
        title: Text(widget.user.name),
        background: Stack(
          fit: StackFit.expand,
          children: <Widget>[
            ImageUtils.cachedImage(widget.user.photoUrl.toString(),
                fit: BoxFit.cover),
            // This gradient ensures that the toolbar icons are distinct
            // against the background image.
            const DecoratedBox(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment(0.0, -1.0),
                  end: Alignment(0.0, -0.4),
                  colors: <Color>[Color(0x60000000), Color(0x00000000)],
                ),
              ),
            ),
          ],
        ),
      ),
    ),
    SliverList(
        delegate: SliverChildListDelegate(<Widget>[
      AnnotatedRegion<SystemUiOverlayStyle>(
          value: SystemUiOverlayStyle.dark, child: body),
    ]))
  ]),
);

こちらの動画で雰囲気つかめるかと思います。

アプリ内課金で定期購読できる

このアプリの目玉とも言える(笑)アプリ内課金を実装しました。使用したプラグインはflutter_inapp_purchaseです。

このプラグインの使い方はREADMEを見てもらうとわかるのですが、このプラグインから投げられる例外が微妙で、エラー処理がちょっと大変でした。

たとえば、下図はいったん購入しようとしたけどキャンセルした場合の例ですが、PlatformExceptionが投げれるのは良いのですが、codeがTAGになっていて、どの例外もcodeで判断できません。

image.png

よく見るとdetailsの「responseCode: 1」の1がCancelを表すエラーコードになっていますので、それで判断するしか無さそうです。

先程のはAndroidでしたが、同じキャンセルでもiOSでキャンセルすると違うcodeが返ってきます。。

image.png

なので、アプリのコードとしては以下のようにしました。

Future<MyPurchaseItem.PurchasedItem> buySubscription(String productId) =>
    FlutterInappPurchase.buySubscription(productId)
        .then((item) => _purchasedTranslator
            .toModel(_translateToPurchasedItemEntity(item)))
        .catchError((error) {
      // エラーコード統一してほしい・・・
      if (Platform.isAndroid) {
        if (error.code == "InappPurchasePlugin" && error.details is String) {
          // https://developer.android.com/reference/com/android/billingclient/ap
          String details = error.details as String;
          String USER_CANCELED = "1";
          if (details.endsWith(USER_CANCELED)) {
            return;
          }
        }
      } else if (Platform.isIOS) {
        // https://github.com/dooboolab/flutter_inapp_purchase/blob/a249e5fe7563a
        if (error.code == "E_USER_CANCELLED") {
          return;
        }
      }
      throw error;
    });

このプラグインが使えないとか言いたいわけではなくて、自分がプラグインを作るときは以下の点に気をつけたいなぁと思いました。

  • error codeは適切なコードにする
  • error codeはiOS、Androidで統一する
  • アプリケーション側でエラーコードを変数で扱えるようにする

あ、アプリ内課金そのものについて触れてませんでしたね。。とはいえ、Flutterというよりそれぞれのプラットフォームの話なので詳細には触れませんが、参考リンクだけ張っておきます。

その他 細かいけど必要な機能

細かいけど必要だったり便利な機能を見ていきたいと思います。

  • メッセージを送信したら通知が届く
  • メッセージ入力中に画面を戻ってしまったなどのメッセージが消えないようにする
  • チャットメッセージにURLがあるとタップできてリンクを開くことができる
  • チャットメッセージの画像を拡大できる
  • チャットメッセージの画像を横スワイプで切り替えることができる
  • チャットメッセージの画像を上下スワイプで閉じることができる
  • 開発者のための機能
    • analytics
    • crashlytics
    • AdMob
  • リリースまでに必要なの
    • ランチャーアイコン
    • スクリーンショット

メッセージを送信したら通知が届く

firebase_messagingプラグインを使って、roomIdとuserIdをトピックに登録しています。

subscribe,unsubscribeは簡単です。

FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
_firebaseMessaging.subscribeToTopic(topic);
_firebaseMessaging.unsubscribeFromTopic(topic);

Cloud Functionsがfirestoreのメッセージコレクションを監視して、追加があったらCloud Functionsがプッシュを投げるようにしています。(Cloud FunctionsのFCMのサンプルが参考になるかと思います)

メッセージ入力中に画面を戻ってしまったなどのメッセージが消えないようにする

長文を書いてて間違って戻ってしまった場合、長文のメッセージが消えてしまったとかはいやなので、shared_preferencesプラグインを使ってdisposeのタイミングでローカルストレージに保存しています。

Future<bool> saveMessage(Message message, String roomId) async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  return prefs.setString(
      _createMessageKey(roomId), message.content);
}

String _createMessageKey(String roomId) =>
    "${SharedPreferenceType.message.toString()}/$roomId";

画面に戻ってきたときは、initStateのタイミングで保存したメッセージを読み込んでTextFieldにセットしています。

Future<String> getMessage(String roomId) async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  return prefs.getString(_createMessageKey(roomId));
}
getMessage(widget.room.id).then((message) {
  setState(() {
    _texEditingController.text = message;
  });
});

チャットメッセージにURLがあるとタップしてリンクを開くことができる

link.gif

あんまりスマートではないのですが、実装例です。

class Message {
  String id;
  String content;

  List<LinkText> get linkTextList {
    if (content == null) {
      return null;
    }

    List<LinkText> results = List();
    // https://stackoverflow.com/a/6041965
    RegExp exp = RegExp(
        r'(http|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?');

    String _content = content;

    do {
      if (_content.length == 0) {
        return results;
      }

      Match match = exp.firstMatch(_content);
      if (match == null) {
        results.add(LinkText(_content));
        return results;
      }

      // urlが見つかるまでにテキストがあれば追加する
      String str = _content.substring(0, match.start);
      if (str.length > 0) {
        results.add(LinkText(str));
      }
      String url = match.group(0);
      results.add(LinkText(url, url: url));
      _content = _content.substring(match.end, _content.length);
    } while (_content.length > 0);
    return results;
  }

  Message(this.id, this.content);
}

class LinkText {
  String text;
  String url;

  LinkText(this.text, {this.url});
}

Messageクラスのcontentに入力したメッセージが入る想定で、Widget側で次のように使っています。

Widget _buildMessageWidget(BuildContext context, Message message) {
  final TextStyle aboutTextStyle = Theme.of(context).textTheme.body2;
  List<TextSpan> children = message.linkTextList.map((linkText) {
    // textがnullではなくurlがnullならリンクにしない。
    // textもurlもnullではない場合、urlに遷移するリンクにする。
    if (linkText.url == null) {
      return TextSpan(style: aboutTextStyle, text: linkText.text);
    }
    return LinkTextSpan(
        context: context, text: linkText.text, url: linkText.url);
  }).toList();
  return Container(
    child: RichText(
      text: TextSpan(
        style: TextStyle(fontSize: 16.0),
        children: children,
      ),
    ),
  );
}

LinkTextSpanクラスは次のとおりです。

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

// https://github.com/flutter/flutter/blob/master/examples/flutter_gallery/lib/gallery/about.dart#L11-L33
class LinkTextSpan extends TextSpan {
  LinkTextSpan(
      {BuildContext context,
      TextStyle style,
      String url,
      String text,
      bool inAppWebView = false})
      : super(
            style: style ?? url != null
                ? TextStyle(
                    color: Colors.blue,
                    decoration: TextDecoration.underline,
                  )
                : Theme.of(context).textTheme.body2,
            text: text ?? url,
            recognizer: TapGestureRecognizer()
              ..onTap = () {
                launch(url, forceWebView: inAppWebView);
              });
}

launchurl_launcherプラグインで、URLを開くことができます。

チャットメッセージの画像を拡大できる

画像の拡大にはphoto_viewプラグインを使っています。

Container(
  child: Center(
    child: PhotoView(
      imageProvider: NetworkImage(imageUrl),
      minScale: PhotoViewComputedScale.contained * 1.0,
      maxScale: 2.0,
    ),
  ),
);

チャットメッセージの画像を横スワイプで切り替えることができる

swipe.gif

画像を横スワイプで切り替えるのに、DefaultTabControllerTabBarViewクラスを組み合わせて使っています。

全体のコードはこちらのgistを見ていただければと思いますが、雰囲気は下記のような感じです。

DefaultTabController(
      initialIndex: position,
      length: imageUrls.length,
      child: _PageSelector(imageUrls: imageUrls, position: position),
    );

_PageSelectorはStatelessWidgetになっていて、下記を実装しています。

child: TabBarView(
    children: imageUrls.map((String imageUrl) {
  return Container(
    child: Center(
      child: PhotoView(
        imageProvider: NetworkImage(imageUrl),
        minScale: PhotoViewComputedScale.contained * 1.0,
        maxScale: 2.0,
      ),
    ),
  );
}).toList()),

チャットメッセージの画像を上下スワイプで閉じることができる

dismissable.gif

画面全体のWidget(ここではScaffold)をDismissableでWrapするだけです。5

 Dismissible(
  key: key,
  direction: DismissDirection.vertical,
  onDismissed: (direction) {
    Navigator.pop(context);
  },
  child: Scaffold(appBar: AppBar(省略...
);

開発者のための機能

analytics

GoogleAnalyticsもfirebase_analyticsプラグインがあって、スクリーン名やイベントを取ることができます。

crashlytics

crashlyticsはGoogle公式ではありませんが、flutter_crashlyticsプラグインがあります。67

AdMob

広告を表示するために、firebase_admobを使用しました。バナー、インタースティシャル、リワードに対応していますが、READMEのlimitationsに書いてあるようにいくつか制限があります。 バナー広告は画面の上か下しか配置できなかったり、リストの途中に広告を挟むことができないのは使えないと思うかも知れませんが、PlatformViewがAndroid、iOSに対応したので今後に期待しています。(先日のアドベントカレンダーにも投稿されましたね)

今回バナーはデザイン的に使いたくなったので、インタースティシャル広告を実装しました。 ルームやプロフィールを編集する画面があるのですが、その編集画面に遷移するタイミングで20%の確率で広告を表示しています。

プロフィール画面のinitStateでloadします

Future<bool> loadInterstitial() {
  _interstitialAd?.dispose();
  _interstitialAd = _createInterstitialAd()..load();
  return Future.value(true);
}

InterstitialAd _createInterstitialAd() => InterstitialAd(
      adUnitId: _appConfig.adMobIds.interstitialUnitId,
      listener: (MobileAdEvent event) {},
    );

編集画面へ遷移するボタンタップ時のタイミングで広告を表示しています。

Future<bool> showInterstitial() {
  return _interstitialAd.isLoaded().then((loaded) {
    if (loaded) {
      _interstitialAd?.show();
      return true;
    }
    return false;
  });
}

ちなみに20%の確率の実装はRandomを使うとよさそうです。8

Random().nextInt(100) <= 20

リリースまでに必要なの

ランチャーアイコン

ランチャーアイコン(アプリアイコン)はリリースまでには必要になるのですが、サイズが細かく決まってたりしてなれてないと(なれてても?)アイコン作成は大変です。それを便利にするのが、flutter_launcher_iconsプラグインです。

(私の場合は)1024x1024の画像を1つだけ作ってpubspec.yamlに次のように書いて実行すると、Android,iOSの解像度別のアイコンをすべて作成してくれます。

  flutter_launcher_icons: "^0.6.0"

flutter_icons:
  android: true
  ios: true
  image_path: "assets/images/launcher-icons/icon-1024x1024.png"
  adaptive_icon_background: "#FFFFFF"
  adaptive_icon_foreground: "assets/images/launcher-icons/icon-1024x1024.png"

スクリーンショット

これもリリースまでには必要になります。こちらはプラグインではないのですが、flutter screenshotコマンドが便利だったのでご紹介。

いつもならAndroid Studioと端末をつなげてLogCatからScreen Captureするとか、端末側でスクショとって画像をPCに送るとかしていたのが不要になります。

PCと端末つなげてflutter screenshotを実行するだけでスクショを撮ってくれます。これは普通にAndroid,iOS開発中にも使えるので、オススメです。

% flutter screenshot
Screenshot written to flutter_03.png (160kB).

紹介した時点でのプラグインのバージョン一覧

プラグイン バージョン
google_sign_in3.2.4
firebase_auth0.15.4
cloud_firestore0.8.2+3
firebase_messaging2.1.0
flutter_facebook_login1.1.1
firebase_storage1.0.4
firebase_analytics1.0.6
firebase_admob0.7.0
firebase_ml_vision0.2.0+2
flutter_crashlytics0.1.1
image_picker0.4.10
flutter_native_imagecommit 20947f199
photo_view0.1.0
cached_network_image0.4.2
barcode_scan0.0.8
qr_flutter1.1.5
url_launcher4.0.2
flutter_inapp_purchase0.8.5
shared_preferences0.4.3
flutter_launcher_icons0.7.0

本アプリのリリースについて

ここまでお読みいただいた方はもしかしたら触ってみたくなったかもしれませんので、リリース状況についてお伝えします。

Android

本日バージョン1.0.0リリースです🎉 10

iOS

iOSはAppStoreに審査出しました。

image.png

が、Designガイドラインに引っかかってリジェクトされました。 おそらくオリジナリティがないとのことだと思うので、AppStoreへのリリースは当分先になるかもしれません。

2019/08/02追記 iOSは3月にAppStoreにリリースできました。リンク貼るの遅くなりました。。。

ちなみに、上記のオリジナリティがないというリジェクトの対応は、オンボーディングをつけることで通りました。

ソースコードについて ※2020/02/29追記

ここで紹介したチャットアプリのコードをオープンソースにしましたので、全体的なコードを知りたい方はこちらをご覧ください。 https://github.com/kwmt/flutter-inconne PR、issue、スター☆はWelcomeです!

おわりに

以上、いかがでしたでしょうか。おそらくチャットアプリでの主要な機能については一通りご紹介できたんじゃないかなと思っていますので、この記事読んでチャットアプリが作れそう!と思ってもらえたら嬉しく思います。

チャット機能のみのアプリだとapple様にリジェクトされるようなので、なにかメイン機能+チャット機能とするのがいいのかもしれません。

この記事がFlutter開発者のみなさんの助けになれば幸いです。 ここまでお読み頂きありがとうございました。


  1. safe areaにかぶっているので調整が必要かもしれません。 ↩︎

  2. firebase_storageプラグインのv1.0.3で使い方が変わり、取り急ぎ修正した感じなのでもしかしたらもっと良いやり方があるかも知れません。また、プラグインのexampleにはStreamBuilderの使い方が書いてありますが、StreamBuilderを使うと表示とアップロード処理の分離できなさそうだったので使いたくありませんでした。 ↩︎

  3. 使ってみたかっただけです。 ↩︎

  4. 定期購読が停止されてるかどうかをチェックするためのサーバーをGoで作成しましたが、そのときに使わせてもらったライブラリです。 ↩︎

  5. 簡単すぎて感動しました! ↩︎

  6. issueがあります。公式サポートしてほしいと思っている人たちは多そうなので期待しています。 ↩︎

  7. Flutterプロジェクトが古いとiOSでビルドできない状態になってました。Podfileが少し変わった?ようで、その部分を変えたらビルドできるようになりました。新しいプロジェクトなら動くかと思います。 ↩︎

  8. import 'dart:math';が必要です。 ↩︎

  9. https://pub.dartlang.org/ に公開されていないので、コミット番号にしています。 ↩︎

  10. と、言ってみたかった。  ↩︎



comments powered by Disqus