リードアーキテクトのItoです。
カメリオのiOS版では1年ほど前にエクステンションからSwiftを導入し、Swift化を進めてきました。
先日リリースしたiOS 9とwatchOS 2対応版ではそのほぼすべてをSwift2.0化しました。
- 2014/9月: iOS 8 – Today ウィジェットをSwift 1.1化
- 2015/4月: Apple Watch – WatchKitをSwift 1.2で実装
- 2015/9月: iOS 9 – iOS本体およびExtensionをほぼSwift 2.0で実装
APIレスポンスの仕様
カメリオ(初期は違う名前だった)は2013年の秋ごろに最初のプロトタイプを作り始め、その後機能追加や仕様変更がありました。そろそろ最初のコードから3年くらいが経ちます。
ずっとiOSやAndroidと直接通信するAPIサーバーにはNode.jsを使っていますが、プロトタイプの惰性で、細かいAPIレスポンスの仕様を明確に定義してきませんでした。
たとえばAPIのJSONのレスポンスで、値がnullかどうかやレスポンスに値が含まれるかどうかなど。
Objective-CではJSONのnullはNSNullにコンバートされ、値がないものに関してはnilとして扱われるようになります。
また、同じArticleのモデルでも、あるAPIでは画像の数が入っているが、
別のAPIではそれが入っていないなど、使うAPIによって、同じModelでも微妙に返ってくるパラメータの種類が違うなどもありました。
Objective-Cでは値が無いなど、そこまで問題にはなりませんでしたが、Swiftを使う、またJSONを扱うライブラリにHimotokiを使う上では、素直に扱えなくなるなど問題が起きる可能性があるので、先にレスポンスを定義し、Himotokiで扱いやすいようにしました。
同時にAndroid版も開発していた時期なので、Javaにとってもレスポンス仕様が明確に定義されていることは大事です。
Swift 2.0への移行
Swift 2.0対応のAPIやJSONを扱うライブラリはいくつかありますが、
APIKitとHimotokiを使うことにしました。
APIKitは小さいHTTPクライアントライブラリで、Himotokiは
JSONをいわゆるValue Type(Swiftのstruct)に変換するライブラリです。
どちらも新しい書き方を積極的に採用しているライブラリですが、
Swift 2.0時代のやり方を模索するにはちょうどよいと思いました。
規模も小さいので何か問題があっても対応しやすいというのもあります。
(実際ライブラリのバグも見つかりましたが)
以上のライブラリを使って、先にエクステンション(TodayとApple Watch版)からSwift 2.0にアップデートしました。
Xcodeに付属のSwift 1.2 to 2.0コンバーターは正しく動かなかったり、動いても書き方は結局変わらないので、手で直しました。
もちろん、guardや新しいEnum, Switchを使った書き方に直しました。
本体ですが、以下のやることがあり、やはりコードの量は多いですが、地道にリファクタリングしながら書き換えていく感じです。
- ModelのSwift化
- UserDefaults周りのSwift化
- ユーティリティ系のSwift化
- API通信のSwift化
- ViewControllerのSwift化
- ViewのSwift化
- 一時データストアのSwift化
また、同時に次のことも進めることにしました。
- iOS7の切り捨て(4sユーザーはiOS9へアップデートしてもらう)
- Embedded Framework化
- UIAlertControllerに移行
- xibからStoryboardとSegueに移行
- Autolayout化を進める
- 一時データストアにRealmの導入
- ライブラリの組み込みをSubmoduleからCarthageに変更
Embedded Framework
iOS8からの機能であるEmbedded Frameworkを作り、
そこにModelやAPI、UserDefaultsのラッパーなどのコードをいれておくと、メリットがいくつかあります。
- 複数のPlatform(iOS, Extension, watchOS)でModelやAPIを共有できる
- アプリ側から直接操作されなくないclassやfuncなどは、publicにしない限り、使う側(アプリ)からは見えないので、外部に晒さないようにできる
ただ、あまりFrameworkの単位に切りすぎると逆に使いにくくもなるので、
一般的なアプリなら1つだけあれば問題ないような気がします。
ViewとCellのSwift化
ViewとCellは実装工数の関係で、一部はObjective-Cのまま使うことにしました。
しかし、ModelはSwiftのstructなのでこれをそのままObjective-Cの世界へ持っていくことができません。ブリッジのためのクラスを作ることにしました。
struct Article { let title: String let url: NSURL let publishedAt: NSDate public var objcValue: BArticle { // Objective-Cのクラスに変換 return ... } }
@interface BArticle: NSObject @property(nonatomic, copy) NSString* title @property(nonatomic) NSURL* URL; @property(nonatomic) NSDate* publishedAt; @end
単純に値のみを格納するクラスを作って、Swift側から各値をObjective-Cのクラスに詰めて、渡します。
Swift 2.0に移行して
将来的には完全にSwiftに移行する予定で進めてきましたが、タイミングとしては、Swift 2.0 & iOS 9
でちょうどよかったのではないかと思います。
Swift 2.0がかなり実用的になってきたり、周辺ライブラリもそろってきたという状況があります。
また、iOS 9がリリースされたことで、サポートバージョンを1つ前までとすると、iOS 7のサポートやめられるということもあります。(iOS 7でSwift 2.0はかなり面倒)
アプリ側の事情では、UIを削ったり、機能を見直すタイミングであったので、どうせなら最初からSwiftで書いたほうが後々楽ということにもなりました。
Objective-CからSwiftに変えたことで、静的にエラーが分かるようなり、nilやnullが実行時にどうなるかを考えなくてもよくなったのは
大きいと思います。
ソースコード自体も簡潔に記述できるようになり、今ではObjective-Cのヘッダーファイルが冗長にも見えます。
私は2005年くらいからObjective-Cを勉強してきましたが、10年目の今、Swift 2.0~に切り替わることになりそうです。
Swift 2.0の機能を使う
Swift 2.0では多くの機能が追加され、より簡潔に安全に書けるようになりました。
guardや新しいEnum, Switch-Case, Protocol Extensionなど多くの機能が入りました。
今回はいくつか新しいSwiftの機能に挑戦してみたのでそれについて書きたいと思います。
Protocol Extension
たとえば、カメリオではフォローボタンが色々なところに出てきます。
UIButtonやCellなどです。
Protocol Extensionを使うと、UIViewの継承関係に関わらずに別のクラス関係でも、同じコードを共有できます。
protocol TopicFollowButton { func toggleFollow(callback: dispatch_block_t?) var topicID: TopicID? { get } var isFollowing: Bool { get } } extension TopicFollowButton where Self: UIView { var isFollowing: Bool { // LocalのDataを参照してフォローしているか返す } func toggleFollow(callback: dispatch_block_t? = nil) { // APIを呼ぶ } }
プロトコルTopicFollowButtonはUIViewとそのサブクラスに自動的に実装されます。
toggleFollowは実際にフォローするためにAPIを呼び出すので、どのクラスでも共通の処理となり、Extensionに書きます。また、現在フォロー中か返すisFollowingもユーザーはアプリで1つなので同じく共通です。
topicIDはフォローしたいTopicのIDです。IDはViewにセットされるモデルごとに変わるので、ここはExtensionに実装しません。
使う側(UIButtonやCell)は、TopicFollowButtonを継承させて、topicIDが適切に返るようにします。
またボタンやCellがタップされた段階で、toggleFollowを実行して、APIを呼びます。
その2つの実装だけで、ButtonでもCellでも同じフォローする機能を持たせることができます、
実際にフォローする処理はExtensionの1ヶ所だけです。
クラスの継承関係を気にせず共通のコードを使いたいときはProtocol Extensionが便利です。
いままで、UIViewControllerとUITableViewControllerに同じ処理を書きたい時、BaseViewControllerを作っても、BaseViewController:UIViewControllerとするしかなく、UITableViewControllerには適用できませんでした。
Value付きEnumを使う
Value付きEnumを使うとより安全なコードを書けるようになると思います。
たとえば、アプリがどこから起動・終了したのかを記録しておきたい場合、
EnumのValueに対して、Enumを引数に割り当てると、使える値の種類を限定することができます。
enum Type { case AppOpen(AppOpenSub) case AppClose(AppCloseSub) } enum AppOpenSub { case Normal case Push case Handoff case DeeplinkSearch } enum AppCloseSub { case Background case Terminate } struct Status { init(type: Type) { ... } }
使う場合
Status(type: .AppOpen(.Push)) Status(type: .AppClose(.Background)) Status(type: .AppOpen(.Background)) // これはエラー
とすれば、値を保持しておくことができます。
TypeでAppOpenを選択した場合、取れる引数はAppOpenSubのどれかになります。
Typeの数がとても多くなったときにも破綻なく使えます。
—
記事を表すモデル、Articleがあったときに、記事の種類を区別したいとします。
記事の種類は通常の記事・広告用の記事があります。
ただし、広告の時にだけ、Adという付加情報があり、アプリ側で使うとします。
public struct Article { public let title: String public let url: NSURL ... public let type: ArticleType // 値付きEnum let ad: Ad? // 広告なら not nil } public struct Ad { let adId: String public let imageURL: NSURL ... } enum ArticleType { case ArticleType(Article) // 普通の記事なら記事自体を返す case AdType(Ad) // 広告の記事なら広告の情報を返す }
このようなインターフェースを定義して、記事の種類(ArticleType)を値付きEnumで定義しておくと、種類を判別するのと同時にその記事の付加情報を明示的に返すことができます。ViewでこのModelを使うときに、
switch article.type { case .ArticleType(let article): // 通常の記事で行う処理 case .AdType(let ad): // adを使って広告用の記事で行う処理 }
とすれば、値を取り出しつつ種類を判別できます。同時にadという付加情報を使うこともすぐに
分かりますし、caseで取り出したadもオプショナルにはなっていません。
もしここでadを使わないと警告が表示されます。必要の無いときは_で回避できますが、あまりそのパターンはありませんでした。
サーバー側DBの持つユニークID(Primary Keyなど)をStructにする
サーバー側にDBを持って、ユーザーのデータなどを保管する場合、データやオブジェクトにはだいたいIDが付くと思います。user idやbookmark idなどがあるとき、Int型またはString型で扱うことが一般的です。
DELETE /user/me/bookmark/:id のようなHTTPのAPIでは、HTTPのAPIのパラメータやクエリにIDを付けることが多いです。
そうすると、APIKitなどのAPIのstructには、引数として、
struct AddArticleToBookmark { init(articleID: String) { } }
を渡すことが多いですが、ここをInt(String)とせずに独自のstruct型(BookmarkID型やUserID型、ArticleID型)を作るようにしました。
struct AddArticleToBookmark { init(article: ArticleID) { } } struct RemoveBookmark { init(id: BookmarkID) { } }
とできるので、ここに目的のIDと違うIDを渡すこともありませんし、IDでない文字列を渡してしまうこともありません。
またIDの型のイニシャライザはFramework内に隠しておけば(publicにしなければ)、App本体からBookmarkIDなどを生成することもできなくなります。
本来DBが生成する値なのでクライアントのAppから直接生成することもありません。
UserInfoの値をHimotokiでデコード
iOS9になって、外部やWatchなどのデバイスと連携することが多くなってきました、iOSでは連携する際のメタデータはuserInfoという変数の辞書をよく使うことになると思います。
UserActivity、通知のPayload、ActionNotification、SearchActivityなど、
userInfoの型も、
- userInfo: [NSObject : AnyObject]
- userInfo: [NSObject : AnyObject]?
どちらもあります。
userInfoはすべてHimotokiのstructでデコードでしてしまえば、簡単に安全にSwiftの型に変換することができます。
APIKitでリクエストを分ける
アプリをチューニングする上でも、使うHTTPのAPIによって、URLSessionを切り替えたり、
細かい挙動を変えたりしたいことがあると思います。
APIKitとProtocolを使うことによりそれを行ってみました。
public protocol UserAPIRequest { // ユーザー系のAPIのリクエスト } public protocol ArticleAPIRequest { // 記事系のリクエスト }
public static func sendRequest<T: Request where T: UserAPIRequest >(request: T, handler: (Result<T.Response, APIKit.APIError>) -> Void) -> NSURLSessionDataTask? { self.beginNetworkConnection() // ここでネットワークインジケータを表示する処理 return super.sendRequest(request, handler: { result in self.endNetworkConnection() // ここでネットワークインジケータを隠す handler(result) }) } public static func sendRequest<T: Request where T: ArticleAPIRequest >(request: T, handler: (Result<T.Response, APIKit.APIError>) -> Void) -> NSURLSessionDataTask? { Logger.info("request as an article \(request.path)") // リクエストの前処理 // 記事読み込み専用のURLSessionを使う return super.sendRequest(request, URLSession: self.articleDownloadSession, handler: { result in // リクエスト後の処理 handler(result) }) }
APIKitのリクエストを作る時に、
public struct FetchBookmarks: KamelioAPIRequest, UserAPIRequest { // ブックマーク一覧を取得するリクエスト }
としておけば、UserAPIRequestを適用していることになり、
sendRequest時にwhere T: UserAPIRequestのほうが呼ばれます。
API毎にURLSessionを切り替えたり、リクエストの前と後処理を追加することができます。
今回は使いませんでしたが、APIKit.APIのクラスを別の設定で2つ作ってもいいかもしれません。
画像リソースのEnum化
画像リソースのSwiftから使う場合、Stringを直接各所に書いてしまうと、
一覧性が悪くなったり、静的なチェックができないので、Enumを使いました。
同様にStoryboard、Segue、Cellなどもやっておきます。
TargetがiOS 9以上(?)なら自動的に補完できるようです。
extension UIImage { enum ButtonImage: String { case ListFollowButtonNormal = "list-follow-normal" case ListFollowButtonFollowing = "list-follow-higlighted" case WebViewBackNormal = "article-icon-back" case WebViewForwardNormal = "article-icon-forward" ... } enum BarButtonImage: String { case Menu = "icon-menu" case Close = "icon-close-g" } enum SettingIconImage: String { case Message = "setting-icon-news" case Alert = "setting-icon-alert" .... } convenience init?(buttonImage: ButtonImage) { self.init(named:buttonImage.rawValue) } convenience init?(settingIconImage: SettingIconImage) { self.init(named:settingIconImage.rawValue) } convenience init?(barbuttonImage: BarButtonImage) { self.init(named:barbuttonImage.rawValue) } }