Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

Sansan iOSアプリの発信者名表示機能に関する実装・運用改善について

はじめに

Sansan事業部で法人向けクラウド名刺サービスSansanのiOSアプリ開発を担当している栗山です。

Sansanアプリには、名刺に記載されている電話番号や同僚から電話があった際に発信者名を表示する機能があります(以下 発信者名表示機能 )。

f:id:kotetuk:20200222155745p:plain
Sansanアプリの発信者名表示機能

Sansan iOSアプリで発信者名表示機能が搭載されて1年以上経ちますが、実装面と運用面で改善すべき課題が見えてきました。今回は発信者名表示機能に対して行った下記2つの改善の取り組みについてご紹介します。

  1. Call Directory Extensionにおけるメモリ使用量改善 (実装面の改善)
  2. Call Directory Extension内でのエラー発生を検知する仕組みの構築 (運用面の改善)

発信者名表示機能(Call Directory Extension)について

改善内容について紹介する前に、簡単にiOSアプリにおける発信者名表示機能の構成について紹介します。

iOSで発信者表示を行うためには、事前にiOSに電話番号と表示内容を登録しておく必要があります。*1

iOSへ電話番号情報を登録する際には、 Call Directory Extension という別プロセスを経由して登録する必要があります(iOSに電話番号を登録するためのAPIはCall Directory Extension内からしか利用できません)。

アプリ本体からCall Directory Extensionへ電話番号情報を渡す際には、 App Group という共通領域を経由して行います。App Groupは許可されたアプリやApp Extensionからしかアクセスできない共有ディレクトリのようなものなので、通常は何らかのファイルを使ってデータを渡します。*2

f:id:kotetuk:20200222175757p:plain
アプリとCall Directory Extension、App Groupの関係

直面した課題と改善について

さて、ここからは直面した課題と実際にどのように改善していったのかを個別に紹介します。

Call Directory Extensionにおけるメモリ使用量改善

Call Directory Extensionはメモリを使い過ぎると落ちる

App Extension Programming GuideのCreating an App Extensionによると、Call Direcrory ExetnsionをはじめとするApp Extensionは、通常のアプリと比べて使用可能なメモリサイズが少なくなっています。

ただ、具体的な上限サイズについては上記ドキュメントには記載されておらず、検索しても有力な情報が見つかりませんでした。

iOS13がリリースされたある日のこと、別な機能の調査でCall Directory Extensionをデバッグ実行していたときに、突如下記のようにクラッシュに見舞われました。

f:id:kotetuk:20200222183134p:plain
クラッシュ発生!

Thread 2: EXC_RESOURCE RESOURCE_TYPE_MEMORY(limit=12MB, unused=0x0)

内容的には、Memory Limitと書いてあるので、色々と調べてみて、メモリの上限に達したということはなんとなくわかりました。

何度もデバッグ実行していると、このクラッシュが発生した際にはXcodeのMemory Reportに正常実行時には表示されることがないMemory Limitに関する情報が表示されているではありませんか・・・。

f:id:kotetuk:20200226144116p:plain
いつの間にか「Memory Limit」が表示されてる・・

App Extension Programming Guideには、 一部種類のExtensionについては他のExtensionよりもさらにメモリ使用量が制限されている と記載されており、Call Directory Extensionがまさにそれだったのでしょうか・・・。

いずれにせよ、この12MBという上限に達しないように実装を改善しなければならなくなりました。

改善のポイント

Sansanアプリでは、Call Directory Extensionへ電話番号情報を渡すために Realm を利用しています。

realm.io

アプリ内キャッシュとしても利用しており、チームとして知見もあり、パフォーマンス面においても有効だと判断して導入したまでは良かったものの、よく実装を見ると

  1. 一度に全てのデータを読み出している
  2. Call Directory Extension内でソートしている

となっていました。

元々、登録する電話番号の想定が少なめだったというのもありますが、いずれにせよ上記2点がメモリ使用量増大の要因となっていると考え、今後も登録する電話番号数は増えていくと想定されるため、上記2点を修正し、以前よりメモリ使用量を減らすよう、実装改善することにしました。

一度に全てのデータを読み出している → 複数回に分けて読み出す

Realmは遅延読み込みの仕組みがあるため、仮に最初に全件取り出したとしても、登録時は1件ずつ CXCallDirectoryExtensionContext#addIdentificationEntry を使ってセットする際に実際のデータ取り出しが行われると考え、メモリ使用量が大幅に増えることはないだろうと考えていましたが、登録する件数が非常に多いケースだとどうしても12MBをオーバーしていました。

そこで、1000件単位で小分けにして徐々にRealmから取り出すことにしました。

