20211025のiOSに関する記事は6件です。

SwiftUI + Combine で MVVM & Clean Architecture な設計を考えてみた

はじめに こんにちは。 iOS 13 で SwiftUI がリリースされてから早 2 年。 そろそろ SwiftUI をちゃんと学んでおこうと思い立ち、趣味プロダクトで SwiftUI の習作アプリを開発してみました。 出来上がったもの 小説投稿サイト 小説家になろう の公開 API を利用させていただき、該当サイトの閲覧アプリを開発しました。 動作イメージ 実装した機能 「日間」「週間」「月間」「四半期」の小説ランキング閲覧機能 小説の検索機能 小説の閲覧機能(ただの WebView ですが。。。) 実際のコード 以下のリポジトリに置いてあります。もし興味がありましたら是非触ってみてください。 https://github.com/inokinn/NaroViewer アーキテクチャについて SwiftUI を使ってみるにあたり、まず悩んだのはアーキテクチャの選定でした。 SwiftUI は Combine との相性が良く、折角なので Combine によるデータバインディングが活きる MVVM でやってみようと考えました。 とはいえ、 MVVM は所謂 GUI アーキテクチャであり、プレゼンテーションロジックとドメインロジックの切り離し以外は興味の対象外です。そこで、データの永続化や API へのアクセス周りに関しても総合的に勘案の上、 MVVM ベースの Clean Architecture について考えてみることにしました。 検討した結果のアーキテクチャ こんな感じになりました。 アーキテクチャのルール レイヤー構造は、 Domain 、 Presentation 、 Infrastructure の 3 層構成なのですが、 Clean Architecture の有名な同心円状の図のレイヤー構造にも準拠しています。 ( Entity 、 UseCase 、 Interface Adapter 、 Framework & Driver の 4 層。) この図の上から順に上位のレイヤーであることを表します(スペースの都合で UseCase と Entity が並んでいますが、 Entity は最上位レイヤーです)。 この 2 通りのレイヤー構造のどちらにおいても、依存の方向は 下位レイヤー -> 上位レイヤー であることを徹底しています。 上位レイヤーが下位レイヤーにアクセスする際には、必ず実装ではなくプロトコルに依存することによって、下位レイヤーに直接依存することを避けています(依存性逆転の原則)。 上位レイヤーのモジュールから下位レイヤーの参照を保持する場合、 Swinject を用いて依存性注入を行っています。 データの流れ データの取得には Combine によるデータバインディングを用いています。 例として、API 通信を行って小説のランキングデータを取得する際の、データの流れは下図のようになります。 この部分について、ソースコードを抜粋したものが以下になります。 View から ViewModel のメソッドを呼び出す RankingView.swift import SwiftUI import Combine struct RankingView: View { @ObservedObject var viewModel: RankingViewModel @State var rankingType = Ranking.RankingType.Daily var body: some View { ZStack { NavigationView { (略) } .navigationViewStyle(StackNavigationViewStyle()) .onAppear { if viewModel.ranking.rowList.count == 0 { self.loadRanking() } } (略) } } func loadRanking() { viewModel.fetchRanking(type: self.rankingType) } ここでは、画面が表示された際に ViewModel のメソッド呼び出しを行っています。 ViewModel にて、 UseCase の処理を購読する RankingViewModel.swift import SwiftUI import Combine final class RankingViewModel: ObservableObject, Identifiable { @Published var ranking: Ranking = Ranking(rowList: []) @Published var loading: Bool = false private var disposables = Set<AnyCancellable>() // ランキングの取得 func fetchRanking(type: Ranking.RankingType) { self.loading = true self.ranking = Ranking(rowList: []) AppBuilder.shared.rankingUseCase.startFetch(type: type) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { completion in self.loading = false switch completion { case .finished: break case .failure(_): // TODO: エラーハンドリングちゃんとやる break } }, receiveValue: { [weak self] ranking in self?.ranking = ranking }) .store(in: &disposables) } } ViewModel では UseCase の処理を購読し、結果を受け取って値を保持したりエラー処理を行っています(サンプルコードではエラー処理をちゃんとやってないけど。。。)。 UseCase でアプリケーション固有のロジックを書く RankingUseCase.swift final class RankingUseCase: RankingUseCaseProtocol { let rankingGateway: RankingGatewayProtocol let rankingRowsGateway: RankingRowsGatewayProtocol init(rankingGateway: RankingGatewayProtocol, rankingRowsGateway: RankingRowsGatewayProtocol) { self.rankingGateway = rankingGateway self.rankingRowsGateway = rankingRowsGateway } func startFetch(type: Ranking.RankingType) -> AnyPublisher<Ranking, Error> { return rankingGateway .fetch(type: type) .flatMap { [weak self] ranking -> AnyPublisher<Ranking, Error> in return (self?.rankingRowsGateway.fetch(ranking: ranking))! } .eraseToAnyPublisher() } } UseCase です。 なろう API の仕様として、ランキング情報取得 API のレスポンスには、小説の識別子データはあるものの、実際の小説データ(タイトルや作者名など)は含まれませんので、ランキング情報を元に小説情報をリクエストする必要があります。 そこで、今回は Combine のオペレーターの一つである flatMap を用いて、ランキング情報を持つ Publisher を、小説情報を取得する Publisher に変換し、 2 つの API アクセスを直列で実行しています。 このように、一連の処理の流れを宣言的に記述出来るのも Combine の利点です。 Gateway は下位レイヤーとドメインの橋渡しをする RankingGateway.swift final class RankingGateway: RankingGatewayProtocol { let apiClient: RankingAPIClientProtocol init(apiClient: RankingAPIClientProtocol) { self.apiClient = apiClient } func fetch(type: Ranking.RankingType) -> AnyPublisher<Ranking, Error> { // type をパラメータの rtype に変換 var rtype = "" let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyyMMdd" dateFormatter.locale = Locale(identifier: "ja_JP") let weekDayFormatter = DateFormatter() weekDayFormatter.dateFormat = "EEEEE" weekDayFormatter.locale = Locale(identifier: "ja_JP") // 月初を取得 var components = Calendar.current.dateComponents([.year, .month, .day],from: Date()) components.day = 1 let firstDay = Calendar.current.date(from: components)! switch type { case .Daily: // "yyyyMMdd-d" の形に変換 rtype = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + "-d" break case .Weekly: // 火曜日を特定した後、 "yyyyMMdd-w" の形に変換 var targerDay = Date() while weekDayFormatter.string(from: targerDay) != "火" { targerDay = Calendar.current.date(byAdding: .day, value: -1, to: targerDay)! } rtype = dateFormatter.string(from: targerDay) + "-w" break case .Monthly: // 月初を特定した後、 "yyyyMMdd-m" の形に変換 rtype = dateFormatter.string(from: firstDay) + "-m" break case .Quarter: // 月初を特定した後、 "yyyyMMdd-q" の形に変換 rtype = dateFormatter.string(from: firstDay) + "-q" break } // レスポンスを Entity に変換し UseCase に返す return apiClient.fetch(rtype: rtype) .tryMap { response -> Ranking in var rankingRows: [RankingRow] = [] for row in response { rankingRows.append(RankingRow(ncode: row.ncode, pt: row.pt, rank: row.rank, novel: nil)) } return Ranking(rowList: rankingRows) } .eraseToAnyPublisher() } } ランキングの取得方式は「日間」「週間」「月間」「四半期」を選択することが可能です。 この API のリクエストパラメータには、多くのルールが存在します。 日間ランキングは、朝になるまでデータが生成されない(夜中に本日の日時を指定するとエラーになる) 週間ランキングなら集計の都合上、火曜日の日付を指定する必要がある 月間および四半期ランキングの場合、対象月の1日(20211001 など)を指定する必要がある リクエストパラメータを作成する際には、これらを念頭に置かなければなりません。しかし、これは API の都合であり、アプリの仕様や要件には直接関係の無いものです。したがって、 Gateway は入力としては「ランキングのタイプは月間としてデータを取得したい」など、 API の仕様とは直接関係ないパラメータを受け取って API が必要とするパラメータに変換し、実際に API リクエストを行う最下レイヤーとの橋渡し役を担います。 また、後に受け取ったレスポンスを Entity のデータ構造に変換する役割も担っています。 APIClient でリクエストの生成を行う RankingAPIClient.swift import Alamofire import Combine final class RankingAPIClient: RankingAPIClientProtocol { func fetch(rtype: String) -> AnyPublisher<[RankingResponse], Error> { let request = RankingRequest() request.rtype = rtype request.out = "json" return APIAccessPublisher.publish(request).eraseToAnyPublisher() } } APIAccessPublisher が実際の API アクセスを行う APIAccessPublisher.swift import Alamofire import Combine import Foundation struct APIAccessPublisher { private static let contentType = "application/json" private static let decoder: JSONDecoder = { let jsonDecoder = JSONDecoder() jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase return jsonDecoder }() static func publish<T, V>(_ request: T) -> Future<V, Error> where T: BaseRequest, V: Codable, T.ResponseType == V { return Future { promise in let api = AF .request(request) .responseJSON { response in switch response.result { case .success: do { if let data = response.data { let json = try self.decoder.decode(V.self, from: data) promise(.success(json)) } else { promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil))) } } catch { promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil))) } case .failure: promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil))) } } api.resume() } } } APIAccessPublisher は、 Alamofire を用いて実際に API 通信を行います。 レスポンスのデータ構造を定義しておき、 json をそのデータ構造に変換します。 結果は promise() を用いて Publish します。 機能追加のために新規で API を作成する場合でも、実際に通信を行うのはこのクラスになります。 ちなみに、レスポンスデータは Entity とデータ構造が類似しており、使いまわして直接 Entity に格納したいと感じることもありますが、これらのデータ構造の目的は全く異なり、それぞれ違う理由で変更が入るため、単一責任の原則に反するため、使い回さない方がよいとされています。 ところで、先述のとおり、この例では直列でもう一つ API を叩くのですが、もうひとつの APIClient についてはここでは割愛します。 依存性の注入 アーキテクチャのルール で述べた通り、上位レイヤーが下位レイヤーにアクセスする際には、必ず実装ではなくプロトコルに依存することによって、下位レイヤーに直接依存することを避けています。 そこで、上位レイヤーのモジュールから下位レイヤーの参照を保持するために、 Swinject を用いて下記のように依存性の注入(DI)を行っています。 AppBuilder.swift import Swinject final class AppBuilder { static let shared = AppBuilder() let rankingUseCase: RankingUseCaseProtocol let searchNovelUseCase: SearchNovelUseCaseProtocol // DI コンテナに Service を登録 let swinjectContainer = Container() { c in // フレームワーク・ドライバ c.register(RankingAPIClientProtocol.self) { _ in RankingAPIClient() } c.register(RankingRowsAPIClientProtocol.self) { _ in RankingRowsAPIClient() } c.register(SearchNovelAPIClientProtocol.self) { _ in SearchNovelAPIClient() } // インターフェイスアダプター c.register(RankingGatewayProtocol.self) { r in RankingGateway(apiClient: r.resolve(RankingAPIClientProtocol.self)!) } c.register(RankingRowsGatewayProtocol.self) { r in RankingRowsGateway(apiClient: r.resolve(RankingRowsAPIClientProtocol.self)!) } c.register(SearchNovelGatewayProtocol.self) { r in SearchNovelGateway(apiClient: r.resolve(SearchNovelAPIClientProtocol.self)!) } // ユースケース c.register(RankingUseCaseProtocol.self) { r in RankingUseCase(rankingGateway: r.resolve(RankingGatewayProtocol.self)!, rankingRowsGateway: r.resolve(RankingRowsGatewayProtocol.self)!) } c.register(SearchNovelUseCaseProtocol.self) { r in SearchNovelUseCase(searchNovelGateway: r.resolve(SearchNovelGatewayProtocol.self)!) } } private init() { // ユースケースの作成 self.rankingUseCase = self.swinjectContainer.resolve(RankingUseCaseProtocol.self)! self.searchNovelUseCase = self.swinjectContainer.resolve(SearchNovelUseCaseProtocol.self)! } } おわりに 今回は、 SwiftUI + Combine (と Swinject) で MVVM & Clean Architecture を構築し、アプリを作成してみました。 とはいえ SwiftUI ならではという話は結果的に皆無になってしまったので、 UIKit でも同じような感じになると思います。 Combine を初めて使った際、データの伝播のさせ方や直列による処理の書き方などがあまり分からず、やや手探りでの実装となりましたが、何となく慣れてきた今はこのように宣言的に記述出来るのは楽でよいなと感じています。 SwiftUI は現時点でまだまだ UIKit で出来ていたことには及ばず、不便を感じることが多いですが、既に新規プロダクトでは SwiftUI で開発しているという事例もいくつか耳にするようになったため、引き続き SwiftUI でやっていけるアーキテクチャを模索したいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React Native】アプリ内でWebViewを表示する時のヘッダータイトルを動的に変更

