Skip to content

MVVMとCombineを利用し、GitHubのリポジトリを検索するアプリ

Notifications You must be signed in to change notification settings

KyusokLee/CodeAssignment_MVVM_Combine

Repository files navigation

Swift 5.9 Xcode 15.2

GitHub Repository Search Project

GitHub APIを参考にしてGitHub上のリポジトリを検索し、Personal Access Tokenを用いてお気に入りのリポジトリに星付け・解除ができる検索アプリ

 

デモ動画

リポジトリを検索

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-06-07.at.18.43.29.mp4

リポジトリ詳細画面

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-06-18.at.14.58.01.mp4

リポジトリに星付け・解除

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-06-18.at.15.01.11.mp4

入力ワード切り替え

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-06-18.at.15.02.33.mp4

 

参考資料

GitHub API: - 検索用 REST API エンドポイント
GitHub API: - 星付け用 REST API エンドポイント

 

目次

🗂 ディレクトリ構成

CodeAssignment_MVVM_Combine
 ├── Resources
 │   ├── Info.plist
 │   └── Assets.xcassets
 └── Sources
     ├── Apps
     │   ├── SceneDelegate
     │   └── AppDelegate
     ├── Extensions
     │   ├── HTMLURLResponse
     │   │   └── HTMLURLResponse+Utils
     │   ├── Error
     │   │   └── ErrorType+Utils
     │   └── UIColor
     │       └── UIColor+Utils
     ├── Controllers
     │   ├── HomeViewController
     │   └── DetailViewController
     ├── ViewModels
     │   ├── Home
     │   │   └── HomeViewModel
     │   └── DetailView
     │       └── DetailViewModel
     ├── Views
     │   ├── LoadingView
     │   ├── ReadySearchView
     │   └── Cells
     │       └── HomeViewController
     │           └── RepositoryCollectionViewCell
     └── Models
         ├── Enums
         │   ├── ErrorType
         │   ├── GitHubAPIType
         │   ├── Constants
         │   └── Tokens
         ├── Network
         │   └── APIClient
         ├── Entities
         │   └── RepositoriesRespone
         └── Presentations
             └── Repositories

 

🛠 技術スタック

フレームワーク

UIKit

  • コードベースのUIで開発しました。
  • UICollectionView の DataSource に関しては、UICollectionViewDiffableDataSource を使用しました。

アーキテクチャ

MVVM

  • UIを担当する View の開発をビジネスロジック部分と分離させることで、コードの可読性と保守性を向上させるため、使用しました。
  • データバインディングにより、View の更新を効率化します。

データ・UIイベント処理

Combine

  • 連続したエスケーピングクロージャを避け、宣言的プログラミングによる高い可読性と、オペレーターを用いた効率的な非同期処理のために使用しました。
  • データが発生する時点からビューに描画されるまで、一つの大きなストリームとしてデータをバインドしました。

AutoLayout実装

SnapKit

  • 直感的なコードでコンポーネントのレイアウトの制約を作成・管理できるため、使用しました。

Web画像の処理

SDWebImage

  • 画像の非同期読み込みとキャッシュ機能を提供するため、使用しました。

 

📱 機能及びUI

機能/UI 説明
検索 GitHub REST APIを使用して検索ワードをパラメータとして渡すことで、GitHub上のリポジトリを検索できます。
一覧リスト リポジトリ検索結果を一覧として表示します。一覧リストで表示するデータはリポジトリ名, リポジトリのDescription, ユーザ名, ユーザプロフィール写真, スター数, 使用言語です。
詳細画面 一覧リストで確認したいリポジトリのセルをタッチすると、そのリポジトリの詳細情報を表示した画面に遷移します。詳細画面では、一覧リストで表示したリポジトリのデータに加えてWatchersの数, forksの数, issuesの数を表示します。
星付け・解除 詳細画面の星ボタンをタッチすると、リポジトリに星を付けたり解除したりできます。Personal Access Tokenを用いてGitHub認証を行うことで、自分のアカウントでこれらの操作が可能になります。

 

💻 設計及び実装

MVVM

スクリーンショット 2024-07-01 17 11 34

 

役割分担

class/struct 役割
HomeViewController UISearchControllersearchBarを用いて検索ワードを入力し、リポジトリを検索してその結果を一覧リストで表示する画面です。
DetailViewController HomeViewControllerでタッチしたリポジトリの詳細データを表示する画面です。
RepositoriesResponse API叩きから得られたリポジトリのデータモデルを直接管理します。
Repositories RepositoriesResponseのデータをViewControllerの画面に表示するためのデータモデルです。

 

Utilities

class/struct 役割
APIClient APIを呼び出すために必要な処理を管理するクラス。HTTPリクエストのビルドと送信、データの取得・デコーディング、エラーの分岐を担当します。

 

💪🏻 技術的チャレンジ

MVVM

 これまでの開発はほぼアーキテクチャ未導入かMVPアーキテクチャを用いて開発してましたが、リアクティブプログラミングの理解のための座学ということで、今回の開発でMVVMアーキテクチャを導入することにしました。これをきっかけにMVVM について取り上げてみたいと思います。理解した内容を以下に記載しました。

 MVVMアーキテクチャの特徴をまとめると、ViewControllerViewは画面を描く役割だけに集中させ、画面上で必要なデータ管理とロジックはViewModelで進められるようにし、関心事を分離することです。

スクリーンショット 2024-06-24 20 18 58

 MVVM は Model-View-ViewModel の略称であり、ソフトウェア開発で使われるアーキテクチャパターンの一つを指します。MVVMはアプリケーションを上記のように3つのコンポーネントに分離して管理し、各コンポーネントが特定の役割を果たします。

 

Model

  • アプリケーションのデータとビジネスロジックを含む
  • データベース、ネットワークリクエスト、ローカルストレージなどと相互作用してデータを取得・更新
  • アプリケーションの状態とデータを表現し、データの変更を検出して通知を行うことが可能

 本アプリではAPIリクエストロジックを処理する APIClientや そのリクエスト時に得られるリポジトリのデータモデル RepositoriesResponse などが当てはまります。以下は RepositoriesResponse のコードです。

