20211203のSwiftに関する記事は8件です。

Fat Singletonを救いたい【Swiftでデザインパターン攻略 #2】

はじめに 今回は「FatSingletonを救いたい」と題して、Singletonパターンについて書きました。 自分は、Singletonパターンを結構使うので、かなり馴染み深いデザインパターンです。 Singletonパターンは便利なのですが、気をつけて使わないと、何でもありコードになってしまうので、そのあたりを記事にできたらと思い書いてみました。 この記事で学べること SwiftのSingletonパターンの書き方 Singletonのメリット、デメリット FatSingletonにならないようにするためのリファクタリング <- Point UnitTestに都合の良いSingletonクラスにしておく <- Point Singletonパターンとは 概要 特定のクラスのインスタンスがアプリケーションの中で1つしか生成されないことを保証するデザインパターンのことです。 Wikipediaより SwiftにおけるSingletonパターン iOSアプリ開発においては、アプリ内の共通利用データを保持させるために用いられることが多いパターンです。 複数のクラスから参照が必要なものの、同じクラスの複数のインスタンスが乱立するとデータ整合性が保ちづらくなり、バグが発生することを防ぎたいときに使うことが多いです。 SessionManagerやLoginManagerがあるあるです。 メリット アプリ全体から利用できるため、アプリ内で共通利用したいデータをメモリに持たせたいときに活用することができます。 また、インスタンスが1つしか生成されないので、データの整合性が保証されます。 デメリット メリットの性質がそのまま仇となるのですが、「どこからでも参照可能」という便利がゆえに、本来の役割を超えたプロパティやメソッドが書かれてしまい、FatViewControllerならぬFatSingletonが完成されてしまいます。 また、Singletonクラスに色々なプロパティやメソッドを追加しすぎたあまり、知らない間に密結合なコードになり、UnitTestがままならなくなります。(←体験談) Singletonパターンの雛形を実装する // 1. 継承を防ぐためにfinalをつける final class FatDataStore { // 2. 外部から参照するためにstatic変数でインスタンスを返すプロパティを用意する static let shared = FatDataStore() // 3. 外部からインスタンス化されないためにinitをprivateにする private init() {} // 4. 必要に応じて変数を追加する(この2つの値はAPIなどから取得した値をSetするものとする) var realMoney = 0 var electricMoney = 0 // 5. 必要に応じてメソッドを追加する func calcTotalMoney() -> Int { return realMoney + electricMoney } func calcRealMoneyRatio() -> Int { return realMoney / calcTotalMoney() * 100 } func calcBalance(payMoney: Int) -> Int { return calcTotalMoney() - payMoney } } // 利用する時 print(FatDataStore.shared.realMoney) // 0 FatDataStore.shared.realMoney = 1000 print(FatDataStore.shared.realMoney) // 1000 1〜3は、Singletonクラスを作る上で定番のパターンで、とりあえずこのようにしておけばSigletonパターンとして使えると言って問題ないです。 "Fat" Singleton問題 結論から言うと、上記のSingletonクラスはFatSingletonになる危険性があります。 なぜなら、上記のSingletonクラスはあくまでDataStoreとしての位置づけですが、自身のプロパティを用いた計算、アクセス元から渡された引数を用いた計算もSingletonクラスで行われています。つまりこのクラスは、DataStoreとしての責務を超えて「なんでも実行クラス」になってしまっていると言えます。 このように、Singletonはアプリ内で共有する利用頻度の高いプロパティを持つことから、それに関するメソッドがSingletonクラス内に乱立してしまうという危険性があるということです。 というわけで、計算に関するメソッドはちゃんと計算クラスを定義することでFatSingletonを防ぐことができます。 class Calcrator { let dataStore: FatDataStore // Singletonクラスのプロパティを利用したければ初期化時にセットする init(dataStore: FatDataStore) { self.dataStore = dataStore } func calcTotalMoney() -> Int { return dataStore.realMoney + dataStore.electricMoney } func calcRealMoneyRatio() -> Int { return dataStore.realMoney / calcTotalMoney() * 100 } func calcBalance(payMoney: Int) -> Int { return calcTotalMoney() - payMoney } } UnitTestを考慮してもう一歩踏み込んでみる FatSingleton問題以外のもう1つのデメリットであるこれも解消してしまいましょう。 また、Singletonクラスに色々なプロパティやメソッドを追加しすぎたあまり、知らない間に密結合なコードになり、UnitTestがままならなくなります。(←体験談) サンプルコードのrealMoneyやelectricMoneyがAPIから取得した値をセットするなどという仕様の場合、UnitTest時には、本来期待される値が入っていないことが多いです。 全財産の合計計算、残高計算のテストをしたいのに、わざわざAPIを投げてサーバーから値を受け取ってテストするというのは、正しい単体テストのあり方ではありません。 そんな問題点を解決するためのコードがこれです。 // DataStoreProtocolを準拠させる final class SlimDataStore: DataStoreProtocol { static let shared = SlimDataStore() private init() {} var realMoney: Int = 0 var electricMoney: Int = 0 } class Calcrator { let dataStore: DataStoreProtocol // 引数で受け取る型を「DataStoreProtocol」に変更する init(dataStore: DataStoreProtocol) { self.dataStore = dataStore } func calcTotalMoney() -> Int { return dataStore.realMoney + dataStore.electricMoney } func calcRealMoneyRatio() -> Int { return dataStore.realMoney / calcTotalMoney() * 100 } func calcBalance(payMoney: Int) -> Int { return calcTotalMoney() - payMoney } } まず、DataStoreProtocolを定義して、Singletonクラスに必ず必要なプロパティやメソッドを確定させます。 そのプロトコルをSlimDataStoreというSingletonへ準拠させます。 最後に、Singletonを利用したいクラスでは、SlimDataStoreを直接受け取る形ではなく、DataStoreProtocolを初期化時の引数として受け取るようなロジックへ変更します。 こうすることで、UnitTestの時は、DataStoreProtocolを準拠したTestDataStoreクラスに差し替えるだけでいいので、計算テストが単体テストとして実行できるようになります。 class TestDataStore: DataStoreProtocol { var realMoney: Int = 11111 var electricMoney: Int = 88888 } let calcTest = Calcrator(dataStore: TestDataStore()) print(calcTest.calcTotalMoney()) // 99999 こんな感じです。 おわりに Singletonパターンはデザインパターンについて知る前から、プロジェクトでよく使われていました。 なんで、わりと思い入れのあるデザインパターンで長くなってしまいましたが、皆さんの参考になれば幸いです。 参考にさせていただいた記事
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode13 × SwiftUIにおけるNCMB SDKの初期化処理

