ITMS-90165 が起きてアプリの提出ができなくなったが証明書再作成で解決した

朝からこんなエラーが起きてて、特に設定変えたりした覚えもないけどなんだろうなあと思ったけど証明書再作成で直った。

Error uploading ipa file:
[Transporter Error Output]: ERROR ITMS-90165: Invalid Provisioning Profile Signature. The provisioning profile included in the bundle 'com.hoge' (Payload/Runner.app) cannot be used to submit apps to the iOS App Store until it has a valid signature from Apple. (Expired profile signing certificate.) For more information, visit the iOS Developer Portal.
[Transporter Error Output]: Return status of iTunes Transporter was 1: ERROR ITMS-90165: Invalid Provisioning Profile Signature. The provisioning profile included in the bundle 'com.hoge' (Payload/Runner.app) cannot be used to submit apps to the iOS App Store until it has a valid signature from Apple. (Expired profile signing certificate.) For more information, visit the iOS Developer Portal.
[Transporter Error Output]: The call to the iTMSTransporter completed with a non-zero exit status: 1. This indicates a failure.

match で証明書管理しているので fastlane match nuke distribution だけでよかった。

どうやら2022/04/13 の夜中(日本時間)から発生しているらしい。Apple のサーバ側で何かあったのかな?

AppBar actions アイコンに背景色を設定したい

Icons.account_circle の人間の部分の色だけ変えたい。が、それをやるのは結構ややこしい。普通にアイコンを設置すると人間部分は透過となっている。

After のように人間部分に色を設定する。

Before After
f:id:star__hoshi:20220405085334p:plain f:id:star__hoshi:20220405085343p:plain

最初のコード

普通に actions に IconButton を入れてるだけ

    return Scaffold(
      appBar: AppBar(
        actions: [
            IconButton(
              onPressed: () {},
              icon: const Icon(
                Icons.account_circle,
                size: 30,
            ),
          ),
        ],
      ),
      body: Container(),
    );

Container で囲って背景色を設定してみる

しかしなんかズレて見切れてしまう。

f:id:star__hoshi:20220405085504p:plain

    return Scaffold(
      appBar: AppBar(
        actions: [
          Container(
            color: Colors.black,
            width: 30,
            height: 30,
            child: IconButton(
              onPressed: () {},
              icon: const Icon(
                Icons.account_circle,
                size: 2,
              ),
            ),
          ),
        ],
      ),
      body: Container(),
    );

Stack で裏側に Container を置く

SizedBox で箱を作り、その中に Stack で黒の背景をおきその上にIconButtonを乗っけている。タップフィードバックも問題なし。

f:id:star__hoshi:20220405085546p:plain

return Scaffold(
      appBar: AppBar(
        actions: [
          SizedBox(
            width: 48,
            height: 48,
            child: Stack(
              children: [
                Center(
                  child: Container(
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(13),
                      color: Colors.black,
                    ),
                    width: 22,
                    height: 22,
                  ),
                ),
                Center(
                  child: IconButton(
                    onPressed: () {},
                    icon: const Icon(
                      Icons.account_circle,
                      size: 28,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      body: Container(),
    );

これで AppBar の Icons.account_circle の人間部分の色をつけることができた。(最初から画像で用意した方が早そう)

注意事項として、SliverAppBar などで上にスクロールするとアイコンの透過率が変わるが今回背景色に設定した部分の色は変わらないので注意。

iOSアプリ開発時のシミュレータはiPadを使うと便利

iOSアプリ(もしくはFlutterアプリ)を開発するとき、iOS Simulator は iPhone を使うより iPad を使うと色々な画面サイズが簡単に確認できて便利。なお、iPadアプリに対応していることが前提。(この開発方法は前職の同僚がやっていた)

iPhone だと固定サイズの画面確認しかできないが、iPad の Split View を利用すると色々な画面サイズを簡単に確認できる。

f:id:star__hoshi:20220405082233g:plain

便利。

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