struct RepositoriesResponse: Codable {
    /// queryに当てはまる結果の数
    let totalCount: Int
    /// リポジトリの詳細データが入っている配列形
    let items: [RepositoryResponse]
    
    struct RepositoryResponse: Codable {
        var owner: RepositoryUserResponse
        var name: String
        var description: String?
        var language: String?
        var stargazersCount: Int
        var forksCount: Int
        var watchersCount: Int
        var openIssuesCount: Int

        struct RepositoryUserResponse: Codable {
            var login: String
            var avatarUrl: String
        }
    }
}

 

View

  • いやゆるUIを指す
  • ユーザがアプリケーションと相互作用できる画面を構成し、ユーザの入力イベントを受け取る
  • ユーザにデータを表示し、ユーザの入力イベントを ViewModelに伝達
  • UIKitでは UIViewControllerView に当てはまる

 本アプリではリポジトリを検索してその結果を一覧リストで表示する HomeViewController と特定のリポジトリの詳細情報が見れる DetailViewControllerがこれに当てはまります。
 以下は HomeViewController のコードであり、ViewModel とのリアクティブなデータ相互作用を可能にするため、bind メソッドでデータバインディング処理をします。

// MARK: - Life Cycle & Variables
class HomeViewController: UIViewController {

    private let viewModel = HomeViewModel()
    private var cancellables = Set<AnyCancellable>()
    private var dataSource: UICollectionViewDiffableDataSource<Section, Repositories.Repository>!
    private lazy var repositoryCollectionView: UICollectionView = {
        var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        let layout = UICollectionViewCompositionalLayout.list(using: config)

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.backgroundColor = .secondarySystemBackground
        collectionView.contentInsetAdjustmentBehavior = .always
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }
}

// MARK: - Functions & Logics
extension HomeViewController {
    
    /// ViewModel と ViewController の間のデータ相互作用のため、bind処理
    private func bind() {
        viewModel.repositoriesSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] repositories in
                guard let self, let repositories else { return }
                self.updateSnapshot(repositories: repositories.items)
            }
            .store(in: &cancellables)
        
    }

    // 他のコードは省略
}

// MARK: - UISearchBarDelegate
extension HomeViewController: UISearchBarDelegate {
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchWord = searchBar.text else { return }
        // Returnキーを押して ViewModelで定義したsearch logicを実行
        viewModel.search(queryString: searchWord)
    }
}

// 他のコードは省略

 

ViewModel

  • ViewModelの間の中間レイヤーの役割を果たす
  • Model からデータを取得して、View が使いやすい形に加工・フォーマット
  • UIに関連するロジックを処理し、View にデータを提供
  • View と完全に分離されており、ViewLife Cycleとは独立して動作
  • 主にユーザの入力を処理し、データを常に監視(Observe)して更新事項を View に通知

 以下のコードは HomeViewController で使用するビューモデル HomeViewModel のコードの一部です。
 ここでモデルである APIClient のインスタンスを用いて GitHub のリポジトリを検索し、その結果をビューである HomeViewControllerに渡す役割を果たします。
 このビューモデルは、モデル(データ取得と加工)とビュー(データの表示)の間の中間層として機能し、データの取得と加工、ビューへのデータ提供を行います。
 CurrentValueSubjectsendメソッドに関しては後述の Combine の箇所で説明します。

final class HomeViewModel {
    private let apiClient = APIClient()
    var repositoriesSubject = CurrentValueSubject<Repositories?, Never>(nil)
    var repositoriesPublisher: AnyPublisher<Repositories?, Never> {
        return repositoriesSubject.eraseToAnyPublisher()
    }
    
    /// GET リクエストを送信し、repositoryを持ってくるメソッド
    func search(queryString searchWord: String) {
        let trimmedQuery = searchWord.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedQuery.isEmpty else { return }

        let requestProtocol = GitHubSearchRepositoriesRequest(searchQueryWord: trimmedQuery)
        apiClient.request(requestProtocol, type: GitHubAPIType.searchRepositories) { result in
            switch result {
            case let .success(repositories):
                // model: API側から持ってくるRepositories
                guard let repositories else { return }
                // VCに渡す用のinstance
                let repositoriesView = Repositories(repositories: repositories)
                // subjectを通してModelを送る
                self.repositoriesSubject.send(repositoriesView)
            case let .failure(error):
                print(error.localizedDescription)
            }
        }
    }
}

 


Combine

 2019年 Apple が公表した非同期フレームワーク Combine を利用してリアクティブプログラミングの実装にチャレンジしました。
 Combineの公式ドキュメント によると、Combine を下記のようにまとめられます。

時間の経過に応じて変更する値をエクスポートする Publisher と、それを受信する Subscriber を利用して時間の経過に応じた値を処理する Swift API

 つまり、PublisherSubscriber を利用して効率的に非同期プログラミングを処理するために登場したと思います。Combine の登場前はこの非同期プログラミングの処理に RxSwift を使っていました。
 それでは、Combine の主要概念である PublisherSubscriberOperator についてみていきましょう。

 

📍 Publsiher

 Publisher は時間の経過に応じて、値と完了信号を発行する役割を果たします。この完了信号は、正常に値の発行を完了したか、エラーが起きて失敗したかを表す信号です。

/// 
public protocol Publisher<Output, Failure> {
    associatedtype Output
    associatedtype Failure : Error

    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}

  Publisher は必ず、2つの Generic Type を持つ必要があります。上記のコードでわかるように、OutPutFailure のタイプを定義する必要があります。意味は下記のようになります。

  • Output : Publisher が放出する値の種類
  • Failure : Publisher が放出するかもしれないエラーの種類   - Never : Error が発生することがないのを明示するタイプ

 後述しますが、Publisher から発行されるデータを受け取る SubscriberInput & Failure も当然に PublisherOutput & Failure タイプと一致しなければならないです。
 この Publisher は主に以下のような種類があります。

  • Just
  • Future
  • Subject

本アプリでは Subject を用いた開発を進めたため、この文書では Subject について説明します。

Subject

 Subject は、Publisher プロトコルを採択したプロトコルです。つまり、Publisher の一種のオブジェクトです。
