Admob の本番広告はアプリリリース後審査が完了するまで表示されない

support.google.com

Admob の本番広告を表示するには以下の流れとなる。

  1. アプリをリリースする
    • この時、Admobステータスは要審査
  2. Admobの管理画面でリリースされたアプリとAdmobをリンクさせる必要がある
    • Admob側で反映させるのに時間がかかる
    • 私の iOS アプリでは13時にリリース → 当日の22時くらいにリンク可能となった
    • この時、Admobステータスは審査中
  3. リンクが完了するとAdmob側で審査が走り、審査が完了すると本番で広告が表示される
    • 審査開始が22時、審査完了メールが来たのが翌午前4時だった
    • この時、Admobステータスは準備完了

1日かからずiOSアプリにAdmob本番表示ができたが、ググると3日とか書いてる人もいる。海外のストアでリリースしてそれでAdmobリンクして日本語版を公開するなどの手法を取ってる人もいて良さそう。

Flutter で build メソッドをリファクタするとき Widget は class に分割するべし

www.youtube.com

この Youtube で述べられている内容だが、Flutter で build メソッドがでかくなってきて component に分割するか〜と思う時がある。
その時、 Helper Methods と呼ばれる方法と、 class で Widget を作る方法があるがパフォーマンスの観点から class Widget を利用した方がよい。

どう違うのか?

こういう Widget があったとする。ボタンをタップすると state が更新されるだけの Widget
これを Helper Methods と class Widget の2つのパターンで確認してみる。

class WidgetSample extends StatefulWidget {
  const WidgetSample({Key? key}) : super(key: key);

  @override
  State<WidgetSample> createState() => WidgetSampleState();
}

class WidgetSampleState extends State<WidgetSample> {
  var count = 0;

  @override
  Widget build(BuildContext context) {
    print('build');

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('count $count'),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  count += 1;
                });
              },
              child: const Text('tap'),
            ),
            const Text('very heavy widget.'),
          ],
        ),
      ),
    );
  }
}

Helper Methods

Helper Methods と呼ばれるのは以下のようにメソッドとして widget を切り出す方法である。

@@ -90,12 +90,17 @@ class WidgetSampleState extends State<WidgetSample> {
               },
               child: const Text('tap'),
             ),
-            const Text('very heavy widget.'),
+            _buildVeryHeavyWidget(),
           ],
         ),
       ),
     );
   }
+
+  Text _buildVeryHeavyWidget() {
+    print('very heavy widget.');
+    return const Text('very heavy widget.');
+  }
 }

このコードでボタンをタップすると、state が更新されるたびに print('very heavy widget.'); が呼ばれることを確認できる。

Class Widget

では次に class で Widget を作成し、 const で呼び出す。

               },
               child: const Text('tap'),
             ),
-            const Text('very heavy widget.'),
+            const _VeryHeavyWidget(),
           ],
         ),
       ),
@@ -98,6 +98,18 @@ class WidgetSampleState extends State<WidgetSample> {
   }
 }
 