Xcode13(iOS 15)になって、SwiftUI周りの挙動が変わっています。特に変わったのがアプリの初期化のタイミングで、WindowGroupのonChangeが呼ばれるのが遅くなってしまいました。 元々NCMBの初期化をWindowGroupのonChangeにて行っていたのですが、これだとアプリ起動時に認証やデータ取得を行っていると、初期化前に実行されるようになってしまいます。 そこで初期化のタイミングをinitに移すのをお勧めします。 NCMB Swift SDKのインストール Xcode13になって、SDKのインストール法も変わっています。 Xcodeにて次の操作を行います。 プロジェクト選択 → PROJECTでプロジェクトを選択 → Package Dependencies で + を押す これで依存パッケージをインストールするダイアログが出るので https://github.com/NIFCLOUD-mbaas/ncmb_swift.git を入力してインストールします。 SDKのインポート (アプリ名)App.swiftファイルにてSDKをインポートします。 import NCMB 初期化する Appではinitが初期化のタイミングで呼ばれるので、ここでNCMBの初期化処理を行います。関数名は init です。 @main struct NCMBDemoApp: App { init() { // APIキーの設定とSDK初期化 NCMB.initialize(applicationKey: "YOUR_APPLICATION_KEY", clientKey: "YOUR_CLIENT_KEY") } var body: some Scene { WindowGroup { ContentView() } } } まとめ NCMBが初期化されていない状態で実行すると emptyApplicationKey というエラーメッセージが出ますので注意してください。もし、そのエラーメッセージが出た場合には、初期化タイミングの問題かも知れませんので、この記事を参考に修正してみてください。 イントロダクション (Swift) : クイックスタート(Swift UI App版) | ニフクラ mobile backend
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swifアプリケーション開発に、NoSQLドキュメント指向モバイルデータベースCouchbase Liteを使ってみる

はじめに 本稿では、Swiftアプリケーション開発に、NoSQLドキュメント指向モバイルデータベース Couchbase Liteを使うための具体的な方法について解説します。 本稿では、Couchbase Lite 3.0.0ベータ版をKotlinアプリケーションで利用する際の動作確認までを行います。 Couchbaseをモバイルアプリケーションで利用する意義については、以下の記事をご参考ください。 確認環境 macOS Catalina Version 10.15.7 Xcode Version 12.4 実行手順 プロジェクト作成・Couchbase Liteインストール 本稿では、Swiftパッケージマネージャーを使用して、Couchbase Liteをセットアップする手順を紹介します(Swiftパッケージマネージャーを使用してCouchbaseLiteSwiftをインストールするにはXcode12 +が必要です)。 Xcodeで、新しいプロジェクトを作成します:File > New > Project  今回は、「SwiftUI」ではなく、「UIKit」を使います。  下記のように、Swift Packages画面を表示します。  「+」アイコンを押下し、「Choose Package Repository」ダイアログを表示します。 入力欄に次のURLを入力します。 https://github.com/couchbase/couchbase-lite-swift-ee.git 「Next」ボタンを押下します。  「Choose Package Options」ダイアログが表示されます。 下記のように「Version」入力欄に、3.0.0-beta02と入力します。 「Next」ボタンを押下します。 パッケージ追加が始まります。 下記のように、「CouchbaseLiteSwift」パッケージが表示されます。チェックし、「Finish」ボタンを押下します。  下記のように、「CouchbaseLiteSwift」パッケージがプロジェクトに登録されます。 追加されたパッケージの名前、バージョン、URLが表示されています。  これで、CouchbaseLiteSwiftをインポートして、アプリで使用できます。 プログラミング・実行結果確認 ソースコード編集 今回は、稼働確認のため、ViewController.swiftを以下のように編集しました。 import UIKit import CouchbaseLiteSwift class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. getStarted(testReplication: false) } func getStarted (testReplication: Bool) { // データベース作成または取得(再実行時) let database: Database do { database = try Database(name: "mydb") } catch { fatalError("Error opening database") } // ドキュメント作成 let mutableDoc = MutableDocument() .setString("user", forKey: "type") // ドキュメント保存 do { try database.saveDocument(mutableDoc) } catch { fatalError("Error saving document") } // ドキュメント取得、変更、保存 if let mutableDoc = database.document(withID: mutableDoc.id)?.toMutable() { mutableDoc.setString("田中", forKey: "name") do { try database.saveDocument(mutableDoc) let document = database.document(withID: mutableDoc.id)! print("ドキュメント更新完了 - ドキュメントID: \(document.id), 名前: \(document.string(forKey: "name")!)") } catch { fatalError("Error updating document") } } // クエリ print("クエリ実行") let query = QueryBuilder .select(SelectResult.all()) .from(DataSource.database(database)) .where(Expression.property("type").equalTo(Expression.string("user"))) do { let result = try query.execute() print("ドキュメント数 : \(result.allResults().count)") } catch { fatalError("Error running the query") } } } ログ 以下のログが出力されます。 ドキュメント更新完了 - ドキュメントID: -YamrzlmIFovzOXYUp1YWDo, 名前: 田中 クエリ実行 ドキュメント数 : 1 データベースファイル ターミナルで、作成されたデータベースファイルを確認します。 $ cd ~/Library/Developer/CoreSimulator/Devices $ find . -name "mydb.cblite2" ./5C245945-C956-495A-B7CB-D646905E95C7/data/Containers/Data/Application/4E0B4959-9D0B-4181-A2E6-F3D3A233D5A9/Library/Application Support/CouchbaseLite/mydb.cblite2 最後に 本稿では、Swiftアプリケーション開発のために、プロジェクトにCouchbase Liteをダウンロードし、基本的な操作が行えるところの確認までを行いました。 モバイルアプリケーション開発に組込データベースを使った経験のある方であれば、以降の応用は、比較的簡単に可能かと思います。 最後に、さらなるステップに進むための情報を示して、本稿の締め括りとしたいと思います。 Couchbase Liteについての記事を以下の投稿で整理していますので、ご関心に応じて、参照してみてください。 本稿の記事の内容(インストール、動作確認)に関する、一次情報として、下記のドキュメントがあります(Swiftパッケージマネージャーを使う以外の方法も説明されています)。 Couchbase Lite(のみを「スタンドアローン」で)を使ったチュートリアルとして、以下が公開されています。 上記チュートリアルからはじめ、さらに進んだトピックとして、「クエリ」、「Couchbase Serverとの同期」、「バックグラウンドでのフェッチ」までがカバーされています。 また、上記とは別に、サンプルアプリケーションを動作させる環境の構築を通じて、Couchbase Serverとのデータ同期をカバーした、以下のチュートリアルがあります。 上述のチュートリアルは、全てCouchbase社によるものでしたが、それとは別に下記のようなチュートリアルも公開されています(こちらも、Couchbase Serverとの同期までをカバーしています)。 本稿で扱っていない、Couchbase Serverについては、日本語で読むことができるまとまった情報として、次の拙著を紹介させていただきます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alamofire+Codableをasync/awaitを使ってリクエストする