Publisher が値を保持する主体だったとすれば、この値に他の値を注入できるのが Subject であると理解していただければいいです。
Subject は以下のように2つのクラスで実現されます。

  • CurrentValueSubject
  • PassthroughtSubject

 後述する send メソッドを通して、Publisher から発行されたデータを購読者 Subscriber に送信することができます。特に、Subject クラスのインスタンスで主に使用されます。
 それでは、それぞれについて見ていきましょう。

CurrentValueSubject
  • 常に最新の値を保持し、購読が開始されたときにその値を即座に送信します。
  • 初期値の設定が可能です。
  • 現在の値にアクセスしたり、値を更新することが可能です。
  • 上記の理由より、主に現在の状態を追跡するときに便利です。
import Combine

// 0に初期値を設定
let currentValueSubject = CurrentValueSubject<Int, Never>(0)
// 初期値にアクセス可能
print(currentValueSubject.value) // 0

// sinkメソッドで発行されるデータを受け取る
let subscription = currentValueSubject.sink { value in
    print("Received value: \(value)")
}

currentValueSubject.send(1) // "Received value: 1"
currentValueSubject.send(2) // "Received value: 2"
print(currentValueSubject.value) // 2
PassthroughSubject
  • 値を保持せず、新しい値が発行されたときのみSubscriber に送信します。
  • 過去の値にアクセスすることが不可能です。
  • 上記の理由より、イベントストリームの送信に適しています。
import Combine

// 初期値を設定不可
let passthroughSubject = PassthroughSubject<String, Never>()
let subscription = passthroughSubject.sink { value in
    print("Received value: \(value)")
}

// 過去の値にアクセス不可
passthroughSubject.send("Hello") // "Received value: Hello"
passthroughSubject.send("World") // "Received value: World"

 


📍 Subscriber

 値や完了信号を発行する Publisher が存在すれば、それらを受信して処理する存在も必然的に必要になりそうですよね。この存在を Subscriber といいます。
 SubscriberPublisher を購読することで、が発行するデータ(値、完了信号)を受信し、それらを処理します。
 この Subscriber には主に以下のような種類があります。下記のメソッドで PublisherSubscriber をバインディング(連結)できます。

  • subscribe
  • assign
  • sink
  • receive

それでは、それぞれについてみていきましょう。

subscribe
  • Subscriber プロトコルを継承した購読者クラスを直接定義し、生成したクラスを使用して購読します。

 しかし、Apple は、直接実装して購読を実現することをお勧めしないらしいです。
 また、後述する sink などのメソッドでは明示的に subscribe を実装しなくても暗黙的に処理してくれるので、実務では sinkassign を使うケースが大多数のようです。
下記に書いたコード例を見ればわかると思いますが、開発のときに毎度 Publisher ごとに Subscriber クラスを定義・生成して一つ一つ実現する方法は非効率なので、、

/// 1 ~ 7を放出する Publisherを生成
let publisher = (1...7).publisher
  
// Subscriber を継承する Custom Subscriberを生成
class IntSubscriber: Subscriber {
  
  // type alias を用いて 生成した publisherの OutputとFailure タイプと一致する Input と Failure タイプを定義
  typealias Input = Int
  typealias Failure = Never
  
  // publisherから生成された subscriptionを受け取る際に呼び出されれるメソッド
  func receive(subscription: Subscription) {
    // subscription の .request(_:) を呼び出しを通して受信する値の数に制限がないことを知らせる (制限したい場合は .max(数) を使う)
    subscription.request(.unlimited)
  }

// 各値を受信する際に呼び出されるメソッド
  func receive(_ input: Int) -> Subscribers.Demand {
    // 受信した値を print
    print("Received value", input)
    // .none を返して、subscriberの需要に関して調整が不要であることを知らせる (=.max(0))
    return .none
  }

  // 完了イベントを受信される際に呼び出されるメソッド
  func receive(completion: Subscribers.Completion<Never>) {
    // 受信処理を完了したことを print
    print("Received completion", completion)
  }
}
  
// 上記で作成したクラスのインスタンスを生成
let subscriber = IntSubscriber()
// publisherにsubscribeメソッドをつけて使用
publisher.subscribe(subscriber)

// 出力結果
Received value 1
Received value 2
Received value 3
Received value 4
Received value 5
Received value 6
Received value 7
Received completion finished
assign
  • オブジェクトのプロパティに直接値を割り当てるときに使用します。
  • 完了シグナルやエラーイベントは処理しません。
class CustomClass {
    var receivedInt: Int = 0 {
        didSet {
            print("Received Int : \(receivedInt)", terminator: "\n")
        }
    }
}

var customObject = CustomClass()
let customRange = (0...3)
cancellable = customRange.publisher
    .assign(to: \.receivedInt, on: customObject)

// 出力結果 (完了信号は出力されない)
Received Int : 0
Received Int : 1
Received Int : 2
Received Int : 3
sink
  • 値と完了及びエラーイベントの両方を処理できます。
  • 値を処理する際に使われる最も一般的な方法です。
let customRange = (0...3)
cancellable = customRange.publisher
    .sink(receiveCompletion: { print ("completion: \($0)") },
          receiveValue: { print ("Received Value: \($0)") })

// 出力結果
//  Received Value: 0
//  Received Value: 1
//  Received Value: 2
//  Received Value: 3
//  completion: finished

 sink メソッドを通して、値が発行された時に呼び出される receiveValuePublihser が正常に終了したり、Error が起きて終了した時に呼び出される receiveCompletion クローザーを用いて、値やイベントを処理することができます。

receive
  • Publisher の値を特定のスレッドで受信したり、後述する Operator のチェーン内で値を渡すときに使用します。
    • ここで、「チェーン内」といのは、複数の Operator を順番に組み合わせて使用することを指します。
  • 完了およびエラーイベントは処理しません。

receivePublisherSubscriber をバインディングするという認識よりかは、スレッドの指定のときによく使われるので、ここに記載するか迷ったんですが、一応 受信するという観点から入れておきました。

let subject = PassthroughSubject<String, Never>()

let subscription = subject
    .receive(on: DispatchQueue.main) // メインスレッドで受信 (参考:UIの Drawingはメインスレッドで処理する必要がある)
    .sink { value in
        print("Received Value: \(value)")
    }

subject.send("Hello, Combine!")

// 出力結果
//  Received Value: Hello, Combine!

