Apple 特急審査 2017

ここ最近は Apple の審査が早いけど、致命的なバグなどを出してしまったら急いでレビューして欲しい、そういう時のために apple expedited review というのが用意されている。

これを使うと優先的にアプリをレビューしてくれる。
しかしこれには注意が必要で、使いすぎてはいけないのでマジでやばい時にだけ使うべき。

今回はマジでやばいバグを生み出したので特急審査を出した。

f:id:star__hoshi:20171121021826p:plain

* マジでやばいバグの図

やり方

1. Contact Us

iTunes Connect にログインして右下の Contact Us をクリック

f:id:star__hoshi:20171121021855p:plain

2. Request an Expedited App Review

  1. App Review
  2. App Store Review
  3. Request Expedited Review

を選択すると特急審査へのリンクが表示されるので、 Request an Expedited App Review をクリック。

f:id:star__hoshi:20171121022059p:plain

3. Contact the App Review Team

なんか突然今っぽいデザインになる。
内容をいい感じに入力して、 Explanation に説明を書く。
ここはなぜ特急審査が必要なのか真面目に書いた方がいいと思う。

自分は Apple への連絡はいつも英語 + 日本語で投げるようにしている。(英語に自信がないので...)

f:id:star__hoshi:20171121022132p:plain

最後に Send を押すと「問い合わせ受け付けたよ!」みたいな画面が出ておわり。
なお、問い合わせ完了メールなどは来ないもよう。

あとは迅速に審査が終わることを祈るのみ、しかし日本時間15時に特急審査願いを出して12時間経っても音沙汰なし。
普通のレビューと大して変わらないのでは...?

iTunes Connect の段階的リリースを試してみた

段階的リリースとは

WWDC2017 で発表された機能。 1%, 2%, 5% ... 100% と限られた人から段階的に自動アップデートされていく。

新機能のテストや、バグが起きてないかなどの確認で使えそう。

注意

これは 自動アップデート が対象なので、新規インストール/手動アップデートされると最新版がインストールされる。

やってみた

3 日目の浸透率

自動アップデートされるのは 5% だけ。

f:id:star__hoshi:20171117021037p:plain

でも iTunes Connect の分析画面では 30% くらいいってそうだ...。

f:id:star__hoshi:20171117021115p:plain

同じく Firebase を見ても 30% くらいいってる。

f:id:star__hoshi:20171117021151p:plain

感想

確かに100%リリースに比べると浸透率は穏やかなんだけど、自動アップデート以外の人は普通にアプデされるしあまり意味なさそう...。
ちゃんとやるなら Firebase の A/B Testing とか Remote Config でやった方が全然良い、これはないよりマシかな〜くらいの機能。

新機能リリースでバグがないか不安で段階的リリースを使ったんだけど、3日目になってクラッシュ報告が特にないので100%リリースに変更した。まあやっぱりないより便利だ。

UIView の Swipe イベントは実行して Tap は後ろの View に流したいけど出来なさそう

とある View の上に透明な UIView を載せて、 Swipe だったら透明な View でイベントをハンドリングして Tap だったら後ろの View にスルーしようとしたけど出来なさそうだった。

どういうことか

f:id:star__hoshi:20171114210748p:plain

こういう View があって、右にスワイプしたら数値を200にして、左にスワイプしたら数値を100にしたい。でもボタンタップや TextFeild の入力はできるようにしたい。
この View の上に透明な View を被せて Swipe イベントを取って、 Tap イベントだったら後ろの TextField やボタンをタップできるようにすれば良さそうじゃんって最初は思った。

でも出来なさそう。

View のイベントを後ろにある View に流す方法

1. isUserInteractionEnabled = false

isUserInteractionEnabled = false にすると透明な View のタップイベントがスルーされ後ろにある View をタップできる。
しかしこれだと Swipe もスルーされてしまうのでだめ。

2. hitTest:withEvent:

hitTest を override して、 return nil しても後ろの View にイベントを流せる。しかし Swipe も反応しなくなってしまうのでダメ。

if event?.type == .swipe { } ができればよかったが、UIEventType は touches, motion, remoteControl, presses の4種類しかなく、 Tap もスワイプも touches になってしまいダメだった。

3. point(inside:with:)

hitTest と似てる point というものあって、これはタップの判定位置を広くしたり狭くしたりできるっぽい。
ここで false を返すと後ろの View にイベントを流せる。
(しかしそういう用途には hitTest を使うべきで point は純粋にタップ範囲の変更に使った方が良さそう。)

結局どうしたか

透明な UIView を上に貼って、 Tap だったら後ろに流すというのは難しそう。
なので、透明な UIView の中にボタンや TextView を addSubview した。

class SwipableView: UIView {
    let leftSwipeGesture = UISwipeGestureRecognizer()
    let rightSwipeGesture = UISwipeGestureRecognizer()

    override init(frame: CGRect) {
        super.init(frame: frame)

        leftSwipeGesture.direction = .left
        rightSwipeGesture.direction = .right
        addGestureRecognizer(leftSwipeGesture)
        addGestureRecognizer(rightSwipeGesture)
    }