+class _VeryHeavyWidget extends StatelessWidget {
+  const _VeryHeavyWidget({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    print('very heavy widget.');
+    return const Text('very heavy widget.');
+  }
+}

このコードでボタンをタップすると、state が更新されても print('very heavy widget.'); は呼び出されない。
必要がなければ build method が呼ばれないようになっている。

おわり

簡単な画面構成だったら Helper Methods 方式でも大きな影響は出ないが、動画にも述べられているようにアニメーションが走る画面であったり、地図アプリでユーザがスクロールするたびに state が更新されるうような画面は class にしておかないとチラつきや遅延が目立ってしまうので普段から class で Widget を分割するように心がけておくと良い。

Dart でデフォルト引数を使う

Dart でデフォルト引数を使いたくて、こんな感じで書いてみると The default value of an optional parameter must be constant. というエラーが出てしまう。

class HogeClass {
  // The default value of an optional parameter must be constant.
  HogeClass({Object object = Object()}) : _object = object;

  final Object _object;
}

どうやら const しかデフォルト引数に持てないっぽい。でも工夫すればデフォルト引数っぽいことは可能になる。

nullable で引数を設定する

nullable で引数を設定して、引数が null だったらクラス側で Object() を作っている。これで実質デフォルト引数と同じことができる。

class HogeClass {
  HogeClass({Object? object}) : _object = object ?? Object();

  final Object _object;
}

final hogeClass1 = HogeClass();
final hogeClass2 = HogeClass(object: Object());

これは Class のコンストラクタだが、メソッドでも同様のことができる。

たまに HogeClass({Object? object}) : _object = Object();と書いてしまって引数が設定されないが? とミスることがあるので注意。

Named constructor を使う (コンストラクタのみ)

https://dart.dev/guides/language/language-tour#named-constructors

Named constructor を利用することで複数のコンストラクタを用意できる。

class HogeClass {
  HogeClass(this._object);

  HogeClass.noArgs() : _object = Object();

  final Object _object;
}

var hogeClass1 = HogeClass(Object());
var hogeClass2 = HogeClass.noArgs();

この書き方だとコンストラクタで何か処理をする場合、2箇所に処理を書かなければならなくなる。

class HogeClass {
  HogeClass(this._object) {
    print(_object);
  }

  HogeClass.noArgs() : _object = Object() {
    print(_object);
  }

  final Object _object;
}

おわり

2箇所に処理を書きたくないので nullable を使ってデフォルト引数を利用するのが良さそう。

Flutter でのタブレット対応を考える Readable Content Guide

iOS には Readable Content Guide という考え方があり、iPad などの大きな端末では横幅いっぱいにコンテンツが表示されないようになっている。
参考: iOSでの読みやすい幅 - クックパッド開発者ブログ

Flutter で iPad を意識せず実装するとボタンが横に間伸びしていたり、横幅の比率で表示するコンテンツが異常に大きくなってしまう。最悪レイアウトが崩壊する。
それをいい感じに表示するために横幅の最大値を制限した Widget を作成する。

普通に実装した場合

横幅いっぱいに表示するボタンが2個並んでいる画面。

import 'package:flutter/material.dart';

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

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('example app'),
        ),
        body: Container(
          padding: const EdgeInsets.only(left: 18, right: 18),
          child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                const SizedBox(height: 18),
                SizedBox(
                    height: 88,
                    width: double.infinity,
                    child: ElevatedButton(
                        child: const Text('ログインする'), onPressed: () {})),
                const SizedBox(height: 32),
                SizedBox(
                    height: 88,
                    width: double.infinity,
                    child: ElevatedButton(
                      child: const Text('新規会員登録する'),
                      onPressed: () {},
                    )),
              ]),
        ),
      ),
    );
  }
}

画面はこのように表示される。幅が広い画面だと間伸びして表示されてしまいダサい。

通常幅の画面 横幅の広い画面
f:id:star__hoshi:20220225101852p:plain f:id:star__hoshi:20220225101932p:plain

画面の最大幅を制限する

ReadableWidthContainer という、 maxWidth を設定しているだけの Widget を作成する。

import 'package:flutter/material.dart';

class ReadableWidthContainer extends StatelessWidget {
  const ReadableWidthContainer({Key? key, required this.child})
      : super(key: key);
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 600), child: child),
    );
  }
}

これを利用して画面を表示すると以下のように表示される。コンテンツが中央によって、600を最大幅として表示される。

通常幅の画面 横幅の広い画面
f:id:star__hoshi:20220225102508p:plain f:id:star__hoshi:20220225102510p:plain

(ボタンが2つしか表示されていないボタンだとメリットが分かりにくいが…)

ReadableWidthContainerを利用した main.dart は以下。

import 'package:flutter/material.dart';

import '/components/readable_width_container.dart';

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

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('example app'),
        ),
        body: ReadableWidthContainer(
          child: Container(
            padding: const EdgeInsets.only(left: 18, right: 18),
            child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  const SizedBox(height: 18),
                  SizedBox(
                      height: 88,
                      width: double.infinity,
                      child: ElevatedButton(
                          child: const Text('ログインする'), onPressed: () {})),
                  const SizedBox(height: 32),
                  SizedBox(
                      height: 88,
                      width: double.infinity,
                      child: ElevatedButton(
                        child: const Text('新規会員登録する'),
                        onPressed: () {},
                      )),
                ]),
          ),
        ),
      ),
    );
  }
}

おわり

これでサイズの大きな端末でもレイアウトが壊れずに実装できるようになった。

ファイルの変更を検知して自動でテストを実行する

Flutter のテスト実行には watch がなくて不便だと思っていたのでファイルの変更を検知して自動でテストを実行する。
watchman を使うので別に Flutter に限った話ではない。

watchman いれる

https://facebook.github.io/watchman/
install 方法は色々あるけど brew install watchman で入れた

コマンド

以下のコマンドでOK。
test ディレクトリ以下のファイルに変更があったら flutter test を実行するだけ

$ watchman-make -p 'test/*' -r 'flutter test'

もしくはこう

 watchman-make -p 'test/**/*.dart' 'lib/**/*.dart' -r 'fvm flutter test'

Xcode11 で Resource の Localization をする

Xcode11 から Resource の Localization ができるようになった。 https://help.apple.com/xcode/mac/11.0/index.html?localePath=en.lproj#/dev7c584bb2a

やり方は簡単で、まずベースとなる言語のファイルを追加、Localization の設定、Localize 用のファイルを追加となる。