次に PublisherSubscriber の間の関係を管理し、非同期処理のキャンセル時に使われる storeAnyCancellable について紹介します。

store
  • AnyCancellable オブジェクトをSetに保存し、メモリ管理を支援する役割を担います。
  • Publisher が生成する AnyCancellable インスタンスをこのSetに保存することで、これらのインスタンスがすべてキャンセルされるまで PublisherSubscriber の関係を維持します。
AnyCancellable
  • SubscriberPublisher を購読する際に返される型です。
  • これを使用して SubscriberPublisher との購読をキャンセルできます。

 まとめると、storeAnyCancellable を保持しておいて、当該の変数が deinit されるとき、購読をキャンセルする方法になります。
 Set で複数のSubscription(購読)を1つにまとめることができ、Subscription の値を保持します。
 実際のコード例を下記に示します。

/// ViewModel
private let viewModel = HomeViewModel()
private var cancellables = Set<AnyCancellable>()

// repositoriesSubjectは viewModel側で定義した CurrentValueSubjectのインスタンス
viewModel.repositoriesSubject
   .receive(on: DispatchQueue.main)
   .sink { [weak self] repositories in
      guard let self, let repositories else { return }
      self.updateSnapshot(repositories: repositories.items)
   }
   .store(in: &cancellables)

// 他のコード省略

 


📍 Operator

 Publisher が発行する値を変換・操作、またはフィルタリングするメソッドです。さまざまな演算子を使用してデータストリームを処理し、希望する形に変換することができます。  Operator を効率的に使うことで、データの処理パイプラインを構築することができます。 Operator には map, filter, flatMapなどが当てはまりますが、今回新しく学んだ CombineLatesteraseToAnyPublisher について紹介したいと思います。

CombineLatest
  • 複数の Publisher からの最新の値を組み合わせて新しい値を生成します。
  • Publisher が新しい値を出すたびに、それらを組み合わせて新しい出力を生成します。

 実際のコード例を下記に示します。

import Combine

// Publisher 側の役割として、PassthroughSubject を用意
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<String, Never>()

// CombineLatest で組み合わせた Publisher を定義し、出力をコンソールに print する sink を設定する
let combined = Publishers.CombineLatest(publisher1, publisher2)
    .map { "\($0) \($1)" } // 組み合わせた各値を一つの文字列にマップ(タイプの変換)
    .sink { print("Combined value: \($0)") }

publisher1.send(1)
publisher2.send("A")
publisher1.send(2)
publisher2.send("B")

// 出力結果はsendの順(最新の値)
Combined value: 1 A
Combined value: 2 A
Combined value: 2 B
eraseToAnyPublisher
  • 型消去を行い、具体的な Publisher の型を非公開の AnyPublisher 型に変換します。
  • Publisher の型の詳細を隠蔽できるため、外部に露出せず Type Safety を保ちながらもコードを簡潔に保つことができます。
  • API 設計を単純化し、使う側は Publisher のタイプに対する知識がなくても、使用しやすくなります。

 eraseToAnyPublisherの役割・使用利点 (韓国サイト) を参考にした実際のコード例を下記に示します。

// eraseToAnyPublisher 未使用
final class APIClient {
  // return の方で一番下に書いたメソッド(receive(on: _))を先に記述する(Publishers.ReceiveOn<>)
   // Operator を多く使用した場合、複雑なタイプを変換することになる
   // 必要に応じてメソッドの中間演算過程が変更されたら、このメソッドを使う全ての既存のコードに影響を与える
   func fetchWeather1(
      city: String
   ) -> Publishers.ReceiveOn<Publishers.Catch<Publishers.Map<Publishers.Decode<Publishers.MapKeyPath<URLSession.DataTaskPublisher, Data>, WeatherResponse, JSONDecoder>, Weather>, Empty<Weather, Error>>, RunLoop> {
      guard let url = URL(string: Constants.weather(city: city)) else { fatalError("Invalid URL!") }
      return URLSession.shared.dataTaskPublisher(for: url)
         .map(\.data) // KeyPathを用いて data, responeの中、dataのみを抽出してdownstreamに移動させる
         .decode(type: WeatherResponse.self, decoder: JSONDecoder()) // デコーディング
         .map { $0.main } // デコードしたWeatherResponseのmain( Weather フィルド )のみをdownstreamに移動させる
         .catch { _ in Empty<Weather, Error>() } // エラー発生時に、Empty Typeに返す
         .receive(on: RunLoop.main) // main threadで受け取って動作するようにする
   }
}

// eraseToAnyPublisher 使用
final class APIClient {
   // 中間の演算過程が露出されず、OutPutと Error タイプだけ確認できるようになる
   // 外部からは抽象化された AnyPublisher タイプを使うので、コードの可読性も向上される
   // つまり、外部のコードに影響与えない
   func fetchWeather2(city: String) -> AnyPublisher<Weather, Error> {
      guard let url = URL(string: Constants.weather(city: city)) else { fatalError("Invalid URL !") }
      return URLSession.shared.dataTaskPublisher(for: url)
         .map(\.data)
         .decode(type: WeatherResponse.self, decoder: JSONDecoder())
         .map { $0.main }
         .catch { _ in Empty<Weather, Error>() }
         .receive(on: RunLoop.main)
         .eraseToAnyPublisher()
   }
}

 


UICollectionViewDiffableDataSource

UICollectionViewDiffableDataSource は、UICollectionView のデータソースをより簡単かつ安全に管理できるクラスです。
既存に使っていた UICollectionViewDataSource との相違点としては、スナップショット(Snapshot) を使用して変更事項をアニメーションと共に安全に適用することができることです。

private enum Section: CaseIterable {
    // 今回はsection1つしか使わないので、mainだけ定義
    case main
}

// MARK: - Life Cycle & Variables
class HomeViewController: UIViewController {

    // 他のコード省略

    private var dataSource: UICollectionViewDiffableDataSource<Section, Repositories.Repository>!
    private lazy var repositoryCollectionView: UICollectionView = {
        var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        let layout = UICollectionViewCompositionalLayout.list(using: config)

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.backgroundColor = .secondarySystemBackground
        collectionView.contentInsetAdjustmentBehavior = .always
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        bind()
    }
}