アプリ内でWebViewを表示する時、WebView内で画面遷移してもも動的にヘッダータイトルを変更したい アプリを実装していく中で、ほぼ間違いなくStackNavigationを使って画面遷移の制御をすると思います。 アプリで表示するところは画面ごとにタイトルを決めれますが、webViewは一つタイトルを決めて、そこからwebView内で画面遷移しても同じタイトルが使われてしまいます。 webViewを使うときはタイトルを表示しないという選択肢もありますが、タイトルを動的に変更する方法を見つけたので共有します。 ライブラリをインストール react-native-webview cd ios && pod install 実装 WebViewのonNavigationStateChangeというメソッドを使ってWebViewのタイトルタグを取得します。 import React from 'react'; import {WebView} from 'react-native-webview'; import { SafeAreaView } from 'react-native'; export const WebViewTitle = () => { const onChangeState = (e) => { console.log(e); }; return ( <SafeAreaView style={{flex:1}}> <WebView source={{uri: 'https://reactnative.dev/'}} onNavigationStateChange={e => onChangeState(e)} /> </SafeAreaView> ); }; するとWebView内で画面遷移するたびにonNavigationStateChangeが発火し、コンソールに情報が表示されます。 実際のlog↓ {"canGoBack": true, "canGoForward": false, "loading": false, "navigationType": "other", "target": 4383, "title": "React Native · Learn once, write anywhere", "url": "https://reactnative.dev/docs/getting-started"} ここからタイトルタグを取得し、それをアプリ内で管理すればいいだけです。 ただ、画面のタイトルを制御するファイル(StackNavigation.js)とWebViewを表示するファイル(実際にwebViewを呼び出しているところ)は別々のディレクトリであり、階層的にwebViewの方が子要素になると思います。 ですのでpropsで渡すことは不可能なので、 1、Reduxを使って管理 2、useContextを使って管理 のどちらかを使う必要があります。 またwebViewのタイトルタグが英語だったり、違う表現をしている可能性もありますので、webViewを作るエンジニアと相談する必要があります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【SwiftUI】おしゃれを導入!デザイン系ライブラリ

