はじめに
先日、swiftで「食レポ -Food Report-」というiOSアプリをリリースしました。
開発期間は3週間で申請に1週間だったので、ちょうど1ヶ月でリリースまでいけました。
今回はそのアプリを開発したときにやったことをまとめてみます。
食レポ -Food Report-
まずアプリの紹介となりますが、「食レポ -Food Report-」は現在位置から付近のレストランを検索し、そのレストランで投稿された写真を表示するアプリです。
レストラン情報はFoursquareから、写真はInstagramから取得しています。
詳しくはアプリ紹介ページをご覧いただけると嬉しいです。
App Store : 食レポ -Food Report- - iTunesプレビュー
LP : Food Report - peromasamune.com
紹介ブログ : 近くのレストランの食レポ検索アプリ 「食レポ -Food Report-」をリリースしました【iOS】- peromasamuneのブログ
開発期間
このアプリの開発期間は2015年8月6日 ~ 2015年8月25日の約3週間弱というタイトなスケジュールでの開発となりました。
なぜこうなったかというと、毎年8月頃にYahoo! JAPAN インターネット クリエイティブアワードというものがYahoo!Japanさんで開催されており、それにエントリーするためにこの期間で作らなければいけなかったという話です。
まぁ、ただ単にだらけて作り始めるのが遅くなってしまっただけなんですが。。。
このコンテストには毎年応募していて、去年と一昨年もエントリーしています。
去年のはさらにだらけていて、1週間で仕上げました。
残念ながらというか当然ながら、今まで1回も入選すらできていないので今年は入選できればいいなぁと思っています。
一昨年(2013) : Arrival Task - 着いたらやること - 領域観測TODOアプリ
去年(2014) : Material Mine -マインスイーパー- - マテリアルデザインのマインスイーパー
短期間で開発をするために
短期間でアプリを開発するために、下記のことを意識しました。
1.スケジュールを逆算して考える
アプリ開発には実装以外にやることが多々あります。
それぞれの作業にどれ位の時間がかかるかを考えながら、作業時間を逆算する必要があります。
- アプリアイデア
- 画面設計
- アプリ設計・実装
- 画像作成(アプリアイコン, スプラッシュ, ボタンなど)
- ローカライズ(多言語対応)
- 単体・結合テスト
- アプリ申請 (申請作業, App Store文言など)
今回は上記に加えてLP(ランディングページ)の作成がありました。
結局はエントリーには間に合ったものの、ラスト3日間は徹夜しました。(笑)
2.無駄なことは極力省く
限らた時間を最大限活用するためには、無駄な時間を極力消費しないようにします。
例えば、 すでにありもののライブラリなどがある場合はそれを活用したりするなど
やらなくていいことに時間をかけずに、やるべきことに時間を集中させます。
今回は作りためておいた自作のライブラリが活用できたので、かなりの時間短縮になりました。
3.設計はきちんとやる
アプリでも家でも基礎となる土台の部分は重要です。クラス設計や画面設計をはじめにきちんと行っておくと仕様変更や機能追加に柔軟に対応できることがあります。
時間が無いからと突貫で実装を行って、リリース間際の結合テストで不具合が見つかってベース部分から作り直しなんてことが起こってしまうと目も当てられません。
設計は時間をかけてもきちんと行うことをオススメします。
4.再利用性を高める
似たようなViewや処理を1つにまとめたりしてカスタマイズしやすくしておくなど、再利用できるようにしておきます。
コピペソースが乱立したり処理が冗長になってくると修正に時間がかかったり、バグの原因となりやすく、後々辛い思いをすることがあります。
5.動作確認は最低限
シミュレータや実機での確認は重要ですが、ビルドやインストールに意外と時間がかかるので、動作確認の回数は抑えると時間短縮になります。
swiftの言語仕様なのかObjective-Cに比べてXcodeでのビルドは遅い印象があります。
機能単位でのビルド+コミットみたいな感じでやるといいかもしれません。
実機での確認は作業時間外(電車の中や休憩中など)にやるようしたところ、TODOの整理もできてすんなり作業に入れました。
開発環境
さて、では開発の時の話をしていこうと思います。
開発環境は下記となります。()内はバージョン。
- Swift (1.2)
- Xcode (6.3.1)
- git (2.3.2)
- CocoaPods (0.37.2)
gitのリモートリポジトリにはBitBucketを使用しました。
ライブラリ
いつもはライブラリ類はあまり使わずに作る事が多いのですが(特にUI周り)、今回は単期間だったのでいくつか使っています。
"PM~"のプレフィックスのライブラリは自作のものです。
その他に、キャッシュやUserDefaults、Utility系(URLエンコード・デコード、バージョン判定)などのライブラリがありますが、昔作ったアプリからコピペしてきたりして全然管理されていないのでここには載せてません。順次切り出してgithubに公開していく予定です。
- Fablic (Crashlytics) - クラッシュレポート
- Relam - DB
- Alamofire - 通信
- SwiftyJSON - JSONパーサー
UI周り
- iCarousel - カバーフローUI
- PMSideMenuViewController - サイドメニュー
- PMAnimationLabelView - 動くラベル
- PMFlatButton - ボタン
プロジェクト構造
下記はXcode projectのディレクトリ構造になります。
だいたいいつもこんな感じになります。
Project/
├ Vendor/
└ AppName/
├ API/
│ ├ Model/
│ └ Request/
├ DB/
├ Manager/
├ Utility/
├ Lib/
├ View/
├ ViewController/
└ Supporting Files/
Pods/
Vendor
: Submoduleなどの外部ライブラリ
API
: APIのモデルやリクエスト周り
DB
: DBのモデルとラッパーなど
Manager
: Singletonクラスでビジネスロジックが絡むもの
Utility
: Static関数のみのクラスでビジネスロジックが絡むもの
Lib
: ビジネスロジックの絡まない汎用的なManager,Utility
View
: AlertViewやNavigationBarなどのView周り
ViewController
: ViewController系
Supporting Files
: Plist,Localizable.strings,Bridging-Headerなど
ViewやViewControllerではビジネスロジックや独自処理のような処理はさせずに、Manager,Utility周りで担保させています。
そうすることによってView周りのソースの見通しがよくなり、改修もしやすくなります。
ただ、そうするとよく言われるゴッドクラスやゴッドメソッドなどが爆誕し、アンチパターンとなりがちなため、クラス名に明示的に意味を持たせたりして処理を細分化させることを意識します。
今回は上記のような感じになりました。それぞれ多くても1クラス150行で、だいたい50~100行程度でした。
ビジネスロジックを外出しすることによって、View・ViewController側はUIや動きなどに専念して実装ができました。
APIリクエスト周り
APIはFoursquare APIとInstagram APIを使用しています。
Foursquare API
venues/search
: 現在地、クエリから建物を検索
Rate limits
- 1アプリあたり5000request/hour (venues/*)
- 1アプリあたり500request/hour (venues/*以外)
- 1トークンあたり500request/1hour
Instagram API
oauth/authorize
: ユーザーログイン
users
: ユーザー情報取得
locations/search
: Foursquareのvenue_idをInstagramのlocation_idに変換
locations/media/recent
: location_idに関連する写真情報を取得
Rate limits
- 1アプリあたり5000request/hour
- 1トークンあたり5000request/hour
モデル
APIリクエスト周りのファイル構造は下記のような感じです。
ModelBase
では共通のInitializerなど。
class PMInstaModelBase: NSObject {
//MARK : - Initialzier
convenience override init() {
self.init(json : nil)
}
init(json : JSON?) {
super.init()
}
}
APIEndopoint
ではリクエストのURLの作成。
class PMInstaAPIEndpoint: NSObject {
static func userProfile(userId : Int, accessToken : String) -> NSURL {
let user : String = (userId != 0) ? String(userId) : "self"
let url = PMInstaConst.ApiBase+"users/"+user+"/?access_token="+accessToken
return NSURL(string: url)!
}
}
Const
ではAPIのBaseURLやClientIdなどを記述しています。
struct PMInstaConst {
static let ApiBase = "***"
static let ClientId = "***"
static let ClientSecret = "***"
}
RequestBase
ではAPI共通のメタデータなどを保持。
class PMInstaRequestBase: NSObject {
//MARK : - Properties
//MARK : Public
var code : Int = 0
var errorType : String?
var errorMessage : String?
var isLoading : Bool = false
var isError : Bool = false
//MARK : Private
//MARK : - Initializer
override init() {
super.init()
self.isLoading = false
}
//MARK : - Public Method
func setMetaData(json : JSON) {
if let code = json["meta"]["code"].int {
self.code = code
}
if let errorType = json["meta"]["error_type"].string {
self.errorType = errorType
}
if let errorMessage = json["meta"]["error_message"].string {
self.errorMessage = errorMessage
}
}
//Must override method
func notificationKey() -> String {
return "NotificationKey"
}
}
Request
ではAPIリクエストを行います。
class PMInstaRequestUser: PMInstaRequestBase {
//MARK : - Properties
//MARK : Public
var result : PMInstaModelUser?
//MARK : Private
//MARK : - Public Method
func apiRequest(userId : Int, accessToken : String) {
if self.isLoading == true {
return
}
let url = PMInstaAPIEndpoint.userProfile(userId, accessToken: accessToken)
self.isLoading = true
Alamofire.request(.GET, url).responseJSON { (request, response, responseData, error) -> Void in
if let data : AnyObject = responseData {
let results = JSON(data)
self.setMetaData(results)
if self.code > 200 {
self.isError = true
}
self.result = PMInstaModelUser(json: results["data"])
}
if let resError = error {
self.isError = true
}
self.isLoading = false
self.postNotification()
}
}
override func notificationKey() -> String {
return "InstagramUser"
}
//MARK : - Private Method
private func postNotification() {
NSNotificationCenter.defaultCenter().postNotificationName(self.notificationKey(), object: self, userInfo: nil)
}
}
認証
本アプリではInstagramのユーザー認証を必須としています。
locations/search
とlocations/media/recent
はauth requiredではないと思いますが、Instagram APIの仕様上リクエスト数が多くなりがちなため、やむなくログイン必須としました。
詳しくは後述します。
OAuth認証の流れは下記となります。
https://instagram.com/oauth/authorize/?response_type=token&client_id=****&redirect_uri=foodreport://auth
上記URLにWebviewでアクセスし、コールバックにアプリのスキーマを指定します。
AppDelegateでスキーマを処理し、AccessTokenを受け取ったことを確認してTop画面にNotificationを飛ばします。
簡単な実装で認証周りを作れてしまうのはいいですね。
写真情報取得
さて、写真の検索ですが下記の流れで取得します
1. 現在地からレストラン情報を取得(venue_id)
2. Foursquareのレストラン情報をInstagramのロケーション情報に変換(venue_id -> location_id)
3. ロケーション情報から関連する写真を取得(location_id -> media)
なぜこのような回りくどい方法をしているかというと、Instagram APIには位置情報から写真を取得することはできるが、レストランのみのような絞り込みはできないためです。
最初はタグを指定して絞り込みをしようと思ったが、位置情報とタグの2つを指定してのリクエストはできない、タグを複数指定でのリクエストはできないなどの制約があったため断念しました。
そのためか、foursquareのvenue_id
をInstagramのlocation_id
に変換するAPIが用意されていたため、それを使うことにしました。
しかしここでも問題が発生し、この変換APIは複数指定でのリクエストができません。
また、media
を取得するAPIもlocation_id
を複数指定でのリクエストができません。
どうなるかというと、例えば、Foursquareでvenue_id
を30件取得した場合、変換APIに30回リクエスト、media
取得に30回リクエストで1回のリクエストに計61回のAPI通信が必要となります。非常にアホらしいです。
しかしながら、複数指定でのリクエストができないというInstagramの仕様上仕方がないため、変換API部分をDBにキャッシュするようにしました。
そうすることによって、同じ場所での検索の場合初回は61回ですが2回目以降は31回にできます。(それでも多い...)
また、頻繁にリクエストをするとマズいので、ユーザーの移動距離を判定してリクエストするようにしました。
リクエストのトリガーはCLLocationManager
のdidUpdateLocations
として、前回リクエスト時の位置からある程度距離が離れていたら再度リクエストするようにしました。(現在の設定は500m)
画面遷移
今回はサイドメニュータイプの画面遷移を採用しています。
PMSideMenuViewControllerという自作のライブラリを使用していますが、こちらの実装を紹介します。
UITabBarViewController
に近いView構成となっていますが、異なる点としてはDelegateで渡すViewControllerを制御しているところです。
Delegate(DataSource)の構造はUITableViewController
に近いです。
enum FLSideMenuType : Int{
case UserProfile = 0
case Main = 1
case Favorite = 2
case Search = 3
case Settings = 4
case Authenticate = 5
}
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, PMSideMenuViewControllerDelegate {
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Make Window
self.window = UIWindow(frame: UIScreen.mainScreen().bounds)
self.window?.backgroundColor = UIColor.whiteColor()
let vc = PMSideMenuViewController()
vc.delegate = self
vc.currentSideMenuIndex = FLSideMenuType.Authenticate.rawValue
self.window?.rootViewController = vc
self.window?.makeKeyAndVisible()
return true
}
//MARK: - PMSideMenuViewControllerDelegate
func PMSideMenuViewNumberOfSideMenuListItems() -> NSInteger {
return 5
}
func PMSideMenuListItemAtIndex(index: NSInteger) -> PMSideMenuListItem? {
if index == FLSideMenuType.Main.rawValue {
return PMSideMenuListItem.itemWith("Map", image: "map")
}
...
return nil
}
func PMSideMenuViewControllerTransitionViewControllerWhenSelectedItemAtIndex(viewController: PMSideMenuViewController, index: NSInteger) -> PMSideMenuBaseViewController? {
if index == FLSideMenuType.Favorite.rawValue {
if favoriteViewController == nil {
favoriteViewController = FLFavoriteListViewController()
}
return favoriteViewController
}
...
if index == FLSideMenuType.Authenticate.rawValue {
return FLActivateViewController()
}
return nil
}
}
class FLBaseViewController: PMSideMenuBaseViewController {
func setSideMenuType(type : FLSideMenuType) {
let delayInSeconds = 0.1 * Double(NSEC_PER_SEC);
let popTime : dispatch_time_t = dispatch_time(DISPATCH_TIME_NOW, Int64(delayInSeconds));
dispatch_after(popTime, dispatch_get_main_queue(), { () -> Void in
self.sideMenu?.transitionToSpecificViewControllerFrimSideMenuType(type.rawValue)
})
}
}
遷移の方法はUITabarViewController
の様にindexを差し替えて切り替えます。
UINavigationController
がPMSideMenuViewController
のchildViewControllerとなっており、UINavigationController
のrootViewControllerを差し替えて、画面を切り替えます。
UIViewController
のライフサイクル通りに動作するようになっています。
UITabarViewController
はinit時にあらかじめViewControllerをセットする必要があり、一度呼び出されるとViewControllerが保持されたままになってしまいますが、こちらはDelegateから呼び出されるためそれらの制御が可能です。
認証画面や設定画面などの保持しておく必要の無いものは画面が消えたタイミングでdeinitするようにしました。
また、遷移はPMSideMenuViewController
を経由して行われるため、各ViewControllerは遷移について考える必要がなく、疎結合となり、シンプルな実装となりました。
画面の追加もDelegateを修正するだけで良くなり、画面追加も簡単に行えます。
多バージョン・多画面対応
多バージョン対応
本アプリはiOS7,8(iOS9でも動作確認済み)のバージョンに対応しています。
iOS6以下のバージョンを意識しなくなってから大分楽になりました。
なので、特にここら辺は今回はあまり考えずに済みました。
iOS8のCLLocationManager
あたりの修正くらいですかね。
Xcode7からはバージョンチェックにif #available(iOS 8.0, *)
などが使えるとか。
static func systemVersionGreaterThanEquals(version : String) -> Bool {
return (UIDevice.currentDevice().systemVersion.compare(version, options: NSStringCompareOptions.NumericSearch) != NSComparisonResult.OrderedAscending)
}
static func isVersionGreaterThan8() -> Bool {
return systemVersionGreaterThanEquals("8.0")
}
多画面対応
画面も3.5Inch,4.0Inch,4.7Inchに対応しています。
5.5Inchは未対応ですごめんなさい。スプラッシュ画像がでかすぎで作れませんでした。
こちらも特に意識せずにできました。
このアプリはStory Board
もInterface Builder
も使わずにコードだけで全て書いています。
レイアウトなどは伸縮しても崩れないようなものにして、小さい画面(3.5Inchなど)をベースとして作成することで見切れたりしないようにしました。
大きい画面ベースで考えると小さい画面で確認した時にViewが見切れたり操作範囲が小さくなったりして大変なので、小さい画面ベースでレイアウトを考えると後々楽です。
画像作成
毎回頭を悩ませるのが画像作成の部分で、アイコン画像やスプラッシュ画像、ボタン・背景などの画像の作成に時間がかかってしまいます。
いつもはKeynote
でざっくり作って、Gimp
で調整・書き出しがメインで、今回もそれでやりました。
ワードアートをそのままコピペできるのでなかなか便利です。
illustrator
とか使ってみたいんですが、絵心が皆無なので難しいですね。
今度はsketchでどうにかしてみたいなぁと思っています。
申請
申請は約1週間と、初回リリースの割には早く終わりました。
また、今回はレビューを1発で通過できましたが、リワード広告問題などもあり最近レビューが厳しくなっている感じがしたので下記に気をつけました。
- レーティングの設定
レーティング周りで良くリジェクトをくらうので、怪しい部分はとりあえずYESにしました。
今回は食べ物の写真を扱うこともあり、アルコールなどの記述など少なからずありそうだったのでそこらへんのチェックはしました。
- テストアカウント
Instagramのテストアカウントを記載する必要があったのですが、実名アカウントなどはリジェクトをくらう場合があるらしく、テスト感のあるアカウントを作成しました。(test@hogehoge.com
的な)
おわりに
本記事ではアプリを3週間でつくった時に心がけたこととやったことについて説明しました。
アプリ自体のボリュームはそこまでありませんが、申請まで完了させるのは結構辛いですね。
機能は単純であったとしてもアプリとしてリリースできる品質まで持っていくのは大変です。
今回は作りためておいたライブラリに大分助けられたので、日々の準備が重要だと感じました。
毎回フルスクラッチじゃ体力持たないですね。
実は、swiftでアプリをリリースまで持って行ったのは初めてだったのですが、やはりヘッダーファイルがない分Objective-Cと比べて書きやすいし、画面切り替えも少ないし、ファイル数も少ないのでサクサクと開発ができました。Xcodeの補完が遅い、ビルドが遅い点を除けば概ね満足です。
これからアプリを開発しようと思っている方は3週間で作ろうなどと無謀なことは考えずに、じっくりと時間をかけて良いアプリを作っていただければと思います!では!
関連
おまけ
時間判定(昼&夜)
このアプリは昼と夜で見た目が変わるんですが、処理がこちら。
分岐処理は雑です。5:00 ~ 18:00までが昼でそれ以外が夜です。
あと、画像出し分けが面倒だったので画像の名前のサフィックスに_dark
とか_light
をつけて自動で出し分けるようにしました。
* レストランの検索結果(夜だとナイトスポットも含まれるようになる)
* サイドメニュー・認証画面の背景画像
* ステータスバーの色
* ナビゲーションバーの色(背景、ラベル、ボタン)
enum FLDayAndNightMode {
case Noon
case Night
}
class FLDayAndNightUtility: NSObject {
static func currentMode() -> FLDayAndNightMode{
let hour = self.currentHour()
if hour < 5 || hour > 18 {
return .Night
}
return .Noon
}
static func currentHour() -> Int {
let date = NSDate()
var calendar : NSCalendar?
if FLVersioningUtility.isVersionGreaterThan8() {
calendar = NSCalendar(identifier: NSCalendarIdentifierGregorian)
}else{
calendar = NSCalendar(calendarIdentifier: NSGregorianCalendar)
}
if calendar != nil {
let dateComps = calendar!.components(NSCalendarUnit.HourCalendarUnit, fromDate: date)
return dateComps.hour
}
return 0
}
static func converImageName(name : String) -> String {
let mode = self.currentMode()
if mode == .Noon {
return name+"_dark"
}
if mode == .Night {
return name+"_light"
}
return name
}
}
ナビゲーションバー
ここがこのアプリで一番力を入れたと言える場所です。
コンテンツのスクロールに合わせてナビゲーションバーの見た目を変えています。
背景のブラーはalpha、ラベルはtextColorを徐々に変えていくだけなので簡単ですが、
画像を使っているボタン部分を徐々に変えるのに難航しました。
最終的にたどり着いた結論としては、
1. 画像を2枚用意(上記の場合は白い画像とグレーの画像)
2. 下に白画像、上にグレー画像を配置
3. スクロールに合わせて上のグレー画像のalphaを徐々に下げていく
単純でしたね。
画像のtintColorを変えたりいろいろ試しましたが、これが一番楽でした。