// MARK: - Functions & Logics
extension HomeViewController {
    /// ViewControllerのUIをセットアップする
    private func setupUI() {
        view.backgroundColor = .secondarySystemBackground
        
        setupNavigationController()
        setupDataSource()
        setAddSubViews()
        setupConstraints()
    }
    
    /// CollectionViewのDatasource 設定
    private func setupDataSource() {
        /// RepositoryCollectionViewCellをCellRegistrationで設定
        let repositoryCell = UICollectionView.CellRegistration<RepositoryCollectionViewCell, Repositories.Repository>() { cell, indexPath, repository in
            // <CellのType(クラス名とか), Itemで表示するもの>
            cell.backgroundColor = .white
            cell.configure(with: repository)
            // cellにUICellAccessory(accessories)を追加
            cell.accessories = [
                .disclosureIndicator()
            ]
        }

        // DiffableDataSourceの初期化
        dataSource = UICollectionViewDiffableDataSource<Section, Repositories.Repository>(collectionView: repositoryCollectionView) { collectionView, indexPath, repository in
            return collectionView.dequeueConfiguredReusableCell(using: repositoryCell, for: indexPath, item: repository)
        }
        /// DataSourceに表示するSectionとItemの現在のUIの状態
        var snapshot = NSDiffableDataSourceSnapshot<Section, Repositories.Repository>()
        // Snapshotの初期化
        // appendSections: snapShotを適用するSectionを追加
        // apply(_ :animatingDifferences:) : 表示されるデータを完全にリセットするのではなく、incremental updates(増分更新)を実行してDataSourceにSnapshotを適用する
        snapshot.appendSections([.main])
        dataSource.apply(snapshot, animatingDifferences: true)
    }
    
    /** ViewModelなどViewController側で常に監視しておくべき対象を、セットアップ
     イベント発生時に正常にデータバインドをするために、Observerを設定する感じ
     
    - Combineで流れたきたデータのアウトプットsinkする
    - sink : Publisherからのイベントを購読する.  つまり、イベントを受信したときの処理を指定できる。
    - receive(on:):イベントを受け取るスレッドを指定する
    - store: cancellabeなどを保
     */
    private func bind() {
        viewModel.repositoriesSubject
            .receive(on: DispatchQueue.main)
            .sink { [weak self] repositories in
                guard let self, let repositories else { return }
                self.updateSnapshot(repositories: repositories.items)
            }
            .store(in: &cancellables)
    }

    // viewModelから受け取った値をsnapShotの更新を通して、画面に反映させる
    private func updateSnapshot(repositories: [Repositories.Repository]) {
        /// DataSourceに適用した現在のSnapShotを取得
        var snapshot = dataSource.snapshot()
        // reloadItemsは既存セルの特定のCellだけをReloadするので、deleteしたあとに改めてappendする形でSnapshot適用
        snapshot.deleteAllItems()
        snapshot.appendSections([.main])
        snapshot.appendItems(repositories, toSection: .main)
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

// MARK: - UICollectionViewDelegate
extension HomeViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let repository = viewModel.repositoriesSubject.value?.items[indexPath.row] else { return }
        let detailViewController = DetailViewController(repository: repository)
        navigationController?.pushViewController(detailViewController, animated: true)
        collectionView.deselectItem(at: indexPath, animated: true)
    }
}

 


UICollectionViewCompositionalLayout

UICollectionViewCompositionalLayout は、複雑で多様なレイアウトを簡単に構成できるようにするレイアウトクラスです。
さまざまなアイテムとグループを組み合わせて、柔軟なレイアウトを作成できます。
また、UICollectionViewCell のサイズを動的に計算してくれるので、既存の Cellheight や width のようなサイズを動的に計算するために使用した UICollectionViewDelegateFlowLayout プロトコルの sizeForItemAt メソッドを使う必要がなくなります。

var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let layout = UICollectionViewCompositionalLayout.list(using: config)

UICollectionLayoutListConfiguration は、リストスタイルの CollectionView のレイアウトを設定するために使用しました。
appearance はリストの外観を設定しており、ここで insetGrouped はグループ化されたスタイルを指します。
UICollectionViewCompositionalLayout.list(using:) は、リストスタイルのレイアウトを作成します。
上記のコードを使うことで、リストスタイルの UICollectionView を簡単に設定できるようにしてくれます。
本アプリでは、設定アプリの TableView に似たUIを作成さたかったので、UICollectionLayoutListConfiguration を採用しました。

 


🎯 実装時に意識したこと

Extension活用

/// Status Codeの値ごとに有効であるか無効であるかを定義しておくためのExtension
extension HTTPURLResponse {
    func isResponseAvailable() -> Bool {
        return (200...299).contains(self.statusCode)
    }
}

// MARK: - UISearchBarDelegate
extension HomeViewController: UISearchBarDelegate {
    /// Return(検索)キーをタップしたときの処理
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchWord = searchBar.text else { return }
    }
    // 他は省略
}

// MARK: - Life Cycle & Variables
class HomeViewController: UIViewController {
    private let viewModel = HomeViewModel()
    private let loadingView = LoadingView()
    private let readyView = ReadySearchView()

    // 他は省略
}

// MARK: - Functions & Logics
extension HomeViewController {
    /// ViewControllerのUIをセットアップする
    private func setupUI() {
        view.backgroundColor = .secondarySystemBackground
        
        setupNavigationController()
        setupDataSource()
        setAddSubViews()
        setupConstraints()
    }

    // 他は省略
}
  • extensionを用いて、既存のオブジェクトやタイプを修正することなく、新しい機能を追加することができます。

  • 上記のコードに記載したHTTPURLResponseのように isResponseAvailable の関数を追加することで、複数の場所で同様の機能が使用できるようにし、コードの重複を減らすことを意識しました。

  • UISearchBarDelegateのように deleage パターンは extensionを使って責任の分離をしておき、特定の機能に関するコードを一箇所にまとめて管理しやすくしました。

  • 人の好みによると思いますが、ViewControllerViewclassの定義する際に、extensionを用いて「ライフサイクル・プロパティ」と「ロジック・関数」を分離するようにしました。

    • 理由としては、classのコードが長くなり過ぎないように一度 extension で区切って整理することで、コードの可読性を向上させたかったからです。
    • また、delegateパターンを extensionを使って責任分離を行なうのと同様に、ロジックの部分とクラスのライフサイクルを分離しました。

 