    required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
        let swipableView = SwipableView()
        contentView.addSubview(swipableView)

        swipableView.leftSwipeGesture.rx.event.subscribe { event in
            // left event handler ...
        }
        aIVSwipeView.rightSwipeGesture.rx.event.subscribe { event in
            // right event handler ...
        }

        let textField = UITextField()
        swipableView.addSubView(textField)
        let button = UIButton()
        swipableView.addSubView(button)

これで Swipe した時はそれぞれハンドリング出来て、 textField や button のイベントはそれぞれちゃんと扱える。

最初からこうしろよ、という話なのだがこれをすると既存の View 構造を変えないといけないので気が重かった、透明な View を上に貼り付けるだけなら既存の View 構造は変えなくて済むので...。

遠回りだったかもしれないけど hitTest や pointinside の知識が深まってよかった。

Realm でローカルのデータベースに大量のデータを追加したい

10万件くらいのデータを Realm に持ってそのデータを使って図鑑とかが見れる iOS アプリを作っている。固定データをわざわざ API から取得するのは無駄なので、最初からアプリ内にデータを保持する設計になっている。
そのアプリに2万件くらいのデータを追加しないといけなくなった。

ここで考慮しないといけないのは以下の2つで、これが達成できれば問題ない。

  • データベースの更新中にユーザがアプリを kill してもデータベースを壊さない
  • ユーザが作成したデータもあるので、それを消す / 壊してはいけない

というわけで、何に悩んだかとどうやったかをつらつら書いていく。

背景

実は今までもローカルのデータの更新をしていて、その時はまるっと Realm ファイルの入れ替えをしていた。アプデ前のアプリで使っていたデータベースを削除して、新しいデータベースをコピーしていた。

しかし前回のアップデートでユーザが任意のデータを作れるようになった。

つまり、今まで使っていたデータベースを削除できなくなってしまった。
ユーザが作成したデータを消さずに新たなデータを追加する必要がある。

悩み

データの更新に時間がかかる

更新データは text でアプリ内に持っており、そのファイルを forloop でくるくる回して Realm に write する。

更新処理は iPhone7 で5秒くらいかかったので、 iPhone5s とかはもうちょっとかかりそう。
5秒もあったら絶対に途中でアプリを閉じるユーザが出てくるので、途中で閉じられても問題ない作りにする。

どうするか

1 の案で実装した。1以外はボツ案。

1. realm.write + UserDefaults でフラグ管理

Realm にデータを書き込む際に try realm.write をするが、これは全ての処理が完了した時に commit される。
そのため、更新処理中にアプリを kill されても commit はされず、また最初からやり直すことができる。

そして更新処理が完了したら UserDefaults のフラグを true にして今後は実行されないようにするだけ。これだけの簡単な仕組みでうまくできた。

コードにするとこんな感じ:

// https://github.com/yaslab/CSV.swift
// import CSV

let filename = "更新ファイル名"

if UserDefaults.standard.bool(forKey: filename) {
    return
}

guard let moveCSVPath = Bundle.main.path(forResource: filename, ofType: "csv"),
    let stream = InputStream(fileAtPath: moveCSVPath) else {
        return
}

try! realm.write {
    for row in try! CSV(stream: stream, hasHeaderRow: false) {
        let model = Moves()
        model.id = Int(row[0])!

        // ...

        realm.add(model)
    }
}

UserDefaults.standard.set(true, forKey: filename)
UserDefaults.standard.synchronize()

2. 何行目まで write したか UserDefaults に保持する

Realm にデータを書き込む際に try realm.write をするが、これは全ての処理が完了した時に commit される。

最初はこの仕様を知らなくて「途中まで write してアプリを kill されたら最初の方のデータが2重登録されちゃうやんけ...」などと考えていた。
なので、何行目まで write したのか保持してデータを重複させない仕組みを作らねば、などと考えて以下のように実装した。

let count = UserDefaults.standard.integer(forKey: filename)

try! realm.write {
    for (index, row) in try! CSV(stream: stream, hasHeaderRow: false).enumurated() {
        if index < count { continue }
        let model = Moves()
        model.id = Int(row[0])!

        // ...

        realm.add(model)

        UserDefaults.standard.set(index + 1, forKey: filename)
        UserDefaults.standard.synchronize()
    }
}

これだと、途中でアプリを kill したとしても前回どこまで write したかを覚えられるので write 処理が重複することがない。
しかし、 realm.write が完了する前にアプリを kill してもそれは commit されていないので、これだと逆に if index < count { continue } されたデータが保存されなくなる問題が生まれる、というか生まれた。

あと UserDefaults に書きまくっているのでパフォーマンスがやばくて30秒くらいかかった。

3. 今まで通りデータベース丸ごと入れ替える案

