はじめまして、こんにちは。 iOSエンジニアの とと です。 STORES 決済 アプリ / SDK の開発を担当しています。
少し前になりますが、決済 iOSチームでCI上でのビルド時間削減の取り組みをおこなっておりました。
Bitriseがクレジット制になるということで、ビルド時間を短くしてクレジットの消化(ひいてはコスト)を抑えようというのが目的でした。
そもそも、エンジニアとして日々の業務でビルド待ち時間が短くなるのは嬉しいですよね。
今回は、その時の取り組みの中から、主にコンパイル時間削減のためのSwiftでのコードの書き方を修正したお話をします。
環境
- Xocde:13.3.1
- Swift:5.6
- ローカルで計測時に使用したMacのスペック
- MacBook Pro (16-inch, 2019)
- 8コアIntel
- メモリ64 GB
コンパイル時間の計算
XCLogParser を使って、ローカルで計測していました。
型チェックや、関数単位でコンパイルの時間を計測して、時間がかかっている順を視覚的に表示してくれます。
ここで計測した時間に閾値を決めて、それを超えている箇所を改修対象としました。
大体上位20くらい改修した気がします。
やったこと
修正前と比較して、その箇所のコンパイルがどれくらい速くなったかを記載しております。
(ないやつは、計測記録をどっか飛ばしてしまってございません。すみません……)
XcodeやSwiftのバージョンによって、またはコードの内容によって大きく差はでるとは思いますので、ご参考まで。
一度変数にとる
ネストが深ければ深いほど、一度変数に取った時の効果がある傾向に見えました。
コンパイル時間:70.1%減
// Before if viewController == viewController?.navigationController?.viewControllers.first { // After let firstVC = viewController?.navigationController?.viewControllers.first if viewController == firstVC {
型指定
特に計算箇所で指定すると効果が大きい傾向にありました。
コンパイル時間:44.1%減
// Before view.layer.cornerRadius = view.frame.height / 2 // After view.layer.cornerRadius = view.frame.height / CGFloat(2)
クロージャーの型チェック
コンパイル時間:1.9%減
// Before let task = dataTask() { data, response, error in ⋮ } // After let completion: (Data?, URLResponse?, Error?) -> Void = { (data: Data?, response: URLResponse?, error: Error?) in ⋮ } let task = dataTask(completionHandler: completion)
forEachの中で行う処理は最低限に
// Before list.forEach { $0.isEnabled = status == .notDetermined || status == .denied } // After let isEnabled = status == .notDetermined || status == .denied list.forEach { $0.isEnabled = isEnabled }
演算子ではなくswitch構文を使う
コンパイル時間:23%減
// Before let isEnabled = status == .notDetermined || status == .denied list.forEach { $0.isEnabled = isEnabled } // After let isEnabled: Bool switch status { case .notDetermined, .denied: isEnabled = true default: isEnabled = false } list.forEach { $0.isEnabled = isEnabled }
空の配列から要素を足すのではなく最初から突っ込む
コンパイル時間:67.3%減
// Before var items: [Fruit] = [] items.append(.apple) items.append(.durian) items.append(.avocado) // After let items: [Fruit] = [ .apple, .durian, .avocado ]
変数はなるべくシンプルにする
コンパイル時間:6%減
// Before let status = permission.status if status == .allow { ⋮ } // After let isEnable = permission.status == .allow if isEnable { ⋮ }
Stringで演算子で足すのは不要なため削除
// Before message = "\(title)" + "\(version)\n" + "\(message)" // After message = "\(title)\(version)\n\(message) "
式のなかで ?? (Nil-Coalescing Operator)を使わない
コンパイル時間:33.6%減
// Before let name: String = (lastName ?? "") + (firstName ?? "") // After let lastName = lastName ?? "" let firstName = firstName ?? "" let name = lastName + firstName
余計な型変換を削除
CGFloat -> Float -> CGFloatと変換しているのをCGFloatで完結させました。
コンパイル時間:13%減
// Before let width = CGFloat(ceilf(Float(lineWidth / 2.0))) // After let width: CGFloat = (lineWidth / CGFloat(2)).rounded(.up)
計算は1度にまとめる
コンパイル時間:21.6%減
// Before path.addLine(to: CGPoint(x: 0, y: - width)) path.addLine(to: CGPoint(x: 10, y: - width)) // After let minusWidth = - width path.addLine(to: CGPoint(x: 0, y: minusWidth)) path.addLine(to: CGPoint(x: 10, y: minusWidth))
大きい関数を分割
関数ごとのビルド時間は短くなりますがが、分けた関数トータルでのビルド時間は変わらないことがほとんどでした。
ビルド時間というよりは、可読性のため分割対応となりました。
他にやったこと
コンパイル時間だけでなく、ビルド時間も短くしようと、 上記以外にも、いろいろと対応をおこないました。
periphery を利用して、未使用コードの削除したり、
FengNiaoを利用して、未使用アセットの削除をしたり。
また、以下のサイトを参考にさせていただき、Xcodeの設定周りの見直しもおこないました。
そして、Swiftlintの実行にCI上で20秒ほどかかっていたのですが、それを変更したファイルのみ実行するようにスクリプトを書き換えることで5秒ほどまで短くできました。
できなかったこと
コンパイルに、決めた閾値を超えていても、どうしてもリファクタ案が浮かばない/色々触ってみたが改善が見られなかったものについては現状ままの箇所もあります。
特にUI系の処理はどうしても時間がかかるものの、他の書き方はなく対応出来ませんでした。
まとめ
コードの修正だけの効果にとどまりませんが、最終的にコード全体のコンパイル時間への効果は以下のとおりです。
対応前 | 対応後 | 効果 | |
---|---|---|---|
アプリ | 120~129s | 113~124s | 4.4%減! |
SDK | 47~52s | 39~41s | 19%減! |
また、設定やコードを見直すきっかけになり、ビルド時間以外の恩恵が受けられた部分もありました!
他の取り組みについては、以前同僚の@k_koheyi さんが以前発表した時の資料をご覧ください。
BitriseのCredits-Basedな 新プランの利用と改善 - Speaker Deck
AndroidのCIビルド時間改善の話はこちら!
なんと、Androidチームは50%の削減(ちょっと悔しい)
AndroidアプリのCIビルド時間が半分以下になった話 - STORES Product Blog
モバイルエンジニア募集中です!ご興味があればお気軽にご連絡ください。
私のTwitterのDMでも大丈夫ですよ