はじめに SwiftUIはAppleの標準アプリのような画面が実装できるので、デザインに関しては素人でも大失敗はしないという安心感があります。ただちょっと一工夫するのが意外と苦労したりします。今回はSwiftUIにおいて使えるデザイン系ライブラリのライブラリを紹介したいと思います。 紹介するライブラリは全てSwift Package Managerによる導入が可能です。 Liquid 泡のような図形を表示できるライブラリです。アニメーションが含まれているので、ゆっくり動くのが特徴です。色をつけて背景にすることもできますし、写真のフレームのような使い方もできます。 画像はライブラリの用意したView、Liquidを3つ重ねて作っていますが、泡のような感じを出すために重ねて使うことが多いかと思います。ひとつひとつ設定を変えることもできるので、一番手前だけ色を変えるなど細かい調節が可能です。 Colorful 画像のようなカラフルなViewを作成することができます。色やアニメーションの可否なども設定できます。色は複数指定できますし、逆に単色にして霧のような表現をすることも可能です。 基本的には背景として使うことになりそうですが、以前紹介したグラスモーフィズムとも相性がよいかもしれません。 StatefulTabView Statefulの名前の通り、状態によってTabの表示を変更できるようにすることが主な使い方です。例えば、アイコンに数字や文字を表示して通知などを知らせることができます。 これだけでも便利なのですが、実はこのライブラリを使うとTab部分の色を細かく変更することができます。 SwiftClockUI アナログ時計を表示するもので、文字盤は4種類用意されています。時計の色ももちろん変えられますし、アニメーションもなめらかです。 もともと画面に時間表示のあるiOSだとあまり出番がありませんが、MacOSのアプリだと活用の場面は増えてきます。例えば、ToDoアプリなどではアナログ表示で時間が見られると便利かもしれません。 MarkDownUI その名の通りマークダウンで文章を扱えるようになります。長文でもオフラインで表示したい規約や使い方などはこれを使うのがいいかもしれません。URL部分の色の変更など、細かい調整も可能です。 Textそのままよりもバランスが取りやすくなるのは非常に便利だと思います。マークダウン記法は広く普及しているので、サイトや文書を構成の参考にできるのが大きなメリットではないでしょうか。 記法としてはCommonMark Specに完全に準拠しているそうです。 一行目に日本語のみを見出し表示すると文字の上半分が途切れてしまうことがあります。対策としては以下のように空のHeaderを挿入してください。 # # # 見出し1 本文 DynamicColor UIColorとColorに便利な機能を追加できるものです。まずUIColorには色の調整を自動で行ってくれる機能が追加されます。下の画像のように基本の色に対して濃淡を変えたり、対抗色を生成したりすることが可能です。 SwiftUIのColorでもhex指定で色を決定できるようになるので、ちょっとした色の微調整も簡単になります。 さいごに SwiftUIに対応したライブラリもかなり増えてきたので今回はデザイン系に絞って紹介してきました。全てSwift Package Managerで簡単に管理できるのでとりあえず試してみるのも良いかと思います。ぜひ開発に役立ててください! よかったらこちらもよろしくお願いします☺️ 各種お知らせ:Twitter 作成途中のアプリについて:note
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode13で作成したプロジェクトにInfo.plistがないor あるが中の項目がほとんどない