AutoLayout

本アプリでは SnapKitを用いて AutoLayoutの設定をしました。今回、コードベースで画面のUIを設定するのが技術的な制限として設けられたので、Storyboardなしで開発を進めました。 SnapKit を利用した経緯は過去の経験から以下のことを感じたからです。

"画面の数が多くて複雑になって、Storyboard の数が増えている.. Storybard自体も重くなってファイルを開くたびにXcodeが落ちちゃう..."
"Storyboardって使わなくていいよね?"
"Storyboardなしでプロパティの constraint をコードで実装してみよう!"
"あれ?やってみたら、constraint を追加するコードも長くなちゃったな.."
"SnapKit 使ってみたら、便利..!"

なぜ、SnapKit を使って便利だと思ったかについては以下のコードを参考にしながら、説明します。

// SnapKit 未使用
mainStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStackView)

NSLayoutConstraint.activate([
    mainStackView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true,
    mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true,
    mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true,
    mainStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
])

// SnapKit 使用
mainStackView.snp.makeConstraints { constraint in
    constraint.edges.equalToSuperview()
    // 上記と下記のコードは同じ動作をする
    // constraint.leading.top.trailing.bottom.equalToSuperview()
}

// または
mainStackView.snp.makeConstraints {
    $0.edges.equalToSuperview()
}
  • 上記に示した「SnapKit 使用」のコードを見ると、「未使用の例」より簡潔で直感的になっており、可読性が向上されたと感じます。

  • また、SnapKitconstraint.edges.equalToSuperview() や constraint.leading.top.trailing.bottom.equalToSuperview() のようにメソッドチェーンで複数のプロパティに一度に制約を設定することができ、NSLayoutConstraintsよりコードの量を減らせることができます。

  • これは実装中に気づいたことですが、SnapKitは内装コードに translatesAutoresizingMaskIntoConstraintsfalseにする設定があるため、別途に同様のコードを記載する必要がないので便利でした。

  • 今度は VFL (Visual Format Language)を導入して、制約の設定をより視覚的に実装することにチャレンジしようと思っています。

 

参照及びARC関連

Swiftでは、メモリ使用を追跡して管理するために、それらを自動的に処理する ARC(Automatic Reference Counting) を使用します。
  ARC は、オブジェクトのライフサイクルを管理してメモリ漏れを防止し、オブジェクトがもはや必要ないときにメモリを解除します。
つまり、メモリの参照回数を計算して、参照回数が 0 になれば、これ以上使わないメモリだと思って解除してくれるという仕組みになっています。
ここで、RC とは、あるインスタンスを現在誰が指しているかどうかを数字で表したものです。 この ARC において重要な概念は、以下のようになります。

  • 強参照(strong reference)
  • 弱参照(weak reference)
  • 循環参照(retain cycle)

強参照

  • デフォルトの参照タイプで、オブジェクトのライフサイクルを維持します。
  • オブジェクトが強参照で接続されていると、参照カウントが増加し、このカウントが 0 になるまで、オブジェクトはメモリから解放されません。
class Person {
    // 強参照
    var name: String
    var city: City? // Optionalタイプの有無は 参照の強度とは関係ない
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

class City {
    var name: String
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

var person: Person? = Person(name: "Kyulee")
var city: City? = City(name: "Tokyo")

person?.city = city  // person が city を強く参照
city = nil  // city インスタンスは nil であるが、 person が強参照を維持
person = nil  // ここで、person と pet 、両方とも deinit される

// 出力結果 (上からの順番)
Kyulee is being deinitialized
Tokyo is being deinitialized

弱参照

  • 弱参照は参照カウントを増加させることはなく、参照対象オブジェクトが解除されると自動的に nil に設定されます。
  • 弱参照は常に Optional タイプである必要があります。理由としては、オブジェクトが解除されたときに nil が割り当てられる可能性があるからです。
  • 弱参照は、参照対象オブジェクトが解放された後も安全にアクセスできます。
  • 主に強い参照循環を避けるために使用されます。
  • weak キーワードを使って、参照カウントの増加を防げます。
class Person {
    var name: String
    // 弱参照
    weak var friend: Person?
    
    init(name: String) {
        self.name = name
    }
}

var person1: Person? = Person(name: "Kyu")
var person2: Person? = Person(name: "Lee")

person1?.friend = person2
person2?.friend = person1

person1 = nil
// person2はまだ存在するが、person1に対する弱参照がnilになる

循環参照

  • 2つのオブジェクトが互いを強く参照し、互いの参照カウントを減少させることができない状況を指します。これにより、メモリのリークが発生します。
  • 循環参照によるメモリリークを回避するために、弱参照(weak)、または無所有参照(unowned)を使用します。

ここで、無所有参照について軽く見ていきましょう。

unowned 無所有参照
  • 参照するオブジェクトがメモリから解放されても、無所有参照は自動で nil に割り当てされません。
  • 無所有参照は Optional タイプでなくてもよいです。これは、オブジェクトが常に有効であると仮定するときに使用されます。
  • 参照対象オブジェクトが解除された後、無所有参照にアクセスすると、ランタイムエラーが発生する可能性があります。
  • 上記の理由から、無所有参照は、オブジェクトが同じライフサイクルを共有するか、オブジェクトが解除されないと確信している場合にのみ使用する必要があります。
class Customer {
    var name: String
    var card: CreditCard?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

class CreditCard {
    var number: UInt64
    unowned var customer: Customer  // 無所有参照
    
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    
    deinit {
        print("Card \(number) is being deinitialized")
    }
}

var customer: Customer? = Customer(name: "Kyu Lee")
customer?.card = CreditCard(number: 1234_5678_9012_3456, customer: customer!)

customer = nil
// customerが解除されcardも解除される

今回の開発において、closure 内の循環参照を避けることを意識し、weak self を使うことにしました。

let button = UIButton(configuration: config)
button.addAction(.init { [weak self] _ in
  guard let self else { return }
  self.didTapStarButton()
}, for: .touchUpInside)

上記のコードの closure 内で weak self を使う理由はメモリリークの発生可能性のある強い循環参照を避けるためです。
UIButtonaddAction メソッドに closure を渡すとき、clousure は self をキャプチャーして参照することになります。
もし、closureself を強く参照すると、UIButtonself を強く参照し、selfclosure を強く参照する循環参照が発生します。
この循環参照により、両方のオブジェクトがメモリから正常に解放されないという問題が生じます。
これを防止するために、closureself を弱く参照するように weak 弱参照のキーワードを使いました。

 

例外処理・通信時のエラー処理

例外処理

/// 通常の関数
func decode(from data: Data) -> RepositoriesResponse? {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    
    do {
        let response = try decoder.decode(RepositoriesResponse.self, from: data)
        return response
    } catch {
        // エラーのとき、メッセージの表示と同時に nil を返す
        print("Failed to decode JSON: \(error)")
        return nil
    }
}

/// throw関数
func decode(from data: Data) throws -> RepositoriesResponse {
   let decoder = JSONDecoder()
   decoder.keyDecodingStrategy = .convertFromSnakeCase
   return try decoder.decode(RepositoriesResponse.self, from: data)
}

/// 使う側でのコード
do {
   let results = try requestProtocol.decode(from: data)
   completion(.success(results))
} catch {
   completion(.failure(ErrorType.decodeError))
}
  • throws関数を用いて、Errorの発生可能性があることをthrowsキーワードで明示し、エラーを投げるようにしました。