文章で説明するだけではイメージがつかめないと思うので、実装例を用意しました。

※ 実際のプロダクトコードとは異なります。

まずはRealmに格納されている電話番号情報の参照処理から。

import Foundation
import RealmSwift

final class Phone: Object {
    @objc dynamic var phoneNumber: Int64 = 0
    @objc dynamic var label: String = ""
}

final class PhoneDataStore {
    private let realm: Realm

    init(realm: Realm) {
        self.realm = realm
    }

    func items(at offset: Int, size: Int) -> [Phone] {
        return realm.objects(Phone.self)
            .dropFirst(offset)
            .prefix(size)
            .compactMap { $0 }
    }

    var itemCount: Int {
        return realm.objects(Phone.self).count
    }
}

itemsメソッドはoffsetで指定した数値分dropFirstし、そこからsize分だけ取り出します。itemCountは電話番号情報の総数を取得します。どちらも、至って単純なコードです。

上記を使い、CXCallDirectoryProvider内で実際に電話番号を登録する処理は次のとおりです。

import CallKit
import Foundation
import RealmSwift

final class CallDirectoryProvider: CXCallDirectoryProvider {
    override func beginRequest(with context: CXCallDirectoryExtensionContext) {
        defer {
            context.completeRequest()
        }

        do {
            // 簡略化のためRealm.Configurationを指定していません
            let realm = try Realm()
            let phoneDataStore = PhoneDataStore(realm: realm)
            execute(with: context, and: phoneDataStore)
        } catch {
            context.cancelRequest(withError: error)

        }
    }

    func execute(with context: CXCallDirectoryExtensionContext, and dataStore: PhoneDataStore) {
        let pageSize = 1000
        let count = dataStore.itemCount
        for offset in stride(from: 0, to: count, by: pageSize) {
            addItems(at: offset, size: pageSize, context: context, dataStore: dataStore)
        }
    }

    func addItems(at offset: Int, size: Int, context: CXCallDirectoryExtensionContext, dataStore: PhoneDataStore) {
        autoreleasepool {
            dataStore.items(at: offset, size: size).forEach { [weak self] phone in
                self?.addEntry(phone, context: context)
            }
        }
    }

    func addEntry(_ phone: Phone, context: CXCallDirectoryExtensionContext) {
        context.addIdentificationEntry(withNextSequentialPhoneNumber: phone.phoneNumber, label: phone.label)
    }
}

strideメソッドを使って1000件ずつ取り出して登録する処理を実現しています。また、Realmからデータを取り出す処理はautoreleasepoolで囲み、登録後は迅速にメモリが解放されるようにしています。

Call Directory Extension内でソートしている → アプリ側でRealmに入れる前にソートする

iOSに電話番号情報を登録する際、電話番号の登録順は電話番号情報 *3 の昇順である必要があります。また、重複した電話番号を登録することもできません。電話番号の重複についてはアプリ側でRealmへデータを投入する前に重複チェックを行っていましたが、ソートについてはCall Directory Extension側で全ての電話番号情報を取り出し、電話番号の昇順になるようにソートした上で1つずつセットしていました。

そのため、処理時間がかかるばかりかRealmの遅延読み込みの恩恵を活かせていない状態でした。

また、複数回に分けてデータを取り出すようにしたことで、そもそもCall Directory Extensionでソートすることができなくなってしまいました。

そこで、アプリ側で電話番号情報を登録する際にソート処理を行った上でデータを投入するようにし、Call Directory Extension側では一切ソート処理を行わないようにしました。

改善の結果

改善の結果、これまでメモリ上限オーバーになっていた件数についても、問題なく登録することができるようになりました。

f:id:kotetuk:20200222223427p:plain
12MB上限をクリア!!

また、Call Directory Extensionでソート処理を行わなくなったため、Realmからの参照と登録処理のみとなり、処理速度もこれまでよりも高速化されました。

これにて一件落着です。

余談) 12MBという数字について

Memory Limit 12MB、Xcodeでデバッグ実行すると必ず12MBになるのですが、Xcodeに付属する Instruments というツールで実行した場合は12MB上限をオーバーしても登録できることがあり、ストアアプリ等では本当に12MBが上限になっているのかについては不明です。

Apple Developerのドキュメント等を見ても、上限が固定値なのか変動値なのかも明記されておらず、12MBが全てにおいて適用されているのか最後までわかりませんでした。

ただ、デバッグ実行のときにだけ12MB上限が適用されたとして、デバッグ実行時に頻繁に登録失敗するのは開発効率が悪いですし、12MB上限をパスするような作りになっていればストアアプリでもまず間違いなく上限オーバーになることはないはずで、その意味でも12MB上限を遵守することは大事ではないかと思います。

