AmebaでiOSアプリを開発している木藤です。
Amebaといえばブラウザのアメーバブログ(アメブロ)のイメージが強いかと思いますが、アメブロの「読む・書く・探す」をスマートフォン上で簡単にできる「Ameba」というアプリがあり、私はそのiOSチームの一員として開発を行っています。
社内ではブラウザと区別する意味もあり、「Amebaアプリ」と呼ばれています。先日Amebaアプリでは既存コードをSwift 2.2からSwift 3.0へ移行する対応を行いました。本記事ではAmebaアプリがSwift 3化する際に直面した問題やその解決方法をいくつかピックアップして書いていきます。
長い歴史を持つAmebaアプリ
まず、Amebaアプリがどんな背景を持ったアプリなのかを説明したいと思います。
アメブロは2004年にサービスを開始しました(この時木藤は12歳の小学6年生)。
Amebaアプリが誕生したのは2010年で、この時はアプリなんだけど中身はWebViewという、いわゆるガワネイティブアプリでした。アプリ制作は外部に委託しており、もちろんObjective-Cで書かれていました。
2014年に弊社のスマホシフト戦略の流れを受け、脱ガワネイティブとコードのSwift化が行われました。そして今年の1月には今までWebViewで表示していたブログ記事の閲覧面をネイティブ化しました。
このことから、Amebaアプリは歴史が長く、大きな変更を何度も経てきた、SwiftとObjective-Cが共存する大規模なアプリと言うことができます。そのためレガシーな書き方のコードもあれば、まだしっかりと読んだこともないコードもあり、ソースコードは1283ファイルにも及びます(うちSwift59%、Objective-C36%)。
そんなアプリをSwift史上最大のアップデートであるSwift 3に移行するのは大きなチャレンジとなりました。
実装スケジュール
プロジェクト内で話した結果、以下の図のようなスケジュールで動くことが決まりました。このときのiOSチームは4人で、実装とテストを合わせて1ヶ月で終わるのか正直不安でしたが、せっかくのチャンスなのでやりきろうという心境でした。
ライブラリのSwift 3対応
まずアプリ本体の移行に取り組む前に、使用しているライブラリのSwift 3への対応状況を確認するという作業が発生します。Amebaアプリの場合は、大体のライブラリがSwift 3へ対応済みもしくは対応中であり、対応していなかったライブラリについても社内のエンジニアの方が作ったライブラリだったので、対応のお願いをしたり、こちらからプルリクエストを送ったりすることで解決することができました。
しかし、AlamofireのSwift 3対応バージョンである4.0がiOS 9以降しか対応していなかったことが問題として浮上しました。AmebaアプリではiOS 8以降をサポートしていたため、単純にAlamofireをアップデートしてしまうとiOS 8のサポートを切ることになります。
Alamofire 4.0のコードを読むと、iOS 9以降しか対応していない理由が、URLSessionStreamTask (iOS 9+) を使ったメソッドが追加されたからであることが判明しました。したがって、Amebaアプリの選択肢としては以下の2つに絞られました。
- iOS 8のサポートをやめて素直にAlamofireを4.0にアップデート。
- AlamofireをフォークしてURLSessionStreamTaskを使っているメソッドを全てコメントアウトし、iOS 8のサポートを可能にする。
プロジェクト内で相談した結果、iOS 8のサポートをやめてAlamofireをアップデートする方向になりました。理由としては、以下の4点です。
- フォークして使う場合はメンテナンスコストがかかる。
- サポートを切ると言ってもアプリが使えなくなるわけではない(アップデートができなくなる)。
- iOS 8対応の最終リリースバージョンが大きなバグもなく安定していた。
- ユーザーに告知をすれば影響を最小限に抑えることができるという判断。
これでAmebaアプリ本体をSwift 3へ移行する準備が整いました。
ビルドを通す
XcodeのMigration Toolによるmigrationが終わった後、まずはビルドを通すことだけを目標に作業を開始しました。コンパイラからのwarningの修正やリファクタはやらずに、とにかくビルドを通すことだけをゴールに黙々とコンパイラが吐くエラーを修正していく辛い作業です。
初めはこれを1人でやろうと考えていましたが、Amebaアプリの大規模&レガシーという性質上エラーの量が多く、最終的にはiOSチーム4人で分担して作業することにしました。
まずはUtility系やManagerといったプロジェクト全体に影響が出そうなところを1人が修正し、その後できるだけコンフリクトが起きないようにするため、チームメンバーに担当するディレクトリを割り振って作業を進めました。いつもの開発用のブランチからswift3ブランチを切ってそこに向けて各メンバーがプルリクエストする流れです。CIのテスト等も止め、swift3ブランチではエラーが出ていてもマージできるようにしておきます。
全員の担当分が完了したところで1人が最後の微調整を行い、ビルドを通すことができました。swift3ブランチを開発用ブランチにマージするまで1.5~2週間かかりました。なかなかの辛さでしたが、初めてビルドが通ったときの喜びはとても大きかったです(起動直後クラッシュ)。
この後は実際にアプリを動かしてみてクラッシュするところや挙動がおかしいところをひとつずつ直していきました。
ここからは、ビルドが通った後に直面した技術的な問題について書いていきます。
StructureのMemberwise Initializer
Amebaアプリで使っているライブラリのひとつにPullToRefreshSwiftがあります。PullToRefreshSwiftは、弊社エンジニアの波戸さんが作ったpull to refresh機能をを簡単に実装できるライブラリです。Amebaアプリはフィード型の画面が多くあるのでPullToRefreshSwiftを多用しています。Swift 3へ素早く対応してくださりビルドは問題なく通りましたが、pull to refreshが動作しなくなってしまっていました。
これの原因がStructureのMemberwise Initializerのアクセスレベルでした。
SwiftのStructureでは、イニシャライザを明示的に定義しない場合、各プロパティに対応する引数を持ち、値を代入するだけのイニシャライザ(Memberwise Initializer)が自動で定義されます。しかし、このイニシャライザはプロパティのアクセスレベルがpublicでもinternalとして定義されます。
public struct Option {
public let backgroundColor: UIColor
public let fixedSectionHeader: Bool
// Memberwise Initializerが自動で定義される
// init(backgroundColor: UIColor, fixedSectionHeader: Bool) {
// self.backgroundColor = backgroundColor
// self.fixedSectionHeader = fixedSectionHeader
// }
}
この場合そのStructureは異なるモジュールからはinitできなくなります。つまりStructureがライブラリで定義されている場合は、ライブラリ外からinitできないということになります。この現象がPullToRefreshSwiftに起きていました。
この問題はpublicなイニシャライザを再定義することで解決できましたが、気づきにくい仕様だと思うので今後も注意したいと思います。
ちなみにこの仕様はThe Swift Programming LanguageのAccess Controlの項目に書いてあります。
if you want a public structure type to be initializable with a memberwise initializer when used in another module, you must provide a public memberwise initializer yourself as part of the type’s definition.
dispatch_onceとdispatch_time
Swift 3でGrand Central Dispatchの書き方が大きく変わりました。Amebaアプリで使っているものを修正していく中で、特に特徴的な変更だなと思ったのはdispatch_onceとdispatch_timeでした。
dispatch_once
Swift 2系では、一度だけ処理を実行するといったものをdispatch_onceを使って以下のように実装していました。
// Swift 2
private var impOnceToken: dispatch_once_t = 0
func sendImpression() {
dispatch_once(&impOnceToken) { [weak self] _ in
guard let me = self else { return }
me.type.impressed(me.impContents)
}
}
しかし、Swift 3からはdispatch_onceそのものがなくなってしまったため、lazyプロパティを使って以下のように実装しました。
// Swift 3
private lazy var impress: () = {
self.type.impressed(self.impContents)
}()
func sendImpression() {
_ = impress
}
impress
はlazyなのでクラスまたは構造体初期化時ではなく、使うときに(sendImpression時に)初めて評価されます。その上クロージャであるため処理は評価時にしか走らず、結果として一度しか実行されない処理を実現しています。
Swift.orgのMigrating to Swift 2.3 or Swift 3 from Swift 2.2にも同じような実装が載っていますが、個人的には _ =
とやるのが気に入らないので何か代替案がないか模索中です。
dispatch_time
Swift 2では、一定時間後に処理を実行するといったものをdispatch_timeを使って以下のように実装していました。
// Swift 2
let delay = 0.5 * Double(NSEC_PER_SEC)
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
dispatch_after(time, dispatch_get_main_queue()) {
// do something
}
しかし、Swift 3からはシンプルになり、以下のように書くことができます。
// Swift 3
let dispatchTime = DispatchTime.now() + 0.5
DispatchQueue.main.asyncAfter(deadline: dispatchTime) {
// do something
}
ここでdispatch_timeを使ったことがある人なら、「0.5にDouble(NSEC_PER_SEC)をかけなくていいのか?」という疑問を持つと思います。しかし、Swift 3からは以下の演算子が定義されているため、上記の書き方でOKです。
public func +(time: DispatchTime, seconds: Double) -> DispatchTime
Implicitly Unwrapped Optional
Swift 3からImplicitly Unwrapped Optional (IUO) が廃止になりました。廃止になったと言っても使えなくなったわけではなく、使ってもエラーにはなりませんが、普通のOptionalと同じ挙動になります。
struct User {
let name: String! // IUO
let imageUrl: URL!
}
label.text = user.name // => Optionalとしてセットされる
Amebaアプリでは、APIから取得したJSONをパースしてモデルに落とし込んでいるのですが、このモデルのプロパティにIUOを使っており、その値をUILabelのテキストとして使っている箇所が多くあったため、アプリ上の表示がOptionalだらけになってしまいました。
これは単純にアンラップしてから値を代入するようにすれば直りますが、根本的に修正するにはIUOの使用をやめなければいけません。そうなるとAmebaアプリの場合では変更が大きくなってしまうため、一旦アンラップして使うようにし、今後少しずつ修正していく方針にしました。
ユーザーデータへのアクセス
こちらはSwift 3の問題ではなくiOS SDK 10の問題ですが、Swift 3に対応するということはXcode 8を使うということであり、Xcode 8を使うということはiOS SDK 10を使うということなので紹介させていただきます。
iOS 9以前でもユーザーのフォトライブラリや端末のカメラにアクセスすることは可能でしたが、iOS 10からは各ユーザーデータにアクセスするアプリは、info.plistに特定のキーと使用目的を記述しなければいけなくなりました。この対応をしない場合、許可を求める段階でアプリが強制終了してしまいます。
Amebaアプリでは画像をブログ記事に貼り付ける際にフォトライブラリとカメラにアクセスするため、以下のようにinfo.plistに記述しました。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSCameraUsageDescription</key>
<string>撮影した写真をブログに貼り付けられるようになります。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>写真や動画をブログに貼り付けられるようになります。</string>
こうすることで、アクセスの許可を求める際のアラートにinfo.plistに記述した使用目的が表示されるようになります。
フォトライブラリやカメラ以外にも対応が必要なものが数多くあるので、Information Property List Key ReferenceのCocoa Keys項目を参照してください。
最後に
この他にも細かいものも含めてたくさんの問題がありましたが、なんとかリリースできました。
今回のような言語のバージョンアップやリファクタリングは、事業的なインパクトがほぼ0な上にリスクが大きいため、なかなか実行に移せないプロジェクトもあるかもしれませんが、技術者の成長や開発の快適さにつながるほか、それが更に優秀な技術者を呼び、結果的に事業に貢献できると私は思っているので、今後もこういったチャレンジを継続的に続けていける組織でありたいと思います。
Swift 3対応については、755の林さんの記事もとても参考になるので合わせてご覧ください。
最後にAmebaの皆さん、いつもありがとうございます。
そしてiOSチームの皆さんお疲れ様でした!!
※この記事はCyberAgent Developers Advent Calendar 2016 2日目の記事です。