- 投稿日:2020-10-20T22:49:53+09:00
【Flutter】flavorで環境ごとに、google-services.json や アプリID, アプリ名を切り替える
アプリID, アプリ名をflavorで分ける
android/app/build.gradle
配下のファイルにproductFlavors
を追加する。android/app/build.gradle... android { compileSdkVersion 29 sourceSets { main.java.srcDirs += 'src/main/kotlin' } lintOptions { disable 'InvalidPackage' } defaultConfig { ... } buildTypes { ... } flavorDimensions "app" productFlavors { dev { dimension "app" resValue "string", "app_name", "DEV_{アプリ名}" applicationId "{開発環境のID}.dev" } qa { dimension "app" resValue "string", "app_name", "{アプリ名}" applicationId "{QA環境のID}" } } } flutter { source '../..' } dependencies { ... }ちなみに、
android/app/src/flavor名/res/values/strings.xml
からアプリ名を変更する方法は上手く変更できませんでした。google-service.jsonを環境ごとに切り替える方法
android/app/src
配下にflavor名のフォルダを作成し、'google-service.json'の名前でconfigファイルを設定すると、flavorを指定してbuildした際にそのディレクトリを参照してくれます。そのため、以下のようにディレクトリを配置するだけで、flavorごとのfirebaseの設定ファイルの切り分けは完了です。
app └── src ├── main │ ├── AndroidManifest.xml │ ├── java │ ├── kotlin │ └── res ├── dev │ └── google-servicesjson.json ├── qa │ └── google-services.json └── prd └── google-services.json参考記事
- 投稿日:2020-10-20T21:08:19+09:00
iPhoneショートカットでシステム音量を数値で指定する
問題提起
iPhoneの音量操作は基本的に本体横のボタンから行うが、これによって変更できる音量の幅が大きすぎる事が私の悩みである。今聴いている音楽がやや音量が小さいとき、ボタンを押して1段階大きくしてもちょっと大きすぎる、そしてその逆もまた然り、なんて事がよくある。とはいえ、アプリ側のボリュームスライダーを操作する事で自分の理想の音量に設定することは一応可能である。ただ、指先の絶妙な感覚が要求されるのでなかなか操作が難しい。
解決策
音量を自分の好きな値に細かく調整できるようなショートカットの作成を試みた。
iPhoneのショートカットには音量を指定できるアクションが存在する。そこに、0(消音)から100(最大音量)までの値を入力することで、自在に音量を調整することができる。ショートカットの中身
以下にショートカットのスクリーンショットをまとめる。
実際に作成したショートカットはイヤホンでの使用を想定している。値を入力する際の誤操作によって大音量で出力されて鼓膜が破れる可能性を考えて、40を超える入力が指定された場合再び入力を求めるようなループを組み込んだ。
未解決の問題
iPhoneには現在設定されている音量を取得・出力することができない。そのため、現在の音量から3だけ大きくする(または小さくする)など、ボタン1つで調整することができない。今後のアップデートでできる様になることを願いたい。
共有リンク
以下のリンクから今回のショートカットをダウンロードすることができる。ダウンロードにはiPhoneの設定->ショートカット->信頼されていないショートカットを許可をオンにする必要があるので各自設定の変更が必要。
https://www.icloud.com/shortcuts/28229665d42b493f8c7d6ae1990d72f0
- 投稿日:2020-10-20T19:58:33+09:00
iPhoneショートカットで荷物の追跡を行う
配送状況をもっと簡単に確認したい。
ネットショッピングは今や日常生活には欠かせないものとなっています。みなさんも月に1回は何かネットで購入しているのではないでしょうか?
さて、ネットでショッピングを行った数日後、販売店さんから発送完了メールが送られてくると思います。そのメールには荷物の配送業者および追跡番号が記載されているのが一般的です。親切な販売店さんはより簡単に荷物の状況が確認できるように配送業者のサイトリンクを記載してくれている場合もあります。稀にリンク内に追跡番号が書き込まれており、すぐ自分の荷物がどうなっているか確認できる場合もあります、神。とはいえ、そんな神対応をしてくれる店舗もあまり多くないので、大半の場合、追跡番号コピー -> 荷物追跡サイトへアクセス -> 入力 -> 結果の表示という非常に面倒くさいステップを踏むことになります。もっと楽に荷物の発送状況を確認できる様にしたい…「そんな面倒なことでも簡単にできます、iPhoneならね。」
ショートカットの概要
というわけで、ショートカットを作成しました。
中身はこんな感じです。
- 追跡番号を選択し、共有をタップ
- リストからどの配送業者か選択する(日本郵便・佐川急便・クロネコヤマトの3択)
- 追跡番号を荷物追跡サイトのリンク内に挿入する
- カスタムしたリンクをSafariで開く
これだけです。
要所解説
1. 追跡番号を選択し、共有をタップ
選択した文字をショートカットに渡す時のアクションはショートカットアクションの一覧にはありません、ショートカット右上の詳細 > 共有シートに表示をオンにできます。
今回の場合は共有シートタイプをテキストにします。あまり必要ありませんが、念のため追跡番号と称して変数に変換しています。2. リストからどの配送業者か選択する(日本郵便・佐川急便・クロネコヤマトの3択)
"メニューから選択"アクションで業者を分けます。
3. 追跡番号を荷物追跡サイトのリンク内に挿入する
日本郵便・佐川急便はリンクに追跡番号を加えると直接その荷物の配送状況を確認できます。
日本郵便: https://trackings.post.japanpost.jp/services/srv/search/?requestNo1=[追跡番号]&search=1
佐川急便: http://k2k.sagawa-exp.co.jp/cgi-bin/mole.mcgi?oku01=[追跡番号]直接確認できるよう、リストを組んで結合させることでカスタムリンクを作成します。
クロネコヤマトはそれができません。そのため、クリップボードに追跡番号をコピーして、自動的にクロネコヤマトアプリを開くようにしました。4. カスタムしたリンクをSafariで開く
リンク先を"Safariで開く"に投げるだけです。
ショートカットの全貌
ショートカットの共有
以下にショートカットのダウンロードリンクを貼っておきます。ダウンロードにはiPhoneの設定->ショートカット->信頼されていないショートカットを許可をオンにする必要があるので各自設定の変更が必要です。
https://www.icloud.com/shortcuts/9064256f0d5549cebd30fbe2cc0f263f
- 投稿日:2020-10-20T13:24:25+09:00
大規模なiOSアプリでFatViewControllerを解消するために導入したMVVMパターンのサンプル
前書き
大規模なiOSアプリ開発を2年以上継続して得られた知見
上の記事ではアーキテクチャーパターンについては概要のみの記載でしたので、本記事で掘り下げます。
- 「既存の作りをなるべく活かしてFatViewControllerを解消したい」と考えている方にとっては、一つの実践事例として参考になるかもしれません。
- 一方で、教科書的な内容ではありませんので、本記事の内容を読んで「これが正しいMVVMだ」という理解はしない方が良いと思います。
前提環境:
・Xcode 12.0.1
・Swift 5.3課題
- アプリの画面数は120画面ぐらいです。ソコソコの規模かと思います。
- UI/UXデザインには結構こだわっていて、一つ一つの画面実装が複雑です。
- 一例として、API通信中はローディングプレースホルダー(スケルトン)を表示してユーザー操作を妨げないようにしています。すなわち画面全体をブロックをしないことでユーザーは他画面遷移等の操作が可能です。
- 当初は標準的なCocoa MVCで作っていました。当然FatViewController化しました。
- アプリのエンハンスメントを重ね規模が大きくなるにつれて改修が困難になってきました。
課題解決のための方針
FatViewControllerを解消して改修しやすくするために、アーキテクチャーパターンを導入することにしたわけです。
既存の作りを大幅に変えずにアーキテクチャーパターンを導入するためには、MVVMが最も適していると思われました。
既存のViewControllerからビジネスロジックをViewModelに切り出し、View-ViewModel間の通知を盛り込めば「イケる」と考えたためです。また、Clean Architectureなどの、MVVMより複雑なパターンは学習コスト面でチームに合わないと考えました。
MVVMの適用に際して、以下のように方針を考えました。
- 自前で作り込んでいたカスタムUIパーツやAutoLayoutのHelperがあるために、ライブラリの後付け導入は厳しいと考え、データバインディングは見限りました。
- 元々ユニットテストがなく、XCUITestによるUIテストの自動化に取り組んでみたものの、機能追加変更の際のテストコードのメンテコストが大きく、私たちにとっては割りに合いませんでした。
- 新たにXCTestでロジック部分のテストを書くようにしたいですが、View層のテストを書くことはやはりコストパフォーマンスが不安だったので、テストコードはロジック部分のみに絞り、UIは実動作で確認するよう割り切ることにしました。
ということで、
ライブラリ等を使わずに、自前でMVVM(的な?)アーキテクチャーを導入する方針で、FatViewControllerの解消を目指しました。サンプルコードと簡単な解説
本記事のために作ったサンプルアプリです。
一応アーキテクチャーの説明のための要素は含んだつもりですが、実務のコードとは異なっていることをご了承ください。サンプルアプリ
GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
APIを叩いてレスポンスを待っている間はローディングの表示になります。
初期表示と、UITableViewを下に引っ張られた時です。
すなわちこのアプリは「ローディングの状態を持っている」ということです。
Modelのサンプルコード
- このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
- RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
- Google News Rss(API)
- ViewModel、Viewには依存しません。
- 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。
- だだしAPIのモック化は骨なので、私はXCTestでAPIのモック化が必要な時はOHHTTPStubsを使っています。
- ニュースフィードの取得結果はクロージャ(コールバック関数)で呼び元に返します。
- 実業務では、通知先が複数オブジェクトの場合などには、NotificationCenterを使う場合もあります。
Model.swiftimport Foundation /// DIのためにModelの振る舞いを抽象化したProtocol protocol ModelProtocol { func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void) func createItems(with data: Data) -> Result<[Model.Article], Error> } /// アプリのドメイン(問題領域)のデータ保持と手続きを担う class Model: NSObject, ModelProtocol { /// ニュース記事 class Article { var title = "" var link = "" var pubDateStr = "" var pubDate: Date? { return createDate(from: pubDateStr) } var description = "" var source = "" } private var articles = [Article]() /// GoogleNEWSのXML要素の定義 enum Element: String { case item = "item" case title = "title" case link = "link" case pubDate = "pubDate" case description = "description" case source = "source" var name: String { return self.rawValue } } private var currentElementName : String? // XMLパースで発生したエラー private var parseError: Error? /// GoogleNEWSのRSSを取得する func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void) { let url = URL(string: "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja")! URLSession.shared.dataTask(with: url, completionHandler: { [weak self] (data, response, error) in guard let self = self else { return } sleep(3) // 擬似的なレスポンス遅延 if let error = error { completion(Result.failure(error)) return } guard let data = data else { completion(Result.success([Article]())) return } print("\(String(data: data, encoding: .utf8) ?? "decode error.")") // DEBUG completion(self.createItems(with: data)) }).resume() } /// GoogleNEWSのRSSを元にニュース記事の配列を生成する func createItems(with data: Data) -> Result<[Model.Article], Error> { let parser = XMLParser(data: data) parser.delegate = self parser.parse() if let parseError = parseError { return Result.failure(parseError) } else { return Result.success(articles) } } } // MARK: - XMLパーサーの処理群 extension Model: XMLParserDelegate { // 解析_開始時 func parserDidStartDocument(_ parser: XMLParser) { articles.removeAll() } /// 解析_要素の開始時 func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String]) { currentElementName = nil if elementName == Element.item.name { // 次のニュース記事が現れた場合、新規の記事classをデフォルトで生成 articles.append(Article()) } else { // 各要素の場合 currentElementName = elementName } } /// 解析_要素内の値取得 func parser(_ parser: XMLParser, foundCharacters string: String) { // 末尾の記事classを上書き更新 guard let lastItem = articles.last else { return } switch currentElementName { case Element.title.name: lastItem.title = string case Element.link.name: lastItem.link = string case Element.pubDate.name: lastItem.pubDateStr = string case Element.description.name: lastItem.description = string case Element.source.name: lastItem.source = string default: break } } /// 解析_要素の終了時 func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { currentElementName = nil } /// 解析_終了時 func parserDidEndDocument(_ parser: XMLParser) { self.parseError = nil } /// 解析_エラー発生時 func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { self.parseError = parseError } } // MARK: - ユーティリティ関数 extension Model { /// GoogleNEWSの日付StringからDateを生成する static func createDate(from dateString: String) -> Date? { let formatter: DateFormatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.dateFormat = "E, d M y HH:mm:ss z" return formatter.date(from: dateString) } }ViewModelのサンプルコード
- Viewから送られたアクションを仲介してModelに問合せを行い、問合せ結果(ステータス:ロード中、成功、失敗)をViewに通知するクラスです。
- Modelに依存しています。
- XCTestのためにModelをDIできるようにしています。
- サンプルコードの
init(:model)
に着目してください。- Viewにて描画するのに必要な情報を加工して、保持します。
- Viewへの問合せ結果(ステータス)の通知は
ViewModelDelegate
を使っています。ViewModel.swiftimport Foundation /// Viewにデータの取得状態が変化したことを通知するためのProtocol protocol ViewModelDelegate: AnyObject { func didChange(status: Status) } /// データの取得状態 enum Status { case loading case loaded case error(String) } /// ViewとModelの間の情報の伝達と、Viewのための状態を保持する役割 class ViewModel { // Viewに提供するオブジェクト struct ViewItem { let title: String let link: String let source: String let pubDate: String? } private(set) var viewItems = [ViewItem]() // 取得状態を扱うオブジェクト weak var delegate: ViewModelDelegate? private(set) var status: Status? { didSet { // 随所でdelegate.didChange(:status)を呼び出すとモレる可能性があるのでdidSetにて行う guard let status = status else { return } delegate?.didChange(status: status) } } // テストのためにModelクラスをDIする private let model: ModelProtocol init(model: ModelProtocol = Model()) { self.model = model } /// データ取得 func load() { status = .loading model.retrieveItems { [weak self] (result) in switch result { case .success(let items): self?.viewItems = items.map({ (article) -> ViewItem in return ViewItem(title: article.title, link: article.link, source: article.source, pubDate: self?.format(for: article.pubDate)) }) self?.status = .loaded case .failure(let error): self?.status = .error("エラー: \(error.localizedDescription)") } } } } // MARK: - ユーティリティ関数 extension ViewModel { /// Dateから表示用文字列を編集する func format(for date: Date?) -> String? { guard let date = date else { return nil } let formatter = DateFormatter() formatter.dateFormat = "yyyy/MM/dd HH:mm" formatter.timeZone = TimeZone(identifier: "Asia/Tokyo") formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.string(from: date) } }Viewのサンプルコード
- いわゆるプレゼンテーション層のクラスです。
ViewModelDelegate
を実装することで、問合せ結果(ステータス)の通知を受け取り、画面に描画します。- 前述の通り、個々のView項目のバインディングは行いません。
- また、Viewのテストコードを書くことは諦めているので、
UITableViewDataSource
およびUITableViewDelegate
は「なり」で実装しています。ViewController.swiftimport UIKit import SafariServices class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! private let viewModel = ViewModel() override func viewDidLoad() { super.viewDidLoad() tableView.dataSource = self tableView.delegate = self // 引っ張って更新 tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(refresh(sender:)), for: .valueChanged) viewModel.delegate = self viewModel.load() } } // MARK: - UITableViewの処理群 extension ViewController: UITableViewDataSource, UITableViewDelegate { /// 行数を返す func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return viewModel.viewItems.count } /// cellを返す func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let identifier = "TableViewCell" let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) let item = viewModel.viewItems[indexPath.row] cell.textLabel?.text = item.title cell.detailTextLabel?.text = "[\(item.source)] \(item.pubDate ?? "")" return cell } /// cellの選択時 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let url = URL(string: viewModel.viewItems[indexPath.row].link) else { return } let safariVC = SFSafariViewController.init(url: url) safariVC.dismissButtonStyle = .close self.present(safariVC, animated: true, completion: nil) tableView.deselectRow(at: indexPath, animated: true) } } // MARK: - ViewModelDelegate extension ViewController: ViewModelDelegate { /// ViewModelのステータスが変化した時の処理 func didChange(status: Status) { switch status { case .loading: tableView.refreshControl?.beginRefreshing() tableView.reloadData() case .loaded: DispatchQueue.main.async { [weak self] in self?.tableView.refreshControl?.endRefreshing() self?.tableView.reloadData() } case .error(let message): DispatchQueue.main.async { [weak self] in self?.tableView.refreshControl?.endRefreshing() } print("\(message)") } } } // MARK: - Action extension ViewController { /// UITableViewを引っ張って更新 @objc func refresh(sender: UIRefreshControl) { viewModel.load() } }成果
以下の面において成果はあったと考えます。
- FatViewControllerを解消し維持保守しやすくなった。
- 既存の作りを大きく変えないことで、リアーキテクチャー工数と学習コストを抑えられた。
- アーキテクチャーは変わったものの細部の実装は以前と変わらないので、新規機能開発の生産性は(ほぼ)下がらなかった。
- ロジック部分のテストが書けるようになった。
リポジトリ
https://github.com/y-some/MVVMSample
参考リンク
- 投稿日:2020-10-20T09:42:44+09:00
react-native-track-playerのiOSのプレイヤーで一時停止が効かない
capabilitiesの設定が必要です!
await TrackPlayer.updateOptions({ stopWithApp: true, capabilities: [ TrackPlayer.CAPABILITY_PLAY, TrackPlayer.CAPABILITY_PAUSE, TrackPlayer.CAPABILITY_SKIP_TO_NEXT, TrackPlayer.CAPABILITY_SKIP_TO_PREVIOUS, TrackPlayer.CAPABILITY_STOP ], compactCapabilities: [ TrackPlayer.CAPABILITY_PLAY, TrackPlayer.CAPABILITY_PAUSE ] })example動かして気づいた・・・
参考文献
- 投稿日:2020-10-20T01:13:51+09:00
タップした場所にフォーカスを合わせる
iPhoneのプリインストールのカメラアプリと同じように、タップした場所にフォーカスします。
手順
1、タップでView内の座標を取る
UITapGestureRecognizerをカメラのプレビューレイヤーを含むViewに加えます。
タップで以下の関数を呼びます。@objc func tapForcus(_ recognizer:UITapGestureRecognizer) { let pointInView = recognizer.location(in: recognizer.view) print(pointInView) // (562.0, 282.0) }2、View内のタップ座標を、AVCapturePreviewLayer内の座標(0〜1)に正規化する
カメラ画像領域の座標は、常にLandScapeLeftの左上が(0,0)右下が(1,1)です。
タップしたView座標をカメラ画像領域の座標に変換するには、AVCapturePreviewLayerの座標変換メソッドが使えます。let pointInCamera = previewLayer?.captureDevicePointConverted(fromLayerPoint: pointInView) print(pointInCamera) // Optional((0.38380281690140844, 0.41796875))3、取得した座標にフォーカスを合わせる
フォーカスの関心ポイントをタップしたポイントに設定してから、フォーカスモードを設定しなおします。
関心ポイントを設定した後で、都度フォーカスモードを設定します。でないと、フォーカスされません。// avCaptureDeviceは、当該セッションのAVCaptureDevice do { try avCaptureDevice.lockForConfiguration() avCaptureDevice.focusPointOfInterest = pointInCamera! avCaptureDevice.focusMode = .autoForcus // .autoForcus(固定) もしくは .continuousAutoFocus(デバイスによる自動監視継続) avCaptureDevice.unlockForConfiguration() } catch let error { print(error) }おまけ:フォーカスアニメーション
フォーカスビューの設定
var forcusView = UIView()var forcusView = UIView() forcusView.frame = CGRect(x: 0, y: 0, width: view.bounds.width * 0.3, height: view.bounds.width * 0.3) forcusView.layer.borderWidth = 1 forcusView.layer.borderColor = UIColor.systemYellow.cgColor forcusView.isHidden = trueタップでアニメーション
// @objc func tapForcus(_ recognizer:UITapGestureRecognizer) { // let pointInView = recognizer.location(in: recognizer.view) forcusView.center = pointInView // タップしたポイントへ移動する forcusView.isHidden = false // 現れる UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0, options: []) { self.forcusView.frame = CGRect(x: point.x - (self.view.bounds.width * 0.075), y: point.y - (self.view.bounds.width * 0.075), width: (self.view.bounds.width * 0.15), height: (self.view.bounds.width * 0.15)) // タップしたポイントに向けて縮む } completion: { (UIViewAnimatingPosition) in Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { (Timer) in self.forcusView.isHidden = true // 少し待ってから消える self.forcusView.frame.size = CGSize(width: self.view.bounds.width * 0.3, height: self.view.bounds.width * 0.3) // 少し大きめのサイズに戻しておく }?
お仕事のご相談こちらまで
rockyshikoku@gmail.comCore MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。