Swiftにも5.5でついにSwift Concurrencyが導入されたので一番有用そうなAPI取得周りをasync/awaitで記述できるようにしてみます。 今回はSwiftの通信ライブラリとしてスタンダードであろうAlamofire+Codableを環境として用いて実装しています。 (そのうちAlamofire公式にもasync/awaitで受け取れる口は出来るとは思いますが現時点ではiOS15以降が要求されるのでしばらくは入らないかと思います) AlamofireのExtensionを記述する Alamofireはクロージャで結果を受け取とれるので、これをasync/awaitに変換します。 旧来のクロージャ構文を変換するにはwithCheckedThrowingContinuationを利用します。 DataRequestExtension.swift import Foundation import Alamofire extension DataRequest { func publish<T>(_ type: T.Type) async throws -> T where T : Decodable { try await withCheckedThrowingContinuation { continuation in self.response { response in switch response.result { case .success(let element): do { let decodedResponse = try JSONDecoder().decode(type, from: element!) continuation.resume(returning: decodedResponse) } catch { continuation.resume(throwing: error) } case .failure(let error): continuation.resume(throwing: error) } } } } } continuation.resumeを呼ぶことで呼び出し元へ結果を返して処理をすすめる事ができます。 実際に呼び出してみる あとは生やしたメソッドを呼び出すだけでasync/awaitへの変換は完了します。 普段どおりCodableを実装したstructを用意してください。 GithubUser.swift import Foundation struct GithubUser: Codable, Equatable, Identifiable { let id: Int let name: String let htmlUrl: String let avatarUrl: String enum CodingKeys: String, CodingKey { case id = "id" case name = "name" case htmlUrl = "html_url" case avatarUrl = "avatar_url" } } その上で、Alamofireのリクエストクラスから作成したpublish()を呼びだしてください。 GithubApi.swift struct GithubApi { func fetchUser(userName: String) async throws -> GithubUser { try await AF.request("https://api.github.com/users/\(userName)").publish(GithubUser.self) } } withCheckedThrowingContinuationとwithCheckedContinuation 上記ではクロージャの変換用にwithCheckedThrowingContinuationを紹介しましたが、 これとは別に withCheckedContinuationという関数もあります。 こちらはErrorをThrowすることがない場合に利用できます。こちらの関数ではtryの指定が不要になるので処理が失敗しうるかどうかで使い分けてください!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIを実務で使ってわかったアレコレ