Call Directory Extension内でのエラー発生を検知する仕組みの構築

Call Directory ExtensionでCrashlyticsが使えない

Call Directory Extensionはメモリを使いすぎた場合には上記のとおりクラッシュします。それ以外にもRealm関連で例外が発生した場合もクラッシュします。しかし、Call Directory Extensionがクラッシュしているにも関わらず、アプリ側は一切クラッシュすることはありません(アプリとプロセスが異なるので当たり前と言えば当たり前ですが)。

Sanasan iOS / Androidアプリではクラッシュ発生時のクラッシュログ収集に Firebase Crashlytics を利用しています*4

firebase.google.com

アプリ側でクラッシュが発生した場合はもちろんクラッシュログが送信されますが、Call Directory ExtensionをはじめとしたApp Extension側で発生したクラッシュについては、調査した限りではCrashlyticsへ送信されないことがわかりました(これも、アプリとプロセスが異なるということを考えれば、なんとなく納得できる話ではありますが)。

Call Directory Extensionで発生したエラーを検知できるようにする意義

調べてみたところでは、電話番号の一部が昇順になっていない状態でセットした場合など、不適切な順番で値をセットしたケースなどの実装側のミスで登録に失敗した場合については処理は最後まで正常に行われ、そもそもクラッシュ自体発生しません。

リリースにあたっては当然のことながら十分にテストを行った上でリリースはしますが、モバイルアプリの場合は特定の端末やOSのバージョンの場合に発生する問題をはじめとして、全ての問題にリリース前の時点で完全に対応しきるのは難しいのが現実です。リリース前にバグを出さないように十分にテストをすることと同じくらい、リリース後にエラーの発生を検知して迅速に対応する運用面での体制作りも大変重要です。そう考えた場合に、Call Directory Extensionで発生したエラーを検知できないのは大きな問題です。

CXCallDirectoryManager#reloadExtensionでError型パラメータによって成否がわかる

どうしようか悩んでいた際に、とある場所でXcodeのOrganizerで見ることができるという話を聞き、何も手を打たないよりは良いと考え、チームで毎日実施しているクラッシュチェックの運用を、Crashlyticsの他にOrganizerを確認するように変更しました。ただ、運用開始以来、一度もCall Directory Extensionが原因と思しきクラッシュを確認できておらず、またCrashlyticsとOrganizerを両方見る作業を毎日実施するのは割と手間でした*5

もう少し良い方法がないかと思いながら既存実装を見ていたときに、 CXCallDirectoryManager#reloadExtension にその答えがありました。

CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: bundleId) { error in
    // Call Directory Extensionの処理が完了した際にここが呼ばれる(正常終了、エラー終了いずれの場合も呼ばれる)
}

reloadExtensionメソッドのリファレンス に記載されている引数のうち、 completionHandler に着目します。completionHandlerが呼ばれた際の引数に error という引数が入っているのがわかるかと思います。そうです、エラー発生時にこのerrorパラメータにError型でエラーの内容が入ってきていました(正常終了の場合はnilになります)。

試しに、意図的にCall Directory Extension側でエラーを発生させてみました。

例えば、例のMemory Limitに関するエラーを発生させた場合。この場合は CXErrorCodeCallDirectoryManagerError という型が渡ってきます。

CXErrorCodeCallDirectoryManagerError型だった場合は errorCode というInt型のパラメータを見ることで、 Call Directory Extension内でどのようなエラーが発生したかがわかります。このerrorCodeの値は CXErrorCodeCallDirectoryManagerError.Code というenum値の値が入るようになっているため、このerrorCodeの数値とCXErrorCodeCallDirectoryManagerError.Codeの数値を紐付けることでエラー内容を把握できます。

Memory Limitエラーの話に戻ります。Memory Limitエラー発生時には、 errorCode2 となっていました。これは CXErrorCodeCallDirectoryManagerError.Code で言うと loadingInterrupted にあたります。loadingInterruptedだとApp Extension実行中に割り込みが発生した、といった意味にしか見えませんが、Memory Limitエラーもこの割り込みに含まれるということなのだと思います。というわけで、Memory Limitエラーの場合はloadingInterrupted(errorCode=2)と覚えておきましょう。

それ以外で開発時に割と遭遇しそうなエラーとしては 不適切な順番で値をセットした場合 ですが、この場合もきちんとCXErrorCodeCallDirectoryManagerErrorが返ってきていました。errorCodeは 3 、CXErrorCodeCallDirectoryManagerError.Codeで言うところの entriesOutOfOrder が返っていました。これは名前のとおりのエラーですね。

