20211016のSwiftに関する記事は11件です。

選択範囲のスクリーンショットを取得するmacOSアプリの作成

概要 「Mac でスクリーンショットを撮る」のような、画面上を選択しその範囲のスクリーンショットを取るような実装を行う GitHub Qiita_ScreenshotDemo 参考 Kyome22/ScanTextFromImage 主に参考にしています [iOS] frameとboundsの違いを理解する - Qiita frameが今回は対象 How to set a custom cursor in an SKScene? (XCode, Swift4, Spritekit) マウスカーソルの座標のNSTextFieldを今回はNSPanel側に表示しているが、カーソルの画像として表示させる方法も考えられる CGWindowListCreateImage のオプション スクリーンショットの影をつけるオプション Programmatically Screenshot | Swift 3, macOS Ask Question Swift:あるNSWindowより下層領域のスクリーンショットを取得 - Qiita 実装 方針 前提として、複数のスクリーンがある状態(マルチディスプレイ)でも動作できるように考える まず各スクリーン上に全体を覆うようなScreenshotPanel(NSPanel)を最前面に配置する 下図では分かりやすい用に青く着色している ScreenshotPanel(NSPanel)上でマウスで領域を選択し取得できるようにする 選択した範囲に沿うようにScreenshotView(NSView)を配置・変形させる ViewController.swift ScreenshotPanelの表示 下記の通りScreenshotPanelを各スクリーン上に表示する @IBAction func selectRect(_ sender: Any?) { guard panels.isEmpty else { return } NSCursor.crosshair.set() NSApp.activate(ignoringOtherApps: true) for (i, screen) in NSScreen.screens.enumerated() { let panel = ScreenshotPanel(screen.frame) panel.screenshotPanelDelegate = self panel.name = "Screen_\(i)" panels.append(panel) panel.orderFrontRegardless() } setupMonitors() } esc押下時にScreenshotPanelを閉じる escを押下した際に、上記のScreenshotPanelを全て閉じるため、下記プロパティを持たせておく Swift3 ViewControllerでキーイベント(keyDown)を捕捉する private var monitors = [Any?]() // keyDownの監視用 private var panels = [ScreenshotPanel]() // escが押された際にこれらを閉じる カーソルの表示変更 またスクリーンショット開始時のカーソルにはNSCursor.crosshair.set()を使用する 参考: NSCursor カーソルが切り替わるトリガは正確に把握していないけれど、ScreenshotPanel側でもマウスのアクションを受け取る際に随時呼び出すようにするといい感じに。 残念ながら、下記のMac標準のスクリーンショット時のカーソルは用意されてない 選択領域から画像の作成 下記で取得できるselectedRectは、各スクリーンの左下を原点としたときのNSRectである つまりorigin=NSPoint.zeroで固定としている extension ViewController: ScreenshotPanelDelegate { func screenshotPanel(_ screenshotPanel: ScreenshotPanel, didSelectRect selectedRect: NSRect) { 各画面の全体のスクリーンショット画像作成関しては、Kyomeさんの記事を参照 座標の変換がややこしいので下記を参考にしつつ補正する Swift: macOSでの座標系のややこしい話 macOSでは左下が原点 スクリーンショットの画像作成時にy軸が反転させる必要がある (旧) Cocoaの日々: NSScreen座標系 から CGWindow座標系へ // NSScreen座標系からCGWindow座標系に変換 let selectedRectInCGImage = NSRect(x: selectedRect.origin.x, y: screenshotPanel.frame.height - selectedRect.origin.y - selectedRect.height, width: selectedRect.width, height: selectedRect.height) ScreenshotPanel.swift 座標の扱い 下記2つの座標を情報として扱う(参考: Swift: macOSでの座標系のややこしい話) メイン画面の左下を原点としたときの座標 パネルの画面の左下を原点としたときの座標 // メイン画面の左下を原点としたときの座標 private var pointInScreen: NSPoint { return NSEvent.mouseLocation } // パネルの画面の左下を原点としたときの座標 private var pointInPanel: NSPoint { return NSEvent.mouseLocation - self.frame.origin } 今回pointInScreenはカーソルがScreenshotPanel上にあるかを判断するためだけに使用する private var containsCursorInScreen: Bool { return self.frame.contains(pointInScreen) } 基本的に座標はScreenshotPanel上で考えるためpointInPanelを扱うことになる 絶対的な座標はScreenshotPanelから見ると関係ないことだし、またそうしたほうが扱いやすいため マウスアクションの取得 下記を使用してクリック・ドラッグ・移動時のマウスアクションを受け取る addLocalMonitorForEvents(matching:handler:) NSEvent.EventTypeMask マウスアクションに対して、選択領域の表示や座標のラベルの更新を行う また領域が選択された場合は、delegateを使って選択領域をViewControllerへ通知を返している // MARK: - Monitors private func setupMonitors() { monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown, handler: { (event) -> NSEvent? in self.startPoint = self.pointInPanel self.endPoint = nil self.configurePointLabel() NSCursor.crosshair.set() return event })) monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged, handler: { (event) -> NSEvent? in self.endPoint = self.pointInPanel self.configurePointLabel() NSCursor.crosshair.set() return event })) monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp, handler: { (event) -> NSEvent? in self.endPoint = self.pointInPanel if self.selectedRect == .zero { self.screenshotPanelDelegate?.screenshotPanelDidCancel(self) } else { self.screenshotPanelDelegate?.screenshotPanel(self, didSelectRect: self.selectedRect) } self.startPoint = nil self.endPoint = nil self.configurePointLabel() NSCursor.arrow.set() return event })) monitors.append(NSEvent.addLocalMonitorForEvents(matching: .mouseMoved, handler: { (event) -> NSEvent? in self.configurePointLabel() NSCursor.crosshair.set() return event })) } private func teardownMonitors() { for monitor in monitors { NSEvent.removeMonitor(monitor!) } monitors.removeAll() } CursorPointLabel.swift 概要 カーソルの右下に座標を表示するためのNSTextField 座標に関して、Mac標準のスクリーンショット時のカーソルは各ディスプレイの左上を原点としているが、今回は左下を原点として実装している 座標の文字属性 標準のスクリーンショット時のカーソルを参考に、座標の文字の属性を設定する 黒い画面でも視認するよう、右下に向かって白い影をつけているのが分かる 上: macOS標準の範囲指定時のカーソル 下: 今回実装したもの private var textAttributes: [NSAttributedString.Key : Any] = { var textAttributes: [NSAttributedString.Key : Any] = [ .foregroundColor : NSColor.black, ] let myShadow = NSShadow() myShadow.shadowColor = NSColor.white myShadow.shadowBlurRadius = 1 myShadow.shadowOffset = NSSize(width: 1, height: 1) textAttributes[.shadow] = myShadow return textAttributes }() 座標のフォーマット マウスカーソルの座標のカンマ区切りは下記の通り実装 【Swift5】数値を3桁毎にカンマ区切りにした文字列にする - xyk blog private var cunsomFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.groupingSeparator = "," // 区切り文字を指定 formatter.groupingSize = 3 // 何桁ごとに区切り文字を入れるか指定 return formatter }()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Lottie アニメーションの復習

