- 投稿日:2019-12-03T22:57:28+09:00
【Swift】UI自動化テストフレームワークEarlGreyを使ってみた
この記事は、TECOTEC Advent Calendar 2019 の4日目の記事です。
開発環境:Xcode:10.2.1
開発言語:swift4.0UI自動化テストフレームワークEarlGreyとは
Googleが作ったネイティブiOS用のUI自動化テストフレームワークです。
Google公式のGitHub
XCTestを拡張してありXcodeまたはコマンドラインにて実行が可能。EarlGreyを選んだ理由
Androidと一緒に開発することが多いので、
Espressoと(たぶん)対になってるEarlGreyを使ってみようと思った。
あとはただの好奇心!いざ導入!
しようと思ったら、まず記事が少ない!
2016年とかの記事ばっかりでそもそも情報少なくて心が折れかけながら
公式のInstall and runを見ながらとりあえず入れてみる。そして案の定詰まった。。。
【原因】
流し読みしてたせいでstep2のAdd EarlGrey as a framework dependencyという部分を、実行し損ね2時間ぐらい無駄にしながらなんとか無事に導入完了。UITest書いてみる
Googleが用意してくれたチートシートとにらめっこしながらそれっぽいものを探して、
ボタンを押して表示されるテキストが正しいかチェックするテスト書いてみた。sample.swiftimport XCTest import EarlGrey @testable import test class testTests: XCTestCase { func testExample() { // ボタンをタップする EarlGrey().selectElementWithMatcher(grey_accessibilityID("testButton")).performAction(grey_tap()) // TESTというテキストが表示されていることを確認する EarlGrey().selectElementWithMatcher(grey_text("TEST")).assertWithMatcher(grey_sufficientlyVisible()) } }とりあえず問題なく動いた。
2、3日遊んでみての感
・EarlGreyはシステムアラートのテストができない。(結構致命的)
・別にandroidに合わせてフレームワーク使う意味もない気がしてきた
・XcodeのXCUITestの方が記事がたくさん出てくる(当たり前)
・実はEarlGrey2がある。。。【ちょっとだけEarlGrey2について】
EarlGreyの時にはテストできなかったアラートなどもテストできる用になっているので、今から触るなら断然EarlGrey2がオススメしたいが・・・
まだベータ版なので、試しに使うのはいいが本番稼働に使うのは微妙。
結構Issuesもあるし、毎日のようにコミットが走っているのでなんとも言えない。。。
そして案の定記事はほぼないのでそこがネック。
ただgoogleなので公式サンプルはそこそこある(全てが最新とは言わない)。個人の感想ですが、XCUITestでいいかなーと思いました。
- 投稿日:2019-12-03T22:47:47+09:00
Swiftで型を実行時に作る
この記事は 型を実行時に作る:怖くないリフレクション(サンプルコードはHaskell)をSwiftに移植したものです。
型レベル自然数
剰余計算をする場合など、自然数に依存したデータ型を定義したいことがあります。この辺の動機付けは @taketo1024 さんの記事や SwiftyMath
を参照してください。
例えば、 Swift 以下のような感じで自然数を表す型(プロトコル)を定義できます。
protocol Nat { static var intValue: Int { get } } struct Zero : Nat { static let intValue = 0 } struct Succ<T: Nat> : Nat { static var intValue: Int { T.intValue + 1 } } typealias _0 = Zero typealias _1 = Succ<_0> typealias _2 = Succ<_1> typealias _3 = Succ<_2> typealias _4 = Succ<_3> typealias _5 = Succ<_4>プロトコル
Nat
に適合する型T
からはT.intValue
という感じで整数値を取得できます。これを使って「mod m で計算する型」は、例えば次のように定義できます。struct IntMod<m: Nat> { var x: Int init(_ x: Int) { self.x = x % m.intValue } static func + (_ lhs: Self, _ rhs: Self) -> Self { Self((lhs.x + rhs.x) % m.intValue) } } print(IntMod<_3>(5) + IntMod<_3>(2)) // => IntMod<Succ<Succ<Succ<Zero>>>>(x: 1)いろいろ手抜きですが、そこは気にしないでください。(ちゃんとやるなら多倍長整数を使いたいところですが、ここでは簡略化のため
Int
を使っています)実行時の値に基づいた型を作る
さて、普通は型と言ったらコンパイル時に決まっているものですが、実行時の値に依存した型を作りたい時があります。
例えば、筆者の「週刊 代数的実数を作る」で紹介した整数係数多項式の因数分解アルゴリズムでは、入力となる多項式に応じて剰余計算の法を選びます。
- 週刊 代数的実数を作る(#12 多項式の因数分解 その4 整数係数の因数分解)
実行時の値に依存した型を作るというのは、どのプログラミング言語でもできることではありません。例えばC++やRustではそういうのは不可能でしょう。一方で、Haskellでは「型を実行時に作る:怖くないリフレクション」に書いたように、実行時の値を反映した型を構築することが可能です。
Haskellの場合でポイントとなっていたのは多相再帰でした。ではSwiftはどうなのかというと、多相再帰ができます。
例えば、次のような関数を考えます。この関数は
Nat
に適合する型p
に依存します。func doSomeCalculation<p: Nat>(_: p) { print("doSomeCalculation \(p.intValue)") print(IntMod<p>(3) + IntMod<p>(2)) }この関数を、実行時の値に基づいて呼び出してみましょう。つまり、次のような関数を定義します。
func doSomeCalculationWithDynamicVal(_ m: Int) { /* どうにかして doSomeCalculation(Succ<...Succ<Zero>...>())(Succがm回) みたいなことをしたい */ }勿体ぶっても仕方がないのでコードを貼ってしまいますが、次のように書くと
doSomeCalculation
を値レベルの引数に基づいて呼び出せます:func doSomeCalculationWithDynamicValRec<n: Nat>(_ acc: n, _ m: Int) { if m == 0 { doSomeCalculation(acc) } else { doSomeCalculationWithDynamicValRec(Succ<n>(), m-1) } } func doSomeCalculationWithDynamicVal(_ m: Int) { doSomeCalculationWithDynamicValRec(Zero(), m) }補助関数
doSomeCalculationWithDynamicValRec
で多相再帰を行っています。与えられた型n
に対して、Succ<n>
によって自分自身を呼び出しています。C++やRustでこういうことをするとコンパイラーに怒られますが、Swiftでは怒られません。この関数は、例えば次のように呼び出せます:
doSomeCalculationWithDynamicVal(3) let x = Calendar(identifier: .iso8601).component(.minute, from: Date()) doSomeCalculationWithDynamicVal(x)コンパイル時に決定しているリテラルだけではなく、実行時の日時のような動的な値に依存した計算もできることがわかります。
汎用的にする
型レベル自然数を使う処理ごとにさっきのような多相再帰を書くのは面倒です。
Haskellの場合はランク2多相があったので
reifyNat :: Integer -> (forall n. (IsNat n) => Proxy n -> a) -> a
みたいな関数を定義できましたが、Swiftにはランク2多相はなさそうです(あったら教えてください)。仕方ないので、多相なメソッドを持つプロトコルを定義して、それを呼び出す関数という形で多相再帰の部分を汎用的にします。
protocol NatDependentAction { associatedtype Result func invoke<n: Nat>(_: n) -> Result } func reifyNatRec<n: Nat, F: NatDependentAction>(_ acc: n, _ m: Int, _ f: F) -> F.Result { if m == 0 { return f.invoke(acc) } else { return reifyNatRec(Succ<n>(), m-1, f) } } func reifyNat<F: NatDependentAction>(_ m: Int, _ f: F) -> F.Result { reifyNatRec(Zero(), m, f) }この
NatDependentAction
とreifyNat
は、例えば次のような感じで使えます。struct SomeCalc: NatDependentAction { typealias Result = Void func invoke<n: Nat>(_: n) -> Void { print("SomeCalc \(n.intValue)") if n.intValue != 0 { print(IntMod<n>(3) + IntMod<n>(2)) } } } reifyNat(7, SomeCalc())C++の関数オブジェクトやJavaのSingle Abstract Methodを彷彿とさせるやり方になりました。
効率化する
現在の実装では自然数の値に応じた
Succ
型を使うため、例えば、法が 1000000007 だと 1000000007 回多相再帰してSucc
を 1000000007 回適用することになってしまいます。これは大変非効率的です。スタックオーバーフロー待ったなしです。実は、自然数の表し方を変えることで、再帰の深度を O(n) から O(log n) に落とすことができます。
struct Double<T: Nat> : Nat { static var intValue: Int { 2 * T.intValue } } struct DoublePlus1<T: Nat> : Nat { static var intValue: Int { 2 * T.intValue + 1 } } func reifyNat2Rec<n: Nat, F: NatDependentAction>(_ acc: n, _ b: Int, _ m: Int, _ f: F) -> F.Result { if b < 0 { return f.invoke(acc) } else if (m & (1 << b) == 0) { return reifyNat2Rec(Double<n>(), b-1, m, f) } else { return reifyNat2Rec(DoublePlus1<n>(), b-1, m, f) } } func reifyNat2<F: NatDependentAction>(_ m: Int, _ f: F) -> F.Result { reifyNat2Rec(Zero(), Int.bitWidth - m.leadingZeroBitCount - 1, m, f) }何をしたかというと、自然数の2進展開を使いました。この方法なら
reifyNat2(1000000007, SomeCalc())
も楽々表現できます。任意の値を表す
自然数以外の値にもこの方法を適用できると便利です。
まず、自然数の有限列は1個の自然数にエンコードできるので、この方法を使えそうです。(具体的な方法はここでは割愛します。数理論理学を勉強された方には、ゲーデル数とかでおなじみのやつです)
そして、いわゆるシリアライズという操作は、オブジェクトをバイト列で表すということをやっています。バイト列というのは自然数の列なので、シリアライズ可能なオブジェクトならこの方法を適用できることになります。
シリアライズが出来なさそうなオブジェクトの場合でも、希望はあります。コンピューターの64ビットのアドレス空間に乗ったオブジェクトであれば、64ビット整数で表せるのではないでしょうか?……そう、ポインターです。ポインターを整数値として受け渡してやれば、任意の値を型レベル自然数を通して受け渡しできそうです。
残念ながら筆者はSwiftには明るくないので、この辺は読者への課題とします。
- 投稿日:2019-12-03T22:25:26+09:00
Swift Network.framework Study 20191203
Study
Network.framework
Study:Client側環境
Client:Swift、Xcode
Server:Java、NetBeansClient Source Swift
import Foundation import Network func startConnection() { let myQueue = DispatchQueue(label: "ExampleNetwork") let connection = NWConnection(host: "localhost", port: 7777, using: NWParameters.tcp) connection.stateUpdateHandler = { (newState) in switch(newState) { case .ready: print("ready") sendMessage(connection) case .waiting(let error): print("waiting") print(error) case .failed(let error): print("failed") print(error) default: print("defaults") break } } connection.start(queue: myQueue) } func sendMessage(_ connection: NWConnection) { let data = "Example Send Data".data(using: .utf8) let completion = NWConnection.SendCompletion.contentProcessed { (error: NWError?) in print("送信完了") } connection.send(content: data, completion: completion) } startConnection() while true { sleep(1) }最後のループとか格好悪い。
Server Source Java
package example.java.network; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; public class ExampleServerSocket { public static void main(String[] args) { try { ServerSocket serverSocket = new ServerSocket(7777); System.out.println("socket create"); Socket socket = serverSocket.accept(); System.out.println("accept"); Scanner scanner = new Scanner(socket.getInputStream()); while(scanner.hasNext() == true) { System.out.println(scanner.nextLine()); } } catch(Exception e) { System.out.println(e); } } }久々でScannerのhasNext()条件を忘れている。確認して改良しないと。
- 投稿日:2019-12-03T20:54:52+09:00
App内課金をSANDBOXユーザーでテストする - AppStoreConnect編(2019年)
最近App内課金を実装したので、忘れないようにまとめています。
AppStoreConnectのフォーマットはちょくちょく変わってるので、ハマりそうな箇所を記事に書いてます。AppStoreConnectでやることは主に以下の3つです。
- 契約/税金/口座情報の登録
- マイAppでApp内課金の登録
- テストユーザーの登録
参考にしたサイト
- アプリ内課金の実装方法 - TERAKOYA https://terakoya.site/ios_dic/ios-dic-in-app-purchese/
- 契約/税金/口座情報の概要 - App Store Connect ヘルプ https://help.apple.com/app-store-connect/#/devb6df5ee51
- App内課金のプロモーション - App Store - Apple Developer https://developer.apple.com/jp/app-store/promoting-in-app-purchases/
契約/税金/口座情報の登録
銀行口座
、納税フォーム
、連絡先
の項目を入力していきます。▼契約/税金/口座情報 / AppStoreConnect
- APP内課金をSANDBOXユーザーで試すには
契約/税金/口座情報
の登録は必須
です!- ※最低でも
「銀行口座」
と「納税フォーム」
は入力しておかないとステータスがアクティブにならないため、(課金周りのテストができない)ので注意です。- 入力には
Admin
かFinance
以上の権限が必要です。 権限がない場合はAppStoreConnect上で上記のアイコンが表示されないで注意です。▼有料App / 契約/税金/口座情報
▼銀行口座
▼納税フォーム
▼連絡先
- 所属先に下記役割の方がいない場合は同一のユーザーでも大丈夫そうです。
▼契約
マイAppでApp内課金の登録
▼マイApp
▼新規Appの追加
- 新規Appの追加には
Admin
かAccountHolder
以上の権限が必要です。▼App内課金の追加
▼App内課金のタイプを選択
▼参照名と製品IDを入力
- 製品IDはコードで実装するProductIDと一致するものになります。
- 私は
アプリ名
.製品名
で登録しました。 ベストプラクティスがあれば教えてください。▼ローカリゼーション
- AppStoreに表示される名前になります。
▼App Store プロモーション(オプション)
- プロモーションを登録するとアプリのプロダクトページからApp内課金を表示や購入することができます。
▼WIP?♂️
さいごに(WIP)?♂️
WIP?♂️
コードで実装する編も書く予定です。?♂️
- 投稿日:2019-12-03T20:54:52+09:00
App内課金をSANDBOXユーザーでテストする - AppStoreConnect編(2019年版)
最近App内課金を実装したので、忘れないようにまとめています。
AppStoreConnectのフォーマットはちょくちょく変わってるので、ハマりそうな箇所を記事に書いてます。AppStoreConnectでやることは主に以下の3つです。
- 契約/税金/口座情報の登録
- マイAppでApp内課金の登録
- テストユーザーの登録
参考にした記事
- アプリ内課金の実装方法 - TERAKOYA https://terakoya.site/ios_dic/ios-dic-in-app-purchese/
- 契約/税金/口座情報の概要 - App Store Connect ヘルプ https://help.apple.com/app-store-connect/#/devb6df5ee51
- App内課金のプロモーション - App Store - Apple Developer https://developer.apple.com/jp/app-store/promoting-in-app-purchases/
- App Store ConnectでSandboxテスターを追加できないときはパスワードを見直す - Qiita https://qiita.com/pompopo/items/96466afe2e6d4288cee2
契約/税金/口座情報の登録
銀行口座
、納税フォーム
、連絡先
の項目を入力していきます。▼契約/税金/口座情報 / AppStoreConnect
- APP内課金をSANDBOXユーザーで試すには
契約/税金/口座情報
の登録は必須
です!- ※最低でも
「銀行口座」
と「納税フォーム」
は入力しておかないとステータスがアクティブにならないため、(課金周りのテストができない)ので注意です。- 入力には
Admin
かFinance
以上の権限が必要です。 権限がない場合はAppStoreConnect上で上記のアイコンが表示されないで注意です。▼有料App / 契約/税金/口座情報
▼銀行口座を入力
▼納税フォームを入力
▼連絡先を入力
- 所属先に下記役割の方がいない場合は同一のユーザーでも大丈夫そうです。
▼契約
マイAppでApp内課金の登録
▼マイApp
▼新規Appの追加
- 新規Appの追加には
Admin
かAccountHolder
以上の権限が必要です。▼App内課金の追加
▼App内課金のタイプを選択
▼参照名と製品IDを入力
- 製品IDはコードで実装するProductIDと一致するものになります。
- 私は
アプリ名
.製品名
で登録しました。 ベストプラクティスがあれば教えてください。▼ローカリゼーションを入力
- AppStoreに表示される名前になります。
▼App Store プロモーション(オプション)を入力
- プロモーションを登録するとアプリのプロダクトページからApp内課金を表示や購入することができます。
▼審査に関する情報を入力
- 製品のスクリーンショット or 購入ページのスクリーンショットを追加します。
テストユーザーの登録(WIP)?♂️
▼ユーザーとアクセス
▼SANDBOX テスターを追加
![]()
▼テスターアカウントを追加
- メールアドレスは
hoge@hoge.com
でも通ります。実際に作成しているhoge@gmail.com
でなくても大丈夫です。- 複数アカウントを作る際は連番で作成しました。
hoge1@hoge.com
、hoge2@hoge.com
など- パスワードは8文字以上+英数字+英字大文字+小文字が必要になります。
▼SANDBOXユーザーでログイン
- 設定>itunes StoreとApp Store>SANDBOXアカウントでサインインできます。
- もしくは購入ボタン押下時、ログインしていない場合は、ログインを要求するダイアログが出るので、ダイアログからサインインできます。
// 購入ボタンを押した時の購入処理 SKPaymentQueue.default().add(SKPayment(product: productID)WIP?♂️
コードで実装する編も書く予定です。?♂️
- 投稿日:2019-12-03T19:48:02+09:00
enumを利用して複数の識別子を受け付けるAPIを呼び出す
userIDやUUIDを同じパラメータとして扱い、サーバー側でよしなに判別するAPIを呼び出す時にどのように書けば良いかを考えていきます。
GET /v1/users/{user_identity}
このAPIのuser_identityは次のようにuserのidでもUUIDでも呼び出すことができます。
GET /v1/users/123456
GET /v1/users/AAAAA-BBBB-CCCCC-DDDD
このAPIを呼び出すために2つのメソッドを定義しました。
func fetchUser(byID id: Int) { request(url: "/v1/users/\(id)") } func fetchUser(byUUID uuid: String) { request(url: "/v1/users/\(uuid)") }内部の処理は同じなので、次のように一つのメソッドにまとめてみます。
func fetchUser(by id: Int? = nil, uuid: String? = nil) { if id == nil && uuid == nil { fatalError() } if id != nil && uuid != nil { fatalError() } if let id = id { request(url: "/v1/users/\(id)") } else if let uuid = uuid { request(url: "/v1/users/\(uuid)") } }
fetchUser
は、idかuuidを埋めれば適切にapiを呼ぶようになりました。
ただし、idとuuidはどちらか一つを与えるようにしなくてはfatalError
が発生します。fetchUser(uuid: userUUID) //OK fetchUser() // Crash fetchUser(by: userID, uuid: userUUID) // Crashあなたは実装者なのでこのパラメータの与え方のルールを知っていますが、他の開発者は中のコードを読むか、あなたが書いたコメントを読むか、はたまた間違えて呼んでクラッシュするのを確認しないと、気がつくことは出来ません。
不正な呼び出しをコンパイルの時点で気が付けない事も良くないですね。そこで、Swiftの強力なenumを利用します。
enum UserIdentity { case id(Int) case uuid(String) } func fetchUser(by identity: UserIdentity) { switch identity { case .id(let id): request(url: "/v1/users/\(id)") case .uuid(let uuid): request(url: "/v1/users/\(uuid)") } } fetchUser(by .uuid(userUUID))このようにenumで識別子を管理する事で、
fetchUser
を呼ぶには必ず任意の識別子を一つ指定する制約が生まれました。
間違えた呼び方をしてビルドすることはもう出来ません。
おまけ
今回のケースでは、APIのパスをenumが生成することで次のように書く事もできます。
enum UserIdentity { case id(Int) case uuid(String) } extension UserIdentity { var path: String { switch identity { case .id(let id): return "/v1/users/\(id)" case .uuid(let uuid): return "/v1/users/\(uuid)" } } } func fetchUser(by identity: UserIdentity) { request(url: identity.path) } fetchUser(by .uuid(userUUID))メソッドの内部実装をシンプルにする事で、可読性が上がり処理が追いやすくなります。
これが正解かはプロジェクトの状況に寄りますが、可読性の高いコードを追求してみましょう。
- 投稿日:2019-12-03T19:29:37+09:00
iOSでFirebaseを使用して、データベースに対してCRUDを試してみる。
Xcodeで新規プロジェクトを作成しよう
Create a new Xcode project -> SingleViewApp の順で新規プロジェクトを作成する。
UserInterfaceはStoryBoardにしておきましょう。Firebaseのプロジェクトを作成しよう
Firebaseに下記のURLからGoogleアカウントでログインします。
Firebaseログインできたら「プロジェクトを追加」ボタンでプロジェクトを追加しましょう。
Firebaseの設定をしよう
プロジェクトのコンソール画面からアプリを追加することができます。
今回は「iOS」を選択しましょう。
アプリのBundleIDを入力します。アプリ名は適当で良いです。
設定ファイルをダウンロードして、Xcodeプロジェクトに追加します。
その後プロジェクトフォルダまで移動して、ターミナルから、下記コマンドを打ちましょう。
pod initCocoaPodsをインストールしていない方は、インストールしてから行ってください。
こちらが参考になります。CocoaPodsを導入してみた - Qiita今回はRealTimeDatabase を使用するので、Podファイルに、以下の文言を追加しましょう。
pod 'Firebase/Core' pod 'Firebase/Database'ターミナルから、下記コマンドでSDKをインストールしましょう。
pod installインストールが完了したら、白いプロジェクトファイルが出来上がっていると思いますので、Xcodeで開きます。
AppDelegeteファイルに、画像のように追記します。![スクリーンショット 2019-12-03 15.22.18.png]
データベースを作成しよう
左のタブのDataBaseを選択し、RealTimeDatabaseを選択します。
今回はテストモードで開始しましょう。
これで準備完了です!
データベースに対して操作してみよう
データを追加してみよう
viewDidLoad に以下のように書いて実行します。
override func viewDidLoad() { super.viewDidLoad() let ref = Database.database().reference() // KeyValue型の配列を用意しておきます。 let user = ["name":"Tarou Yamada", "age":"20", "favorite":"jogging"] // データを追加します。idは自動で設定してくれます。 ref.child("Users").childByAutoId().setValue(user) }Firebaseのコンソール画面に移動すると、データが追加されているのが確認できます!
データを更新してみよう
先程のデータを更新します。
override func viewDidLoad() { super.viewDidLoad() let ref = Database.database().reference() // 先程のIDを指定します。(人によって変わるので、自分のDatabaseからコピペしてね) let id = "-Lv9asdz8vsqeZSZQdyh" // 先程のIDを指定してデータを上書きします。 ref.child("Users/\(id)/name").setValue("名無しの権兵衛") }データを削除してみよう
先程のデータを削除します。
override func viewDidLoad() { super.viewDidLoad() let ref = Database.database().reference() // 先程のIDを指定します。(人によって変わるので、自分のDatabaseからコピペしてね) let id = "-Lv9asdz8vsqeZSZQdyh" // 先程のIDを指定してデータを削除します。 ref.child("Users/\(id)").removeValue() }データを取得してみよう
サンプルデータを作ろう
FireBaseのコンソール画面で、データを直接編集することができます。
めんどくさい人は、下記のJSONをファイルにして、FireBaseのインポートしましょう。test.json{ "Users" : { "-Lv4cGsQbS3cXDLTErXS" : { "age" : "18", "favoriteFood" : "クッキー", "name" : "田中 花子" }, "-Lv8Lrf3ii9bB5p6rcIc" : { "age" : "22", "favoriteFood" : "牛丼", "name" : "山田 太郎" }, "-Lv8gSwpG-ZkEbPsbORu" : { "age" : "13", "favoriteFood" : "たこ焼き", "name" : "木村 正人" }, "-Lv8vAUr79kcpoygkpDl" : { "age" : "26", "favoriteFood" : "寿司", "name" : "川上 純子" } } }サンプルデータを取得してみよう
下記コードで、Xcodeのコンソールに取得結果が表示されます。
override func viewDidLoad() { super.viewDidLoad() let ref = Database.database().reference() // データの変更を監視(observe)してるため、変更されればリアルタイムで実行されます。 ref.child("Users").observe(.value) { (snapshot) in // Users直下のデータの数だけ繰り返す。 for data in snapshot.children { let snapData = data as! DataSnapshot // Dictionary型にキャスト let user = snapData.value as! [String: Any] print(user) } } }まとめ
環境の設定から、CRUDの基本的なところまでは押さえられたはず。 次はFirebaseを使って簡単なアプリを作ってみたい。
- 投稿日:2019-12-03T17:20:24+09:00
Swiftのプロパティを三つの分類で整理してみた
OSSのコードを読んでいたり、他の人の講演やLTを聞いていると、あれ?lazyってなんだったっけ?と、「プロパティについてちゃんと理解していないよな。。。」って思うことがよくあります。
今回はそんなプロパティについてまとめましたので、同じような悩みをもった方の参考となれば嬉しいです。対象とする読者
- プロパティについてなんとなく使えるけど、聞かれると困る人
- lazyってなんだっけ?コンピューテッドプロパティってなんだっけ?って人
目次
- プロパティの分類方法
- 分類①:定数と変数
- 分類②:インスタンスプロパティとスタティックプロパティ
- 分類③:ストアドプロパティとコンピューテッドプロパティ
検証環境
- Swift : 5.0.1
- Xcode : 11.0(Playground)
プロパティの分類方法
プロパティとは
型に紐づく値のこと。プロパティと言ったり、属性と言ったりする。
型はプロパティとメソッド(関数/ふるまい)で構成される。分類①:定数と変数
値の再代入が不可能な定数と、再代入が可能な変数での分け方。letとvar。
分類②:インスタンスプロパティとスタティックプロパティ
インスタンスに紐づくプロパティと型自身に紐づくプロパティでの分類。
インスタンスによらず、型共通の値をもたせたいときには、スタティックプロパティを用いる。
スタティックプロパティにはlet/varの前にstaticを付ける。分類③:ストアドプロパティとコンピューテッドプロパティ
値を保持するプロパティと値を都度計算するプロパティ
値を保持するストアドプロパティに対して、コンピューテッドプロパティは既存のプロパティを用いて都度用意されるため、計算もととの整合性が常に保たれるという特徴がある。分類①:定数と変数
プロパティは値の名前の前に必ずletかvarを付けて、プロパティが定数か変数かを宣言しなければならない。変数には値の再代入が可能だが、定数への値の再代入は不可能。
struct Message { let greeting = "Hello," // 定数 var name = "Everyone!" // 変数 } var message = Message() message.name = "@ichikawa7ss!" // 変数には再代入可能だが message.greeting = "Goodbye," // 定数には再代入不可能 -> コンパイルエラー分類②:インスタンスプロパティとスタティックプロパティ
インスタンスに紐づくプロパティと型自身に紐づくプロパティでの分類。
次のソースコードでの例のように、インスタンスプロパティではプロパティがインスタンスに紐づいているので、インスタンスごとにプロパティの設定が可能。
struct Message { let greeting = "Hello," // インスタンスプロパティ(定数) var name = "Everyone!" // インスタンスプロパティ(変数) } // Message構造体のインスタンスを生成 var message1 = Message() let name1 = message1.name // Everyone! // 別のMessage構造体のインスタンスを生成 var message2 = Message() // インスタンスプロパティに値を代入 message2.name = "@ichikawa7ss!" let name2 = message2.name // @ichikawa7ss!一方でスタティックプロパティでは、プロパティが型自身に紐づくため、インスタンスによらずプロパティの値は一定である。
また、呼び出し方法もインスタンスプロパティがインスタンス名.プロパティ名
であるのに対して、型に紐づくスタティックプロパティは型名.プロパティ名
で値の呼び出しを行う必要がある。struct Student { static let className = "3-A" // スタティックプロパティ static var teacherName = "Ms.Tanaka" // スタティックプロパティ var name = "Shoma Ichiakwa" // インスタンスプロパティ } // 一つ目のインスタンスを生成 var student1 = Student() let teacherName1 = Student.teacherName // Ms.Tanaka (スタティックプロパティ) let name1 = student1.name // Shoma Ichiakwa // 別のインスタンスを生成 var student2 = Student() // インスタンスプロパティ(インスタンスに紐づくプロパティ)に値を代入 student2.name = "Taro Yamada" // スタティックプロパティも変数であれば変更可能 // スタティックプロパティは'型.プロパティ名'で値を呼び出す Student.teacherName = "Ms.Suzuki" let teacherName2 = Student.teacherName // Ms.Suzuki (スタティックプロパティ) let name2 = student2.name // Taro Yamada Student.className = "3-B" // 定数は再代入不可能 -> コンパイルエラーletとstaticは最初混同するかもしれないが、staticは型に共通の変数を持たせるだけなので、値の変更は可能。
分類③:ストアドプロパティとコンピューテッドプロパティ
ストアドプロパティ
これまで見てきたプロパティはインスタンスや型に値を保持することができた。これをストアドプロパティという。一方で値を保持せずにアクセスするたびに値を計算するプロパティをコンピューテッドプロパティと呼ぶ。
まずはストアドプロパティに特有なプロパティオブザーバーいう機能とレイジーストアドプロパティについて確認する。プロパティオブザーバー
ストアドプロパティにはプロパティオブザーバーという、値の変更を監視する処理を定義することができる。(コンピューテッドプロパティは変更前の値を保持しないので、変更の監視という考え方自体が存在しない。)
以下は、インスタンス化したStudentのnameプロパティに値が更新された際に、オブザーバープロパティが機能している例。struct Student { static let className = "3-A" // プロパティオブサーバーの用いてプロパティの変更を監視 var name = "Shoma Ichiakwa" { // ストアドプロパティ // 値変更の"直前"に実行 willSet { // 代入された値は'newValue'として使用可能 print("**Student name will chenge to \(newValue) from \(name)**") } // 値変更の"直後"に実行 didSet { // didSetの場合は代入された値はプロパティを指定して呼び出し print("**Student name had chenged to \(name)**") } } } // Student構造体のインスタンスを作成 var student = Student() print("Created Instance: \(student)") student.name = "Taro Yamada" print("Student name: \(student.name)")この結果は以下の通り。
【実行結果】Created Instance: Student(name: "Shoma Ichiakwa") **Student name will chenge to Taro Yamada from Shoma Ichiakwa** **Student name had chenged to Taro Yamada** Student name: Taro Yamadaインスタンス生成後、studentのnameプロパティに値の変更を行うとwillSet->didSetの順に呼び出される。
レイジーストアドプロパティ
var
の前にlazy
をつけることで、レイジーストアドプロパティを宣言することができる。
通常のプロパティはインスタンスが生成され初期化されるときに値が取得されるのに対して、レイジーストアドプロパティではインスタンス生成後、実際にプロパティへのアクセスが行われるまで値の取得を遅延できる。
これにより初期化コストの高いプロパティの初期化を遅延させることができるため、アプリケーションのパフォーマンス向上に有効。
ちなみにコンピューテッドプロパティでは、そもそも毎回アクセスのたびに値が取得されるため、アクセスを遅延させる必要がない。struct SomeStruct { // ストアドプロパティ var value : Int = { print("** Accessed and set 'value' **") return 1 }() // レイジーストアドプロパティ lazy var lazyValue: Int = { print("** Accessed and set 'lazyValue' **") return 2 }() } var someStruct = SomeStruct() print("Instance Created") print("value is \(someStruct.value)") print("lazyValue is \(someStruct.lazyValue)")この出力結果は以下の通り。
【実行結果】** Accessed and set 'value' ** Instance Created value is 1 ** Accessed and set 'lazyValue' ** lazyValue is 2インスタンス作成時には
lazyValue
へのアクセスは遅延されており、値をプリントするときに初めてlazyValue
へのアクセスが行われている。また以下の例のように、レイジーストアドプロパティはインスタンス生成後にアクセスされるため、他のストアドプロパティを使用することができる。
struct SomeStruct { // ストアドプロパティ var value = 1 // ストアドプロパティ var notLazyDoubleValue: Int = { return value * 2 // 通常のストアドプロパティは初期化時に他のプロパティを使うことができない -> コンパイルエラー }() // レイジーストアドプロパティ lazy var lazytripledValue: Int = { return value * 3 // レイジーストアドプロパティはインスタンス生成後にアクセスされるため、他のプロパティを使用可能 }() }コンピューテッドプロパティ
コンピューテッドプロパティはすでにあるプロパティを用いて、アクセスするたびに計算を行うため、計算元のプロパティと常に値の整合性が取れることが特徴。
コンピューテッドプロパティには値を呼び出すときに、他のプロパティの値を用いて値を返してくれるゲッタと、プロパティに値が代入されたときに、他のプロパティを更新してくれるセッタが存在する。ゲッタ
ゲッタではプロパティにアクセスしたときに他のプロパティを用いて、値を返却してくれる。以下の例では
message.signature
にアクセスするたびにgetの処理が走り、to
プロパティを用いて値を計算する。struct Message { var to : String // コンピューテッドプロパティ var signature : String { // ゲッタを使用して、他のプロパティを用いた値の取得が可能 get { return "Dear,\(String(describing: to))" } } init(to: String) { self.to = to } } let message = Message(to: "Ms.Tanaka") message.signature // Dear,Ms.Tanakaセッタ
セッタを定義されたプロパティは、プロパティに値が代入されると、
set
のスコープ部に書かれた処理を実行する。これにより他のプロパティの更新処理ができるため、ゲッタと併用することで、相関のある二つのプロパティ間での整合性を記述することが可能。
以下の処理ではkmとmという二つの距離単位をゲッタとセッタを用いることで二変数間の整合性を保っている。struct Distance { var kirometers : Int = 0 // コンピューテッドプロパティ var meters : Int { // ゲッタを使用して、他のプロパティを用いた値の取得が可能 get { return kirometers * 1000 } // セッタを使用して、他のプロパティを更新することが可能 // セットされた値を変数として命名して利用可能 set(inputMeters) { kirometers = inputMeters / 1000 } } } var distanceStations = Distance() distanceStations.kirometers = 2 distanceStations.meters // 2000 var distanceSchoolAndHome = Distance() distanceSchoolAndHome.meters = 5000 distanceSchoolAndHome.kirometers // 5
distanceSchoolAndHome.meters = 5000
のようにと値を格納するので、直観的にはプロパティが値を保持しているように思えるが、実際にはset
キーワード定義された処理を行なっているだけで、値は保持されていない。
kirometers
に値が代入されようと、meters
に値が代入されようと、結果的に値の更新・保持がなされるのはストアドプロパティであるkirometers
である。meters
の値を取得するときにはget
内の処理により、kirometers
を通して正しいmeters
の値取得を行なっている。まとめ
プロパティ、ややこしくて今まで避けてきましたが、Swiftの基礎なだけに一度整理することで様々な領域の理解に繋がると感じました。三つの分類に分けて考えると、役割の明確化なプロパティの宣言ができるようになりそうです。
参考
[改訂新版]Swift実践入門 ── 直感的な文法と安全性を兼ね備えた言語 (WEB+DB PRESS plus)
(いつも大変お世話になっております?♂️)
- 投稿日:2019-12-03T13:43:05+09:00
ARKITで顔認識(ARKIT2.0のFaceTrackingが使えないiphone7にて。)
swift初心者です。(というかプログラム初心者。)
いろいろ言語を学んでいるところなので、swiftの勉強記録ということで後で見返せるように投稿しておこうと思います。ARで顔認識するアプリ作ってみたいと思ったのが発端で、調べたところ、iphone X以上なら以下の機能でAR用の顔認識が使えることがわかりました。
Tracking and Visualizing Faces
でも自分はiphone7と8しか持ってないので、iphone7でも使える2次元用の顔認識のVISIONを組み合わせて実現してみることにしました。作ったもの
「iphoneのカメラを人物に向けると、人物の頭上に球体が表示され、その球体をタッチするとその人物にARサングラスをかける」
コード
import UIKit import SceneKit import ARKit import Vision class ViewController: UIViewController, ARSCNViewDelegate { var condition = "OFF" var originalFrame:CVPixelBuffer? var finalImage:CIImage? var results:[VNFaceObservation]? var faceHeight:CGFloat? var midiumX:CGFloat? var midiumY:CGFloat? var screenPosition:SCNVector3? let screenWidth: CGFloat = UIScreen.main.bounds.size.width let screenHeight: CGFloat = UIScreen.main.bounds.size.height @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // Set the view's delegate sceneView.delegate = self // Show statistics such as fps and timing information sceneView.showsStatistics = true // Create a new scene let scene = SCNScene() let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped)) self.sceneView.addGestureRecognizer(tapRecognizer) // Set the scene to the view sceneView.scene = scene } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Create a session configuration let configuration = ARWorldTrackingConfiguration() // Run the view's session sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // Pause the view's session sceneView.session.pause() } // MARK: - ARSCNViewDelegate func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { guard let currentFrame = self.sceneView.session.currentFrame?.capturedImage else { return } originalFrame = currentFrame convertImageForFaceDetection() faceDetection() } @objc func tapped(recognizer: UITapGestureRecognizer) { let sceneView = recognizer.view as! SCNView let location = recognizer.location(in: sceneView) let hitResults = sceneView.hitTest(location, options: [:]) if !hitResults.isEmpty{ let node = hitResults[0].node let material = node.geometry?.material(named: "Color") if condition == "OFF"{ condition = "ON" material?.diffuse.contents = UIColor.yellow }else{ condition = "OFF" material?.diffuse.contents = UIColor.white } } } func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } } extension ViewController { func convertImageForFaceDetection() { //Pixelbuffer doesn't have rotate information and default angle is landscape. Need to convert to CIImage to fix these issues. let ciImage: CIImage = CIImage(cvPixelBuffer: originalFrame!).oriented(.right) let imageWidth = ciImage.extent.width let imageHeight = ciImage.extent.height //Original pixelbuffer size is 1920 * 1440. iphone7 bounds size iws 667* 350. which means aspect ratio is different. So adjusting the pixelbuffer size to iphone7 bounds size. let adjustSize = imageHeight / screenHeight let adjustedImageWidth = screenWidth * adjustSize let croppedPosition = floor((imageWidth - adjustedImageWidth) / 2) let finalSize = CGRect(x:croppedPosition, y:0 ,width:adjustedImageWidth, height:imageHeight) finalImage = ciImage.cropped(to: finalSize) } func faceDetection(){ let faceDetectionRequest = VNDetectFaceRectanglesRequest() let faceDetectionHandler = VNSequenceRequestHandler() try? faceDetectionHandler.perform([faceDetectionRequest], on: finalImage!) guard let detectResults = faceDetectionRequest.results as? [VNFaceObservation] else { return } results = detectResults if results!.isEmpty { return } else { addingARSphere() //Touching existing sphere turns "condition" to "ON". then sunglass will appeared. Details of "condition" is in "func tapped". if condition == "ON" { addingARSunglasses() } else { //Touching existing sphere again turns "condition" to "OFF". then sunglass will disappeared. self.sceneView.scene.rootNode.enumerateChildNodes {(nodes, _) in if nodes.name == "model"{ nodes.removeFromParentNode() } } } } } func addingARSphere(){ //Delete previous position's sphere before making new one. self.sceneView.scene.rootNode.enumerateChildNodes {(nodes, _) in if nodes.name == "normalSphere"{ nodes.removeFromParentNode() } } //Sphere attribute. let sphereNode = SCNNode() faceHeight = floor(results![0].boundingBox.height * 1000)/1000 let sphereSize = faceHeight! / 5 sphereNode.geometry = SCNSphere(radius: sphereSize) sphereNode.name = "normalSphere" let sphereMaterial = SCNMaterial() sphereMaterial.name = "Color" if condition == "ON"{ sphereMaterial.diffuse.contents = UIColor.yellow } else { sphereMaterial.diffuse.contents = UIColor.white } sphereNode.geometry?.materials = [sphereMaterial] //Getting camera position. guard let cameraNode = self.sceneView.pointOfView else { return } let toCameraPosition = SCNVector3(x: 0, y: 0, z: -0.3) let objectPosInWorld = cameraNode.convertPosition(toCameraPosition, to:nil) screenPosition = self.sceneView.projectPoint(objectPosInWorld) //The result of VNfaceobservationresult is ratio. so multiply bounds size. And result (0,0) is upper left. but iphone UI (0,0) is below left. y needs to be converted. midiumX = floor(results![0].boundingBox.midX * 1000)/1000 midiumY = floor(1) - floor(results![0].boundingBox.midY * 1000)/1000 screenPosition!.x = Float(screenWidth * midiumX!) screenPosition!.y = Float(screenHeight * midiumY!) - Float(screenHeight * faceHeight! * 2) //Converting 2D position to 3D position. let worldPosition = self.sceneView.unprojectPoint(screenPosition!) sphereNode.position = worldPosition self.sceneView.scene.rootNode.addChildNode(sphereNode) } func addingARSunglasses(){ //No DispatchQueue makes warning when we use SCNscene in renederer. Btw actually it works without DispatchQueue. just for deleting warning. DispatchQueue.main.async{ self.sceneView.scene.rootNode.enumerateChildNodes {(nodes, _) in if nodes.name == "model"{ nodes.removeFromParentNode() } } let eyeScene = SCNScene(named: "sunglasses.scn") let sunglassesNode = eyeScene!.rootNode.childNode(withName: "model", recursively: true) self.screenPosition!.y = Float(self.screenHeight * self.midiumY! * 0.95) let worldPosition2 = self.sceneView.unprojectPoint(self.screenPosition!) sunglassesNode?.position = worldPosition2 let eyeSize = self.faceHeight! * 0.2 sunglassesNode?.scale = SCNVector3(eyeSize, eyeSize , eyeSize) self.sceneView.scene.rootNode.addChildNode(sunglassesNode!) } } }苦労した点と解決策
顔認識の位置が変な位置になっている時に何がおかしいのかわからず何度もprintするなどして苦労して気づいた点と解決策が以下。
・VISIONから返って来る座標(左下が(0,0))がスクリーン座標(左上が(0,0))と違う。
→y座標は反転させて処理する。・VISIONから返って来る座標は相対位置(1が最大)
→画面サイズを掛け算して、絶対位置に変換する必要がある。・スクリーン座標は0から1の相対位置ではなく絶対位置。
→スクリーン座標からワールド座標への変換(unprojectpoint)のときには絶対位置を用いる。・ARSCNで取得したバッファーサイズは1920*1440なので、iphone7の画面サイズの比率(667*335)と違うため、顔認識の位置が微妙にずれる。ちなみにこれが一番意味不明な仕様だった。AVCaptureの方でバッファー取得したら1920*1080だった(iphone7の画面比率と同じ)のに。
→取得したバッファーの端を切り取る等して比率をiphone側に合わせる。・ARSCNで取得したバッファーイメージは回転情報を持たないので、常に横長の画像になってる。
→縦にするには、回転情報を持ち、かつVISIONの対応フォーマットであるciImageにする。・renderer内でSCNSceneをinitするとコンソール上でワーニングが出る。UIview関連をrenderer内でいじるのはお作法違反?らしい?
→dispatchqueueを使うのが対策らしいので使ってワーニングが出ないようにした。が、dispatchqueue使わずワーニングは出たとしてもアプリ自体は動く上にARモデルの位置変更もdispatchqueueなしの方が素早く行われる。ここは結局、なぜそういうお作法なのかよくわからなかった。・3DModelをダウンロードする時に、scnがなかった。
→objファイルをダウンロードし、blenderでdaeにした後、xcodeでscnにした。(usdzならダウンロード可能であったが、usdzでいざAR表示してみると、3Dmodelが原型をギリギリ留めているレベルでめちゃくちゃ汚くなるので、scnで実装する方法にした。)・3DModelのカメラの位置が前後逆?となっており、ARとして表示する時にサングラスが前後逆に表示されてしまった。
→ライト位置とカメラ位置をxcode上でも修正できる。・scnシーンにあるARモデルをノードとして取り出す手段がよくわからず、何度も「nilなのでエラー」と言われ苦戦した。
→xcodeでscnファイルを選択して、3Dmodelを構成しているコンポーネントをまとめた上で名前をつける。その後、その名前をノードとして取り出すようにすると解決した。参考文献
ARアプリの作り方として、
・【6日で速習】iOS 13アプリ開発入門決定版 20個のアプリを作って学ぼう(Xcode 11, Swift 5対応中)VISIONの使い方として、
・[iOS 11] 画像解析フレームワークVisionで顔認識を試した結果
・VNFaceObservationピクセルサングラスの3Dモデルは、
・Pixel Sunglasses3Dモデルをいじるやり方として、
・ARKit を使って 3D モデルを AR で表示してみた【その他感想】
完成までに2~3週間くらいかかった。
各変数の中身を何度もprintしたり公式ドキュメントや似たことをやってる別の方のブログを見たりして解決したけど、かなり大変だった。
公式ドキュメントがもうちょっと充実してても良いと思うんだけどなあ。せめて例くらい載せてほしい。xcodeは超便利だと思った。型の不一致や宣言ミスなどの凡ミスがあった場合に、説明付きで指摘してくれる+修正候補をくれるところが。
そして、作り終えてから思ったけど、あんまりAR感がない・・・。
あと、新しい機能って、ユーザー側に対応端末が普及してないと宝の持ち腐れになるなーと思いました。
- 投稿日:2019-12-03T13:34:38+09:00
【Xcode】複数プロジェクトでのソースの共通化
複数のプロジェクトでソースを共通化する
Xcodeは英語だらけなのでシェアしておく
共通化とは
ここでいう共通化は参照のことです。
元となるAというプロジェクトのソースを、他のプロジェクトBから参照する方法です。やり方
- 参照先のプロジェクトを開く
- ProjectNavigatorに参照元のプロジェクトから参照したいファイルをドラッグ&ドロップ
- コピーするか参照するかを選択するダイアログがでるので「Create folder reference」にチェックをいれてfinishを押す
- これで参照の設定が完了です。
ソースをいじった場合に、参照されているすべてのプロジェクトで変更がかかるようになります。
プロジェクトを複製してコピーアプリを作成する場合などに使いましょう。
- 投稿日:2019-12-03T12:09:52+09:00
iOSで開発/本番環境を分けてFirebaseを運用するTips
iOS Advent Calendar 2019の4日目の@shtnkgmです!
本記事ではiOSで開発/本番環境を分けてFirebaseを利用するTipsを紹介します。
環境を分けて利用したいユースケースは以下のようなものが挙げられます。
開発/本番環境によって運用者を分けたい
例)開発環境と本番環境を利用するチームが異なる開発環境を本番環境と区別することで本番環境に影響なく開発をしやすくしたい
例)テスト時に開発環境へのPush通知を間違って本番環境に送らないようにしたい本番環境の計測数値に開発時のものが含まれないようにしたい
例)AnalyticsやPerformance Monitoring, Crashlyticsの分析精度を高めたい実現方法
Firebaseで開発/リリース環境を分ける
まずはFirebase側の環境を分ける必要があります。Firebase側の環境を分けるには、以下のいずれかの方法で実現できます。
- Firebaseプロジェクトを環境毎に複数作成する
- 同プロジェクト内にアプリを環境毎に複数作成する
プロジェクト単位で分割した場合はユーザー権限設定も個別に行えますが、アナリティクスは同時にまとめて見られなくなるなどの違いがあるので、運用方法を想定して決めると良いかと思います。
GoogleService-Info.plistを環境毎に分ける
通常の設定ではセットアップ時にFirebase Consoleから
GoogleService-Info.plist
という構成ファイルをダウンロードし、それがデフォルトで読み込まれます。環境毎に構成ファイルは異なるため、構成ファイルを切り替える必要があり、以下の2種類の方法があります。
- 環境毎のGoogleService-Info.plistを別々のディレクトリに配置し、Target MembershipによってTargetで切り替える
- 環境毎のGoogleService-Info.plistを別々のファイル名にする
Targetが環境によって別れていれば前者でも問題ありませんが、後者のファイル名を区別する方法が分かりやすいためオススメです。
別々のファイル名にする例)
- GoogleService-Info-dev.plist(開発環境)
- GoogleService-Info-qa.plist(QA環境)
- GoogleService-Info-release.plist(本番環境)
注意
ファイル名で区別する場合は、デフォルトの
GoogleService-Info.plist
というファイル名は利用しないほうが良さそうです。
公式ドキュメントのFirebase / アナリティクスのレポートの信頼性を確保するにアナリティクスが失われる可能性があると記述があります。環境毎に構成ファイルを指定する
環境毎にTargetが分かれている場合は、Build Settingsの
Preprocessor Macros
のマクロで以下のように構成ファイル名を分岐させると良いです。let configFileName: String #if DEBUG configFileName = "GoogleService-Info-dev" #elseif QA configFileName = "GoogleService-Info-qa" #else configFileName = "GoogleService-Info-release" #endif構成ファイルは以下のように
configure(options:)
メソッドで指定できます。guard let filePath = Bundle.main.path(forResource: "Firebase/\(configFileName)", ofType: "plist"), let options = FirebaseOptions(contentsOfFile: filePath) else { fatalError("Firebase plist file is not found.") } FirebaseApp.configure(options: options)CrashlyticsへのdSYMアップロード
クラッシュ分析用にFirebase Crashlyticsを利用している場合、シンボルファイル(dSYM)をアップロードする必要があります。
アップロード処理はFastlaneで自動化すると便利です。Fastlaneではupload_symbols_to_crashlyticsというアクションが利用できます。
以下のように、アクションのパラメータに構成ファイルのパスやdSYMファイルの保存先を指定します。upload_symbols_to_crashlytics( plistName = configuration == "Release" ? "GoogleService-Info-release.plist" : "GoogleService-Info-qa.plist" gsp_path: "./YourApp/Path/#{plistName}", dsym_path: ENV['DSYM_PATH'] )
gsp_path
には環境毎の構成ファイルのパスを指定します。dsym_path
にはCI環境でビルドした際のdSYMファイルの保存パスを指定します。利用しているCIサービスによっては環境変数として与えられていることもあります。おわりに
本記事ではiOSで開発/本番環境を分けてFirebaseを運用するTipsについて紹介しました。
Firebaseの環境を複数に分けると、設定を環境毎に柔軟に行える反面、同じ設定をFirebase Consoleで重複して行う必要があるので、手間を減らすいい方法がないかなと思っています。
本記事と関連する内容で何かアドバイスやご意見があればコメントに記載いただけると嬉しいです。以上iOS Advent Calendar 2019の4日目の記事でした。
参考
- 投稿日:2019-12-03T09:37:50+09:00
SwiftUIでNukeを使ってリモートの画像を表示する(Nuke)
はじめに
SwiftUIを使い始めました。全てSwiftで宣言的にLayoutを記述でき、ReactiveXを使わなくてもデータをバインディングできて、すごくいいなと感じています!
さて、アプリを作るときは多くの場合画像ライブラリを使って画像を表示すると思います。私はNukeを使っているので、ここでは、Nukeを使ってリモートの画像を表示するSwiftUIのCustomViewを作成する方法を説明します。(Nuke以外も同じようにできると思います。)
CustomView
RemoteImageView.swiftimport SwiftUI struct RemoteImageView: View { @ObservedObject var remoteImage: RemoteImage var body: some View { Image(uiImage: remoteImage.image) .resizable() .aspectRatio(contentMode: .fit) } } struct RemoteImageView_Previews: PreviewProvider { static var previews: some View { RemoteImageView(remoteImage: RemoteImage(url: URL(string: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/JRE-E231-500-for-JRyamanote-line.jpg/2560px-JRE-E231-500-for-JRyamanote-line.jpg")!)) } }ObservableObject
ここで画像を読み込みます。
image
が変わるたびに変更がpublishされ、上のCustomViewに反映されます。RemoteImage.swiftimport Foundation import SwiftUI import Nuke class RemoteImage: ObservableObject { @Published var image: UIImage private let url: URL init(url: URL, placeholder: UIImage = UIImage()) { self.image = placeholder self.url = url loadImage() } private func loadImage() { ImagePipeline.shared.loadImage(with: url) { [weak self] result in guard let self = self else { return } switch result { case .success(let response): self.image = response.image case .failure(let error): print(error.localizedDescription) } } } }使い方
SwiftUIのプロジェクトを作成すると自動生成される
ContentView
で、RemoteImageView
を利用します。ContentView.swiftimport SwiftUI struct ContentView: View { var body: some View { RemoteImageView(remoteImage: RemoteImage(url: URL(string: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/JRE-E231-500-for-JRyamanote-line.jpg/2560px-JRE-E231-500-for-JRyamanote-line.jpg")!)) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }表示はこんな感じです。山手線です。
以上です。
- 投稿日:2019-12-03T09:37:50+09:00
SwiftUIでNukeを使ってリモートの画像を表示する
はじめに
SwiftUIを使い始めました。全てSwiftで宣言的にLayoutを記述でき、ReactiveXを使わなくてもデータをバインディングできて、すごくいいなと感じています!
さて、アプリを作るときは多くの場合画像ライブラリを使って画像を表示すると思います。私はNukeを使っているので、ここでは、Nukeを使ってリモートの画像を表示するSwiftUIのCustomViewを作成する方法を説明します。(Nuke以外も同じようにできると思います。)
CustomView
RemoteImageView.swiftimport SwiftUI struct RemoteImageView: View { @ObservedObject var remoteImage: RemoteImage var body: some View { Image(uiImage: remoteImage.image) .resizable() .aspectRatio(contentMode: .fit) } } struct RemoteImageView_Previews: PreviewProvider { static var previews: some View { RemoteImageView(remoteImage: RemoteImage(url: URL(string: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/JRE-E231-500-for-JRyamanote-line.jpg/2560px-JRE-E231-500-for-JRyamanote-line.jpg")!)) } }ObservableObject
ここで画像を読み込みます。
image
が変わるたびに変更がpublishされ、上のCustomViewに反映されます。RemoteImage.swiftimport Foundation import SwiftUI import Nuke class RemoteImage: ObservableObject { @Published var image: UIImage private let url: URL init(url: URL, placeholder: UIImage = UIImage()) { self.image = placeholder self.url = url loadImage() } private func loadImage() { ImagePipeline.shared.loadImage(with: url) { [weak self] result in guard let self = self else { return } switch result { case .success(let response): self.image = response.image case .failure(let error): print(error.localizedDescription) } } } }使い方
SwiftUIのプロジェクトを作成すると自動生成される
ContentView
で、RemoteImageView
を利用します。ContentView.swiftimport SwiftUI struct ContentView: View { var body: some View { RemoteImageView(remoteImage: RemoteImage(url: URL(string: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/JRE-E231-500-for-JRyamanote-line.jpg/2560px-JRE-E231-500-for-JRyamanote-line.jpg")!)) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }表示はこんな感じです。山手線です。
以上です。
- 投稿日:2019-12-03T09:01:02+09:00
SwiftUI Christmas?
今年もクリスマスが近いてきました。
昨年はCore Animationを使ってクリスマスツリーを作成してみました。
https://qiita.com/shiz/items/10cb712a26620f2e3bdcそこで
今年はSwiftUIを使ってクリスマスツリーを作成したいと思います。SwiftUIの要素はたくさん使用していますが
drawingGroup
に注目したいと思います。Shapeに適合させツリーのパーツを作成する
まずツリーに必要なパーツを作っていきます。
図形をShapeプロトコルに適合させたstructとして定義して
それを組み合わせてViewを構築します。星
こちらは下記のサイトを参照させて頂きました。
https://www.hackingwithswift.com/quick-start/swiftui/how-to-draw-polygons-and-stars
path
メソッドの中でPathクラスを生成します。
Pathの指定はUIBezierPathと似たような形で設定できます。
コード
struct Star: Shape { let corners: Int let smoothness: CGFloat func path(in rect: CGRect) -> Path { guard corners >= 2 else { return Path() } let center = CGPoint(x: rect.width / 2, y: rect.height / 2) var currentAngle = -CGFloat.pi / 2 let angleAdjustment = .pi * 2 / CGFloat(corners * 2) let innerX = center.x * smoothness let innerY = center.y * smoothness var path = Path() path.move(to: CGPoint(x: center.x * cos(currentAngle), y: center.y * sin(currentAngle))) var bottomEdge: CGFloat = 0 for corner in 0..<corners * 2 { let sinAngle = sin(currentAngle) let cosAngle = cos(currentAngle) let bottom: CGFloat if corner.isMultiple(of: 2) { bottom = center.y * sinAngle path.addLine(to: CGPoint(x: center.x * cosAngle, y: bottom)) } else { bottom = innerY * sinAngle path.addLine(to: CGPoint(x: innerX * cosAngle, y: bottom)) } if bottom > bottomEdge { bottomEdge = bottom } currentAngle += angleAdjustment } let unusedSpace = (rect.height / 2 - bottomEdge) / 2 let transform = CGAffineTransform(translationX: center.x, y: center.y + unusedSpace) return path.applying(transform) } }
木
次に木の部分です。
木の形の部分
まずは緑の部分の三角形Triangleを定義します。
コード
struct Triangle: Shape { func path(in rect: CGRect) -> Path { Path { path in let middle = rect.midX let width: CGFloat = rect.size.width let height = rect.height path.move(to: CGPoint(x: middle, y: 0)) path.addLine(to: CGPoint(x: middle + (width / 2), y: height)) path.addLine(to: CGPoint(x: middle - (width / 2), y: height)) path.addLine(to: CGPoint(x: middle, y: 0)) } } }
白い線状の飾り
木の上にある飾りを作成します。
今回は
addQuadCurve
を使用して
ちょっと曲線にしています。https://developer.apple.com/documentation/swiftui/path/3271274-addquadcurve
コード
struct Slope: Shape { func path(in rect: CGRect) -> Path { Path { path in path.move(to: CGPoint(x: rect.minX, y: rect.midY)) path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY), control: CGPoint(x: rect.midX * 0.8, y: rect.midY * 0.8)) } } }
球状の飾り
次に球状の飾りを作ります。
これはCircle
に
Gradient
とLinierGradient
を使って
色をグラデーションしています。https://developer.apple.com/documentation/swiftui/gradient
https://developer.apple.com/documentation/swiftui/lineargradient
Gradient
の初期化時にグラデーションさせたい色を指定します。
変化させる位置を直接指定することもできますが
指定しない場合は
フレームワークで自動で調整してくれるようです。
LinearGradient
は
Gradient
と開始と終了位置を指定します。
コード
struct BallView: View { let gradientColors = Gradient(colors: [Color.pink, Color.purple]) var body: some View { let linearGradient = LinearGradient( gradient: gradientColors, startPoint: .top, endPoint: .bottom) return Circle() .fill(linearGradient) } }
そしてこれを複数組み合わせてViewを作ります。
GeometryReader
を使って
Viewの中に規則的にBallView
を配置しています。https://developer.apple.com/documentation/swiftui/geometryreader
三角形をはみ出さないように
mask
を使用してBallViewを描画する範囲を限定しています。
https://developer.apple.com/documentation/swiftui/view/3278595-mask
コード
struct BallsSlopeView<Mask: View>: View { let drawArea: Mask var body: some View { GeometryReader { gr in ForEach(1...10, id: \.self) { index in BallView() .position( self.getPosition(at: index, midX: gr.frame(in: .local).maxX, midY: gr.frame(in: .local).maxY)) .frame(height: 20) } } .mask(drawArea) } private func getPosition(at index: Int, midX: CGFloat, midY: CGFloat) -> CGPoint{ let x = midX * CGFloat(1 - CGFloat(index) * 0.1) let y = midY * CGFloat(1 - CGFloat(index) * 0.05) return CGPoint(x: x, y: y) } }
組み合わせる
最後に上記で作った部品を組み合わせます。
すべてを
ZStack
でグループにして重ねます。
白い飾りの部分では
rotationEffect
を活用することで
少し回転させて木にかかっているようにしています。https://developer.apple.com/documentation/swiftui/scaledshape/3273943-rotationeffect
コード
struct TreeView: View { var body: some View { GeometryReader { gr in ZStack(alignment: .center) { Triangle() .foregroundColor(Color.green) Slope() .stroke(lineWidth: 20) .mask(Triangle()) .foregroundColor(Color.white) Slope() .stroke(lineWidth: 20) .rotationEffect(Angle.degrees(300)) .mask(Triangle()) .foregroundColor(Color.white) BallsSlopeView(drawArea: Triangle()) } } } }
土台
次に土台の部分を作ります。
Rectangle
の中に白い線のShape
を載せます。白い線は
Shape
で作成します。
コード
struct FoundationLine: Shape { func path(in rect: CGRect) -> Path { Path { path in path.move(to: CGPoint(x: 0, y: rect.maxY / 3)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY / 3)) path.move(to: CGPoint(x: 0, y: rect.maxY * 2 / 3)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY * 2 / 3)) path.move(to: CGPoint(x: rect.width / 3, y: 0)) path.addLine(to: CGPoint(x: rect.width / 3, y: rect.maxY)) path.move(to: CGPoint(x: rect.width * 2 / 3, y: 0)) path.addLine(to: CGPoint(x: rect.width * 2 / 3, y: rect.maxY)) } } }
上記で作った白い線と
Rectangle
をZStack
でグループにします。
コード
struct FoundationView: View { var body: some View { GeometryReader { gr in ZStack { Rectangle() .foregroundColor(Color.red) FoundationLine() .stroke(lineWidth: 3) .foregroundColor(Color.white) .mask(Rectangle()) } .frame(width: gr.size.width / 3, height: gr.size.width / 3) } } }
クリスマスツリーを組み立てる
ではこれまで作ったものを組み合わせます。
星と個々の木が少しづつ重なるように位置の調整をしています。また
そのままですと
木の三角の重なり方が
逆になってしまう(上の頂点の部分が上に重なって見える)ため
zIndex
で重なり方を変更しています。
https://developer.apple.com/documentation/swiftui/view/3278679-zindex
コード
struct ChristmasTree: View { var body: some View { GeometryReader { gr in VStack(spacing: -12) { VStack(spacing: -(gr.size.width * 0.1)) { Star(corners: 5, smoothness: 0.5) .foregroundColor(Color.yellow) .frame(width: gr.size.width * 0.3, height: gr.size.width * 0.3) .zIndex(2) ZStack { VStack(spacing: -(gr.size.width / 5)) { TreeView() .frame(width: gr.size.width * 0.6) .zIndex(3) TreeView() .frame(width: gr.size.width * 0.7) .zIndex(2) TreeView() .frame(width: gr.size.width * 0.8) .zIndex(1) } .frame(height: gr.size.height * 0.5) .foregroundColor(Color.green) } .zIndex(1) } FoundationView() .frame(height: gr.size.height * 0.2) } } } }
背景
次に背景を作成していきます。
Circle
をランダムな大きさとopacityと位置に配置します。
コード
struct Particles: View { var body: some View { GeometryReader { gr in ZStack { ForEach(0...200, id: \.self) { _ in Circle() .foregroundColor(.red) .opacity(.random(in: 0.1...0.4)) .frame(width: .random(in: 10...100), height: .random(in: 10...100)) .position(x: .random(in: 0...gr.size.width), y: .random(in: 0...gr.size.height)) } } } } }
背景にアニメーションを設定する
せっかくなので背景にアニメーションをつけて
もう少し豪華(?)にしてみます。今回は
interactiveSpring
というAnimation
を利用しました。※
値は色々と触ってみて
こんな感じなのかなと思った値を設定しているので
適当です。https://developer.apple.com/documentation/swiftui/animation/3344959-interactivespring
画面表示時にアニメーションを起こすための処理
SwiftUIのアニメーションを設定する上で注意したい点として
単純にアニメーションを設定しただけでは
アニメーションが起動しません。これを画面表示時に発生させるためには
例えば@State
を付けたの変数を
onAppear
の中で変更することで
Viewの中の値を動的に変更させて再レンダリングさせるなどの
処理が必要になります。
コード
struct Particles: View { // レンダリングを起こすために必要 @State private var scaling = false var animation: Animation { Animation .interactiveSpring(response: 5, dampingFraction: 0.5) .repeatForever() .speed(.random(in: 0.05...0.9)) .delay(.random(in: 0...2)) } var body: some View { GeometryReader { gr in ZStack { ForEach(0...200, id: \.self) { _ in Circle() .foregroundColor(.red) .opacity(.random(in: 0.1...0.4)) .animation(self.animation) // この値を変化させることで再レンダリングを起こしている .scaleEffect(self.scaling ? .random(in: 0.1...2) : 1) .frame(width: .random(in: 10...100), height: .random(in: 10...100)) .position(x: .random(in: 0...gr.size.width), y: .random(in: 0...gr.size.height)) } } .onAppear { // レンダリングを起こすために必要 self.scaling = true } } } }
パフォーマンスを向上させる
画面としては上記で完成ですが
一つ問題があります。上記のアニメーションの処理を行ったことによって
メモリの使用量がどんどん増えていきます。※ この後もずっと増えていきます。
これは
ZStackの中の各Viewが描画をする際に
それぞれでレイヤーを構築します。そうするとその分のメモリを使用する結果
CPUへの負荷大きくなります。これはアプリのパフォーマンスの低下を招くことがあります。
そこで
drawingGroup
を使って負荷を減らすことができます。drawingGroup
https://developer.apple.com/documentation/swiftui/group/3284805-drawinggroup
このメソッドは
Viewの中の全てのViewを
画面上には見えないオフスクリーン上で
Metal APIを使用して
一つのイメージにまとめて描画し
最終的な内容を画面に出力するようにしてくれます。こうすることでメモリへの負荷を軽減させて
パフォーマンスを向上させることができます。
コード
struct Particles: View { // レンダリングを起こすために必要 @State private var scaling = false var animation: Animation { Animation .interactiveSpring(response: 5, dampingFraction: 0.5) .repeatForever() .speed(.random(in: 0.05...0.9)) .delay(.random(in: 0...2)) } var body: some View { GeometryReader { gr in ZStack { ForEach(0...200, id: \.self) { _ in Circle() .foregroundColor(.red) .opacity(.random(in: 0.1...0.4)) .animation(self.animation) // この値を変化させることで再レンダリングを起こしている .scaleEffect(self.scaling ? .random(in: 0.1...2) : 1) .frame(width: .random(in: 10...100), height: .random(in: 10...100)) .position(x: .random(in: 0...gr.size.width), y: .random(in: 0...gr.size.height)) } } // ここに設定をする .drawingGroup() .onAppear { // レンダリングを起こすために必要 self.scaling = true } } } }
※
一つ注意点として
drawingGroupのドキュメントに下記のような記載あります。Views backed by native platform views don’t render into the image.native platform viewsには
drawingGroup
は効果がないようです。このnative platform viewsとは
何を指すのかわからなかったのですが
twitter上でAppleの方が回答されていた内容によると
NSViewやUIViewのことのようです。https://twitter.com/jsh8080/status/1137045666939768833
まとめ
アドベントカレンダーのネタとして
クリスマスツリーを作っていく中で
SwiftUIの機能についていくつか見ていきました。SwiftUIは宣言的に小さい部品を作り
それを組み合わせていくことができるので
再利用性が高く読みやすいなと改めて感じました。
- 投稿日:2019-12-03T03:59:48+09:00
APIKit による通信リトライの実装と、OHHTTPStubs によるその実装のテスト
これはゆめみ Advent Calendar 2019 3日目の記事です。
リトライ実装編
リクエストの再送信、つまりリトライはよくある用件ですが、通信のリトライの一番面倒なところは通信は基本非同期で動くので、初心者にはかなり厄介な部分になるかと思います。しかし実はこの非同期のリトライ要件は、再帰呼び出しとクロージャの組み合わせを使えばそれほど難しい要件ではありません。
今リファクタリングにあたってるとあるプロジェクトでは通信部分を APIKit で実装しているので、今日はこの APIKit を利用した場合のリトライ実装を解説します。
まず、リトライの実現するための再帰呼び出しですが、私の経験則では
外に使ってもらうための本呼び出し
と、本呼び出しないで実際使っている再帰用呼び出し
を分けて作った方が仕様変更に柔軟なのと、ソースコードが読みやすいです。つまり:SomeObject// 再帰呼び出し用のメソッド private func recursiveDoSomething() { recursiveDoSomething() } // 外側に使ってもらうためのメソッド func doSomething() { recurciveDoSomething() }BodydoSomething()という感じです。確かに今のこの実装では
doSomething
もrecursiveDoSomething
も中身は一緒ですが、しかし実際この二つのメソッドは二つの異なる要件を満たすためにあります。前者は単純に「リクエストを投げる」ためにあるのですが、後者は「万が一通信が失敗した時に、もう一回同じリクエストを投げる」ためにあるのです。用件が違うので、当然それぞれ細かい動作が微妙に違うことも予想できます。DRY 原則でも書いてありますように、重複してはいけないのはソースコードではなく知識=コンテキストです。むしろコンテキストが違えば、たまたま同じコードになってるとしてもそれは分けるべきです。ちょっと脱線しすぎちゃいましたので話戻します。そのリトライをする際に当然ながら回数の制限をしなくてはいけません、じゃないと通信不良が続くといつまで経っても処理が終わらないので無限ループになります。というわけでここでは一旦デフォルト最大 5 回までリトライする、という要件を設定しておきます。
さて仕様を整理します:
- 特定なリクエストを投げて、レスポンスもらったら終了ハンドラーを実行します
- 通信不良によるタイムアウト等のレスポンスをもらった場合、もう一回1.を繰り返します
- 最大5回まで2.を実行しますが、それでもダメなら終了ハンドラーにエラーレスポンスを入れて実行します。
ここまでわかったらコード化するのは簡単ですね:
APIClient// 再帰呼び出し用のメソッド // `Request` 及び `Request.Response` は APIKit によって定義されたリクエストとレスポンスのprotocol private func recursiveSend(_ request: Request, availableRetryTimes: Int, completionHandler: @escaping (Result<Request.Response, Error>) -> Void) { // `session` は APIKit によって定義されたリクエストを投げるためのセッション session.send(request) { result in switch result { case .success(let response): completion(.success(response) case .failure(let error) // 今回はサンプルコードの単純化のために全てのエラーレスポンスを再帰呼び出ししていますが、実際はエラーレスポンスの種類によって切り替えています; // 例えばそもそもリクエスト自体に問題があった場合は何度リトライしても同じ結果なのでリトライしない if availableRetryTimes > 0 { recursiveSend(request, availableRetryTimes: availableRetryTimes - 1, completionHandler: completionHandler) } else { completion(.failure(error)) } } } } func send(_ request: Request, maxRetryTimes: Int = 5, completion: @escaping (Result<Request.Response, Error>) -> Void) { recursiveSend(request, availableRetryTimes: maxRetryTimes, completionHandler: completion) } // 下記の `request` は実際の API にアクセスするための APIKit のリクエストインスタンス send(request) { result in // doSomething(with: result) }お気づきでしょうか、リトライ回数の制御に、私は敢えてそれぞれ違う引数名にしています。外側の呼び出し用のものは「最大リトライ回数」の名前にしていますが、再帰メソッドでは「リトライ可能回数」にしているのです。それぞれ違うコンテキストになりますので。そしてこの二つのメソッドを分けることによって、最大リトライ回数の引数のデフォルト値を設定しながら、再帰呼び出しの関数には該当引数を必ず入れなくてはならない安全性も担保できています。
サンプル記事としての簡潔のために、エラー処理を大幅に簡略化していますが、実際はコメントに書いてあるとおりエラーによって切り分けが必要です。この記事ではあくまでリトライの実現に関する記事なのでとりあえず全てのエラーをリトライさせています。そしてリトライ処理の肝がこれだけです:
APIClientif availableRetryTimes > 0 { recursiveSend(request, availableRetryTimes: availableRetryTimes - 1, completionHandler: completionHandler) } else { completion(.failure(error)) }ご覧の通り、もしエラーがもらったら、まずリトライ可能の回数を確認します。もしもうリトライ可能回数がまだある(0 以上)なら、リトライ可能回数を 1 つ減らして、それ以外の引数は全てそのまま使ってこの再帰呼び出しメソッドを呼び出します;もしもうリトライ可能回数がもうない(0 かそれ以下)になったら、終了ハンドラーに今のエラーを渡して実行させます。再帰呼び出されるときは必ずリトライ可能回数が減るので、度重なる先呼び出しでいつかは 0 になって終了ハンドラーが呼び出されます。
ね、簡単でしょ?
テスト実装編
さて、リトライの実装ができたら、今度はこのリトライのロジックをテストしなくてはならないですね。リトライのテストですから、当然テスト対象として:
- 1 回目で成功レスポンスもらったらそのまま終了ハンドラーを回す
- 1 回目で成功レスポンスもらえず、ただしリトライ可能な失敗レスポンスもらったら所定の回数まで回して、途中で成功レスポンスもらえたらそのまま終了ハンドラーを回す
- 所定の回数まで回して全部失敗レスポンスなら終了して終了ハンドラーに失敗レスポンスを入れて回す
- 1回目で成功レスポンスでもリトライ可能な失敗レスポンスでもなく、リトライ不可な失敗レスポンスもらったらそのまま終了ハンドラーに失敗レスポンス入れて回す
といった感じですかね。
しかしテストするときに必ずしもネットワークが悪いとは限らないし、ましては CI 環境ならなおさらのことです。ここで役に立つのは特定な通信に独自のレスポンスを返させるスタブライブラリーです。この記事では弊社でもよく利用している OHHTTPStubs を使って解説します。
OHHTTPStubs を使ってスタブを作るのはとても簡単です。通信が開始する前に、このように設定してあげればいいです:
Teststub(condition: pathEndsWith("/path/of/api")) { _ in let responseObject: [String: String] = ["key": "value"] // 成功レスポンス return OHHTTPStubsResponse(jsonObject: responseObject, statusCode: 200, headers: nil) }上記のように設定すれば、
/path/of/api
で終わるパスにアクセスするときに、自分で設定したresponseObject
が返されます。しかし、これでは何回
/path/of/api
アクセスしても同じ成功レスポンスが返されますが、我々が欲しいのは特定の回数にアクセスした時だけ成功し、それ以外では失敗するレスポンスが欲しいです。どうしましょう?この問題は実は簡単です。OHHTTPStubs の具体的になんのレスポンスを返すかの処理は、静的なレスポンスオブジェクトではなく、動的にレスポンスオブジェクトを生成するクロージャなので、アクセスする回数のカウンターをつけてあげればいいです。そのカウンター変数は
XCTestCase
クラスにプロパティーとしてつけてあげるのもいいですが、ここで紹介したいのはプロパティーを作らずに通常の変数としてキャプチャーさせる方法です。この方法は本呼び出しのstub(condition:response)
メソッドをラップしたメソッドを作り、このラップメソッドの中に変数を組み込んでresponse
クロージャにキャプチャーさせればいいです。つまりコードにするとこんな感じです:Testprivate func setStub(condition: @escaping OHHTTPStubsTestBlock, successOnTimeOfRequestReceived: Int, response: @escaping OHHTTPStubsResponseBlock) { var timesOfRequestReceived = 0 stub(condition: condition) { (request) -> OHHTTPStubsResponse in timesOfRequestReceived += 1 if timesOfRequestReceived == successOnTimesOfRequestReceived { return response(request) } else { return OHHTTPStubsResponse(error: NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue)) } } } setStub(condition: pathEndsWith("/path/of/api"), successOnTimeOfRequestReceived: 1) { _ in let responseObject: [String: String] = ["key": "value"] // 成功レスポンス return OHHTTPStubsResponse(jsonObject: responseObject, statusCode: 200, headers: nil) }このように
setStub
を呼び出せば、中の処理として、(request) -> OHHTTPStubsResponse
クロージャは該当パスにアクセスされるたびに実行されるので、実行したらラッパーメソッドのtimesOfRequestReceived
を取得し+=1
して現在のアクセス回数を割り出します。初期値が0
で必ず値をプラス 1 してから評価するので、1 回目のアクセスはtimesOfRequestReceived
を評価するときの値が1
になります。そしてこのtimesOfRequestReceived
がsuccessOnTimesOfRequestReceived
と同じ値になる時のみ、成功レスポンスを返し、それ以外の時はURLError.notConnectedToInternet
のエラーが返されるようにスタブが設定されます。ちなみにこのようにラッパーメソッド内にクロージャで使う変数を作るメリットは、このラッパーメソッドにとってスレッドセーフになるのと、大元のオブジェクトに余計な状態を持さず、プロパティーを汚さないで済むことです。
ここまでわかったら、後のテストケースの作成は簡単ですね、こんな感じにすればいいです:
SessionTaskError// `APIKit.SessionTaskError` は `Equatable` に適合していないので、ひとまず対応させておく extension APIKit.SessionTaskError: Equatable { public static func == (lhs: AppError.External, rhs: AppError.External) -> Bool { return lhs.localizedDescription == rhs.localizedDescription } }Test// ここの `Response` も実際の `Request.Response` に置き換えられますが、これも `XCTAssertEqual` を使うために `Equatable` に適合する必要があります // `expectedResponse` が実際の成功レスポンスインスタンス typealias TestCase = (successOnTime: Int, expectedResult: Result<Response, APIKit.SessionTaskError>) let testCases: [TestCase] = [ (0, .failure(.connectionError(NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue)))), (1, .success(expectedResponse)), (2, .success(expectedResponse)), (3, .success(expectedResponse)), (4, .success(expectedResponse)), (5, .success(expectedResponse)), (6, .success(expectedResponse)), (7, .failure(.connectionError(NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue)))), ] for testCase in testCases { let testExpectation = expectation(description: "\(testCase)") useStub(successOnTimeOfRequestReceived: testCase.successOnTime) send(request) { (result) in // この `request` は実際の API パス `/path/of/api` にアクセスするための APIKit のリクエストインスタンス defer { OHHTTPStubs.removeAllStubs() testExpectation.fulfill() } XCTAssertEqual(result, testCase.expectedResult) } wait(for: [testExpectation], timeout: 5) }このように、成功レスポンスを返すときのアクセス回数と、想定されているレスポンスのタップルのテストケース配列を作っておけば、あとはこの配列を回して、実際に本当に想定通りにリトライしているのかが確認できます。
ちなみにここで注意しないといけないのは、上のリトライ実装編で定義したリトライ回数のデフォルト値が
5
ですが、これはリトライ回数ですので、最初のアクセスの試みも含めれば合計最大6
回のアクセスが可能となる仕様になります。この人によってわかりにくいかもしれない仕様も、きちんとテストすることで保証されることがわかります。
最後に、リトライ実装編ではこの解説コードをシンプルにするために、リトライすべき失敗レスポンスとすべきでない失敗レスポンスを区別させていないため、ここのテストも最後のテスト対象である
4. 1回目で成功レスポンスでもリトライ可能な失敗レスポンスでもなく、リトライ不可な失敗レスポンスもらったらそのまま終了ハンドラーに失敗レスポンス入れて回す
をテストしていません。しかしこの記事では一番肝なアプローチを解説しましたので、この仕様の対応はもう難しくないはずです。興味ある方は是非この失敗レスポンスの切り分けとテストの実装を試してみてください。
- 投稿日:2019-12-03T02:02:07+09:00
去年の自分にマサカリを投げる
まえがき
こんにちはrenです。
世間は12月。すっかりアドカレの季節ですね。アドカレといえば去年のアドカレではこんな記事を書きました。
iOSアプリでよく見るチュートリアル画面を作成するえ、なにこれ。
読みづらすぎる。ということで、去年の自分にマサカリを投げることにしました。
本編
まず、前回のコードを見返してみましょう。
ViewController.swift
// ViewController.swift import UIKit class ViewController: UIViewController{ var tutorialCollectionView: UICollectionView! var layout: UICollectionViewFlowLayout! override func viewDidLoad() { super.viewDidLoad() let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトを生成 layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: viewWidth, height: viewHeight) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal // CollectionViewを生成 tutorialCollectionView = UICollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // Cellに使われるクラスを登録 tutorialCollectionView.register(CustomUICollectionViewCell.self, forCellWithReuseIdentifier: "CustomCell") // dataSourceを自身に設定 tutorialCollectionView.dataSource = self // ページングさせる tutorialCollectionView.isPagingEnabled = true // ScrollIndicatorを非表示にする tutorialCollectionView.showsHorizontalScrollIndicator = false // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } override func viewDidAppear(_ animated: Bool) { // ViewDidLoadではSafeAreaが取得できないのでここでリサイズ let safeArea = self.view.safeAreaInsets let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: safeArea.left, y: safeArea.top, width: viewWidth - safeArea.left, height: viewHeight - safeArea.top - safeArea.bottom) layout.itemSize = CGSize(width: viewWidth - safeArea.left, height: viewHeight - safeArea.top - safeArea.bottom) tutorialCollectionView.frame = collectionViewFrame } } extension ViewController: UICollectionViewDataSource { // Cellの数を設定 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 5 } // Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell : CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: cell.backgroundColor = UIColor.blue case 1: cell.backgroundColor = UIColor.orange case 2: cell.backgroundColor = UIColor.yellow case 3: cell.backgroundColor = UIColor.green case 4: cell.backgroundColor = UIColor.red default: break } cell.textLabel?.text = "\(indexPath.row + 1)ページ目" return cell } }CustomUICollectionViewCell.swift
// CustomUICollectionViewCell import UIKit class CustomUICollectionViewCell : UICollectionViewCell{ var textLabel : UILabel? required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! } override init(frame: CGRect) { super.init(frame: frame) // UILabelを生成. textLabel = UILabel(frame: CGRect(x:0, y:0, width:frame.width, height:frame.height)) textLabel?.text = "nil" textLabel?.textAlignment = NSTextAlignment.center // Cellに追加. self.contentView.addSubview(textLabel!) } }うん。読みづらい。
どんなところが読みづらいのか、説明していきます。
viewDidLoad
に処理書きすぎoverride func viewDidLoad() { super.viewDidLoad() let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトを生成 layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: viewWidth, height: viewHeight) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal // CollectionViewを生成 tutorialCollectionView = UICollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // Cellに使われるクラスを登録 tutorialCollectionView.register(CustomUICollectionViewCell.self, forCellWithReuseIdentifier: "CustomCell") // dataSourceを自身に設定 tutorialCollectionView.dataSource = self // ページングさせる tutorialCollectionView.isPagingEnabled = true // ScrollIndicatorを非表示にする tutorialCollectionView.showsHorizontalScrollIndicator = false // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) }処理を分割してみましょう
override func viewDidLoad() { super.viewDidLoad() let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // CollectionViewを生成 tutorialCollectionView = UICollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewを設定 setupTutorialCollectionView() // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout } func setupTutorialCollectionView() { // Cellに使われるクラスを登録 tutorialCollectionView.register(CustomUICollectionViewCell.self, forCellWithReuseIdentifier: "CustomCell") // dataSourceを自身に設定 tutorialCollectionView.dataSource = self // ページングさせる tutorialCollectionView.isPagingEnabled = true // ScrollIndicatorを非表示にする tutorialCollectionView.showsHorizontalScrollIndicator = false }だいぶマシにはなりましたね。
UICollectionViewDataSource
を直接ViewController
に準拠させているextension ViewController: UICollectionViewDataSource { // Cellの数を設定 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 5 } // Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell : CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: cell.backgroundColor = UIColor.blue case 1: cell.backgroundColor = UIColor.orange case 2: cell.backgroundColor = UIColor.yellow case 3: cell.backgroundColor = UIColor.green case 4: cell.backgroundColor = UIColor.red default: break } cell.textLabel?.text = "\(indexPath.row + 1)ページ目" return cell } }これでは
ViewController
の仕事が増えすぎてしまいます。
こういう時はUICollectionView
のカスタムクラスを作りましょう。import UIKit class TutorialCollectionView: UICollectionView { override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension TutorialCollectionView: UICollectionViewDataSource { // Cellの数を設定 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 5 } // Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell : CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: cell.backgroundColor = UIColor.blue case 1: cell.backgroundColor = UIColor.orange case 2: cell.backgroundColor = UIColor.yellow case 3: cell.backgroundColor = UIColor.green case 4: cell.backgroundColor = UIColor.red default: break } cell.textLabel?.text = "\(indexPath.row + 1)ページ目" return cell } }ここに先程
viewDidLoad
から抜き出したsetupTutorialCollectionView
を移動させます。import UIKit class TutorialCollectionView: UICollectionView { override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setup() { // Cellに使われるクラスを登録 tutorialCollectionView.register(CustomUICollectionViewCell.self, forCellWithReuseIdentifier: "CustomCell") // dataSourceを自身に設定 self.dataSource = self // ページングさせる self.isPagingEnabled = true // ScrollIndicatorを非表示にする self.showsHorizontalScrollIndicator = false // BackgroundColorを白にする。 self.backgroundColor = .white } } extension TutorialCollectionView: UICollectionViewDataSource { // Cellの数を設定 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 5 } // Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell : CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: cell.backgroundColor = UIColor.blue case 1: cell.backgroundColor = UIColor.orange case 2: cell.backgroundColor = UIColor.yellow case 3: cell.backgroundColor = UIColor.green case 4: cell.backgroundColor = UIColor.red default: break } cell.textLabel?.text = "\(indexPath.row + 1)ページ目" return cell } }
UICollectionView
をTutorialCollectionView
に変更override func viewDidLoad() { super.viewDidLoad() let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // TutorialCollectionViewを生成 tutorialCollectionView = TutorialCollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout }
cellForItemAt
でCell
の設定をしている// Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell : CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: cell.backgroundColor = UIColor.blue case 1: cell.backgroundColor = UIColor.orange case 2: cell.backgroundColor = UIColor.yellow case 3: cell.backgroundColor = UIColor.green case 4: cell.backgroundColor = UIColor.red default: break } cell.textLabel?.text = "\(indexPath.row + 1)ページ目" return cell }せっかくカスタムセルを作っているので、セルの設定もセル側にやってもらったほうが処理がスッキリします。
import UIKit class CustomUICollectionViewCell : UICollectionViewCell{ var textLabel : UILabel? required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! } override init(frame: CGRect) { super.init(frame: frame) // UILabelを生成. textLabel = UILabel(frame: CGRect(x:0, y:0, width:frame.width, height:frame.height)) textLabel?.text = "nil" textLabel?.textAlignment = NSTextAlignment.center // Cellに追加. self.contentView.addSubview(textLabel!) } func setCell(indexPath: IndexPath) { // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: self.backgroundColor = UIColor.blue case 1: self.backgroundColor = UIColor.orange case 2: self.backgroundColor = UIColor.yellow case 3: self.backgroundColor = UIColor.green case 4: self.backgroundColor = UIColor.red default: break } pageNumberLabel.text = "\(indexPath.row + 1)ページ目" } }// Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as! CustomUICollectionViewCell cell.setCell(indexPath: indexPath) return cell }細かいところですが、必要でないのなら
Optional
は使わないようにしましょう。import UIKit class CustomUICollectionViewCell : UICollectionViewCell{ var textLabel = UILabel() // Optionalでなくてよい required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! } override init(frame: CGRect) { super.init(frame: frame) // UILabelを生成. textLabel = UILabel(frame: CGRect(x:0, y:0, width:frame.width, height:frame.height)) textLabel.text = "nil" textLabel.textAlignment = NSTextAlignment.center // Cellに追加. self.contentView.addSubview(textLabel) } func setCell(indexPath: IndexPath) { // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: self.backgroundColor = UIColor.blue case 1: self.backgroundColor = UIColor.orange case 2: self.backgroundColor = UIColor.yellow case 3: self.backgroundColor = UIColor.green case 4: self.backgroundColor = UIColor.red default: break } pageNumberLabel.text = "\(indexPath.row + 1)ページ目" } }強制キャスト
as!
もできるだけ使わないほうが安全です。// Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell: CustomUICollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath as IndexPath) as? CustomUICollectionViewCell else { return UICollectionViewCell() } cell.setCell(indexPath: indexPath) return cell }
CustomUICollectionViewCell
では役割が分かりづらい
TutorialCollectionViewCell
に変更しましょう。import UIKit class TutorialCollectionViewCell: UICollectionViewCell { ・・・ }// Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TutorialCollectionViewCell", for: indexPath as IndexPath) as? TutorialCollectionViewCell else { return UICollectionViewCell() } cell.setCell(indexPath: indexPath) return cell }
identifier
がハードコーディングになっているので、定数にしましょう。import UIKit class TutorialCollectionViewCell: UICollectionViewCell { public static let identifier = "TutorialCollectionViewCell" var textLabel = UILabel() ・・・ }class TutorialCollectionView: UICollectionView { override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setup() { // Cellに使われるクラスを登録 tutorialCollectionView.register(TutorialCollectionViewCell.self, forCellWithReuseIdentifier: TutorialCollectionViewCell.identifier) // dataSourceを自身に設定 self.dataSource = self // ページングさせる self.isPagingEnabled = true // ScrollIndicatorを非表示にする self.showsHorizontalScrollIndicator = false // BackgroundColorを白にする。 self.backgroundColor = .white } }// Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TutorialCollectionViewCell.identifier, for: indexPath as IndexPath) as? TutorialCollectionViewCell else { return UICollectionViewCell() } cell.setCell(indexPath: indexPath) return cell }
viewDidAppear
でリサイズしている現在の
ViewController
を確認します。
viewDidLoad
で初期化したものをviewDidAppear
でリサイズしていますね。
この書き方は冗長に思えます。import UIKit class ViewController: UIViewController { var tutorialCollectionView: UICollectionView! var layout: UICollectionViewFlowLayout! override func viewDidLoad() { super.viewDidLoad() let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // TutorialCollectionViewを生成 tutorialCollectionView = TutorialCollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } override func viewDidAppear(_ animated: Bool) { // ViewDidLoadではSafeAreaが取得できないのでここでリサイズ let safeArea = self.view.safeAreaInsets let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: safeArea.left, y: safeArea.top, width: viewWidth - safeArea.left, height: viewHeight - safeArea.top - safeArea.bottom) layout.itemSize = CGSize(width: viewWidth - safeArea.left, height: viewHeight - safeArea.top - safeArea.bottom) tutorialCollectionView.frame = collectionViewFrame } func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout } }
viewWillLayoutSubviews
で初期化すればリサイズをする必要場なくなります。import UIKit class ViewController: UIViewController { var tutorialCollectionView: UICollectionView! var layout: UICollectionViewFlowLayout! override func viewDidLoad() { super.viewDidLoad() } override func viewWillLayoutSubviews() { let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // TutorialCollectionViewを生成 tutorialCollectionView = TutorialCollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout } }暗黙的アンラップ型
!
はあまり使わないようにしましょう。import UIKit class ViewController: UIViewController { lazy var tutorialCollectionView = TutorialCollectionView() var layout = UICollectionViewFlowLayout() override func viewDidLoad() { super.viewDidLoad() } override func viewWillLayoutSubviews() { let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: 0, y: 0, width: viewWidth, height: viewHeight) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // TutorialCollectionViewを生成 tutorialCollectionView = TutorialCollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout } }最後にアクセス修飾子を付けたら完成です。
リファクタ後
ViewController
import UIKit class ViewController: UIViewController { private lazy var tutorialCollectionView = TutorialCollectionView() private var layout = UICollectionViewFlowLayout() override func viewDidLoad() { super.viewDidLoad() } override func viewWillLayoutSubviews() { setTutorialCollectionView() } private func setTutorialCollectionView() { let safeArea = self.view.safeAreaInsets let viewWidth = self.view.frame.width let viewHeight = self.view.frame.height let collectionViewFrame = CGRect (x: safeArea.left, y: safeArea.top, width: viewWidth - safeArea.left - safeArea.right, height: viewHeight - safeArea.top - safeArea.bottom) // CollectionViewのレイアウトをセット layout = setLayout(width: viewWidth, height: viewHeight) // CollectionViewを生成 tutorialCollectionView = TutorialCollectionView(frame: collectionViewFrame, collectionViewLayout: layout) // CollectionViewをViewに追加する self.view.addSubview(tutorialCollectionView) } private func setLayout(width: CGFloat, height: CGFloat) -> UICollectionViewFlowLayout { // CollectionViewのレイアウトを生成 let layout = UICollectionViewFlowLayout() // Cell一つ一つの大きさを設定 layout.itemSize = CGSize(width: width, height: height) // Cellの行間隔を設定 layout.minimumLineSpacing = 0 // Cellの列間隔を設定 layout.minimumInteritemSpacing = 0 // CollectionViewのスクロールの方向を横にする layout.scrollDirection = .horizontal return layout } }TutorialCollectionView
import UIKit class TutorialCollectionView: UICollectionView { override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setup() { // Cellに使われるクラスを登録 self.register(TutorialCollectionViewCell.self, forCellWithReuseIdentifier: TutorialCollectionViewCell.identifier) // dataSourceを自身に設定 self.dataSource = self // ページングさせる self.isPagingEnabled = true // ScrollIndicatorを非表示にする self.showsHorizontalScrollIndicator = false // BackgroundColorを白にする。 self.backgroundColor = .white } } extension TutorialCollectionView: UICollectionViewDataSource { // Cellの数を設定 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 5 } // Cellに値を設定する func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { // Cellを取得 guard let cell: TutorialCollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: TutorialCollectionViewCell.identifier, for: indexPath as IndexPath) as? TutorialCollectionViewCell else { return UICollectionViewCell() } // Cellに値を設定する cell.setCell(indexPath: indexPath) return cell } }TutorialCollectionViewCell
import UIKit class TutorialCollectionViewCell: UICollectionViewCell { public static let identifier = "TutorialCollectionViewCell" private var pageNumberLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) addLabel() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func addLabel() { // UILabelを生成. pageNumberLabel = UILabel(frame: CGRect(x:0, y:0, width:frame.width, height:frame.height)) pageNumberLabel.text = "" pageNumberLabel.textAlignment = NSTextAlignment.center // Cellに追加. self.contentView.addSubview(pageNumberLabel) } public func setCell(indexPath: IndexPath) { // Cellに応じてbackgroundColorを変更 switch indexPath.row { case 0: self.backgroundColor = UIColor.blue case 1: self.backgroundColor = UIColor.orange case 2: self.backgroundColor = UIColor.yellow case 3: self.backgroundColor = UIColor.green case 4: self.backgroundColor = UIColor.red default: break } pageNumberLabel.text = "\(indexPath.row + 1)ページ目" } }ソースコードはGitHubにあげました
https://github.com/renchild8/TutorialCollectionView
- 投稿日:2019-12-03T01:04:54+09:00
[Cocoa][Swift]XCFramework
Darwinで採用されています実行形式のバイナリ・フォーマットMach-oは、一つのファイルに複数のアーキテクチャのバイナリが格納できるという素晴らしい特徴があるのですが、同じCPUで異なるシステム向けのバイナリは同時に格納できないという欠点があるようです。以前だと、これで問題はなかったのですが、例えば、iPad OS向けアプリのソースからmacOSアプリを作ることができるUIKit for Mac (Catalyst)だと、x86_64でiOSとiPhoneシミュレータ(macOS)という場合が発生して、同一ファイルに格納できないという問題が発生します。
おそらく、これの対策として用意されたのが、Xcode 11から利用できるXCFramework。簡単に説明すると複数のフレームワークを一つにできるというものだ。
具体的には、MoltenGLというライブラリはiOSとmacOS向けのフレームワークが用意されていて、これを以下のコマンドでXCFrameworkにまとめられる。
% xcodebuild -create-xcframework \ > -framework MoltenGL-0.25.0/MoltenGL/iOS/framework/MoltenGL.framework \ > -framework MoltenGL-0.25.0/MoltenGL/macOS/framework/MoltenGL.framework \ > -output MoltenGL.xcframework xcframework successfully written out to: MoltenGL.xcframeworkこの中身をtreeコマンドで確認してみる。
% tree MoltenGL.xcframework MoltenGL.xcframework ├── Info.plist ├── ios-armv7_arm64 │ └── MoltenGL.framework │ ├── Headers │ │ ├── MoltenGL.h │ │ ├── mglDataTypes.h │ │ ├── mglEnv.h │ │ ├── mglGLKitDataTypes.h │ │ ├── mglMetalState.h │ │ ├── mglext.h │ │ └── mln_env.h │ └── MoltenGL └── macos-x86_64 └── MoltenGL.framework ├── Headers -> Versions/Current/Headers ├── MoltenGL -> Versions/Current/MoltenGL └── Versions ├── A │ ├── Headers │ │ ├── MoltenGL.h │ │ ├── mglDataTypes.h │ │ ├── mglEnv.h │ │ ├── mglGLKitDataTypes.h │ │ ├── mglMetalState.h │ │ ├── mglext.h │ │ └── mln_env.h │ └── MoltenGL └── Current -> A 10 directories, 18 files単なるx86_64でなく、macOSのx86_64となっている。
【関連情報】
Cocoa.swift
Cocoa勉強会 関東
Cocoa練習帳
- 投稿日:2019-12-03T00:37:10+09:00
Alamofireを使用したAPI通信について(MVC)
初めまして!!
休学をしてプログラミングを学んでいるりゅーちゃんです!!
今回はアドベントカレンダーの3日目を担当します!
題名にも記載しているように今回はAlamofireについて解説していきます
(自分自身まだ初学者なのでどんどんFBしてくださると助かります!!ただ優しくしていただけると幸いです)
Alamofireについて
(今回の記事の概要)
言わずともしれたAPI通信を行う際の優秀なライブラリです!!
(APIKitとは何が違うのか?については知識不足でまだわかっていません
)
Qiitaの記事も豊富で学習がしやすく、ドキュメントを参照すればすぐに使うことができ初心者にも優しいものです!!ではなぜ今回は改めて記事を書う理由は。。。
- MVCを採用した際のデーターの渡し方の説明が少ない記事が多い(Alamofireの使い方に重点をおいているため)
- 大まかな情報の流れについて記事が難しすぎる
- 自分自身の振り返りのため
この3つの要因があります!!
稚拙な文章ですが最後までお付き合いしてくださると幸いです!!MVCを利用したAPI通信について(Alamofireを添えて
)
まずは行いたい処理を確認していきます!!
1. ViewControllerから通信に必要な情報をModelに渡してあげる
2. ModelでViewControllerから受け取った情報をheadersに詰め込みAPI通信を行う
3. ViewControllerに取得データを出力する(本来ならTableViewなどに表示するが今回は割愛します)
大まかに分けてこの3つの処理を実行していきます!!Sample.json{ "data": [ { "name": "牛肉", "food_type": "meat", "rate": 60, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "豚肉", "food_type": "meat", "rate": 30, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "鶏肉", "food_type": "meat", "rate": 0, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "鯖", "food_type": "fish", "rate": 0, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "イワシ", "food_type": "fish", "rate": 0, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "にんじん", "food_type": "vegetable", "rate": 30, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "キャベツ", "food_type": "vegetable", "rate": 100, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "玉ねぎ", "food_type": "vegetable", "rate": 100, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "じゃがいも", "food_type": "vegetable", "rate": 100, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } }, { "name": "もやし", "food_type": "vegetable", "rate": 100, "user": { "uuid": "2553846F-996A-4D3F-93FC-9ACD8D8CD16B" } } ] }このJsonデーターを取得しViewで表示するのが今回のゴールです!!
まずはAPI通信のした準備としてCodableを見ていきましょう
SampleResponse.swiftstruct MetaFoodTypes: Codable { let data: [FoodTypes] } struct FoodTypes: Codable { let name: String let foodType: String var rate: Int //スネークケースをキャメルケースに変換している enum CodingKeys: String, CodingKey { case name case foodType = "food_type" case rate } }今回はJSONの形からdataをKeyとしてその中の情報を取得する必要があります!!
そこで上記のようにMetaFoodTypes、FoodTypesを作ることCodableがMetaFoodTypesを見てくれた後にFoodTypesを見る処理を書いていますhttps://qiita.com/_ha1f/items/bf1aad5ea3e927f59f9d
今回の説明では不十分だと感じた方はこの方の記事を見るのをお勧めします次はViewControllweです!!
ViewController.swiftprivate let model = ViewModel() private func getFoodTypesdata() { //userdefaultsの処理については後ほど記述します!! //tokenを取得しModelに情報を渡してあげる guard let token = UserData.token else { return } model.getFoodtypes(token: token, handler: { result in //Modelからの処理の結果がresult部分に渡される print(result) }) }追記:userdefaultsについて
UserDefault.swiftstruct UserData { static let userDefault = Foundation.UserDefaults.standard struct Key { static let token = "token" } } extension UserData { static var token: String? { get { return userDefault.string(forKey: Key.token) ?? nil } set { userDefault.set(newValue, forKey: Key.token) userDefault.synchronize() } } }ここの処理についてはまだまだ言語化ができていないため、理解次第この記事を編集していきます
(参照した記事がQiitaではないため今回は参照リンクは記載しません。。。)最後にViewModelを見ていきましょう!!
ViewModel.swiftimport Foundation import Alamofire class SelectViewModel { func getFoodTypesdata (token: String, handler: @escaping (MetaFoodTypes) -> Void) { let url = URL(string: "********************")! //ここでViewから取得してきた情報を使用している let headers: HTTPHeaders = [ "**********": "********" + token ] AF.request(url, method: .get, encoding: JSONEncoding.default, headers: headers) .validate(statusCode: 200..<300) .responseJSON{ response in switch response.result { case .success( _): guard let data = response.data else { return } guard let foodTypes = try? JSONDecoder().decode(MetaFoodTypes.self, from: data) else { return } //ここの処理の結果がViewのhandlerに格納される!! handler(foodTypes) case .failure(let error): print(error) } } } }この部分の処理についてはクロージャを使っていること以外はAlamofireのドキュメントを読むことで理解することができました!!
是非とも英語ばかりで読みたくはない(実際に自分がそうでした)と思いますが理解度が深くなるので是非ともチャレンジしてください!!
まとめ
今回は大まかに情報の流れに焦点を当てて記事を書いてみました!!
まだまだ1つ、1つの処理に関しては理解が足りない部分が多いですが勉強を怠らず、強くなっていきたいです
間違っているぞ!!、ここはもっと詳しく書いてくれ!!などがありましたら気軽にコメント、編集リクエストしてください
今後の課題としてはAPI通信部分の共通化について取り組んでいきます!!!
(参考にできるものや、書き方を教えてくださると泣いて喜びます)
またこんな初学者が諦めずに頑張れているのはTechTrainというサービスを利用し、壁にぶつかるたびにメンターさんに相談することができたからです
詳しい概要はTechTrain Advent Calendar 2019 1日目に記載してありますので気になった方はみてみてください!!