Non Fatal IssueとしてCrashlyticsへ送信する

CXErrorCodeCallDirectoryManagerErrorを見ればエラーの内容がわかるというのが判明したので、このエラーをCrashlyticsへ送信することを考えます。Crashlyticsには、通常のクラッシュ時にCrashlyticsのSDKが自動で送信してくれるクラッシュログの他に、ユーザが自由に送信できる Non Fatal Issues というものがあります。

firebase.google.com

Crashlytics#recordError メソッドを使って送信しますが、(当たり前ですが)Error型オブジェクトの指定が必須です。今回はcompletionHandlerでErrorオブジェクトが取れるので、これを送ってみましょう。単純に書くと下記のようなコードになります。

CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: bundleId) { error in
    if let error = error {
        Crashlytics.sharedInstance().recordError(error)
    }
}

実際にloadingInterruptedを送信してみた結果、下記のように送られていました。

f:id:kotetuk:20200225131721p:plain
Non Fatal Issueとして送ることができました!

これで、運用面でもエラーの発生を検知することができるようになりました。

Call Directory Extensionでエラーが発生した際にエラー内容をNon Fatal IssueとしてCrashlyticsへ送信する

Sansan iOSアプリでは、Call Directory Extensionと電話番号を共有する際にRealmを使っているという話をしました。では、Realmでもしエラーが発生した場合にはどのように検知すれば良いでしょうか?Call Directory Extensionでエラーが発生するとCXErrorCodeCallDirectoryManagerErrorを受け取れるという話でしたが、それではRealmでエラーが発生した場合にはエラーの詳細をつかむことができません。

実は、Call Directory Extension内で発生した任意のErrorオブジェクトをcompletionHandlerで受け取ることができます。

受け取るためには、Call Directory Extension内で NSExtensionContext#cancelRequest メソッドを呼び出すだけです。

実装例ですが、すでに示したCallDirectoryProviderの実装例内にすでに実装していました。該当部分を抜粋します。

        do {
            // 簡略化のためRealm.Configurationを指定していません
            let realm = try Realm()
            let phoneDataStore = PhoneDataStore(realm: realm)
            execute(with: context, and: phoneDataStore)
        } catch {
            context.cancelRequest(withError: error)
        }

Realmオブジェクトを作る際にErrorオブジェクトがthrowされる可能性があるので、このErrorをcatchしたらcancelRequestを呼び出して処理を終了させるだけです。

cancelRequestメソッドで送ったErrorはcompletionHandlerの引数errorにそのまま入ってきます。上記のリストのように、Realmで発生したエラーをそのままcancelRequestに乗せた場合は次のように io.Realm というドメインのErrorオブジェクトとして受け取ることができます。

f:id:kotetuk:20200225174640p:plain
Realmで発生したエラーも無事Non Fatal Issueとして送信できました!

必要に応じて、CXErrorCodeCallDirectoryManagerError型以外の型で受け取りたい場合はcancelRequestメソッドを使って目的のError型を受け取れるようにすると良いでしょう。

おわりに

おかげさまで、発信者名表示機能はリリース以来多くの方にお使いいただいています。

利用するユーザ数が多くなればなるほど、機能についても信頼性が求められるようになってきています。また、使われる機能であればあるほど、実装の信頼性だけでなく、不測の事態が発生した場合の対応といった運用面についても考慮しておく必要もあります。

今回パフォーマンスと運用面を改善したことで、多くの方により安心して使っていただけるようになったのではないかと思います。今後も発信者名表示機能に限らず、実装面・運用面含めて改善を続けていきたいです。


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

*1:Androidの場合はOSへの事前登録がなくとも、着信時に取得した電話番号情報から自前で検索して表示内容を決定する仕組みとなっており、iOSと作りが根本的に異なっています。iOS/Android両方で発信者名表示機能を提供する際にはこの違いを考慮しておくと良いでしょう。

*2:App Grpupを使う以外にも、Keychainを使ってデータをやりとりすることもできますが、発信者名表示に使う情報は時に大量となることもあるので、Keychainを使ってのやりとりには向きません。

*3:Int64型で保持

*4:以前はFabric CrashlyticsというTwitter社のサービスでしたが、2017年にGoogleに買収されFirebaseのサービスの一部となりました。

*5:Organizerは開いてからクラッシュログをダウンロードしてくるので、その間少し待ってからチェックしないといけない点や、バージョン毎にクラッシュ内容が表示されるので複数バージョン見る場合はそれぞれ個別に見ないといけない点がしんどかったです。

© Sansan, Inc.