今回の内容 コードと簡単解説 import Lottie ~~~~~~~~~~省略~~~~~~~~~~ override func viewDidLoad() { super.viewDidLoad() let animationStartButton = {() -> UIButton in let button = UIButton(frame: CGRect(x: view.frame.maxX / 4, y: view.frame.maxX / 3, width: view.frame.width / 2, height: view.frame.height / 20)) button.setTitle("Start", for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 20) button.titleLabel?.textColor = .white button.backgroundColor = .systemGreen button.layer.cornerRadius = 15.0 button.layer.shadowOffset = CGSize(width: 5, height: 5) button.layer.shadowRadius = 5.0 button.layer.shadowOpacity = 0.8 button.addTarget(self, action: #selector(showLottie), for: .touchDown) return button }() view.addSubview(animationStartButton) } @objc func showLottie(sender:UIButton){ let lottieAnimationView = AnimationView() lottieAnimationView.frame = CGRect(x: view.frame.maxX / 4, y: view.frame.maxY / 4, width: view.frame.width / 2, height: view.frame.width / 2) lottieAnimationView.backgroundColor = .clear lottieAnimationView.animation = Animation.named("ダウンロードしてきたデータの名前") //表示するアニメーションを設定 lottieAnimationView.contentMode = .scaleToFill lottieAnimationView.loopMode = .loop //繰り返しアニメーションをします lottieAnimationView.play() //アニメーションを開始します view.addSubview(lottieAnimationView) DispatchQueue.main.asyncAfter(deadline: .now() + 5) { lottieAnimationView.removeFromSuperview() //画面から消します lottieAnimationView.stop() //アニメーションを停止します } ~~~~~~~~~~省略~~~~~~~~~~ 終わり ご指摘、ご質問などありましたら、コメントまでお願い致します。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swiftにおける`Actor`とはなにか

1. Actorによって解決できること 複数スレッドでの非同期な作業を実装する場合、多くの場合で、キャッシュや結果などの共有の可変状態を参照するケースがあります。 これらは注意深く実装しないと、配列の更新中に別スレッドが配列にアクセスしてしまったり、真偽値の更新に失敗したりするなどの意図せぬ動きをすることがあります。 NSLockなどを注意深く用いることでこの問題を解決できますが、この解決方法はコストが高く、実装ミスなどにより容易に意図せぬ動きを発生させ、結果としてバグの原因となる可能性があります。 この問題を解決するのがActorです。 2. Actorの詳細 Actorは自分自身が管理する共有の可変状態への一貫した安全なアクセスを提供します。 このため、Actorの変数などにプログラムの他の要素が並列アクセスすることはありません。 Actorは通常の型と似た振る舞いをします。 Actorの目的は共有の可変状態を管理することのため、Actorはクラスと同じ参照タイプです。 下記のようなシンプルなコードでActorは定義でき、Actor定義であるために、valueへの並列アクセスは発生しません。 actor Wallet { var amountOf1000Yen: Int func add1000Yen() -> Int { amountOf1000Yen = amountOf1000Yen + 1 return amountOf1000Yen } } ただしActorのコードはその性質上非同期になるので、呼び出し側は下記のように awiat を付与し非同期であることを表す必要があります let counter = Wallet() Task.detached { print(await counter.add1000Yen()) } Task.detached { print(await counter.add1000Yen()) } この問題は、ActorにProtocolに適合させる際に問題となりえます。 例えば、Wallet Actorを拡張し、WalletID型をもたせ、WalletをHashableプロトコルに準拠させてみましょう。 actor Wallet { var id: WalletID var amountOf1000Yen: Int func add1000Yen() -> Int { amountOf1000Yen = amountOf1000Yen + 1 return amountOf1000Yen } } extension Wallet: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } } 実際には上記のコードはコンパイルできません。 なぜなら、hash関数の呼び出しは同期で行う必要があり、Actorへの同期呼び出しを許可してしまうと、Actorの内部状態の保護を継続できないからです。 この問題は、id を let にし、 hash関数の頭に nonisolated attributeを付与することで解決できます。 actor Wallet { let id: WalletID var amountOf1000Yen: Int func add1000Yen() -> Int { amountOf1000Yen = amountOf1000Yen + 1 return amountOf1000Yen } } extension Wallet: Hashable { nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id) } } nonisolated attributeをつけた関数はActorの管理外とみなされるため、varで宣言されたプロパティにアクセスすることはできません。 3. 特別なActor iOSアプリ開発において多くのUI処理は下記コードのようにMain Thread上で行う必要があります。 let userNameLabel = UILabel() // 実行スレッドがMainThread以外の場合に備えてUIへの反映をMainThreadで非同期で行うように指定する DispatchQueue.main.async { userNameLabel.text = "h1d3mun3" userNameLabel.isHidden = false view.addSubView(userNameLabel) } 毎回 DispatchQueue.main.async と書くのは面倒くさいですし、何よりコードを書いている最中に実行スレッドの考慮がもれ、バックグラウンドでUIを操作してしまうかもしれません。 Actorのなかでも、処理がMainThread上で行われることが保証されているMainActorというものがあります。 上記のサンプルをMainActorで書き直してみると下記のようになります let userNameLabel = UILabel() @MainActor func updateUserNameLabel(to userName: String?) { userNameLabel.isHidden = (userName == nil) guard let userName = userName else { return } userNameLabel.text = userName } await updateUserNameLabel(to: "h1d3mun3") この @MainActor Attributeは関数だけではなく下記のようにクラスにも適合させることができます @MainActor final class SomeUsefullMainThreaViewController: UIViewController { .... } 4. おわりに 同時に発表された async/await と併用することで非同期処理をよりかんたんに、そしてより安全に記述することができます。 私の関わるプロダクトにおいても、随時検討を進めた上で導入し、より安全なプロダクトにしていきたいと考えています。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】Qiita デイリー LGTM 数ランキング【自動更新】

他のタグ AWS Android Docker Git Go iOS Java JavaScript Linux Node.js PHP Python Rails React Ruby Swift TypeScript Vim Vue.js 初心者 集計について 期間: 2021-10-17 ~ 2021-10-18 GitHub スターをもらえるととっても励みになります LGTM 数ランキング 1 位: 写真をLEGOアートみたいにしちゃうライブラリを作ってみた MacOSX iOS Swift SwiftUI 4 LGTM 2 ストック @Kyome さん ( 2021-10-17 21:55 に投稿 ) 2 位: 3日でできるSwift爆速学習法 Xcode iOS 初心者 Swift 1 LGTM 0 ストック @jun_kato さん ( 2021-10-17 11:44 に投稿 )
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] Realmで後からプライマリキーを追加しマイグレーション