ベースとなる言語のファイルを追加

ベース言語となるファイルを Project Tree にドラッグ&ドロップ

Localization の設定

Show the file inspector にある Localization を選択し、Base を選択。
今回は画像でやった。

f:id:star__hoshi:20191231010952p:plain

Localize 用のファイルを追加

Show the file inspector 上の方にファイル選択があるのでそこからやる。

f:id:star__hoshi:20191231011409p:plain

ファイルの読み込み

Bundle.main.url(forResource: "image", withExtension: "jpeg")

これで Resource ファイルにアクセスすると、 Localize されたファイルを取得できる。

Share Extension で画像のシェア先に自分のアプリを出す

f:id:star__hoshi:20191230162427p:plain

これの Share 先に自分のアプリを表示させて、保存などの処理を行う。

やることは

  1. Target 追加
  2. App Groups の設定
  3. 証明書の修正
  4. Podfile 修正
  5. 新しいTargetの Info.plist 修正
  6. ShareViewController の実装
  7. データの保存先について
  8. Simulator で確認

です。

Target 追加

Xcode の File > New > Target から Share Extension を選択して作成。
こんな感じの物が出来上がる。

f:id:star__hoshi:20191230172145p:plain

App Groups

次に、App Groups の設定をする。Target を超えてファイルを共有するのに必要。

先ほど作った Target > Signing & Capabilities > + Capability から AppGroups を選択。 group.com.hoge を App Groups に追加する。メインの Target にも同様に group.com.hoge を App Groups に追加する。

証明書の更新

証明書の設定が必要なので、 https://developer.apple.com/account/resources/identifiers/list から Capabilities の App Groups にチェックを入れ、 group.com.hoge を登録する。

f:id:star__hoshi:20191230172657p:plain

証明書の更新をしておく。

Podfile 修正

Share Extension でライブラリを使う必要があるなら Podfile も修正する。
例えば Realm を共有するなら以下のようにして pod update。

# Podfile

target 'hoge' do
  use_frameworks!
  inhibit_all_warnings!

  pod 'RealmSwift'
end

target 'hogeShareExtension' do
  use_frameworks!
  inhibit_all_warnings!

  pod 'RealmSwift'
end

Info.plist 修正

今回は画像を1枚だけ受ける設定にしたいので、Share Extension の Info.plist を以下のようにする。

NSExtensionAttributes を Dictionary に, NSExtensionActivationRule を Dictionary に, NSExtensionActivationSupportsImageWithMaxCount を 1 で設定する。

f:id:star__hoshi:20191230173308p:plain

https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html#//apple_ref/doc/uid/TP40014214-CH21-SW8 で他の設定などが確認できる。

ShareViewController の実装

これまでの手順で、もう画像のシェア先に hoge アプリが出るはず。
画像を受け取った時の実装を書いていく。

import UIKit
import Social
import MobileCoreServices
import RealmSwift

class ShareViewController: SLComposeServiceViewController {
    override func isContentValid() -> Bool {
        // テキスト入力で Validation したい場合に設定する
        return true
    }

    // Post ボタンが押された
    // データの保存処理などここで行う
    override func didSelectPost() {
        let extensionItem = self.extensionContext?.inputItems.first as? NSExtensionItem
        let itemProvider = extensionItem?.attachments?.first
        let imageType = String(kUTTypeImage)

        // 渡されたデータが画像か確認
        if itemProvider?.hasItemConformingToTypeIdentifier(imageType) == true {
            // データを読み込む
            itemProvider?.loadItem(forTypeIdentifier: imageType, options: nil) { item, error in
                // 画像の場合 URL が渡ってくる
                if let url = item as? URL {
                    let image = UIImage(contentsOfFile: url.path)
                    if let image = image {
                        // 画像をローカルに保存やAPI実行するなどやりたいことをする
                        // contentText から入力された文字が取得できる
                    }
                }
            }
        }

        // 最後に実行する。データの保存処理などが非同期な場合は注意
        self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
    }

    override func configurationItems() -> [Any]! {
        // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
        return []
    }
}

こんな感じで画像を受け取ることができる。

データの保存先について

これで共有ディレクトリを取得できます。

let url: URL? =FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.hoge")

メインのアプリとデータを共有するためには documentDirectory などは使えません。
Realm でデータの共有をしたい場合などは以下のようにします。

        var config = Realm.Configuration()
        config.fileURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.memoCamera")?.appendingPathComponent("default.realm")
        Realm.Configuration.defaultConfiguration = config

Simulator で確認

Scheme を変えてデバッグをします。Scheme を変えないと break point など使えません。

f:id:star__hoshi:20191230174653p:plain

参考

以下の記事などが参考になります qiita.com