Xcode13で作成したStoryboardプロジェクトはInfo.plistにほぼ項目がなく(SceneDelegate関連の1項目のみ)、SwiftUIアプリに至っては初期状態ではInfo.plistがありません。 代わりにTarget > Infoや、Target > build settingsから、過去にはInfo.plistで設定していた項目も変更できるようです。 Resolved Issues Projects created from several templates no longer require configuration files such as entitlements and Info.plist files. Configure common fields in the target’s Info tab, and build settings in the project editor. These files are added to the project when additional fields are used. (68254857) 参考 https://useyourloaf.com/blog/xcode-13-missing-info.plist/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode13になってXcodegenのインストールが失敗する Sources/Spectre/XCTest@4.swift': File not found.

最近XcodeGenのインストールが失敗するようになりました。mintを使ってXcodeGenをインストールしているんですが、 以下のようなエラーが出ました。 'Spectre' /private/var/folders/59/hx744wtd3h5d9nc5c17frm_m0000gp/T/mint/github.com_yonaskolb_XcodeGen/.build/checkouts/Spectre: warning: Invalid Exclude '/private/var/folders/59/hx744wtd3h5d9nc5c17frm_m0000gp/T/mint/github.com_yonaskolb_XcodeGen/.build/checkouts/Spectre/Sources/Spectre/XCTest@4.swift': File not found. Swift コマンドラインインターフェース関係の問題だったようでXcodegen公式で対応されていました。 Xcodegenを2.25.0にすると解消されます。 yonaskolb/XcodeGen@2.25.0
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitHub - ローカルのXcodeProjectをgithubにあげます