プライマリキーを後からRealm Objectに追加してマイグレーションする際に、少し手こずったので備忘録します。 Realm Objectクラスにプライマリキーを追加 Before class Diary: Object { @objc dynamic var text: String = "" @objc dynamic var createdAt: Date = Date() } After class Diary: Object { @objc dynamic var id: String = NSUUID().uuidString // primary key @objc dynamic var text: String = "" @objc dynamic var createdAt: Date = Date() //Primary Keyの設定 override static func primaryKey() -> String? { return "id" } } 追加した箇所 @objc dynamic var id: String = NSUUID().uuidString // primary key Realmにはオートインクリメントの機能がないため、プライマリキーを連番の数字にしたい場合は自分で加算するロジックを実装する必要があります。今回は後からプライマリキーを追加するため、既存のデータに対してもidを振る必要があり処理を書くのが色々面倒だと感じました。 その手間を省くため、一意の文字列を生成してくれるUUIDをプライマリキーの初期値に指定しました。 もし作成順でデータが欲しくなった場合はcreatedAtカラムをソートすればいいため、Realmの場合はこの方法がいいかなと思いました。 //Primary Keyの設定 override static func primaryKey() -> String? { return "id" } こちらの関数でプライマリキーのカラム名を返すことでRealm側がプライマリキーを認識します。 マイグレーション ユーザーがアップデートした際に、DBの変更を正しく反映するためにマイグレーションが必要です。 起動時に通るAppDelegate.siwftのapplication~~関数で以下のように記述します。 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { migration() return true } // Realmマイグレーション処理 func migration() { Realm.Configuration.defaultConfiguration = Realm.Configuration( schemaVersion: 1, migrationBlock: { migration, oldSchemaVersion in if(oldSchemaVersion < 1) { migration.enumerateObjects(ofType: Diary.className()) { oldObject, newObject in newObject!["id"] = NSUUID().uuidString } } } ) } RealmのSchema Versionを1へアップデートし既存のデータに対しプライマリキーのUUIDを追加しています。(初期のRealmのSchema Versionは0) if(oldSchemaVersion < 1) { migration.enumerateObjects(ofType: Diary.className()) { oldObject, newObject in newObject!["id"] = NSUUID().uuidString } } このif文について 他のサイトでは、カラムの追加と削除をする場合はこちらのifの中身は何も書く必要がなく自動でRealm側が認識してくれると記載がありましたが、僕の環境ではエラーが出ました。 既存データに対しidの初期値を与えることでエラーが消えたため、おそらくプライマリキーを設定したい場合はこのように記述する必要がありそうです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

required init?(coder: NSCoder) を今更調べた

Storyboardを使用せずUIViewやcellなどのサブViewをinit()すると required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } これを付けて!!と出てくるので,これまでは何も考えずにfixを押してましたが今更調べてみました。 そもそも init?(coder: NSCoder) { } これはNSCodingプロトコルで定義されているメソッドらしい。(単純にこのプロトコルをUIViewが準拠しているからお知らせしてくれる) NSCodingとは二つのメソッドを定義している init?...他に func encode(with: NSCoder)が存在する。 ドキュメントをそのまま翻訳すると、 init? ⇨ アーカイブされていないデータから初期化されたオブジェクトをかえす。必須なやつだよ encode ⇨ アーカイブで受け取ったやつをエンコードするよん あんましよくわからない。。(英語弱者なだけかもしれないけど。。) 続いて NSCodingProtocol NSCodingProtocolはクラスをエンコード、デコードするための二つのメソッドを提供しているよ(init?と encode())。アーカイブやディストリビューションをするための基盤となるプロトコルです。 アーカイブってなに? ⇨オブジェクトとデータをディスクに保存し格納すること ディストリビューションってなに? ⇨オブジェクトやデータと異なるスレッド間やプロセスでコピーすること)) OOP設計の原則に基づいてエンコード、デコードされるオブジェクトはインスタンス変数のエンコードやデコードをする責任があるので、 init?() 提供されたデータから自身をインスタンス化するようにオブジェクトに指示する必要がある。 encode()提供されたインスタンス変数をエンコードする(アーカイブする)指示をオブジェクトに指示する requiredが出てくるのではprotocolにinitをすると自動的にそうなるらしい。 うーん。理解が三ミリくらいしか進まなかった
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