fastlane で Crashlytics に dSYM をアップロードしようとしたら invalid byte sequence in UTF-8 でハマった

  lane :upload_latest_dsyms do
    download_dsyms(version: "latest")
    upload_symbols_to_crashlytics
    clean_build_artifacts
  end

を実行したら、こんなエラーが出た

[22:29:22]: -------------------------------------------
[22:29:22]: --- Step: upload_symbols_to_crashlytics ---
[22:29:22]: -------------------------------------------
[22:29:23]: Extracting '/Users/hoge/hoge-1.0.0-201911121041.dSYM.zip'...
[22:29:23]: $ unzip -qo /Users/hoge/hoge-1.0.0-201911121041.dSYM.zip
+------------------+--------------------------------------------------------------------------------------+
|                                              Lane Context                                               |
+------------------+--------------------------------------------------------------------------------------+
| DEFAULT_PLATFORM | ios                                                                                  |
| PLATFORM_NAME    |                                                                                      |
| LANE_NAME        | upload_latest_dsyms                                                                  |
| DSYM_PATHS       | ["/Users/kensuke/hoge/hoge-1.0.0-201911121041  |
|                  | .dSYM.zip"]                                                                          |
+------------------+--------------------------------------------------------------------------------------+
[22:29:23]: invalid byte sequence in UTF-8

どうやら unzip が失敗しているようなので、手元で unzip してみると、確かに unzip でこけている。

$ unzip -qo /Users/kensuke/hoge/hoge-1.0.0-201911121041.dSYM.zip
error:  cannot create bf73f681-9f66-3e36-83e4-f79219bafdf6.dSYM/Contents/Resources/DWARF/��������+��޿����SS
        Illegal byte sequence

issue を探すと、ここに行き着いた。.app ファイル名が悪いらしい。

github.com

.app ファイルを見てみると、 ダメージ計算SS.app となっており、日本語ファイル名になっている。これを英語名に変える必要がある。

.app ファイル名を変える

Build Settings > Packaging > Product Name を確認すると確かに日本語になっている。

これに対処するためには、以下の順序で設定を変える。

  1. display name を英語のにする
    • f:id:star__hoshi:20191113231424p:plain
  2. Info.plist の Bundle display name をアプリ名に置き換える。
    • f:id:star__hoshi:20191113231024p:plain

そうすると、Product Name は英語で指定した Display Name になり、アプリ名は Bundle display name で指定したものにできる。そして Display Name は Bundle display name で指定したものになる。
めちゃくちゃややこしい。

注意

今度アプリ名を変える時、 Display Name を変えると Bundle display name が上書きされてしまうので、 Bundle display name だけを変えるようにしましょう。

macOS Catalina で Karabiner を使う

これをやるだけ。 Catalina が出たばかりなので暫定処置となる。

github.com

日本語にすると、

セキュリティとプライバシーの入力監視に /Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_grabber を追加。

f:id:star__hoshi:20190605221157p:plain

Terminal で sudo killall karabiner_grabber を実行。

これだけで動くようになった。

セキュリティとプライバシーに「+」ボタンが表示されない人は Krabiner-EventViewer.app を開くとここにアプリケーションが表示される。

Karabiner は Vi Mode、コマンドで英かなを使っているけどどちらも動いてて問題なさそう。

皆さまにご報告。

いつも応援していただいている皆さまへ、私事で恐縮ですがご報告があります。

本日令和元年五月一日に私星川健介は入籍致しましたことをここにご報告させていただきます。
お相手は一般の方です。

元号が令和に変わった初日で、大安という事もあり、この良き日に入籍できたことを嬉しく思います。

自分自身まだまだ本当に未熟な人間ではありますが、どんな時も楽しく思いやりに満ちた家庭を築いていきたいと思っております。

23歳からプログラミングのお仕事を始めて以来、本当にありがたいことに毎日充実した日々を送らせていただき、今日に至るまでたくさんの経験、ご縁が出来た事に心から感謝の気持ちでいっぱいです。
これからもソフトウェアエンジニアとして、人として、より精進していきたいと改めて感じています。
もちろん、今後も変わらずお仕事は続けていきます。

これまで支えてくださった家族、友達、お仕事関係の皆さま、そしてファンの皆さまに感謝の気持ちを忘れず、これからもアプリ・サービス開発などで恩返しをしていけるよう、より一層精進してまいります。

突然のご報告になってしまいましたが、どうか温かく見守っていただけますと幸いです。
今後とも皆さまのご支援賜りますよう、どうぞよろしくお願い致します。

お祝いお待ちしています。
https://www.amazon.jp/hz/wishlist/ls/38J4VLC6989GL

星川健介


この記事は戸松遥様のブログを参考にさせていただきました。

ameblo.jp