Swift Advent Calendar 2018 の 7 日目です。
先日開発中のアプリのプロトタイプを完成させ、自信満々に仲間に見せたところ
「動いてるけど動作重いね、、」
と言われショックで2日間放心状態に陥りました。
しかし!そこから1週間集中的にパフォーマンス改善に取り組み
起動時間を大幅に改善することに成功しました。
今回はその時の取り組み、アホみたいに遅かった原因、結果どれくらい短縮できたのかをまとめました。
※当然ですが、効果は各プロジェクトの実装に完全に依るものです。あくまで一例として参考にしていただけたらと思います。
① TIME PROFILERを活用しよう( −5.6s )
まず、基本のキとして、Xcode Instrumentsの機能であるTIME PROFILERを使いました。
参考:XcodeのInstrumentsのTime Profilerを使って重たい処理を調べる
この機能でスレッドを展開し、時間を食ってる処理を探し出すことができます。
原因:Dateに生やしていたExtention
至極当たり前のことですが、ループ内でのインスタンス生成はアンチパターンです。
しかし、下記のような便利なExtentionをDateに生やしていることによりそこに気づけませんでした。
extension Date {
var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
calendar.locale = .current
return calendar
}
var zeroclock: Date {
return fixed(hour: 0, minute: 0, second: 0)
}
static var now: Date {
return Date()
}
static var today: Date {
return now.zeroclock
}
func fixed(year: Int? = nil, month: Int? = nil, day: Int? = nil, hour: Int? = nil, minute: Int? = nil, second: Int? = nil) -> Date {
let calendar = self.calendar
var comp = DateComponents()
comp.year = year ?? calendar.component(.year, from: self)
comp.month = month ?? calendar.component(.month, from: self)
comp.day = day ?? calendar.component(.day, from: self)
comp.hour = hour ?? calendar.component(.hour, from: self)
comp.minute = minute ?? calendar.component(.minute, from: self)
comp.second = second ?? calendar.component(.second, from: self)
return calendar.date(from: comp)!
}
}
こういったExtentionはとても便利ですが、実際に以下のように膨大な時間を食っていました。
func updateItems() {
items.forEach{
if $0.end.zeroclock < Date.today {
deleteItems($0)
}
}
}
お気づきでしょうか、この類の処理は配列の要素の数だけDate、DateComponents、Calendarの初期化を行なってしまっています。
この中でもDateComponents、Calendarの初期化は繰り返すことで膨大な時食い虫となります。
利用するDateComponents、Calendarはstaticで宣言しておくのが良いと思います。
extension DateFormatter {
static var Current:DateFormatter = {
let formatter = DateFormatter()
formatter.locale = .current
formatter.timeZone = .current
return formatter
}()
}
extension Calendar {
private static var Current: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
calendar.locale = .current
return calendar
}()
}
そもそもDateは絶対的な時間軸上のある一点を指すクラスです。
相対的な時間を扱う際にはCalendarクラスを扱うのが正しいのだと思います。
Dateクラスも毎回初期化する必要のない場合はループの外で初期化しましょう。
TIME PROFILERを用いて隠れていたアンチパターンを見つけ出すことができました。
要素数が数千個あったこともあり、一気にここで5.6秒の短縮に成功します。
WebサービスのAPIを使いこなそう( −2.2s )
WebサービスのAPIの仕様を理解し、それに適したロジック設計を行うことで、アプリのパフォーマンスを改善することができます。
今回は、Google Calendar APIの仕様を活かすことでパフォーマンスを改善しました。
ソースコードは割愛しますが、ユーザのカレンダーイベントをGoogle Calendarから取得する際に、当初は削除された予定、時間変更されたイベントをアプリに反映するため、当初は毎回特定期間の全てのカレンダーイベントを取得していましたが、これを
1. syncTokenを用いた差分のみの取得
2. showDeletedパラメータをセットすることで削除されたカレンダーイベントも取得
3. eventsIdを用いてローカルDBのカレンダーイベントを更新し、利用
のように変更することでデータ取得を高速にすることができました。
Google APIは特にどれも強力な機能をもっているので、しっかりドキュメントを読み込み、
それに合わせたロジック設計を行うことでパフォーマンス改善が見込めます。
データは適切なタイミングで用意しよう( −2.1s )
起動時に複数の重たい処理を走らせると当然起動時間を食ってしまうことになります。
「今表示している画面に必要な情報」を考え、適宜処理することでパフォーマンス改善につながります。
今回はGoogleカレンダーから空き時間を取得するアプリだったため、
起動時に
1. Googleカレンダーから情報を取得
2. 予定間の空き時間を計算
3. 各アイテムに設定された時間帯で空き時間をフィルタリング
4. 連続する時間同士を結合
といった処理を行なっていました。
しかし、3,4の処理は 実はトップ画面では利用していないため、
計算処理をアイテム選択時の画面遷移のタイミングに切り離すことで処理を省略することができます。
また、処理対象のアイテム数も大幅に削減できるため計算処理がかなり早くなりました。
これにより3.8秒の短縮に成功します。
スレッドを正しく制御しよう( −0.7s )
UIKitのライフサイクルのなかでiOSではUIの制御はメインスレッド内でしか行うことができません。
そのため重い計算処理を描画中に書くとUI更新がブロックされてしまいます。
DispatchQueueやRxSwiftを利用して可能な限りUI更新以外の処理を別スレッドで捌くことで複数の
通信、計算処理でUIをブロックしないことが大事です。
DispatchQueue.global(qos: .background).async {
//データの通信処理など
}
//RxSwift
//observeOnで細かくスレッドの指定ができる
downloadItems
.observeOn(MainScheduler.asyncInstance)
.subscribe(onNext: { [weak self] items in
//UIの更新処理
})
.disposed(by: disposeBag)
さらに0.7秒の高速化に成功します!
最後に
今回は計 -10.6秒、起動時間を1/5に改善することに成功しました。
パフォーマンス改善は地道で孤独な試練だと思っていましたが、
エンジニアの実装のこだわりが確かな数字で示すことができ、アプリのUXとなり見てもらえる点など、
わかりやすくて楽しい花のある仕事でした。これからも腕を磨いていきたいです。
先日2ヶ月の開発期間を経て、自分のカレンダーの空き時間のみを用途別にシェアすることのできる
TIMEPACKというアプリのβ版をリリースしました。
まだまだバグも多いですが、ユーザからのフィードバックをもらいながら絶賛改善中です。
フィードバックをくださると幸いです!