復習として無限スクロールを実装した

 記事投稿するの久しぶり \‪( ˙꒳​˙ \三/ ˙꒳​˙)/‬ しばらくflutterをずっと触っていたのですが、久しぶりにswiftをやりたくなったので復習がてら無限スクロールの実装をしてみました。 注文を多くつけないならばそのままプロジェクトに突っ込んでも問題ないのではって感じのやつなので悩んでいる方はぜひみていってください だいたいの要件説明を先にしておく 無限スクロールができます pull to refreshができます 時間経過で再リクエストのフラグが立ちます。画面から戻ってきた際に読んだり、フラグを購読するなどして利用するとよいです 読み込んだとき、データがないなら次のページの読み込みはしません Userクラスを取得してくる想定です class User { init(name: String, age: Int) { self.name = name self.age = age } final let name: String final let age: Int } 簡単な実装しているファイルの紹介 ViewController.swift おなじみViewControllerさんが定義されています。ファイルを分けるのがめんどくさかったという理由だけでその下にPresenterくんとUserのEntityがもいますが気にしないでください。Presenterくんは多少活躍してます。 PagyController.swift 今回の主役。UIKitを読み込みたくも無いなとも思ったが、分けるよりかはpagingの機構と一緒に置いた方がシンプルでよいなという個人的な好みが垣間見えます。UIRefreshControllとUIActivityIndicatorViewが定義されています。 この二つだけです。 初心者にもわかりやすいですね。 ViewController.swift こちらはこんな感じです import UIKit class ViewController: UIViewController { @IBOutlet var table:UITableView! // pagingを管理している final let pagyController = Presenter<User>() required init?(coder: NSCoder) { super.init(coder: coder) // presenterで書いた方がいいか? // 一定期間でリフレッシュしたい pagyController.lifetime = 6 * 60 * 60 // 6時間毎に自動でrefresh } override func viewDidLoad() { super.viewDidLoad() // paging周りの処理を委譲 table.delegate = pagyController // loadingのUI設定 table.tableFooterView = pagyController.spinner table.refreshControl = pagyController.refreshCtl pagyController.reloadData = table.reloadData } } ViewControllerはこれだけです。 とてもシンプルで他のページにも無限スクロールを追加しやすくていいですね。 まぁUITableViewDataSourceのextensionがあるので「これだけ」というのは嘘なんですが。 /// MARK - TableViewDataSource extension ViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if let count = pagyController.listData?.count { return count } // throw してもいいと思ふ。 return 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = table.dequeueReusableCell(withIdentifier: "cell", for: indexPath) let label = cell.viewWithTag(1) as! UILabel // 読み込んだ新しいデータがUIに反映されたことがわかりやすいようにpage番号をcellに表示する if let userData = pagyController.listData?[indexPath.row] { label.text = "\(userData.name) : \(userData.age)" } return cell; } } /// presenter的な立ち回り /// viewmodelとかでも可 /// viewから切り離したいねというやつ class Presenter<T>: PagyController<T>, UITableViewDelegate { override init() { super.init() // scroll下端まで行ったとき呼ばれる pagingCallBack = { [weak self] completion in guard let page = self?.page else { return } // data取得処理 // 疑似apiとして1秒後にデータを取得 & merge DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // 4ページ目以降はpage終了のため読み込まない let fetchedList = page > 3 ? [] : [User](repeating: User(name: "airy", age: 24), count: 25) as! [T] completion(fetchedList, false) } } // scroll上端でpullしたとき呼ばれる refreshCallback = { completion in // page refresh処理 DispatchQueue.main.asyncAfter(deadline: .now() + 1) { let fetchedList = [User](repeating: User(name: "someone", age: 100), count: 25) as! [T] completion(fetchedList) } } // repositoryからとってきてる想定で読んで♡ // api requestで外部からデータを取得してくる // callback内でinitialLoadCompletionを呼ぶ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in let fetchedList = [T](repeating: User(name: "initialize", age: 24) as! T, count: 25) self?.initialLoadCompletion(data: fetchedList) } } // 画面遷移とかするやつ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { print(lifetime) /// FOR TEST /// 1秒ごとに自動でreloadする lifetime = 1 } } Presenterくんです。 genericsはpagingするときapiとかからとってくるデータの型です。 なので今回はapiからUserデータをとってくる想定ということですね。 ここではUIの振る舞いを記述しています。 この一言で説明できていると思う。 ちょっと気に入っていないのは初回ロードは別途で記述しているところ。 まぁこれは仕方ないのかな。 PagyController.swift これはやってることは大したことないんですが、テキストで説明してもわかりづらいのでコードを読んだ方がずっといいです。 大事なのはこの辺かな。 enum PagyStatus { case refreshing // pull to refreshで表示しているデータを刷新する case pageLoading // 画面下端まで行って次のpageを読み込む case available // 何も読み込み中でなく、load可能 case pageEnd // 読み込めるpageが無くなったのでrefreshのみ可能 } コードに何度もloadと表記がありますが、pull to refreshとpaging loadの二つを指しています。 // manage loading status internal var loadStatus: PagyStatus = .available { didSet { print(self.loadStatus) switch loadStatus { case .pageLoading: page += 1 spinner.startAnimating() case .available: spinner.stopAnimating() refreshCtl.endRefreshing() case .refreshing: page = 1 refreshCtl.beginRefreshing() case .pageEnd: spinner.stopAnimating() refreshCtl.endRefreshing() } } } そのPagyStatusですが、読み込みのステータスに応じてindicatorのステータスを連動させています refreshするとpageのデータは全部捨てちゃうのでpage=1, 新しいページを読み込むときはpageを+1しています。 これで管理するべきはこのstatusだけになるので実装がだいぶ楽になりますね! // 下端に行って次のページをとってくる処理 public func scrollViewDidScroll(_ scrollView: UIScrollView) { if (!isPagiable || pagingCallBack == nil) { return } if (hasInitialLoad == false) { reloadData?() return } let currentOffsetY = scrollView.contentOffset.y let maximumOffset = scrollView.contentSize.height - scrollView.frame.height let distanceToBottom = maximumOffset - currentOffsetY if(distanceToBottom < 200) { self.loadStatus = .pageLoading pagingCallBack?() { [weak self] data, hadError in if (hadError) { self?.loadStatus = .available return } if (data.isEmpty) { self?.loadStatus = .pageEnd } else { self?.loadStatus = .available self?.listData?.append(contentsOf: data) self?.reloadData?() } } } } pagingの肝となるのはこの関数だと思います。 流れとしては、 スクロールされた際に、tableviewの全体のheightから、tableviewのフレームのheightを引いて、あとどれくらいスクロールに余裕があるか確認しています。 あとはPagyStatusに応じてこねくり回していくだけですね! timer = Timer.scheduledTimer(timeInterval: TimeInterval(lifetime), target: self, selector: #selector(updateLifetimeStatus), userInfo: nil, repeats: true) ////////////////離れたとこ///////////////////////////// // set need lifetime refresh @objc func updateLifetimeStatus() { needLifetimeRefresh = true } // 画面遷移から戻ってきたときに呼んで、古い情報を更新する func lifetimeRefresh() { if (!needLifetimeRefresh || loadStatus != .available) { return; } refresh() } lifetimeRefreshです。(はい) timerを設定しており、一定期間ごとにneedLifetimeRefreshというフラグを立てています。 githubにあげているコードではlifetimeRefreshは呼んでいませんが、最初に書いてあるとおり、別の画面から戻ってきた際に呼んだり、購読するなどして 使ってみてください。 ざっとした解説はこんな感じです。 お読みいただきありがとうございます。 余談 唐突にやりたくなってこのpagingを作成しました。githubにあげるつもりも、ましてや記事にするつもりも全くなかったので完成品のmasterコミットひとつだけで読みづらいかもですがご容赦を。 昔、一瞬使ったきりStoryBoardを使ってなかったんですが今回いい機会だと思って使ってみました。感想としては、「やはりだるい。」笑 色々文句はあるが何より、マウス動かすのが圧倒的にだるいんじゃ!笑 notionとかに慣れているとqiitaのmarkdownはheadlineの#の後ろにスペースいらないのが逆に困りますね。 久しぶりにqiitaでちゃんと文字を入力したけどまだmarkdownのデコーダはアレなままなんですね。 見直して思ったけどなんでViewControllerのinitにcallback書いてるんやろ。。 Presenterでええやん。。 ってことで投稿して10分程度で書き換えた。 以上
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリからスプレッドシートにデータを書き込む

iOSアプリからスプレッドシートにデータを書き込みたい時が人生にはあります。その方法です。 大まかな流れ iOSからスプレッドシートのデータを書くためには、スプレッドシート側にGAS(Google Apps Script)を実装する必要があります。GASでスプレッドシートをWriteするPOSTを実装し、iOSからそこ目掛けて通信する実装をすれば完成です。 GASの実装 スプレッドシートの「シート1」シートに下記のようなデータがあるものとします。 スプレッドシートメニューのツール>スクリプトエディタを選択します。 するとApps Scriptエディタが開きます。 ここに処理を実装していきます。 Writeする スプレッドシートにデータを書き込む方法は以下の通りです。 function myFunction(){ var targetFileID = DriveApp.getFileById("xxxxx") var targetFile = SpreadsheetApp.open(targetFileID) var targetSheet = targetFile.getSheetByName("シート1") var targetRow = targetSheet.getLastRow() + 1 targetSheet.getRange(targetRow, 1, 1, 4).setValues([["次郎", 28, 30, 1]]) } DriveApp.getFileById("xxxxx")でGoogle Drive内にある対象ファイルのIDを取得します。※1 SpreadsheetApp.open(targetFileID)で対象ファイルを取得します。 targetFile.getSheetByName("シート1")で対象ファイル内の対象シートを取得します。 targetSheet.getLastRow() + 1で現在入力済みの最終行を取得し1を足すことで次の空白行の行数を取得します。 targetSheet.getRange(targetRow, 1, 1, 4).setValues([["次郎", 28, 30, 1]])で対象シート内の対象となる行(ここでは4行目)1列目のセルを起点に、1行分4列目までに次郎", 28, 30, 1の値を書き込みます デバッグするにはメニューバーにある▷実行を押します。 スプレッドシートの内容にアクセスする場合、初回はアクセス権限に関する表示が出るので対処します。 ※1:ファイルIDはURLの https://docs.google.com/spreadsheets/d/xxxxx/edit#gid=0 のxxxxxにあたる部分です 実行後、スプレッドシート側に書き込みが行われていることが確認できます。 POSTを実装する GASでWebAPIを実装できます。GASの中でPOSTならdoPost(e)を定義し実装します。これをウェブアプリとして公開するとWebAPIが発行され外部からアクセスできるようになります。 doPost(e) スプレッドシートにWebAPI経由で書き込む方法は以下の通りです。 //POSTされるデータ形式 postData: { 'contents' : '{"users": [{"name": "次郎", "age": "28", "count": "30", "error": "1"}]}' } 前提として、GASのdoPost(e)で送られたデータは、e.postData["contents"]に格納されることになっています。これを踏まえ、doPost内の処理を実装していきます。 function doPost(e){ var targetFileID = DriveApp.getFileById("xxxxx") var targetFile = SpreadsheetApp.open(targetFileID) var targetSheet = targetFile.getSheetByName("シート1") var targetRow = targetSheet.getLastRow() + 1 var json = JSON.parse(e.postData["contents"]) var user = json["users"][0] targetSheet.getRange(targetRow, 1, 1, 4).setValues([[user.name, user.age, user.count, user.error]]) } 書き込む行(データ最下行の1つ下の行)の取得まではWriteすると同じです。 JSON.parse(e.postData["contents"])でPOSTされたデータそのものを取得します。 usersキーのバリューとしてuserを格納した配列があり、ここではその1つ目を取得します。 setValues()でPOSTされたuserデータの書き込みを行います。 デプロイを行い、WebAPIを発行します。 こちらの手順を参考にして下さい。 すると、このような形でWebAPIが取得できます。 ここではusersには1つしかデータがありませんが、 当然一度に複数人のデータを送り全部書き込ませることも可能です。 内容の修正を行った際は忘れずにWebAPIの処理内容も更新してください。 iOSの実装 ここではiOSからGASのWebAPIへのアクセスを容易にするためにAlamofireとSwiftyJSONを用います。 import UIKit import Alamofire import SwiftyJSON class ViewController: UIViewController { let url = "https://script.google.com/macros/s/xxx/exec" //上記で取得したWebAPI @IBOutlet weak var name: UITextField! @IBOutlet weak var age: UITextField! var count: Int! var error: Int! override func viewDidLoad() { //何かしら下記2つの値を取得する処理など count = 45 error = 3 } @IBAction func onTap(_ sender: Any) { //POSTするデータの生成 var user: Dictionary<String, Any> = [:] user["name"] = name.text //香織 user["age"] = age.text //24 user["count"] = self.count user["error"] = self.error var users: Array<Dictionary<String, Any>> = [] users.append(user) //POST処理 AF.request(url, method: .post, parameters: ["users": users], encoding: JSONEncoding.default, headers: nil ).responseJSON{ (response) in switch response.result { case .success(let elm): print("success") case .failure(let error): print("error", error) } } } } doPost(e)で記載した各データと同じキーを用いて辞書型オブジェクトを作成し、配列に格納します。 AF.request(_, method: .post)で上で取得したWebAPIにPOSTで通信を行います。また、パラメータを辞書型で渡し、エンコードをJSON形式にします。 POSTを行うと、GAS側からのレスポンスが来るので受け口を用意します。 .success(let elm)のelmにはGAS側のdoPostに実装したreturnの値が入ります。必要に応じて実装してください。 .failure(let error)はPOSTに失敗した場合に実行されます。 これをシュミレータで実行し、ボタンを押すとこうなります。 まとめ iOSアプリからスプレッドシートにデータを書き込む方法をまとめました。スプレッドシートのデータを読み込む方法もまとめましたので、よろしければこちらもご覧ください。 おまけ doPost(e)を実装する際、POSTデータを修正する度にGASとiOSを直しiOSシミュレータからアクセスするのは面倒です。 GAS側で下記のように実装し実行する関数をdebugDoPostにすることで、擬似的に外部からのPOSTを実装できるので効率的です。 function debugDoPost(){ const e = { postData: { 'contents' : '{"users": [{"name": "次郎", "age": "28", "count": "30", "error": "1"}]}' } } doPost(e) }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリからスプレッドシードにデータを書き込む

iOSアプリからスプレッドシートにデータを書き込みたい時が人生にはあります。その方法です。 大まかな流れ iOSからスプレッドシートのデータを書くためには、スプレッドシート側にGAS(Google Apps Script)を実装する必要があります。GASでスプレッドシートをWriteするPOSTを実装し、iOSからそこ目掛けて通信する実装をすれば完成です。 GASの実装 スプレッドシートの「シート1」シートに下記のようなデータがあるものとします。 スプレッドシートメニューのツール>スクリプトエディタを選択します。 するとApps Scriptエディタが開きます。 ここに処理を実装していきます。 Writeする スプレッドシートにデータを書き込む方法は以下の通りです。 function myFunction(){ var targetFileID = DriveApp.getFileById("xxxxx") var targetFile = SpreadsheetApp.open(targetFileID) var targetSheet = targetFile.getSheetByName("シート1") var targetRow = targetSheet.getLastRow() + 1 targetSheet.getRange(targetRow, 1, 1, 4).setValues([["次郎", 28, 30, 1]]) } DriveApp.getFileById("xxxxx")でGoogle Drive内にある対象ファイルのIDを取得します。※1 SpreadsheetApp.open(targetFileID)で対象ファイルを取得します。 targetFile.getSheetByName("シート1")で対象ファイル内の対象シートを取得します。 targetSheet.getLastRow() + 1で現在入力済みの最終行を取得し1を足すことで次の空白行の行数を取得します。 targetSheet.getRange(targetRow, 1, 1, 4).setValues([["次郎", 28, 30, 1]])で対象シート内の対象となる行(ここでは4行目)1列目のセルを起点に、1行分4列目までに次郎", 28, 30, 1の値を書き込みます デバッグするにはメニューバーにある▷実行を押します。 スプレッドシートの内容にアクセスする場合、初回はアクセス権限に関する表示が出るので対処します。 ※1:ファイルIDはURLの https://docs.google.com/spreadsheets/d/xxxxx/edit#gid=0 のxxxxxにあたる部分です 実行後、スプレッドシート側に書き込みが行われていることが確認できます。 POSTを実装する GASでWebAPIを実装できます。GASの中でPOSTならdoPost(e)を定義し実装します。これをウェブアプリとして公開するとWebAPIが発行され外部からアクセスできるようになります。 doPost(e) スプレッドシートにWebAPI経由で書き込む方法は以下の通りです。 //POSTされるデータ形式 postData: { 'contents' : '{"users": [{"name": "次郎", "age": "28", "count": "30", "error": "1"}]}' } 前提として、GASのdoPost(e)で送られたデータは、e.postData["contents"]に格納されることになっています。これを踏まえ、doPost内の処理を実装していきます。 function doPost(e){ var targetFileID = DriveApp.getFileById("xxxxx") var targetFile = SpreadsheetApp.open(targetFileID) var targetSheet = targetFile.getSheetByName("シート1") var targetRow = targetSheet.getLastRow() + 1 var json = JSON.parse(e.postData["contents"]) var user = json["users"][0] targetSheet.getRange(targetRow, 1, 1, 4).setValues([[user.name, user.age, user.count, user.error]]) } 書き込む行(データ最下行の1つ下の行)の取得まではWriteすると同じです。 JSON.parse(e.postData["contents"])でPOSTされたデータそのものを取得します。 usersキーのバリューとしてuserを格納した配列があり、ここではその1つ目を取得します。 setValues()でPOSTされたuserデータの書き込みを行います。 デプロイを行い、WebAPIを発行します。 こちらの手順を参考にして下さい。 すると、このような形でWebAPIが取得できます。 ここではusersには1つしかデータがありませんが、 当然一度に複数人のデータを送り全部書き込ませることも可能です。 内容の修正を行った際は忘れずにWebAPIの処理内容も更新してください。 iOSの実装 ここではiOSからGASのWebAPIへのアクセスを容易にするためにAlamofireとSwiftyJSONを用います。 import UIKit import Alamofire import SwiftyJSON class ViewController: UIViewController { let url = "https://script.google.com/macros/s/xxx/exec" //上記で取得したWebAPI @IBOutlet weak var name: UITextField! @IBOutlet weak var age: UITextField! var count: Int! var error: Int! override func viewDidLoad() { //何かしら下記2つの値を取得する処理など count = 45 error = 3 } @IBAction func onTap(_ sender: Any) { //POSTするデータの生成 var user: Dictionary<String, Any> = [:] user["name"] = name.text //香織 user["age"] = age.text //24 user["count"] = self.count user["error"] = self.error var users: Array<Dictionary<String, Any>> = [] users.append(user) //POST処理 AF.request(url, method: .post, parameters: ["users": users], encoding: JSONEncoding.default, headers: nil ).responseJSON{ (response) in switch response.result { case .success(let elm): print("success") case .failure(let error): print("error", error) } } } } doPost(e)で記載した各データと同じキーを用いて辞書型オブジェクトを作成し、配列に格納します。 AF.request(_, method: .post)で上で取得したWebAPIにPOSTで通信を行います。また、パラメータを辞書型で渡し、エンコードをJSON形式にします。 POSTを行うと、GAS側からのレスポンスが来るので受け口を用意します。 .success(let elm)のelmにはGAS側のdoPostに実装したreturnの値が入ります。必要に応じて実装してください。 .failure(let error)はPOSTに失敗した場合に実行されます。 これをシュミレータで実行し、ボタンを押すとこうなります。 まとめ iOSアプリからスプレッドシートにデータを書き込む方法をまとめました。スプレッドシートのデータを読み込む方法もまとめましたので、よろしければこちらもご覧ください。 おまけ doPost(e)を実装する際、POSTデータを修正する度にGASとiOSを直しiOSシミュレータからアクセスするのは面倒です。 GAS側で下記のように実装し実行する関数をdebugDoPostにすることで、擬似的に外部からのPOSTを実装できるので効率的です。 function debugDoPost(){ const e = { postData: { 'contents' : '{"users": [{"name": "次郎", "age": "28", "count": "30", "error": "1"}]}' } } doPost(e) }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIの「$」って何ですか?

$ってなに よくこのようにTextFieldなどで、stateプロパティに「\$」をつけたものを渡します。 「$」をつけると値の変更を検知してプロパティを更新してくれるようなる と何となく理解していましたが、理解が不十分なので調べてみました。 @State private var text1 = "" var body: some View { TextField("text1", text: $text1) } 公式ドキュメントを読んでみる まずは公式ドキュメントに何か書いてないかと思い Stateのドキュメントを確認してみました。 To pass a state property to another view in the view hierarchy, use the variable name with the \$ prefix operator. This retrieves a binding of the state property from its projectedValue property. Viewの階層下にある他のViewにstateプロパティを渡すには、「$」を変数の先頭つけて使う。 これはそのprojectedValueからstateプロパティのbindingを取得する。 太文字部分が「$」をつけてできることを示していることはわかりましたが またわからないことが出てきました。 projectedValueってなに A binding to the state value. StateのprojectedValueを一言で表すと "state valueへのbinding" のようです。 getterプロパティとして用意されています。 var projectedValue: Binding<Value> { get } Bindingってなに A property wrapper type that can read and write a value owned by a source of truth. 一言で表すと "信頼できる一つの情報源に所有された値を読み書きできるプロパティラッパータイプ" のようです。 難しいのでもう少し読んでみます。 Use a binding to create a two-way connection between a property that stores data, and a view that displays and changes the data データを格納するプロパティとViewを表示したり更新したりするデータの間の双方向接続を作るためにbindingを使用する BindingがプロパティとUIの双方向の接続のためのものであることがわかりました。 つまり 「\$」をつけるとprojectedValueというgetterプロパティへアクセスすることになる。 Stateで定義したプロパティのprojectedプロパティは stateプロパティのbindingを取得することになるので 今回の例の場合 $text は Binding<String> を表しています。 TextFieldで実際に確認してみた TextFieldを使って確認していきます。 TextFieldの第二引数にはBinding<String>を渡さなければいけないため stateプロパティをそのまま渡すとエラーになります。 TextFieldはユーザーの入力を受け付けるので 入力された値とプロパティの値が同期されるように Bindingでラップした値を入れる必要があるのは理解できますね。 @ObservedObjectの@Publishedプロパティの場合どうなる? もう少し深掘りしていきます。 今度は@ObservedObjectの@Publishedの値をTextFieldに渡す場合に どこに「\$」を付けるべきなのかを確認していきます。 4つの選択肢のうちどれが正解でしょうか? $model.text - ① $model.$text - ② model.$text - ③ model.text - ④ 私は@Published のprojectedValueが 値をBindingでラップした値を返すと考え それがBindingになると思ったので③model.$textかなと思いましたが違っていました。 正解は①$model.textでした。 他の選択肢はエラーが出たので確認していきます。 ③model.$text 予想していたこれからみていきます。 Cannot convert value of type 'Published.Publisher' to expected argument type 'Binding' 'Binding'ではなく'Published.Publisher'になっているようです。 公式ドキュメント確認すると @PublishedのprojectedValueはPublished<Value>.Publisherでした。 そのため model.$text は Published.Publisher を表しており、不正解です。 ②$model.$text Cannot convert value of type 'Binding.Publisher' to expected argument type 'Binding' 'Binding.Publisher'になっています。 @ObservedObjectのprojectedValueを確認すると ObservedObject<ObjectType>.Wrapperになっていましたがパッとみてわかりません。 説明文を読むとこうありました。 A projection of the observed object that creates bindings to its properties using dynamic member lookup. 理解しづらいですが私は 保持しているプロパティのBindingを動的に生成する監視オブジェクトの投影 つまり 複数のプロパティを持つBindingだと飲み込みました。 そのため \$model.$text は Binding<Published.Publisher> を表しており、不正解です。 ④model.text これは単純にStringを表しているので不正解です。 @ObservedObjectをネストさせたら・・・ さらに@ObservedObjectの中に@ObservedObjectを入れてみたらこの2通りがエラーになりませんでした。 TextField("text", text: $model.model.text) TextField("text", text: model.$model.text) ただビルドして入力してみるとこのようなwarningが出ました。 Binding action tried to update multiple times per frame. おそらく上記のような使い方はしないほうがよさそうです。 これに関しては別記事にしたいと思います。 次に記事にしたいこと ObservedObjectをネストした場合の「$」の使い方 SwiftUIの「@」って何ですか?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift][DI] Resolverを使ったDIについてのメモ