  • 関数内部の try catchのコードブロックの記載が不要になり、実際に使う側でロジックを実行するときにエラー処理を行うようにし、コードの可読性と保守性を意識しました。

通信時のエラー処理

/// ErrorTypeの定義
enum ErrorType: Error {
    case apiServerError
    case noResponseError
    case decodeError
    case unknownError
}

extension ErrorType {
    var errorTitle: String {
        switch self {
        case .apiServerError:
            return "APIサーバーエラー"
        case .noResponseError:
            return "レスポンスエラー"
        case .decodeError:
            return "デコードエラー"
        case .unknownError:
            return "不明なエラー"
        }
    }
    
    var errorDescription: String {
        switch self {
        case .apiServerError:
            return "サーバーにエラーが起きました。\nもう一度、お試しください。"
        case .noResponseError:
            return "レスポンスがないです。\nもう一度、確認してください。"
        case .decodeError:
            return "デコードエラーが発生しました。\nもう一度、お試しください。"
        case .unknownError:
            return "不明なエラーが返ってきました。\nもう一度、確認ください。"
        }
    }
}

/// 使う側でのコード例
func presentError(_ error: ErrorType) {
    print("Error: \(error.errorTitle)")
    print("Description: \(error.errorDescription)")
}

let error = ErrorType.apiServerError
presentError(error)
  • APIリクエスト処理時に発生の可能性があるエラーを明示し、可読性とコードの保守性の向上を意識しました。

  • 各エラータイプに対して明確に定義されたタイトル errorTitle と説明 errorDescription を提供することで、ユーザにエラーが発生した時の詳細情報を表示することができます。

  • エラー発生時の情報を定義することで、ユーザ目線だけでなく開発者も問題を把握しやすくなり、デバッグも容易になります。

 

DRY原則

重複コードを避け、汎用的なコードを書くよう意識しました。

重複コードのメソッド化

private lazy var watchersCountLabel: UILabel = makeCountLabel()
private lazy var forksCountLabel: UILabel = makeCountLabel()
private lazy var openIssuesCountLabel: UILabel = makeCountLabel()

private func makeCountLabel() -> UILabel {
   let label = UILabel()
   label.font = .systemFont(ofSize: 18, weight: .regular)
   label.textColor = .black.withAlphaComponent(0.7)
   return label
}

/// フォントのサイズやテキストカラーに差を付与してインスタンスを生成するときは、以下のように応用できるメリットがある
private func makeCountLabel(fontSize: CGFloat, color: UIColor) -> UILabel {
   let label = UILabel()
   label.font = .systemFont(ofSize: 18, weight: .regular)
   label.textColor = .black.withAlphaComponent(0.7)
   return label
}
  • UILabelインスタンス定義時に同じコードを使用しているものはメソッドとしてまとめ、重複コードを避けました。

 

ジェネリックとプロトコルの使用

class APIClient {
    /// ジェネリック(Tタイプ)を使用することで、GitHubAPIClientProtocolを準拠する全てのタイプのリクエスト処理が可能になる
    func request<T: GitHubAPIClientProtocol>(_ requestProtocol: T, type: GitHubAPIType, completion: @escaping(Result<T.Model?, ErrorType>) -> Void) {
        guard let request = requestProtocol.buildUpRequest() else { return }
        /// 他は省略
    }
}

/// リポジトリ検索用のリクエスト
struct GitHubSearchRepositoriesRequest: GitHubAPIClientProtocol {
   /// リクエストを立てる処理とリクエストを実際に送る処理を分離し、コードの可読性とテストおよび保守時のメンテナンス性を向上させる
   func buildUpRequest() -> URLRequest? {
      let urlString = "[URL string you want to use]"
      guard let url = URL(string: urlString) else { return nil }
      var request = URLRequest(url: url)
      request.httpMethod = "GET"

      return request
   }
   /// 他は省略
}
  • 本アプリはGitHub Rest APIの中、検索用のエンドポイントと星付け・解除用のエンドポイントを使用しています。検索用のstructであるGitHubSearchRepositoriesRequestと星付け・解除用であるGitHubStarRepositoriesRequestはそれぞれ異なるリクエストを処理していますが、ジェネリックとプロトコルを用いることでコードの再利用性を増やすことができます。

  • GitHubSearchRepositoriesRequestGitHubStarRepositoriesRequestが共通のプロトコルGitHubAPIClientProtocolを準拠するように定義し、APIClientクラスでジェネリックを使用してリクエストを送信するようにすると、重複したコードの削減と、複数のAPIリクエストに対して同じロジックを使用できるようになります。

