アプリリリースしました!!!!④(最終回)
前回 ③
アプリに使った技術 Firebase編
Firebaseを採用した理由
アプリの認証機能、DBにはFirebaseを使っています。認証機能を付ける必要があるのは、データをユーザーごとに切り分ける必要があるからです。Firebaseはクラウドサービスです。DBからデータを全て持ってこようとすると全てのユーザーが記録した訪問情報を表示することになるので、ユーザー認証して、そのユーザーのデータのみ持ってくる必要があります。クラウドサービスではなく、Realmのようなローカルに保存するDBを使えばローカル内でしかデータのやり取りをしないので認証機能を付ける必要がありません。そうすればユーザーも少しめんどくさいメールアドレスやパスワードの入力もしなくて済みます。
今回わざわざクラウドサービスを使ったのには2点理由があります。まず1点目はアプリをアンインストールでデータが消えるようにはしたくなかったからです。ローカルDBはアプリをアンインストールするとデータが消えます。仕事で使うことをイメージしたアプリなので、仕事の大事なデータが簡単に消えてしまうのはよくないと考えました。2点目は今後の機能追加を見越してのことです。当初のアプリのイメージは記録と共有ができるというものでした。共有機能があれば仲間内での記録のやり取りができるので、チームのその日のノルマまであとどれくらい必要かがリアルタイムで把握でき、日報提出や引き継ぎにも役立ちます。この機能の実装は難易度が高く、パッとできるものではないので「Done is better than perfect」の精神で今回は見送らせて頂きました。以上2点の理由からFirebaseを採用させて頂きました。
工夫した点
Firebaseの使用にはお金が掛かります。と言っても無料枠があり、1日5万件の読み取り、2万件の書き込み、2万件の削除までは無料で使えます。なのでよっぽどアプリが流行らない限りはお金は掛からないと言っていいでしょう。しかし、アプリが流行らなくてもお金が掛かってしまうことがあります。「クラウド破産」です。クラウド破産とはクラウドサービスの料金が意図せず高額になることです。詳しくは記事を読んで欲しいのですが、主にアプリの設計ミスや公開してはいけない情報が漏れてしまったことが原因で起こるようです。私はこのことにかなりビビりました。なんせアプリが流行ったわけでもないのに、数十万、数百万の請求が突然くるのですからたまったものではありません。Googleに事情を話せば返金してくれることもあるようですが、できるだけそんな面倒ごとは避けたいです。
そこで私は2点ほど工夫をしました。1点目はprint文を使うことです。読み込み、書き込み、削除をした際には必ず「〇〇データの取得に成功しました」や「〇〇データの削除に成功しました」のような文がコンソールに出力されます。これにより設計ミスにより必要もなく何度もデータを取得等していたら、コンソールに文が何度も表示されるのですぐに気づきます。また「セールスマップ」の場合GoogleMapsも使っているので、住所を取得する際にはそれもprint文で表示するようにしています。
2点目はFirebaseとのやり取りそのものを少なくすることです。Firebaseを使っている記事を見るとよくviewWillAppearにデータを取得するメソッドを使っているのですが、私はそんなことはしません。確かにそうすれば簡単に最新のデータを表示することができます。しかしこれは、毎回全てのデータを取得することになるので無駄が多いです。そこでスナップショットリスナーを使います。スナップショットリスナーを使えばデータの変更を感知し、その変更後のデータのみを取得することができます。これにより例えばrecordDataを元にテーブルを表示していた場合、変更後のデータをrecordDataに入れ、reloadDataすれば、必要最低限のデータのやり取りでテーブルを更新できます。 そしてそれと合わせてNotificationCenterを使うことでさらにやり取りを減らします。NotificationCenterを使えば画面遷移が伴わなくても異なるviewController間でデータの受け渡しができます。これによりスナップショットリスナーで取得したデータを他のviewControllerに渡すことができるので、スナップショットリスナーをviewControllerごとにつけて無駄に取得するということがなくなります。以上2点が私がクラウド破産を避けるため工夫したことです。
課題
アプリをインストールして、最初に得られるユーザー体験にあるストレスをできる限り減らすことが一番の課題だと感じています。個人アプリなので、最初でちょっとでも嫌なことがあるとユーザーはそれ以後使ってくれないように思います。最初のインストール後の一番のストレスはやはりユーザー登録でしょう。面倒だし、個人情報を渡すし渡すし、できればやりたくありません。この点でログイン機能を付けるべきか少し悩んだのですが、調べるとFirebaseでゲストログイン機能を実装できるのですね!凄い!今現状のデータだと仕事で使ってくれてる人がいないので当然といえば当然なのですが、10個データを記録した人はいません。なので10個まではゲストでも記録できるけど、それ以降はユーザー登録してねという形に変えたいと思います。10個ならアンインストールしてデータが無くなってもそれほど問題はないでしょうし。「セールスマップ」はポートフォリオも兼ねており、企業さんはユーザー登録とかは避ける傾向にあるので、転職活動中の今このことはできる限り早急に対応したいなと思います。
アプリリリースしました!!!!③
前回 ②
アプリに使った技術 地図編
地図にはGoogle Maps SDKのライブラリを使っています。なぜMapKitではなくGoogleMapを使うのかと言うと、ストリートビューを使うことができるからです。訪問営業アプリなので、現在地やデフォルメされた地図だけでなく、実際に写真で見れた方が圧倒的に分かりやすいだろうと思っていました。なので私の中でストリートビューは割と必須項目でした。でも私のアプリにはストリートビューがありません。技術不足でした。。。GoogleMapのようにしたかったので、左下にUIViewを置いて、そこにストリートビューをaddSubViewして表示するところまではできました。しかし、そのビューの中でずっとぐるぐるとロード中のマークが回ってました。ロードが終わるまではビューをタッチしても何も反応しないようになってました。この問題の解決策が分からず、ストリートビューはアプリの必要条件ではないということでお見送りさせて頂きました。ただこのことはアプリ開発のかなり初期にあったものなので、今の私ならイケるのじゃないかと謎の自身があります。また今度挑戦してみます。
次に現在地の取得の話です。現在地を取得してもいいかどうかはiPhoneの設定のアプリ一覧から設定することができます。ここで面白いのが現在地の取得の許可の他に、正確な位置情報という欄があります。これをぜひお手元の「セールスマップ」か地図アプリで試して欲しいのです。オンの時はいつもの状態ですが、オフの時はぶわっと円が広がり、現在地も半径数キロメートル以内というかなり大雑把なものになります。このアプリ開発を通じて初めて知ったのですが、アプリの設定がこんな風に反映されるんだと少し感動した覚えがあります。Google Maps SDKのチュートリアルの中にpreciseLocationZoomLevelとapproximateLocationZoomLevelというかなり長い変数があると思います。これはこの正確な位置情報がオンの時とオフの時のカメラのズームレベルを表しています。要は円全体が見えるようにカメラを動かすというものです。訪問営業記録アプリ「セールスマップ」の場合、家が正確に分かるまで地図を拡大する必要があるのでpreciseLocationZoomLevelの値が高めに設定されています。
チュートリアルではカメラの動きを、CLLocationManagerDelegateという位置情報を取得した時に通知するデリゲートの中で全部設定しているのですが、実はこれ結構罠です。CLLocationManagerDelegateが通知するタイミングが最初に現在地を取得した時、何メートルか動いた時の他にアプリがバックグラウンドからフォアグラウンドになった時があります。これがかなりうっとおしいです。現在地と異なる場所に地図のカメラがあり、そこからちょっと調べようと思って他のアプリに行って、戻ってくると現在地に戻ります。これでは現在地の記録以外させませんと言っているようなものです。これはいけないと思い、無理やりな感じですがisFirst変数を使って最初の起動時に現在地を取得したらfalseにするようにしています。この起動時のみカメラが動くという設定自体はGoogleMapと多分同じです。
最後に地図アプリを申請する際はぜひやって欲しいことを説明します。「セールスマップ」もしくはAppleマップの位置情報の取得の許可を許可しないに変えてみて下さい。そうすると、上のNavigationBarに「位置情報サービスがオフです>」というボタンが現れます。これを押すとアプリの設定に遷移して、位置情報の取得の許可を促すのですが、このボタンをぜひ設定して欲しいです。私は当初このボタンを設定しないままアプリを申請しました。するとAppleから「ガイドライン4.3に反しているためリジェクトする」とメールが来ました。詳しくはこの記事とかこの記事を読んで欲しいのですが、要は他のアプリと同じだからスパム扱いになっているというものです。私はこれに対して何度かメールで抗議しましたが、全く態度が変わる感じがありませんでした。もしかしたら、このアプリは起動時に位置情報を取得しなかった場合日本地図が表示されるように設定されているので、ただ日本地図を表示するアプリだと勘違いしているのではと考えました。そこでそのボタンを作って提出した所見事「ガイドライン4.3問題」は解消されました。位置情報ぐらいONにしろよと思ってしまいますが、そのせいで操作方法が分からなくなるユーザーもいるということを学ぶいいきっかけになったかなと思います。
次回 ④
アプリリリースしました!!!!②
前回 ①
アプリのコンセプト
そこで「現在地が分かる地図」、「訪問した家に目印をつける」、「訪問結果を記録する」の3つを1つのツールでできるようにしようと思ったのが、今回のアプリの始まりです。今思うとこの3つの機能をもったアプリは他にもあります。当時そのことを知っていたらこのアプリを作ろうとはならなかったかもしれません。
他の地図アプリと差別化する為にこだわった部分は「訪問結果を記録する」の部分です。他のアプリと違い、「訪問」、「約束」、「契約」、「解約」と言う4つのアイコンがあります。このアイコンを使えばその4つの事についてボタンを押すだけで記録ができます。これならば路上で記録する必要があっても結果だけなら簡単に記録できます。落ち着いてからその記録について詳細を書きたければ、編集することも可能です。
アイコンの役割は他にアイコンを押した累計数を表すことです。これにより簡易的なKPIを表すことができます。これが果たしてKPIと呼べるのかどうか微妙ですが、「訪問」の数に対して「約束」の数が少なければ最初の交渉の部分に、「約束」の数に対して「契約」の数が少なければ最後の詰めに、「契約」の数に対して「解約」の数が多ければアフターフォローに問題があると簡単な分析はできると思います。「訪問」については最初からONにしとけよと思われるかもしれませんが、これは電話営業をイメージしているためです。電話営業なら「訪問」にはなりませんが、他のアイコンには繋がる可能性があります。
そして先述のような記録をアイコンにした理由は、楽しいを感じて欲しかったからです。仕事中にちょっと可愛げのあるアイコンを押すことは少ないと思います。アナログな会社だと尚更。なので仕事中にスマホで可愛いアイコンをタッチすると言う体験を通じて、ちょっとでも毎日の仕事が楽しくなればと思い採用しました。
次回予告
明日は「アプリの開発の流れ」、「アプリに使った技術」、「本当はアプリに採用したかった機能」のいずれかについて書きます!お楽しみに!
訪問営業記録アプリ「セールスマップ」はコチラから!
次回 ③
アプリリリースしました!!!!①
アプリを作った背景
セールスマップと言うアプリを作りました!これは訪問営業の記録をするためのアプリです。前職は金融系の仕事に勤めており、私は基本的に事務でしたが、時々営業もさせられることもあり、その時に感じた不満を解決するために作ったアプリです。
前職の仕事は非常にアナログでした。何をするにしても紙とハンコが必要な前時代的な仕事ばかりで、周りもそれに対して不満を持つものの何も変えることはできず、そのまま我慢してずっと同じやり方を10年、20年と続けているような所でした。訪問営業もその一つでした。まず担当地区を決めるのですが、印刷した地図を切り分けて配って、受け取った地図の所が担当地区です。思わずスマホの地図を使えよと思ってしまいます。その地図を見ながらお家に訪問するのですが、当然ながら地図には現在地は書かれていません。今自分はどこにいるのか、どの家に訪問しているのかを把握するためにはスマホの地図を見るか、周りの建物の感じから予測するしかありません。
また訪問していない家がないようにどの家を訪問したか記録する必要があります。その記録はグーグルマップやアップルマップではできません(多分)。スマホでスマートに現在地を把握していたとしても、訪問した家にチェックを入れるために結局紙の地図に戻ることになります。さらに訪問した結果どうだったのかも記録する必要があります。紙の地図にはそれを記録するスペースはありません。その記録をするためにはスマホのメモアプリを使います。路上で歩きながら色んなツールを行ったり来たりするする必要があるので、とてもじゃないですが営業そのものに集中できませんでした。
次回 ②
Apple・Googleそれぞれの逆ジオコーディング
逆ジオコーディングとは
ジーコーディングとは住所から経度緯度のような位置情報に変換することである。逆ジオコーディングはその逆なので、緯度経度から人間が読める住所に変換することである。Apple・GoogleはそれぞれAppleマップ、Googleマップと自社が提供する地図アプリがある。そのこともあってかどちらにも位置情報のライブラリがある。そのライブラリには逆ジオコーディングをするためのメソッドがあるので、今回はそれを使っていく。
AppleとGoogleの違い
func reverseGeocodeLocation(_ location: CLLocation, completionHandler: @escaping CLGeocodeCompletionHandler)
func reverseGeocodeCoordinate(_ coordinate: CLLocationCoordinate2D, completionHandler handler: @escaping GMSReverseGeocodeCallback)
どちらも緯度経度をcompletionHandlerに渡してエラーがなければ住所が返ってくると言う形は変わりません。しかし、その返ってくる情報にはかなり違いがあります。実際に使いながら見てみましょう。
import CoreLocation import Contacts let gecoder = CLGeocoder() gecoder.reverseGeocodeLocation(CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), completionHandler: {(placemark, err) in guard let placemark = placemark?.first else { return } print(placemark.postalCode) //768-0002 print(placemark.administrativeArea) //香川県 print(placemark.locality) //観音寺市 print(placemark.thoroughfare) //高屋町 print(placemark.subLocality) //高屋町 print(placemark.subThoroughfare) //2800 print(placemark.name) //天空の鳥居 print(placemark.subAdministrativeArea) //nil print(placemark.postalAddress) //<CNPostalAddress: 0x281f0ba20: street=高屋町2800, subLocality=高屋町, city=観音寺市, subAdministrativeArea=, state=香川県, postalCode=768-0002, country=日本, countryCode=JP> })
import GoogleMaps let geoCoder = GMSGeocoder() geoCoder.reverseGeocodeCoordinate(coordinate, completionHandler: {(placemarks,err) in guard let placemark = placemarks?.firstResult() else { return } print(placemark.postalCode) //768-0001 print(placemark.administrativeArea) //香川県 print(placemark.locality) //観音寺市 print(placemark.subLocality) //nil print(placemark.thoroughfare) //nil print(placemark.lines?.first) //日本、〒768-0001 香川県観音寺市室本町2731 })
住所の情報をもつクラスのプロパティをprint文で表示させました。print文の右側にコメントで実行結果を書きました。実行結果は私の地元である香川の観光地「天空の鳥居」をタッチした時のものです。Appleの方は簡単に県、市、町、番地と細かく分けることができるのが特徴みたいです。Googleの方は元々プロパティの数は少ないため細かく分けることはできないが、lines?.firstで非常に綺麗にフォーマットされた住所を簡単に取得できることが特徴みたいです。
それなら正直どっちでもいいじゃんとなるのですが、両者には無視できない違いがあります。まずお気づきだと思いますが、表示されている住所が違います。Appleは「〒768-0002 香川県観音寺市室本町2800」なの対し、Googleは「〒768-0001 香川県観音寺市室本町2731」です。正解はAppleの方です。GoogleMapのアプリで見ると「〒768-0002 香川県観音寺市室本町2800」となっています。どういうことなのでしょうか?またGoogleは市までしか取得できないが、Appleなら町やその場所の名前まで取得できたりする場所があります。一方でAppleもGoogleの方なら番地まで表示されるのにAppleの方は表示されないという場所もあります。
色んな場所をタッチして確認して見たところ両者の情報量は、町のGoogle、山のAppleという印象です。Appleは町の中で番地まで表示できないところがちょこちょこあります。一方Googleは山の中となると市までしか取得できないことが結構あります。上の間違った住所を表示した「天空の鳥居」も山の中なのでちょっとおかしくなったのかもしれません。
まとめ
結論としては逆ジオコーディングをする際は、アプリの用途によってAppleかGoogleかを選択するか、高度によって使い分けて両者のいいとこ取りをするのが良いでしょうか。この問題については私のコードや導入方法に問題がある為起きてしまっている可能性も十分にあります。何か分かる方いましたら教えて頂けると助かります。
Google Maps SDK でエラー"This app is not allowed to query for scheme comgooglemaps.Even though i have added "comgooglemaps"
googlemapの左下にあるGoogleのロゴを押したら出た
GoogleMapへ遷移するための設定が上手く出来てなかったみたい。ここに設定の仕方が書いてある。
https://developers.google.com/maps/documentation/ios-sdk/config
具体的なやり方
- infoファイルを開く
- infomation Property Listの欄の右側の+ボタンを押す
- 行が追加されるので
LSApplicationQueriesSchemes
を入力する。
- TypeをAnyにする
- 左側に三角が現れるので押して下向きにする
- その状態で+ボタンを2回押して
googlechromes
comgooglemaps
を入力する
ライブラリ「Instructions」で[ERROR] The overlay view added to the window has empty bounds, Instructions will stop.が出た
「Instructions」はチュートリアル画面を簡単に作れるようになると言うライブラリである。そのライブラリのREADMEのチュートリアルに従って書いたが、なぜかエラーになる。
import UIKit import Instructions class ViewController: UIViewController { @IBOutlet var textFieldCollection: [UITextField]! @IBOutlet weak var sumResultLabel: UILabel! let coachMarksController = CoachMarksController() override func viewDidLoad() { super.viewDidLoad() self.coachMarksController.dataSource = self } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.coachMarksController.start(in: .window(over: self)) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.coachMarksController.stop(immediately: true) } @IBAction func tappedSumButtom(_ sender: Any) { sumResultLabel.text = String(textFieldCollection.compactMap{Int($0.text!)}.reduce(0,+) } } extension ViewController:CoachMarksControllerDataSource,CoachMarksControllerDelegate{ func numberOfCoachMarks(for coachMarksController: CoachMarksController) -> Int { return 1 } func coachMarksController(_ coachMarksController: CoachMarksController, coachMarkAt index: Int) -> CoachMark { return coachMarksController.helper.makeCoachMark(for: sumResultLabel) } func coachMarksController( _ coachMarksController: CoachMarksController, coachMarkViewsAt index: Int, madeFrom coachMark: CoachMark ) -> (bodyView: UIView & CoachMarkBodyView, arrowView: (UIView & CoachMarkArrowView)?) { let coachViews = coachMarksController.helper.makeDefaultCoachViews( withArrow: true, arrowOrientation: coachMark.arrowOrientation ) coachViews.bodyView.hintLabel.text = "Hello! I'm a Coach Mark!" coachViews.bodyView.nextLabel.text = "Ok!" return (bodyView: coachViews.bodyView, arrowView: coachViews.arrowView) } }
このコードはアプリ道場サロンの課題で書いたものにお試しでInstructionsを当てはめたもの。エラーの文章には「[ERROR] The overlay view added to the window has empty bounds, Instructions will stop.
」とある。日本語にすると「ウィンドウに追加されたオーバーレイビューの境界が空であるため、命令が停止します。」イマイチよくわからん。このエラーメッセージはこのライブラリで定義されているものなので、エラーメッセージで検索して定義を見てみる。
// The delegate might have paused the flow, we check whether or not it's // the case. if !self.isPaused { if coachMarksViewController.instructionsRootView.bounds.isEmpty { print(ErrorMessage.Error.overlayEmptyBounds) self.stopFlow() return } coachMarksViewController.show(coachMark: ¤tCoachMark!, at: currentIndex) { self.canShowCoachMark = true self.delegate?.didShow(coachMark: self.currentCoachMark!, afterChanging: change, at: self.currentIndex) } }
この中のoverlayEmptyBounds
がこのエラーメッセージを定義つけている定数。なのでこの部分で引っかかってデバッグエリアにエラーメッセージが出ている。見た感じだとコーチマークの描写がそもそもできて無さそうなのでcoachMarksViewController
がそもそもインスタンス化できていないのではと思った。そこでブレークポイントを使って中身を見てみたが普通に出来てた。
原因がわからず、READMEをもう一度読み直しているとあることに気づく。僕のコードだとviewWillAppear
に書いている部分をREADMEではviewDidAppear
に書いているということを・・・・!僕がviewWillAppear
に書いたのは理由があります。それはREADMEのこの部分
Be careful, you can't call
start
in theviewDidLoad
method, since the view hierarchy has to be set up and ready for Instructions to work properly.
viewDidAppear
としか書いてないもん・・・。ならの次のviewWillAppear
に書こうとなるのが普通の発想だと思うが、viewWillAppear
じゃダメだった。READMEではviewDidAppear
がきちんとが使われいた。結局上のコードも
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.coachMarksController.start(in: .window(over: self)) }
を
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.coachMarksController.start(in: .window(over: self)) }
に直したら普通に動いた😅 ちょっとREADMEに問題があるじゃねえの?と思うのでisseで問い合わせてみたいと思います!この件については次回!