iOSのプロジェクトをgithubで管理したい時、まずgithubにあげるでしょう。これで自分のやり方を共有いたします。 Step1:githubで新しいレポジトリを作成する まずgithubのこのNewを押して Repository nameは普通プロジェクト名と同じでいいです。 Step2:ProjectにGit Repositoryあるかどうかを確認する タミナールを開いて、Projectのフォルダーに行って git remote -vを入力してみましょう。もし以下のようなエラーメッセージが出たら、Git Repositoryを追加する必要があります。 MacBook-Pro:viewPop XXXX$ git remote -v fatal: not a git repository (or any of the parent directories): .git Step3:Git Repositoryを追加する Projectを新しく立てる時に以下のチェックを入れたら追加できます。 チェックを忘れた場合、後から追加してもいいです。↓の「New Git Repositories...」から追加できます。 Step4:ブランチ作成、レポジトリ連結、プッシュ まず、Git Repositoryを確認してみましょう。 エラーメッセージが出てないですね、よし、これで大丈夫です。(まだリモートと連結していないので、何もないはず) MacBook-Pro:viewPop XXXX$ git remote -v MacBook-Pro:viewPop XXXX$ 準備できたから、あとはコマンドを入力すれば大丈夫なはずです! まず、「master」というブランチを作成します MacBook-Pro:viewPop XXXX$ git branch master MacBook-Pro:viewPop XXXX$ 次は、リモートと連結します。 MacBook-Pro:viewPop XXXX$ git remote add origin https://github.com/Wesley-chu/viewPop.git MacBook-Pro:viewPop XXXX$ 再度確認します。 MacBook-Pro:viewPop XXXX$ git remote -v origin https://github.com/Wesley-chu/viewPop.git (fetch) origin https://github.com/Wesley-chu/viewPop.git (push) MacBook-Pro:viewPop XXXX$ よし、これで連結済みです。 最後は、ローカルのソースをリモートにプッシュすればいいです。 MacBook-Pro:viewPop chuwl$ git push -u origin master Enumerating objects: 38, done. Counting objects: 100% (38/38), done. Delta compression using up to 4 threads Compressing objects: 100% (34/34), done. Writing objects: 100% (38/38), 12.91 KiB | 2.15 MiB/s, done. Total 38 (delta 3), reused 0 (delta 0) remote: Resolving deltas: 100% (3/3), done. To https://github.com/Wesley-chu/viewPop.git * [new branch] master -> master Branch 'master' set up to track remote branch 'master' from 'origin'. ↑成功!! 最後に GitHubで確認してみましょう!、、、 うまくあげたようですね!めてだしめてだし!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む