  • ユーザが登録したデータを一時的に text ファイルなどに避難させる
  • その隙に既存のデータベースを新しいデータベースをコピー
  • そして避難させたデータを新しいデータベースに write

でいけそうかもと思ったんだけど、なんか不整合起こりそうな気がしてやめた。

4. UserDefaults でフラグ管理するんじゃなく Realm Migration 使う案

Realm が用意してくれてる Migration 案は Scheme の変更などで使うものであって、今回やりたいのは単なるデータの追加なので Migration ではないしな、と思って使っていない。

やったほうが良さそう

データ更新処理始める前にデータベースのバックアップ取っといたほうが良さそう。

おわり

結構いろいろ悩んだけど最終的にはシンプルな方法で解決できてよかった。

データ更新は裏で自動でやってもいいけど、変な操作されて壊れても困るし更新中だよって出すようにした。
更新中にアプリを kill したら次起動時にまた更新処理が走る。

f:id:star__hoshi:20171112014742g:plain

Firebase Cloud Function の関数を削除する

Function を消したいけど、 Firebase Cloud Function 上では削除メニューなどが見当たらない。 その場合は GCP の方から関数を削除する。

  1. https://console.cloud.google.com/projectselector/home/dashboard にアクセス
  2. 画面左上の「プロジェクトを選択」から該当のプロジェクトを選択
  3. 画面左メニューの Cloud Functions に遷移
  4. Function 一覧から削除したい関数を見つけて、右にある ・・・ から削除を選択

で削除できる。

監視している中で対象を update して無限ループが発生してしまった場合などに便利。

export const cartIsSubmitted = functions.database.ref('/v1/user/{userID}/name').onWrite(event => {
    // …
    event.data.adminRef.update({name:newName})
})

追記

Firebase Cloud Function をローカルで実行する

Cloud Function をローカルで実行する方法を用意してくれている: ローカルでの関数の実行  |  Firebase

手順はドキュメントに書いてある通りで簡単にできる。

firebase ライブラリの更新

$ npm install --save firebase-functions@latest
$ npm install -g firebase-tools

ローカルデプロイ

まず Function を作る

これは https://my-firebase-url/sample?text=text にアクセスするとその text を返却するだけのもの。

index.js:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

exports.sample = functions.https.onRequest((req, res) => {
  const text = req.query.text;
  console.log(text);
  response.status(200);
});

ローカルデプロイ

$ firebase experimental:functions:shell
i  functions: Preparing to emulate functions.
✔  functions: sample
firebase >

これでデプロイ成功で対話モードになっているので、コマンドを入力する。
request の記法で動く。

firebase > sample.get('?text=text');
Sent request to function.
info: User function triggered, starting execution
info: text

RESPONSE RECEIVED FROM FUNCTION: 200, {"text":"text"}
info: Execution took 8 ms, user function completed successfully

こんな感じでローカルデバッグできる。

メリット

Cloud にデプロイするのちょっと時間がかかるし、ログ見るのも Cloud Function のログを見ないといけないのでだるいけど、ローカルデバッグだと関数書き換えたらリアルタイムデプロイ & ログがその場で見えるので手探りで開発するときには便利。
あと Cloud Function で外部 API と通信しようとするとお金がかかるけど(プラン変更が必要)、ローカルデプロイだと外部 API と通信もできた。

Cloud Function がローカルで動くというのがイケてて良い。

fastlane deliver を使ってコマンド1発で Waiting For Review までもっていく

$ bundle exec fastlane release

このコマンドを叩くだけでリリースビルド、iTunesConnect へバイナリ提出、IDFA 情報など入力して審査待ちの状態まで持っていけるようにした。

Deliverfile

こんな感じに書いてる。

app_identifier "com.myapp"
username "appleid@hoge.com"

force true
skip_screenshots false
skip_metadata false
skip_binary_upload false
automatic_release true
submit_for_review true
overwrite_screenshots true
ignore_language_directory_validation ['fonts']

submission_information({
  export_compliance_encryption_updated: false,
  export_compliance_uses_encryption: false,
  add_id_info_uses_idfa: true,
  add_id_info_serves_ads: true,
  add_id_info_tracks_install: false,
  add_id_info_tracks_action: false,
  add_id_info_limits_tracking: true,
})

submit_for_review true にして、 submission_information を書けばいいっぽい。あと force true にしないと申請情報の確認画面が出てしまう。
encryptionadd_id_info は普段手動でぽちぽちしてる状態になるようにした。

他の option 一覧は fastlane/app_submission.rb にある。

Fastfile

deliver で has completed processing まで持っていくので、その後 dsyms を iTunesConnect から DL して Crashlytics に Upload できる。

  desc "Deploy a new version to the App Store"
  lane :release do
    match(type: "appstore")

    increment_build_number(build_number: "#{Time.now.strftime("%Y%m%d%H%M")}")

    gym(scheme: ENV["RELEASE_GYM_SCHEME"])

    deliver

    upload_symbols_to_crashlytics
    
    refresh_dsyms

    payload = {"Git Commit" => changelog}
    slack(
      channel: ENV["SLACK_CHANNEL"],
      message: ":itunesconnect: Successfully uploaded a new App Store build",
      payload: payload,
      default_payloads: default_payloads
    )
  end

Fastfile はソース公開しているのでこっちで全体像を見れる: fastlane-example/Fastfile

おわり

寝る前に bundle exec fastlane release 叩いて、寝ながら Youtube 見てたら Waiting For Review の通知きて最高。