はじめに 個人開発にResolverというライブラリを使ってみました。 ResolverやDIについて色々とメモしていたのですが、サンプルを挟めば記事として役に立ってもらえるのではと思ったので投稿します。 (元がメモなので語尾がちょっと雑な部分もありますがご容赦ください) Resolverとは swift製の軽量DI/サービスロケーターライブラリ。 DIとは 依存性の注入のこと。 依存性とはオブジェクト等の持つ依存関係のこと。 それらを外部から与えることができるようにするデザインパターンをDI(Dependency Injection)と言う。 例えばViewModelでUseCaseを保持し、UseCaseの関数を呼び出している設計の場合、ViewModelはUseCaseに依存していると言える。 class LoginViewModel { let loginUseCase = LoginUseCase() func login() { loginUseCase.login() } } この時ViewModelが依存しているUseCaseを外部から与えることができると以下の利点がある。 ユースケースに応じて依存を差し替えることができる。 例えばテストの時にモックに差し替えたり 抽象に依存しておいてその抽象に依存した具体ならなんでも注入できるようにするなど なので結果としてテストが書きやすく、再利用性の高い作りになる。 またinitializerによって注入する場合や引数によって注入する場合、利用側から利用時に必要な依存対象が分かり、使い方を理解しやすくなる。 モックに差し替えるとは テスト用のダミーの結果を返すインスタンスをモックと呼ぶ。 依存をモックに差し替えることで安全かつ網羅的にテストが書ける。 例えば通信結果を取得して何かする関数のテストでは動作が通信結果に依存する。 しかしテストの度に通信を送るのは無駄にリクエストを増やしたりDBにデータを操作しないといけなかったりでよろしくないので、結果のダミーを用意してそれを返すようにしたい。 enum LoginError: LocalizedError { case dummy } protocol LoginUseCaseProtocol { func login(_ completion: (Result<Void, Error>) -> Void) } struct LoginFailureUseCaseMock: LoginUseCaseProtocol { func login(_ completion: (Result<Void, Error>) -> Void) { completion(.failure(LoginError.dummy)) } } struct LoginUseCase: LoginUseCaseProtocol { let loginRepository: LoginRepositoryProtocol init(loginRepository: LoginRepositoryProtocol) { self.loginRepository = loginRepository } func login() async throws { try await loginRepository.login() } } // ViewModelのテストの時にmockに差し替える。 class LoginViewModel { let loginUseCase: LoginUseCaseProtocol init(loginUseCase: LoginUseCaseProtocol) { self.loginUseCase = loginUseCase } } // 型を抽象化しているのでmockでも初期化できる。 let loginViewModel = LoginViewModel(loginUseCase: LoginFailureUseCaseMock()) 抽象に依存しておいて抽象に依存した具体を注入するとは 型をprotocolで定義しておけば、そのprotocolに準拠したものならなんでも注入できる。 例えば書籍を登録する画面のViewModelが登録する本に依存していたとして、本の種類はたくさんあるけどprotocolは共通という場合は本の全種類分登録画面を用意しなくても良い。 class BookRegisterViewModel { let book: BookProtocol // bookを差し替えても使える let bookRegisterUseCase: BookRegisterUseCase init(book: BookProtocol, useCase: BookRegisterUseCase) { self.book = book self.bookRegisterUseCase = BookRegisterUseCase } func register() { bookRegisterUseCase.register(book: book) } } 依存性注入の方法 DIは依存性を注入することだが、その注入の仕方も色々ある。 Resolverでは以下の6つの方法に対応している。 1.Interface Injection 2.Property Injection 3.Constructor Injection 4.Method Injection 5.Service Locator 6.Annotation いずれの方法も依存対象のインスタンスを登録しておき、Resoverに解決を任せる。 Service Locatorとは 依存先のオブジェクトを解決するための役割もったオブジェクトのこと。 Service Locatorに依存を登録おいて利用時にLocator経由で解決する。そのため利用時まで具体的な型が決まらないインスタンスに対しても依存の解決を行うことができる。 Service Locatorが依存を解決してくれれば上位レイヤーで対象物を初期化して下位レイヤー注入する必要がない。 ので不要なpublicやimportが減る。ViewModelの初期化でrepositoryまで一気に初期化するようなコードもなくなる。 しかし依存対象を先に登録しておく必要があり、登録漏れがあると依存を解決できない。 以下の記事が参考になる。 ServiceLocatorが推奨されない理由も解説してある。 本来不要であるServiceLocatorへの依存が発生してしまう 依存関係が分かりにくくなる テストが困難になる なるほどなあと思う。 一方でレイヤーごとにターゲットを分割しているiOSプロジェクトではServiceLocatorに依存解決を任せるメリットもあると思う。 ServiceLocatorから依存が取り出せることでマルチモジュールなプロジェクトで一貫して依存の解決を担うモジュールが用意できる。 依存関係の分かりにくさは後述する@Injectedによるアノテーションの使用とかで解決できるし、テストもServiceLocatorを丸っとモックに差し替えられるのはそれはそれで楽でいいかなという気もする。 なのでネックになるのはやっぱり依存対象の登録漏れだと思う。 しかしクックパッドではこの課題をsourceryによるコードの自動生成で防止している。 Resolverの特徴 軽量 700行くらいの単一のswiftfileで書かれている。 コードが少ないしシンプルなので最悪サポートされなくなってもプロジェクトに組み込んで自分でもメンテできるのではと思う。(実際は大変だろうけど) annotationによる自動的な依存解決をサポートしてる。 @Injectをつけて宣言したプロパティはResolverが登録されたサービスから一致するものを探して注入する。 Service Locatorに毎回アクセスして依存を取り出すようなコードを書かなくて良いので記述量が減る。 あとswinjectよりパフォーマンスに優れていたり、ユニットテストが完全に用意されてたりするのが売りっぽい。 詳しくは公式を参照 Resolverによる依存の登録と解決 Resolverを使ってやることは大きく分けると「依存対象の登録」「依存の解決」の二つだけ。 依存対象の登録 依存対象の登録はregister関数によって行う。 import Resolver extension Resolver: ResolverRegistering { public static func registerAllServices() { register { LoginUseCase() } } } 上記のようにregisterメソッドのクロージャ内でで初期化したインスタンスがResoverに登録される。 registerAllServicesに記載したregisterはResolverがよしななタイミングで実行してインスタンスを登録してくれる。 実装を見てみるとresolveやregisterが呼ばれるタイミングで一回だけ実行するようにフラグで制御していた。 @inline(__always) private func registrationCheck() { guard registrationNeeded else { return } if let registering = (Resolver.root as Any) as? ResolverRegistering { type(of: registering).registerAllServices() } registrationNeeded = false } 上記のregisterAllServicesのタイミング以外でも、自分で登録することもできる。 registerはResolverのstaticな関数のため、Resolver.registerを呼べば任意のタイミングでの登録も可能。 依存の解決 resolve()を呼ぶことで解決できる。 class LoginViewModel { let loginUseCase: LoginUseCase init(loginUseCase: LoginUseCase) { self.loginUseCase = loginUseCase } } let loginViewModel = LoginViewModel(loginUseCase: Resolver.resolve()) Swift5.1から@Injectedというアノテーションによる解決をサポートしており、個人的にはこっちが好きである。 依存性が注入されることが明示できて、かつresolve()を使って自分で解決する必要がない。 class LoginViewModel { @Injected var loginUseCase: LoginUseCase } let loginViewModel = LoginViewModel() // initするだけで登録しておいたuseCaseが勝手に注入される。 Resolverを使うと何が嬉しいか ResolverというよりService Locatorによる恩恵でもあるが、Resolverを使うと個別のレイヤーで依存性を解決できる。 例えば自分でUseCaseの依存性をViewModelに注入するときは以下のようなコードを書く。 let loginViewModel = LoginViewModel( loginUseCase: LoginUseCase( loginRepository: .init() ) ) class LoginViewModel { let loginUseCase: LoginUseCaseProtocol init(loginUseCase: LoginUseCaseProtocol) { self.loginUseCase = loginUseCase } func login() { self.loginUseCase.login() } } protocol LoginUseCaseProtocol { var loginRepository: LoginRepositoryProtocol { get } init(loginRepository: LoginRepositoryProtocol) func login() } class LoginUseCase: LoginUseCaseProtocol { var loginRepoisitory: LoginRepositoryProtocol init(loginRepository: LoginRepositoryProtocol) { self.loginRepository = loginRepository } func login() { self.loginRepoisitory.login() } } 上記のコードのようにレイヤーごとにprotocolを用意してinitializerで注入していく場合、以下の課題がある。 毎回initで全部の依存性を渡さないといけない。 ネストが深いし読みづらい。 上位レイヤーのインスタンスを初期化する時に下位レイヤーの依存性も一気に解決しないといけない。 viewModelの初期化にrepositoryまでアクセスするのは辛かった。 repositoryをdata層、viewModelの初期化をpresentation層で行う場合にpresentation層でdata層をimportしないといけない。 かと言ってdefault引数でinitするのは依存性を注入できるようにしていること分かりにくくなるのでなんだかなあといった感じになる。 Resolverを使うと以下のようになる。 let loginViewModel = LoginViewModel() class LoginViewModel { @Injected var loginUseCase: LoginUseCaseProtocol func login() { self.loginUseCase.login() } } protocol LoginUseCaseProtocol { var loginRepository: LoginRepositoryProtocol { get } func login() } class LoginUseCase: LoginUseCaseProtocol { @Injected var loginRepoisitory: LoginRepositoryProtocol func login() { self.loginRepoisitory.login() } } register { LoginUseCase() as LoginUseCaseProtocol } register { LoginRepository() as LoginRepositoryProtocol } それぞれの層でのinitでの注入が不要になり、ViewModelの初期化でRepositoryまで初期化しなくて良くなる。 @Injectedで依存が注入されることも分かりやすいのが個人的に良いと思う。 当然デメリットもあり、依存対象の登録が漏れていると@Injectedのプロパティの解決の時にクラッシュする。 クラッシュ自体を防止するために、依存を解決できなかった場合にnilを入れるようにしたりもできる。 class InjectedViewController: UIViewController { @OptionalInjected var service: XYZService? func load() { service?.load() } } あと初期化時に依存を解決できない場合に備えて@LazyInjectedで遅延注入したりできる。 class NamedInjectedViewController:UIViewController { @LazyInjected var service:XYZNameService // searviceが呼び出されるまで解決されない。 func load(){ service.load() // このタイミングで解決される。 } } あと引数を渡して依存を解決したりもできる。 詳しくはこちらを参照。 やっぱり依存対象の登録漏れ自体を防止できるのが一番なのでクックパッドがやっているように、依存対象のDescriptorを用意して、DescriptorのOutputの型はregisterの実装を強制するみたいなことができると安心。 ついでに「@Injectedで宣言される型やresolve()によって解決される型はDiscriptorの作成を強制する」のようなルールもSouceryで設定できたらいいなと思うけどそこまでできるんだろうか。要調査。 Swinjectとの比較 SwinjectはYAMLの設定ファイルからコードの自動生成する機能がある。 まとめ マルチモジュールなiOSプロジェクトではResolverに依存解決を任せるメリットはある。 ボイラープレートを減らす。 依存解決のために望まないレイヤーの公開をしなくてよくなるなどなど。 怖いのは依存対象の登録漏れ。 しかしsouceryのようにコードの自動生成ツールを使えば依存対象の登録を強制でき、登録漏れを防止できそう。(まだ未検証。これからやっていき。) ※ DI周りの知識は参照する記事やフレームワークによって色々と認識が違う部分が多そうだったので、本記事でもそれは違うのではというところがあるかと思います。もしそういった箇所があればお気軽にコメントしていただけると幸いです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む