この記事はGoodpatchエンジニアアドベントカレンダー5日目の記事です。 こんにちは!iOSエンジニアのとうようです。2日目の記事も書いているのですが、あまり技術的な内容は触れなかったのと、まだ空いていたので急遽この枠で技術的な記事も書いていこうということにしました。 多分このアドベントカレンダーはほとんどQiitaに書く人がいないので、一個ぐらいはQiitaに書き残しておこうかなとここで書き進めています。 今回書く内容は、自分が実際に参加した案件での実例を通したSwiftUI周りのお話になります。実際どのアプリなのかは伏せさせていただきますが、以下のような前提条件での学びになるためそこを踏まえた上で、実プロダクトに現段階でSwiftUIをどう取り入れるべきなのか?といったところの参考にしてもらえればいいなと思います。 途中までiOS13対応も考慮されていた アプリのリニューアルに際して導入されたため、ベースはUIKitであり、実際の見た目の部分をUIHostingControllerを使ってSwiftUIで実装している また、画面遷移はStoryboard + Wireframeパターン1で作られており、全画面にStoryboardファイルが存在している また、自分が担当した役割の性質上、デザイン寄りの話題もだいぶ多いことも踏まえた上でお読みいただければと思います。 Listのセパレーターを消すには UITableViewのような表示をしたい時、Listだとセパレーターが入ってしまいます。普通であれば別に問題ないですが、デザイン上消したいということもあるでしょう。そのようなときどのような方法があるでしょうか? まず、通常コンポーネントで各iOSでの解決法を見ていきます。 解決法 iOS13 iOS14 iOS15 Listのままの解決法 なし なし .listRowSeparator(.hidden)を使う それ以外の方法 VStackを使う LazyVStackを使う LazyVStackを使う この表から分かるように、iOS15以降でしか正式な手段は提供されていません。 またそれ以外の方法もLazyVStackを使えるiOS14以降はいいですが、iOS13だとただのVStackになってしまうため大量の要素を表示する際のパフォーマンス面が気になります。 これらを全てのバージョンについて解決する方法としては、二つの手法を組み合わせるアプローチを取りました。 一個が、基本的に内部でUIKitがレンダリングに使われているiOS13に対応するためのSwiftUI-Introspect、もう一つがiOS14のためのちょっとしたハックModifierです。 iOS13については完全にUITableViewのパラメータをいじらなければ見た目を変えることができないのですが、もちろん外側から普通にやっていじることはできません。そこで再帰関数を用いて中のUIKitのパーツまでいきいじれるようにするのがSwiftUI-Introspectです。ライブラリを導入して、.introspectTableView { tableView in }というModifierで中のUITableViewのあれこれをいじることができます。 iOS14は少し厄介です。iOS15のように設定できるものがないのですが、レンダリングはSwiftUI独自のものになっているため、Introspectでいじっても見た目には反映されません。 大人しくLazyVStackを使えという話ではあるのですが、iOS13対応をしていると単純にそうもいかないので、Listのまま解決する方法がAppleのフォーラムに上がっています。 具体的には以下のようなViewModifierを用意することになります。 struct HideRowSeparatorModifier: ViewModifier { static let defaultListRowHeight: CGFloat = 44 var insets: EdgeInsets var background: Color init(insets: EdgeInsets, background: Color) { self.insets = insets var alpha: CGFloat = 0 if #available(iOS 14, *) { UIColor(background).getWhite(nil, alpha: &alpha) assert(alpha == 1, "Setting background to a non-opaque color will result in separators remaining visible.") } self.background = background } func body(content: Content) -> some View { content .padding(insets) .frame(minWidth: 0, maxWidth: .infinity, minHeight: Self.defaultListRowHeight, alignment: .leading) .listRowInsets(EdgeInsets()) .background(background) } } これは何をやっているのかを図解してみたものがこちらです。 SwiftUIのListのレンダリングの要素としては、中のコンテンツ、そしてListRowというものがあると考えられます(完全に推測です) さまざまなModifierはこのListRowに対しての設定がされていると見ていいでしょう。セパレーターも同様です。そして全般的に重なり順としてはコンテンツが上に来るようになっています。 そこで.listRowInsetsを0にしてあげるとコンテンツが目一杯に広がり、透過されていない限りセパレーターを隠してくれます。 唯一解決できないのは全体の一番上についているセパレーターです。下に引っ張らないと出てこない部分ではあるのですが、こちらは色々調整してみたものの消すことはできませんでした。 この工夫により無事セパレーターを全バージョンListのまま消すことが叶ったのですが、いかんせんこのModifierの中で余白などを指定することになるのでレイアウトの調整が煩雑になります。iOS13を切れるならiOS13を切って、LazyVStackを使う方が幸せになれると思います。 スクロール量を検知する 続いてはスクロール量を検知する際の話です。基本的にSwiftUIでこのような座標などをとる操作をするときにはGeometryReaderというものを使うのですが、これに関してiOS13とiOS14以降で少し挙動が変わるため注意が必要でしたという話です。 いくつかの記事ではスクロール量をとる際に一つのGeometryReaderを使ってスクロール量を直接検知しようとするコードがあるのですが、ここに少し罠があります。 何かというと、以下のようにスクロール量として取れるoffsetの基準がiOSバージョンによって変わってしまうのです。 特にLarge Titleがある画面だと注意が必要になります。そこでこちらの記事のように二個のGeometryReaderを使って差分を取ることでこのバージョン差を無くすという解決法に至りました。 struct TrackableScrollView<Content: View>: View { private let axes: Axis.Set private let showIndicators: Bool private let content: Content private let onChangeOffset: (CGFloat) -> Void init( _ axes: Axis.Set = .vertical, showIndicators: Bool = true, onChangeOffset: @escaping (CGFloat) -> Void, @ViewBuilder _ content: () -> Content ) { self.axes = axes self.showIndicators = showIndicators self.onChangeOffset = onChangeOffset self.content = content() } var body: some View { GeometryReader { outsideProxy in ScrollView(axes, showsIndicators: showIndicators) { content .background(GeometryReader { insideProxy in Color.clear.preference( key: ScrollViewOffsetKey.self, value: calculateContentOffset(from: outsideProxy, insideProxy: insideProxy) ) }) .onPreferenceChange(ScrollViewOffsetKey.self) { onChangeOffset($0) } } } } private func calculateContentOffset(from outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat { if axes == .vertical { return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY } else { return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX } } } private struct ScrollViewOffsetKey: PreferenceKey { typealias Value = CGFloat static var defaultValue: Value = CGFloat.zero static func reduce(value: inout Value, nextValue: () -> Value) { value += nextValue() } } ただしこれをListにも応用したいというときは注意が必要です。これはあくまでスクロールする中のコンテンツに透明な背景色をつけ、その要素の座標をとっているだけです。そのため、Listのようなものの場合一番上にくる要素にこの座標取得用の背景をつけるようにしないと正しい座標を取れなくなってしまいます。コンポーネントとして切り出すのはやや難しい対応になってしまうので、その場合は個別実装することになるでしょう。 NavigationBarのあれこれ SwiftUIをUIHostingControllerで使う際、NavigationBarの扱いは少しややこしくなります。 UIKitかSwiftUIのどちらかによっていれば起きにくいことではあるのですが、以下の問題がありました。 NavigationBarを隠す設定がUIHostingControllerのデフォルト挙動で上書きされてしまう問題 ScrollView/Listが厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題 一個ずつ見ていきます。 NavigationBarを隠す設定がUIHostingControllerのデフォルト挙動で上書きされてしまう問題 何らかの理由でNavigationBarを隠したい時、SwiftUI + UIHostingControllerだとUIKit側のライフサイクルで設定しても反映されないという現象があります。iOS14まではこのワークアラウンドとして、SwiftUI側で.navigationBarHidden(true)をするという解決法がよく言われていましたがこれがiOS15から効かなくなりました。 これに関してよくよく探っていくと、どうやらUIHostingControllerのライフサイクルにNavigationBarを表示するような動作が入っており、その実行が親のUIViewControllerのライフサイクルの後になってしまうためにうまくいってないようでした。 逆に言えば、UIHostingControllerのライフサイクルのタイミングで諸々の設定ができれば良さそうです。そのためにこのようなクラスを用意することで解決することができました。 class CustomHostingController<Content>: UIHostingController<AnyView> where Content: View { private var onViewWillAppear: (() -> Void)? private var onViewWillDisapper: (() -> Void)? public init(onViewWillAppear: (() -> Void)?, onViewWillDisapper: (() -> Void)?, rootView: Content) { self.onViewWillAppear = onViewWillAppear self.onViewWillDisapper = onViewWillDisapper super.init(rootView: AnyView(rootView)) } @available(*, unavailable) @MainActor @objc dynamic required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) onViewWillAppear?() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) onViewWillDisapper?() } } このCustomHostingControllerはviewWillAppearとviewWillDisappearで実行してほしい内容を渡すことができるため上書きされずに対処が可能です。 もちろん他のライフサイクルでも何か実行したいことがあれば拡張することもできるでしょう。中でどのようなデフォルト動作が組まれているかは謎ですが、もし何かうまく設定できないということがあればこちらを試してみるのもおすすめです。 ScrollView/Listが厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題 こちらの問題は実はUIKitにも存在しているものです。スクロールする要素が最背面かつセーフエリアなど画面一番上まで領域が広がっていないと、Large Titleがスクロールに合わせてしまわれなかったり、iOS15だとスクロールしてもNavigationBarの背景色がつかずに透明なままになったりします。 UIKit + SwiftUIではこのためにいくつかチェックする必要のある項目がありました。 SwiftUIをアタッチするViewの後ろに別のViewがないか ScrollView/Listが画面上部まで広がっているか?またZStackなどで後ろに他の要素がある状態になっていないか ScrollView/Listにbackgroundがついていないか 特に最後のポイントが少し厄介です。この問題でいう重ね順にはbackgroundでつけた要素も別個のものとしてカウントされてしまいます。そのためもし背景色をつけたい場合は、UIHostingController().view.backgroundColorでUIKit側から設定するようにしなければなりません。 実は存在した、iPhone世代間の挙動の差異 他にもさまざまあるのですがあまりにも長くなり過ぎてしまうのでこれで最後のトピックにしようと思います。最後に取り上げるのはiPhoneの世代間の差異です。iOSのバージョン違いやiPhoneのサイズの違い、あるいはセーフエリアの有無とかいう話ではありません。 一番顕著だったところで言うと、iPhone11までとiPhone12以降でいくつかの挙動が変わっていたのでそれを最後に紹介したいと思います。 今回発見しているのは以下の二点です。ただ、これがある以上注意深く検証すれば他にもあるかもしれません。 Pickerの標準の大きさが違う Textのトランケーションされる基準が違う この二つはほとんど同じ原因の問題とも考えられます。実はiPhone12以降、若干標準コンポーネントの大きさが大きくなっている場合があるのです。(少なくともそう考えるしかないようなバグがちらほらありました) そのため、Pickerに関してはframeで大きさを想定通りのものに調整できるようにし、Textは発見するたびにfixedSize(horizontal: false, vertical: true)をつけていく対応が発生しました。 Dynamic Typeを扱ったりもしていたので完全に推測通りの原因とは言い切れませんが、少し検証時に注意が必要なのかもしれません。 まとめ 以上、SwiftUIを実際に実プロダクトに使ったときに見つかった少し変わった注意点をいくつかご紹介してみました。 個人的な肌感としては、サポートはなるべくiOS15以上にできるアプリで、なおかつSwiftUIをメインに、一部UIViewRepresentableで対応するというのが実プロダクトでSwiftUIをストレスなく使う条件になってくると思いました。ただまだまだ足りない機能やonAppearの挙動が不安定などの問題もあるのでうまく検証しながら向き合っていきたいですね。 VIPERアーキテクチャのRouterで使う、Wireframeプロトコルをプロトコルエクステンションで記述したものです。extensionの中でStoryboardからの初期化やさまざまな設定を実装することで、各画面からは関数を呼び出すだけで遷移できるようになり、個別の実装を行う必要がなくなります。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ARをさわる【コンピュータ・ビジョン*AR】

ARオブジェクトにさわれたらいいと思いませんか? 【ARをさわる】 VisionとRealityKitで指が触れたARオブジェクトを反応させられます。 まずはVisionで指先の位置を取得します。 import Vision import RealityKit ... var handPoseRequest = VNDetectHumanHandPoseRequest(completionHandler: handDetectionCompletionHandler) // 手のポイントを取得してくれるリクエスト // CompletionHandler内で結果を処理します ... func session(_ session: ARSession, didUpdate frame: ARFrame) { let pixelBuffer = frame.capturedImage // ARSessionで取得したフレームでVisionリクエストを実行 DispatchQueue.global(qos: .userInitiated).async { [weak self] in let handler = VNImageRequestHandler(cvPixelBuffer:pixelBuffer, orientation: .up, options: [:]) do { try handler.perform([(self?.request)!]) } catch let error { print(error) } } } 取得した指先の位置でヒットテストをし、ARオブジェクトを検出します。 ARViewはView上のポイントの延長線上にあるエンティティを検出できます。 func handDetectionCompletionHandler(request: VNRequest?, error: Error?) { // リクエストの結果から、人差し指の先の位置を取得する guard let observation = request?.results?.first as? VNHumanHandPoseObservation else { return } guard let indexFingerTip = try? observation.recognizedPoints(.all)[.indexTip], indexFingerTip.confidence > 0.3 else {return}  // Visionの結果は0~1に正規化されているので、ARViewの座標に変換する let normalizedIndexPoint = VNImagePointForNormalizedPoint(CGPoint(x: indexFingerTip.location.y, y: indexFingerTip.location.x), viewWidth, viewHeight) // 取得した指先の座標でヒットテストを実施 if let entity = arView.entity(at: normalizedIndexPoint) as? ModelEntity { // 見つけたボックス・オブジェクトに物理的な力を加える entity.addForce([0,40,0], relativeTo: nil) // addForceするには、対象のエンティティにPhysicsBodyComponentを与えてください } } ヒットテストで検出するエンティティにはコリジョン(衝突)シェイプが必要です。 let box = ModelEntity(mesh: .generateBox(size: 0.05), materials: [SimpleMaterial(color: .white, isMetallic: true)]) box.generateCollisionShapes(recursive: false) GitHubのサンプルコード:RealityKit-Sampler 手のポイントの取得について:VisionでHand Pose Detection 手のトラッキング: MLBoyだいすけ ARオブジェクトの作り方など、RealityKitの取り扱いについて:RealityKitの参考書
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

未経験から月収100万円のフリーランスエンジニアになった経緯とコツ

未経験からフリーランスとして稼げるようになったポイントは3つあると思っています。 1、時間をつくる 2、全体像をつかむ 3、発信する です。それぞれ解説していきます。 【著者近影】 僕がニートからエンジニアになった経緯は、 Youtubeチャンネル「エンジニアと人生」でも取材してもらいました。 そちらもあわせて見てもらえると嬉しいです。 1、時間をつくる 雪がたっぷり積もったら、外に出ていろいろ遊んでみようと思いますよね。雪だるまを作ったり、かまくらを建てたり、雪合戦をしたり。 それと同じで、時間的なスペースがあると、いろいろ工夫してみようという気持ちが生まれます。 一個失敗しても、「じゃあこうしてみようかな」みたいな試行錯誤ができる。 子どもが雪の中で転げ回るみたいに。失敗ができる。 無駄って大事なんです。 なんならこたつで丸くなってもいい笑。 僕は以前の仕事の合間にも、プログラミングをかじっていたのですが、形にならないままでした。 本業の隙間時間で一個失敗してしまうと、次がないんですよ。 で、ニートになった。 時間ができたから、すぐプログラミングに邁進するかというと、そんなこともない笑 しばらくの間はネットフリックスを見たり散歩したりしていました。 そういうのもだんだん飽きてきて、「なんかやることないかなあ」とやってみたプログラミングが楽しくなってきた。 それぐらいのペースだと思うんですよ。自発性が生まれるタイミングって。 そうはいっても大体の人はまとまった時間を作るのは難しいと思うから、1日1時間、時間的なスペースを作ってみる、というルールでもいいと思います。 やり始めると、習慣的な勢いが出てきます。 2、全体像をつかむ ものごとがうまくいかないときって、全体像が見えていないときだと思うんですよ。 逆上がりがうまくできない子って、最初のキックはやってみるんだけど、そのキックが以降の動作とどう結びついて、全体の中でどう位置づけられるかイメージができていない。それで、ただただ高くキックしようとするのを繰り返していると思うんです。 つまり、初心者コースから抜け出せない人は、一つのエレメントには取り組めるけど、それが全体のどこに位置づけられるのかわからない。せっかく学んでも結び付けられないと意味を成さない。 何かがうまくできるようになるには、ぼんやりとでもいいから、全体像を感じることができることが大事だと思うんです。 女の子を口説くときと一緒ですね。ゴールまでイメージを。。。いや、このたとえはやめよう。ポリティカリー・インコレクトな気がする。 全体像があって、目の前のエレメントを有機的に結び付けられる。 プログラミングを勉強し始めて、写経してみたものの、それをどう使えばいいかわからない。初学者あるあるだと思うんですよ。 「1+1はこう書くとプリントされる」「でもこっからどうすればアプリが作れるんだ??」みたいな。 僕もそうでした。ネットの講座を受けてみたり、オライリー本を買ったり。 で、そこからどう脱却したかというと、 スタンフォード大学の講座を受けたんですよ。 コンピュータサイエンスiOS講座(CS193)です。 この講座を受けて僕はアプリが作れるようになりました。 ここの授業は、アプリを作るのに必要な知識を網羅していて。「本当に必要なことは全部わかる」という感じです。世界最高峰の大学だけあって、世界最高峰にわかりやすい。アプリを作るの必要なランドマークとその結び付け方が、手を動かしながら実感できる仕組みになっています。 実際に作れるようになりました。 全体像をワンセットで見せてくれてつかませてくれるものを見つけるって大事だと思います。 大工の徒弟制度とかあるじゃないですか。最初はひたすらカンナクズがきれいになるまでカンナがけするとかいうけど、カンナがけ自体は実は重要じゃなくて、それをしながら親方のそばにいて1から10まで親方が仕事をするのを見ているってことが大事なんだと思うんです。 ひとの真似をするって、全体像をつかむうえで重要だと思います。 アプリを作るということも、一度全体像がわかってしまえば、作りたいものがあったときに、どの知識が不足しているのかがわかります。あとは必要な知識をウェブで拾って全体像の中に落とし込んでいけばいいだけです。 講座の最終課題で「自分のアプリを作ってみる」というのがありました。 授業で習ったことにプラスアルファして、何か最新のテクノロジーをくっつけたアプリを作ってみようと思いました。ネットで面白そうなフレームワークを探したりして、機械学習を使ったアプリを作ってみようとおもいました。 けっきょく、機械学習だろうがiOSだろうがなんだろうが、ネットを検索したら、ちゃんとドキュメントなり文章なりコードなりで情報が載っていて、それをちゃんと読めば(全体像さえあれば)理解できるようになっています。全体像と情報のありかがわかっていれば、できることが格段に増えます。 CS193に話を戻すと、これはYoutubeで無料で全授業が見られます。 英語は、Youtubeの英語字幕でなんとかなりました。あと、アメリカドラマ「ブレイキング・バッド」を一通り英語で見たのも役に立ったかな笑 英語がわかると、ドキュメントなどが原語で読めますし何かと便利です。 まあGoogle翻訳もかなり優秀なので、英語は大した問題ではなくなっている。 3、発信する 箱の中の猫と同じで、見られるまでは他の人にとって自分は存在しないのと同じなんです。 実力があるだけでは仕事はこない。 箱をぶちやぶって顔を出して、にゃーと鳴かないといけない。 自分はここにいるよ!と叫ぶ効率的な方法が「ネット上に情報を発信する」ということだと思います。 技術記事などを書いて、自分の実力という情報をネット上に公開していく。その記事がクライアントの目に届けば、仕事がくる。 お金を出してくれる人と自分を繋げないといけないんですよね。 そうすることで、プログラミングが一気にお金につながりました。 僕はQiitaで技術記事を発信して、Mediumという英語のサービスでも同じ記事を書いています。GitHubにコードもアップしています。 それを見た国内外の企業から開発の依頼が来て、こなしていくうちに3ヶ月で月収100万円を超えました。 それまで1円も稼げない時期が半年ぐらいありました。 個人開発アプリをAppStoreにアップロードして、広告収入で当てようと、10個ぐらいリリースしました。 顔の似ている度を計算するアプリとか、ARでバーチャル背景をつけるアプリとか、機械学習で写真の加工をはがすアプリとか、写真をアニメ風にするアプリとか。 箱の中でキメラ的な化け猫として特異進化を遂げていたんです。怖いですよね。 でも、肝心のお金を出してくれる人の元には、僕の情報は届かなかった。 僕の場合はお金を出してくれるのはコンシューマーではなかったということでしょうか。 お金を払える人にきちんとリーチするマーケティングが大切だと思います。 発信を始めたのは「エンジニアと人生」というコミュニティに入ったのが大きかったです。 有名iOSエンジニアの方が始められたコミュニティです。 このコミュニティの中で「発信講座」があります。 この講座の先生がすごくたくさん技術記事や技術本を書いておられて、それらが仕事につながっていく全体感をわかりやすく教えてくれました。さっきの大工の徒弟制度の話じゃないけど、仕事につなげるまでの全体感を見せてもらえたのが大きいと思います。 個人相談にのってくださるイベントも開催されていて、某社の採用担当の方とお話しする機会を設けてもらったり、フリーランスになることの背中を押してもらったり、技術記事の添削をしてくださったりして、それがすごく財産になりました。依頼主との交渉の仕方も教えてもらいました。 ちょうど僕も暇だったので、コミュニティの方の名言集をまとめたり、Youtubeチャンネルの編集を手伝ってみたりしていて、そういうのもあって可愛がってもらったと思います。これも時間があったからできたことですね。 1、たっぷりと時間つくる 2、全体像をつかむ 3、発信する これらは、「プログラミングで稼ぐ」以外にも使えそうな方法な気がします。 この方法で女の子にモテた人は詳細を教えてください。 ? この記事は、#エンジニアと人生 #2 Advent Calendar 2021の3日目の記事です。 フリーランスエンジニアです。 お仕事のご相談はこちらまでお気軽に? rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 GitHub Twitter Medium
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iPhoneからでもクロネコヤマトの配達状況をAPIで取得したいんじゃ!~SwiftでHTMLを解析~

初めに M1 ProのMacBook Proを買いました。性能には120%の満足なのですが、デザインだけはどうも受け入れ難いです、どうもこんにちはTOSHです。 でもなんやかんやPS5とは違って、MacBookは抽選販売にならなかったので、よかったです? さて、近年はコロナの影響もあり、ネットで物を買うことが多くなってきているのではないでしょうか? そうなってくると、いつ配達されるのかは気になりますよね?そこで、クロネコヤマトの配達状況のWebページなんかを利用すると思います。しかし、RestAPIのような形式で使えるものが欲しいですよね? では、そういったAPIはないのでしょうか? 業者用のAPIは用意されているようですが、こちらに関しては法人としての登録が必要だったり使用が難しいです。 また、本家クロネコヤマトのアプリが使用してるAPIはどうでしょう? クロネコヤマトのセキュリティに関することになってしまうので、あまり詳しく解説はしないのですが、配達IDを暗号化してクエリに追加しているような形式になっているため、鍵を知らない一般ユーザーがこのAPIを使用するのもどうやら難しそうです。 荷物お問い合わせシステム そうなったら、荷物お問い合わせシステムを使用するしかありません。 このようなシステムを使用して、おり、送り状番号を入力すると、その商品の配達状況を確認することができます。 では、具体的にはこのサービスはどのようにしてリクエストを送信しているのでしょうか? "endpoint": "https://toi.kuronekoyamato.co.jp/cgi-bin/tneko" "methodType": "Post" "query": ["number00": Int, "number01": Int, "number02": Int, "number03": Int, "number04": Int, "number05": Int, "number06": Int, "number07": Int, "number08": Int, "number09": Int, "number10": Int] このようにリクエストをすると、配達状況のデータを持ったHTMLが帰ってきます。クエリはnumber00には1を入れてあげ、その後number01~10まで最大10個の伝票番号を同時に問い合わせることができます。 また、クエリを付与する際には、number00=1&number01=伝票番号のような形式に変更してあげる必要があります。 コードで書くとこんな感じです。 extension Dictionary where Key == String, Value == Int { func equalEncode() -> String { return map { key, value in return key + "=" + String(value) } .joined(separator: "&") } } レスポンスの処理方法 さて、さっきまでの方法で無事リクエストをすることができるようにはなりましたが、問題はレンポンスの形式です。実際に叩いてみるとわかるのですが、これは、jsonが帰ってくるわけではなく、HTML形式でレスポンスが帰ってきます。 なので、クライアント側でそのHTMLを解析して、使いやすい形に変換してあげる必要があります。 まずはModelの作成です。 public struct Tneko: Codable { public var deriveryList: [DeliveryList] public struct DeliveryList: Codable { public var deliveryID: Int public var statusList: [DeliveryStatus] public init(deliveryID: Int, statusList: [Tneko.DeliveryList.DeliveryStatus]) { self.deliveryID = deliveryID self.statusList = statusList } public struct DeliveryStatus: Codable { public var status: String public var date: String public var time: String public var shopName: String public init(status: String, date: String, time: String, shopName: String) { self.status = status self.date = date self.time = time self.shopName = shopName } } } } 先ほども述べた通り、JSON形式でレスポンスが帰ってくるわけではないので、Codableに準拠する必要はないのですが、あくまで、Codableで定義できる型以外を定義しないという意味や他のエンドポイントとの統一性、JSON形式でmockを生成した際のことなどを考えてCodableに準拠しておくことをおすすめします。 では、ここからが鬼門です。HTMLをこのような形のModelへと変更する必要があります。 HTMLは容易にattributedStringへと変換できるので、attributedStringへと変換してしまうのが良いでしょう。 let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in if let attributedString = try? NSAttributedString(data: data!, options[.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) { let tneko = Tneko( idList: request.idList(), response: attributedString.string ) completion(.success(tneko)) } else { completion(.failure(.decodeErrror("This is not HTML"))) } } このようにして形式を変更しておくと、TnekoのModelではStringからModelを作成すれば良いということになります。 StringからModelへの変更 先ほどの、HTMLをStringに変更したものを一部抜粋するとこのような形式になっています。 配達完了 このお品物はお届けが済んでおります。 お問い合わせはサービスセンターまでお願いいたします。 お受け取り日時・場所変更をする ※このお荷物は対象外です お届け完了のメール通知を受け取る ※このお荷物は対象外です • 商品名:
宅急便
 • お届け予定日時:
-
 1. 荷物受付
10月27日 11:27
ZOZO支店
 2. 発送済み
10月27日 11:27
ZOZO支店
 3. 輸送中
10月28日 01:35
〇〇ベース店
 4. 配達完了
10月28日 11:30
〇〇センター
 詳細印刷 Yahoo!地図からもお荷物の状況が確認できます。 ※推奨環境はこちら ▲上部に戻る 上記のStringはすべて一行に収まっているので、\nで区切ってあげて、Stringの配列へと変更すると良いでしょう。 ここからは力技です。HTMLの中からルールを見つけ出し、Modelの要素へと変換していきます。変換するコードのイメージは以下の通りです。 extension Tneko { public init(idList: [Int], response: String) { self.deriveryList = idList.enumerated().map { initialIndex, id in let stringList = response.components(separatedBy: "\n") var newStatusList: [Tneko.DeliveryList.DeliveryStatus] = [] var indexCounter = 0 stringList.enumerated().forEach { index, str in if str.contains("お届け予定日時:") { if initialIndex == indexCounter { var counter = 0 let initialStatusGroup = stringList[index + 1].split(separator: "
") if var statusCode = initialStatusGroup[0].split(separator: "\t")[safe: 1]?.description { while statusCode.isValidStatusCode { let newStatusGroup = stringList[index + counter + 1].split(separator: "
") let date = newStatusGroup[1].split(separator: " ")[0].description.replacingOccurrences(of: "月", with: "/").replacingOccurrences(of: "日", with: "") let time = newStatusGroup[1].split(separator: " ")[1].description let shopName = newStatusGroup[2].description let status = Tneko.DeliveryList.DeliveryStatus( status: statusCode, date: date, time: time, shopName: shopName ) newStatusList.append(status) counter += 1 statusCode = stringList[index + counter + 1].split(separator: "
")[0].split(separator: "\t")[safe: 1]?.description ?? "" } } } indexCounter += 1 } } return DeliveryList(deliveryID: id, statusList: newStatusList) } } } extension String { var isValidStatusCode: Bool { return self == "荷物受付" || self == "発送済み" || self == "輸送中" || self == "配達中" || self == "配達完了" || self == "持戻(ご不在)" || self == "配達完了(宅配ボックス)" } } だいぶ、力技の解析にはなってしまっていますが、このようにすると、Modelへと落とし込むことができます。 まとめ HTMLを力技で解析して、クロネコヤマトのWebページから情報を持ってくる方法を紹介しました! 実際に、これを使って作成したのでよかったら使ってみてください!すべてSwiftUIで作成しております。 https://apps.apple.com/jp/app/%E3%82%AF%E3%83%AD%E3%83%8D%E3%82%B3%E9%85%8D%E9%81%94%E7%8A%B6%E6%B3%81/id1585504785
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む