  • 各リクエストタイプが自分自身のリクエストを組み立てるロジックbuildUpRequestを持つようにし、APIClientはリクエストタイプに応じた処理を行う必要がなくなり、関心事の分離が実現されます。

 

カスタムコンポーネント

final class LoadingView: UIView {
    /// didSetを用いてプロパティの値が更新された直後に実行し、古い値を新しい値に置き換えることが可能
    var isLoading = false {
        didSet {
            isHidden = !isLoading
            isLoading ? loadingIndicatorView.startAnimating() : loadingIndicatorView.stopAnimating()
        }
    }
    /// 他は省略
}

/// loadingViewを使う側で以下のように定義することで、どの画面でも利用できる
private let loadingView = LoadingView()
loadingView.isLoading = true

/// 他は省略
  • ローディング中であることをユーザに示すLoadingViewをカスタムコンポーネント化し、コードの再利用性を増やした。同じ機能やUI要素を一つの箇所にカプセル化したため、ViewControllerViewなどどの場所でもこれらを利用することができます。

  • UIやロジックを修正する際、当該コンポーネントだけ修正すればいいので、コードの保守がしやすくなります。例えば、UIActivityIndicatorViewの色や表示するテキストを変えたいときは、LoadingViewクラスを修正すればいいので、関心事の分離ができ、テストも容易にします。

 

🧐 工夫点

Personal Access Token の管理方法

星付け・解除の機能を使うにあたって、自分自身のGitHubアカウントの認証が必要だったため、アクセストークンをどのように使うかを悩みました。
本アプリの認証機能の実装において、下記の4つの方法を工夫しました。

UserDefaults

func getAccessToken() -> String? {
    UserDefaults.standard.string(forKey: "accessToken")
}

func saveAccessToken(_ accessToken: String) {
    UserDefaults.standard.set(accessToken, forKey: "accessToken")
}
  • 既存の個人開発では簡単な環境設定などの管理はUserDefaultsを用いて実装しました。コードの書き方も上記のようにとても簡単なため、よく使っていました。

  • しかし、UserDefaults上のデータはproperty list (.plistファイル)に保存されるため、特定のツールなどを使用すると、UserDefaults にアクセスできるようになり、データの確認・修正が可能になるという脆弱性があります。そのため、トークンなどの機密情報の保存には適していないらしいです。

  • 上記の理由から、UserDefaultsを用いた方法は採用しませんでした。

ProcessInfo を用いる方法

func loadTokenFromProcessInfo() -> String? {
    return ProcessInfo.processInfo.environment["PERSONAL_ACCESS_TOKEN"]
}

// 使い方
if let token = loadTokenFromProcessInfo() {
    print("Loaded token: \(token)")
} else {
    print("Token not found")
}
  • ProcessInfoを用いた方法は、Xcodeの Scheme設定の修正を通して実装可能になります。Xcode上の環境変数として保存して使えばいいので、コードの書き方が簡単です。

  • しかし、Xcode上でビルドしない限り、設定したトークンが正常に反映されないので、トークンの保存方法としては不適合だと判断しました。そのため、この方法も採用しませんでした。

Keychain を用いる方法

  • Keychain を使用すると、ユーザのパスワードに限らず、クレジットカード情報、あるいは短いメモなどもユーザが暗号化したいものであれば、Keychain データベースに暗号化して保存することができます。

  • Appleは Keychain Services APIを通して、主に以下のことを提供しています。

    1. 長くて難しいパスワードなどの機密情報を作っても私が代わりに覚えてあげます!
    2. パスワードの奪取が心配ですか?こっちの方で暗号化して持っているので、心配しないで!
  • つまり、この Keychainの実装方法を用いて、ユーザは簡単で便利にパスワードやトークンなどの機密情報を管理することができます。

  • また、追加でAPIで自動的に暗号化が必要なものに対する処理をしてくれるので、開発者も簡単で便利に使用できるというメリットがあります。詳しくは下記に貼った公式文書を参考にしてください。

  • しかし、Keychainは実装の方法が複雑であり、ある程度理解度が必要であると感じ、今回は採用しないことにしました。

  • また、今回の実装はあくまでも自分のアカウントで任意のリポジトリに星付け・解除機能の実現有無だけを確認する目的であるため、採択しないことにしました。

  • 今後、Keychainについて勉強したあと、リファクタリングにチャレンジする予定です。

公式ドキュメントに行く (Keychainについて)

gitignore を用いる方法

# gitignore ファイルに無視したいファイルまでのパスを全部記載
# 以下は例
CodeAssignment_MVVM_Combine/Sources/Token.swift
  • 今回の実装の目的は、自分のアカウントで任意のリポジトリに星付け・解除することができるかを確かめることです。そのため、トークンを記載したファイルを作成し、GitHubで公開する際には gitignore を使用してそのファイルをプロジェクトから除外する方法を採用しました。

  • 上記のように、gitignore ファイルにトークンを記載したファイルまでのパスをすべて記録することで対応は完了です。ただし、gitignore ファイルの位置によってパスが異なるため、注意が必要です。

 

📚 学び

画面表示用のレスポンスの結合モデルの作成

背景

  • 今回のアプリを実装するまでは、無意識でAPIを叩いて返ってくるレスポンスをAPIClientで処理してViewControllerで直接渡すようなコードを書いていた。これはレスポンスの形に依存しちゃうのでは?と考えていてこの依存度をどう分離するかを悩んていたものの、依存度を分離せずに普段から慣れていたコードを書きました。
  • すると、レビュアーの方からまさにここの部分を指摘され、API叩きから得られるレスポンス用のデータモデルと画面に表示する用のデータモデルを分岐することで依存度を減らせることを教わりました。

解決

  • レスポンスの形式に依存することはアンチパターンであると考え、ビューに表示するためのモデルを生成して適用しました。
  • データモデルをAPIから取得するリポジトリのデータ用の RepositoriesResponse と、それらを画面に表示するための Repositories に分けました。これにより、Codable を継承する struct 内の不要な CodingKeys ロジックを排除することができます。
  • また、ビュー表示用のモデルを用意して関心事の分離をすることで、テストも容易になります。例えば、API処理のテストを行うときは RepositoriesResponseを、 UIのテストを行うときは Repositoriesのみをテストすればいいので、コード作成の効率性も上がります。

 

About

MVVMとCombineを利用し、GitHubのリポジトリを検索するアプリ

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages