hachinoBlog

hachinobuのエンジニアライフ

今更だけどSwiftの型消去の使い所が少し分かったので備忘録

背景

2016年のtry!Swiftで話題になった型消去

なかなか使い所が分かってなかったんだけど、利用したいシーンに出会ったので備忘録を記す

説明

前提

私は、開発する上で、よくHTTPクライアントのライブラリであるAPIKitを使っています。

APIKitの使い方を簡単に説明すると、Requestというプロトコルに準拠したクラスを書いて、APIKitのSessionクラスのsendメソッドの引数に指定することで、指定したエンドポイントにアクセスして結果を返してくれます。

このAPIKitのRequestプロトコルは、エンドポイントにアクセスして取得するレスポンスの型をassociatedtypeを使って、Requestプロトコルに準拠したクラスで指定できるようになっています。

public protocol Request {
    /// The response type associated with the request type.
    associatedtype Response

    //その他、HttpMethodやpathやパラメータなどのプロパティがある
    //....
}

下記は、あるAPIを叩いて成功した場合にStringの配列を返すクラスです。

public class HogeEndpoint: Request {
    typealias Response = [String]

    //...
}

実際に使う場合は下記のような感じになります

let request = HogeEndpoint()
Session(request) { result in
    switch result {
    case .success(let items):
        //Stringの配列を取得
    case .failure(let error):
        //Error処理
    }
}

型消去を使って解決した課題

APIKitの説明を踏まえた上で、Requestプロトコルに準拠したクラスで、APIのエンドポイントは違うけれども成功した時に返ってくる値の型は同じであるパターンを想定してみます。

例えば下記のようなHogeEndpointFugaEndpointクラスです。

class HogeEndpoint: Request {
    typealias Response = [String]
    
    var path: String = "/api/v1/hoge"
    //...
}

class FugaEndpoint: Request {
    typealias Response = [String]

    var path: String = "/api/v1/fuga"
    //...
}

これは、接続するエンドポイントは違いますが、結果として得られる値の型は同じです。

そこでString型の配列をリストで表示する画面を作ることを想定してみましょう。

リスト表示のViewControllerにRequestプロトコルのStored propertyを宣言してやれば、ViewControllerが汎用的に使えると思い下記のようなコードを書きました。

import UIKit
import APIKit

class StringListTableViewController: UITableViewController {

    var request: Request!

    private var items: [String]? {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "reuseIdentifier")
        fetchItem()
    }
    
    private func fetchItem() {
        
        Session.send(request) { [weak self] result in
            switch result {
            case .success(let items):
                self?.items = items
            case .failure(let error):
                print(error)
            }
        }
        
    }

    // MARK: - Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
        cell.textLabel?.text = items?[indexPath.row]
        return cell
    }        

}

これで色々な画面から、String配列のリスト表示画面であるStringListTableViewControllerのStored propertyであるrequestに値を渡すだけで汎用的に使えると思うかもしれません。

しかし、これではProtocol 'Request' can only be used as a generic constraint because it has Self or associated type requirements.エラーになります。

f:id:hachinobu:20171115200204p:plain

Requestプロトコルassociatedtypeの型が決まってないのでダメよってことですね。

当然といえば当然です。

では、こんな時はどうすれば良いでしょうか?

結局、Requestプロトコルassociatedtypeに引きづられて、同じようなViewを作るしかないのでしょうか?

そうです。こんな時に型消去です。

型消去

(型消去自体の説明は沢山良記事があるので割愛します)

このエラーを回避してViewControllerを汎用的に使い回すために下記のような型消去クラスを作成します

struct TypeEraseEndpoint<ResponseType>: Request {

    typealias Response = ResponseType
    
    var path: String
    var method: HTTPMethod
    private let responseHandler: ((Any, HTTPURLResponse) throws -> ResponseType)
    
    init<R: Request>(request: R) where R.Response == ResponseType {
        self.path = request.path
        self.queryParameters = request.queryParameters
        self.method = request.method
        self.dataParser = request.dataParser
        self.responseHandler = request.response
    }
    
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> ResponseType {
        return try responseHandler(object, urlResponse)
    }

}

このような型消去のクラスを使うことで、先ほどのStringListTableViewControllerのStored propertyであるrequestの型をTypeEraseEndpoint<[String]>!に変更することで、ViewControllerを汎用的に使うことができました。

import UIKit
import APIKit

class StringListTableViewController: UITableViewController {

    var request: TypeEraseEndpoint<[String]>!

    private var items: [String]? {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "reuseIdentifier")
        fetchItem()
    }
    
    private func fetchItem() {
        
        Session.send(request) { [weak self] result in
            switch result {
            case .success(let items):
                self?.items = items
            case .failure(let error):
                print(error)
            }
        }
        
    }

    // MARK: - Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
        cell.textLabel?.text = items?[indexPath.row]
        return cell
    }        

}

この型消去のサンプルプロジェクトをQiitaのAPIを使って作ってみました。

SwiftTypeEraseSample

もしよければ触ってみてください。

最後に

そもそもCleanArchitectureみたいなレイヤードアーキテクチャを意識して作れば、このような場面には出くわさないんですけどね。

ViewControllerにRequestプロトコル渡すような書き方するなよとか言わないで!!

とはいえ、ほんと今更の理解で凹む。。

普段からassociatedTypeのProtocolを書いていないという設計力の低さを露呈しているような気がする。

iOS CleanArchitectureを使ってみた感想

CleanArchitectureを使ったサンプルアプリを作成したので、説明していきたいと思います

サンプルアプリ

まず、作成したアプリについてです

Qiitaのクライアントです

認証 投稿一覧 詳細 ユーザー投稿一覧 詳細
Simulator Screen Shot 2016.10.05 21.49.02.png Simulator Screen Shot 2016.10.10 15.48.59.png Simulator Screen Shot 2016.10.10 15.55.20.png Simulator Screen Shot 2016.10.10 15.53.32.png Simulator Screen Shot 2016.10.10 15.53.24.png

ソースコード

https://github.com/hachinobu/CleanQiitaClient

使い方

一応そのままビルドしてもビルドは通りますが認証していない状態なのでAPIコール制限とストックボタンなどは動かないです

認証したい場合は下記からアプリケーションの登録をしてください https://qiita.com/settings/applications/new

リダイレクト先のURLをサンプルソースコードでは固定にしてしまっているので clean-qiita-client://oauth で登録すると楽です Client IDとClient Secretを取得したらそれぞれ、サンプルソースコードの Secrets.swiftというファイルを開き、

public static let clientId: String = "****"
public static let clientSecret: String = "****"

を書き換えればOKです

先に記載したリダイレクト先のURLについても、違う名前で登録したのであれば、

public static let redirectUrlScheme: String = "clean-qiita-client"

ここを修正すれば動きます

環境

Xcode8 + Swift3.0 cocoapods-1.1.0.rc.2以上

Clean Architectureとは

ドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやFrameworkから引き離し、それぞれのレイヤー毎に役割と責任を分離したArchitectureになります

概念などについては割愛しますが下記の記事を何ども読み込みました

まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて

各レイヤーと対象のサンプルコード

下記がCleanArchitectureのLayerの構造になっています

layer.png

※ 今回は上記の絵に加え、PresentationLayerにRoutingという層を作っています

それでは各レイヤーについてサンプルコードを見ながら説明します

DataLayer

  • 通信やデータ管理のロジックを担当するレイヤー

DataStore

  • データを取得/更新する処理を担当
  • 取得先がAPIならAPI用のDataStore,取得先がDBならDBから取得用のDataStoreができる
  • あるデータを取得する際に取得先(APIやDB)が複数ある場合、RepositoryからFactoryクラスを用いてDataStoreを生成する
  • APIの場合、Endpointごとにつくるイメージ

DataStoreサンプルコード

protocol ItemListDataStore {
    func fetchAllItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void)
}

struct ItemListDataStoreNetworkImpl: ItemListDataStore {
    
    func fetchAllItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void) {
        let request = GetAllItemListRequest(page: page, perPage: perPage)
        Session.send(request, callbackQueue: nil, handler: handler)
    }
    
}

struct ItemListDataStoreFactory {
    
    static func createItemListDataStore() -> ItemListDataStore {
        return ItemListDataStoreNetworkImpl()
    }
    
}

ItemListDataStoreNetworkImplはQiitaの記事一覧をAPIから取得するDataStoreです

ItemListDataStoreFactoryはQiitaの記事一覧を取得するDataStoreを生成します 本サンプルではAPI経由でしかデータを取得しないようになっているので必ずItemListDataStoreNetworkImplを生成して返すようにしています またNetwork部分はAPIKitに依存したサンプルコードになっています

例えば記事一覧をAPIからだけでなく、キャッシュしているDBからデータを取得したい場合などは、ItemListDataStoreFactoryの中で取得すべき条件に応じて分岐処理を書いて正しいデータの取得先となるDataStoreを生成します

ItemListDataStoreFactoryItemListDataStoreを返すようにしています ItemListDataStoreは記事一覧を取得するDataStoreのI/Fです APIからデータを取得するDataStoreもDBからデータを取得するDataStoreも共にItemListDataStoreに準拠することでDataStoreを使うRepositoryは何の意識もすることがなくI/Fであるデータ取得のメソッドを叩くだけで良くなります

Entity

  • DataStoreで扱うことができるデータの静的なモデル
  • データ取得先から取得したデータのValueObject
  • EntityはPresentationLayerでは使用しない(Viewのレンダリングなどに使われない)

Entityサンプルコード

import Foundation
import ObjectMapper

public struct ItemEntity {
    public var renderedBody: String?
    public var body: String?
    public var coediting: Bool?
    public var createdAt: String?
    public var group: GroupEntity?
    public var id: String?
    public var isPrivate: Bool?
    public var tagList: [TagEntity]?
    public var title: String?
    public var updatedAt: String?
    public var url: String?
    public var user: UserEntity?
}

extension ItemEntity: Mappable {
    
    public init?(map: Map) {
        
    }
    
    mutating public func mapping(map: Map) {
        renderedBody <- map["rendered_body"]
        body <- map["body"]
        coediting <- map["coediting"]
        createdAt <- map["created_at"]
        group <- map["group"]
        id <- map["id"]
        isPrivate <- map["private"]
        tagList <- map["tags"]
        title <- map["title"]
        updatedAt <- map["updated_at"]
        url <- map["url"]
        user <- map["user"]
    }
    
}

APIのレスポンスのJSONをObjectMapperでマッピングしてValueObjectとなるEntityを生成しています

Repository

  • DomainLayerのUseCaseにデータ取得のCRUD相当のI/Fを提供する
  • 取得したいデータのDataStoreをFactoryクラスを用いて取得し、そのDataStoreに対してデータ取得/更新のリクエストを行う
  • DataLayerとDomainLayerのI/Fを担う

今回DataLayerの1つとしてRepositoryを書いています

サンプルコード

import Foundation
import APIKit
import Result

public protocol ItemListRepository {
    func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void)
}

public struct ItemListRepositoryImpl: ItemListRepository {
    
    public static let shared: ItemListRepository = ItemListRepositoryImpl()
    
    public func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<GetAllItemListRequest.Response, SessionTaskError>) -> Void) {
        let dataStore = ItemListDataStoreFactory.createItemListDataStore()
        dataStore.fetchAllItemList(page: page, perPage: perPage, handler: handler)
    }
    
}

Repositoryはデータ取得/更新のリクエストを出すDataStoreを生成時に注入される必要がないため生成コストを考慮してシングルトンで実装しています

UseCaseからfetchItemListメソッドが叩かれたら、取得したいデータのDataStoreを生成するFactoryからDataStoreを取得し、そのDataStoreにデータ取得/更新のリクエストを実行します

DomainLayer

Model

サンプルコード

import Foundation

public struct ListItemModel {
    public let id: String
    public let userName: String
    public let title: String
    public let tagList: [String]
    public let profileImageURL: String
}

主にModelの値をViewに表示します

Translator

  • UseCaseで取得したEntityをPresentationLayerで使用するModelへ変換する
import Foundation

protocol Translator {
    associatedtype Input
    associatedtype Output
    
    func translate(_: Input) -> Output
}
import Foundation
import DataLayer

struct ListItemModelsTranslator: Translator {
    
    func translate(_ entities: [ItemEntity]) -> [ListItemModel] {
        return entities.map { ListItemModelTranslator().translate($0) }
    }
    
}

struct ListItemModelTranslator: Translator {
    
    func translate(_ entity: ItemEntity) -> ListItemModel {
        
        let id = entity.id ?? ""
        let userName = entity.user?.id ?? ""
        let title = entity.title ?? ""
        let tagList = entity.tagList?.flatMap { $0.name } ?? []
        let profileImageURL = entity.user?.profileImageUrl ?? ""
        
        return ListItemModel(id: id, userName: userName, title: title, tagList: tagList, profileImageURL: profileImageURL)
        
    }
    
}

ここではItemEntityからListItemModelに変換しています ここでの変換処理は主にOptionalを外したりViewに最適化の処理をしています

最適化といえど金額Int(1000)からFormattingされた金額String(¥1,000)などはしません この処理はPresentationLayerのView(ViewModel)で行うべきです

UseCase

  • ユースケースに必要なロジック処理を記述する
  • UIには直接関与しない(View,ViewControllerから直接参照されない)
  • PresentationLayerで使用するModelを生成するためにRepositoryを複数持つ可能性がある(複数APIを叩かなければ生成できないModelなど
  • Repositoryを叩いてEntityを取得してTranslatorでModelへ変換させる
  • 必要なModelを生成するための材料となるEntityが全て揃うまでの待ち合わせなどもここで行う

サンプルコード

public protocol ItemListUseCase {
    func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<[ListItemModel], SessionTaskError>) -> Void)
}

public struct AllItemListUseCaseImpl: ItemListUseCase {
    
    let repository: ItemListRepository
    
    public init(repository: ItemListRepository) {
        self.repository = repository
    }
    
    public func fetchItemList(page: Int?, perPage: Int?, handler: @escaping (Result<[ListItemModel], SessionTaskError>) -> Void) {
        
        repository.fetchItemList(page: page, perPage: perPage) { result in
            
            let r = generateItemModelsResultFromItemEntitiesResult(result: result)
            handler(r)
            
        }
        
    }
    
}

fileprivate func generateItemModelsResultFromItemEntitiesResult(result: Result<[ItemEntity], SessionTaskError>) -> Result<[ListItemModel], SessionTaskError> {
    
    guard let itemEntities = result.value else {
        return Result.failure(result.error!)
    }
    let listItemModels = ListItemModelsTranslator().translate(itemEntities)
    return Result.success(listItemModels)
    
}

取得すべきデータのI/Fを提供しているRepositoryが注入されているので、そのRepositoryのI/Fを叩くとDataLayerで取得したEntityを取得できるので、そのEntityをTranslatorにかけ最終成果物となるModelを生成します

UseCaseを使うPresenterは、画面を生成するのに何個APIを叩く必要があるといったことは知る必要はなくViewにレンダリングさせるModelを要求するだけで、UseCase内で全てRepositoryなどを管理して通信の待ち合わせなどをしModelを生成します

PresentationLayer

Presenter

  • Viewからイベントを受け取り、必要があればイベントに応じたUseCaseを実行する
  • イベントに応じてViewにレンダリングすべきUIを指定する
  • UseCaseから受け取ったデータをViewへ渡す
  • UseCaseから受け取ったデータを管理する

サンプルコード

import Foundation
import DomainLayer

public protocol ItemListPresenter {
    
    weak var view: ItemListPresenterView? { get set }
    
    func setupUI()
    func refreshData()
    func fetchMorePageItem()
    func reachedBottom(index: Int, isAnimation: Bool)
    func selectedItem(index: Int)
    
}

public protocol ItemListPresenterView: class {
    func showErrorAlert(message: String)
    func setupNavigation(title: String)
    func setupRefreshControl()
    func segueItemDetailScreen(itemId: String)
    func reloadView(itemListSummaryProtocol: ItemListSummaryProtocol)
    func startIndicator()
    func stopIndicator()
}

ItemListPresenterプロトコルは一覧画面のViewからイベントなどを受け取って処理するPresenterのI/Fを提供しています setupUIメソッドではUIの初期化 refreshDataメソッドでは一覧の表示に必要なデータの取得 などです Viewがイベントやライフサイクルに応じてPresenterのI/Fを叩きます

ItemListPresenterViewはItemListPresenterの処理が完了した時に、Viewがどのような振る舞いをすれば良いのかのI/Fを提供しています ViewがItemListPresenterViewに準拠していればPresenterからレンダリングすべきデータを受け取ったり、Presenterからの要求を受け取ることができます

ItemListPresenterの実装です

import Foundation
import DomainLayer
import Kingfisher
import Networking
import APIKit
import Result

public class AllItemListPresenterImpl: ItemListPresenter {
    
    weak public var view: ItemListPresenterView?
    let useCase: ItemListUseCase
    
    private var currentPage: Int = 1
    let perPage = 20
    
    private var listItemModels: [ListItemModel] = [] {
        didSet {
            reloadView(listItemModels: listItemModels)
        }
    }
    private var isFinishMoreLoad: Bool = false
    
    public init(useCase: ItemListUseCase) {
        self.useCase = useCase
    }
    
    public func setupUI() {
        view?.setupNavigation(title: "全ての投稿")
        view?.setupRefreshControl()
    }
    
    public func refreshData() {
        
        ImageCache.default.clearMemoryCache()
        
        let firstPage = 1
        useCase.fetchItemList(page: firstPage, perPage: perPage) { result in
            
            switch result {
            case .success(let listItemModels):
                self.currentPage = firstPage
                self.listItemModels = listItemModels
                
            case .failure(.responseError(let qiitaError as QiitaError)):
                self.view?.showErrorAlert(message: qiitaError.message)
                
            case .failure(let error):
                self.view?.showErrorAlert(message: error.localizedDescription)
            }
            
        }
    }
    
    public func fetchMorePageItem() {
        
        view?.startIndicator()
        let nextPage = currentPage + 1
        useCase.fetchItemList(page: nextPage, perPage: perPage) { result in
            
            self.view?.stopIndicator()
            guard let listItemModels = result.value else {
                return
            }
            self.currentPage = nextPage
            if listItemModels.count == 0 {
                self.isFinishMoreLoad = true
            }
            self.listItemModels = self.mergeItemList(currentListItemModels: self.listItemModels, fetchListItemModels: listItemModels)
            
        }
    }
    
    public func reachedBottom(index: Int, isAnimation: Bool) {
        let bottomIndex = listItemModels.count - 1
        guard bottomIndex == index && !isAnimation && !isFinishMoreLoad else {
            return
        }
        fetchMorePageItem()
    }
    
    public func selectedItem(index: Int) {
        guard listItemModels.count > index else {
            return
        }
        let item = listItemModels[index]
        view?.segueItemDetailScreen(itemId: item.id)
    }
    
    private func mergeItemList(currentListItemModels: [ListItemModel], fetchListItemModels: [ListItemModel]) -> [ListItemModel] {
        return fetchListItemModels.reduce(currentListItemModels) { (currentList, fetchItem) in
            return currentList.contains { $0.id == fetchItem.id } ? currentList : currentList + [fetchItem]
        }
    }
    
    private func reloadView(listItemModels: [ListItemModel]) {
        let summaryVM = ItemListSummaryVM(itemList: listItemModels)
        view?.reloadView(itemListSummaryProtocol: summaryVM)
    }
    
}

Viewのイベントに対応したメソッドを実装しています 一覧画面を表示するために必要なデータの取得をUseCaseに命令したり 表示するUIの初期設定やRefreshControlの有無などを判断してViewに命令を出しています

View(ViewController)

  • Viewのライフサイクル・レンダリング・ユーザーのタッチイベントなどのイベントをPresenterに通知する
  • Presenterから受けたModelのデータやステータスによりViewの表示を切り替える

サンプルコード

import UIKit
import DomainLayer
import Utility

fileprivate extension Selector {
    static let refreshAction = #selector(ItemListViewController.refreshData)
}

public class ItemListViewController: UITableViewController {

    @IBOutlet weak var indicatorCircleView: IndicatorCircleView!
    private var presenter: ItemListPresenter! {
        didSet {
            presenter.view = self
        }
    }
    
    fileprivate var routing: ItemListRouting!
    
    fileprivate var itemListSummaryVM: ItemListSummaryProtocol! {
        didSet {
            tableView.reloadData()
        }
    }
    
    public func injection(presenter: ItemListPresenter, routing: ItemListRouting) {
        self.presenter = presenter
        self.routing = routing
    }
    
    override public func viewDidLoad() {
        super.viewDidLoad()
        presenter.setupUI()
        presenter.refreshData()
    }

    override public func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    func refreshData() {
        presenter.refreshData()
    }
    
    // MARK: - Table view data source
    override public func numberOfSections(in tableView: UITableView) -> Int {
        let numberOfSection = itemListSummaryVM == nil ? 0 : 1
        return numberOfSection
    }
    
    override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return itemListSummaryVM.fetchItemCount()
    }
    
    override public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as ListItemCell
        let displayItemVM = itemListSummaryVM.fetchListItemDisplayProtocol(index: indexPath.row)
        cell.setupContents(displayVM: displayItemVM)
        
        return cell
    }
    
    override public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        presenter.reachedBottom(index: indexPath.row, isAnimation: indicatorCircleView.isAnimating())
    }
    
    // MARK: - Table view delegate
    override public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableViewAutomaticDimension
    }
    
    override public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableViewAutomaticDimension
    }
    
    override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.selectedItem(index: indexPath.row)
    }

}

extension ItemListViewController: ItemListPresenterView {
    
    public func showErrorAlert(message: String) {
        refreshControl?.endRefreshing()
        routing.presentErrorAlert(message: message)
    }
    
    public func setupNavigation(title: String) {
        self.title = title
    }
    
    public func setupRefreshControl() {
        guard refreshControl == nil else {
            return
        }
        refreshControl = UIRefreshControl()
        refreshControl?.tintColor = .qiitaMainColor
        refreshControl?.addTarget(self, action: .refreshAction, for: .valueChanged)
    }
    
    public func segueItemDetailScreen(itemId: String) {
        routing.segueItem(id: itemId)
    }
    
    public func reloadView(itemListSummaryProtocol: ItemListSummaryProtocol) {
        refreshControl?.endRefreshing()
        itemListSummaryVM = itemListSummaryProtocol
    }
    
    public func startIndicator() {
        indicatorCircleView.startAnimation()
    }
    
    public func stopIndicator() {
        indicatorCircleView.stopAnimation()
    }
    
}

イベントに応じてPresenterのI/Fを叩いているのが分かります またItemListPresenterViewに準拠することで、Presenterからの指示のもとViewが一覧を表示したりエラーダイアログを出したり命令を受けます Viewは渡されたデータをレンダリングしたり、Presenterから指示された通りの処理をするだけです View本来の役割です

各レイヤーはDIできるようになっており、ViewControllerのInjectメソッドで依存性が注入されます

Routing

  • 画面遷移をつかさどる
  • DIもここでおこなう

サンプルコード

public protocol AuthScreenRouting: Routing {
    func segueAllItemListScreen()
}
import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

public struct AuthScreenRoutingImpl: AuthScreenRouting {
    
    weak public var viewController: UIViewController?
    
    public init() {
        
    }
    
    public func segueAllItemListScreen() {
        
        let repository = ItemListRepositoryImpl.shared
        let useCase = AllItemListUseCaseImpl(repository: repository)
        let presenter = AllItemListPresenterImpl(useCase: useCase)
        
        let navigationController = UIStoryboard(name: "ItemListScreen", bundle: Bundle(for: ItemListViewController.self)).instantiateInitialViewController() as! UINavigationController
        let viewController = navigationController.topViewController as! ItemListViewController
        
        let routing = AllItemListRoutingImpl()
        routing.viewController = viewController
        viewController.injection(presenter: presenter, routing: routing)
        
        UIApplication.shared.delegate?.window??.rootViewController = navigationController
        let appDelegate = UIApplication.shared.delegate
        appDelegate?.window??.rootViewController = navigationController
        appDelegate?.window??.makeKeyAndVisible()
        
    }
    
}

認証画面後に一覧画面が表示されるので認証画面から一覧画面に遷移する際に呼ばれます

ここでは一覧画面を構成する上で必要なRepository,UseCase,Presenter,Routingを一覧画面のViewControllerに注入しています DIができる設計になっているので簡単にレイヤー間を差し替えることが可能です

これだけレイヤーが分かれていて、かつDIしやすい設計になっているメリットの一例として [投稿一覧画面]→[詳細画面]に遷移した際に、ユーザー名のリンクをタップすると[ユーザー投稿一覧画面]に遷移します 次に[ユーザー投稿一覧画面]から記事を選択すると[詳細画面]に遷移しますが、ここでまたユーザー名をタップした場合、Pushで[ユーザー投稿一覧画面]に遷移してしまうのは変ですよね? [ユーザー投稿一覧画面]から遷移してきているだからpopするだけで良い

こんな時、注入するRoutingを変えてあげるだけで他のレイヤーは何一つ変えることなく上記の画面遷移を実現できるのです

サンプルコードでいうと

下記は[投稿一覧]→[詳細]に遷移した場合のユーザー名をタップした挙動を記述しているRoutingです

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

class ItemRoutingImpl: ItemRouting {
    
    weak var viewController: UIViewController? {
        didSet {
            viewController?.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
        }
    }
    
    func segueItemList(userId: String) {
        
        let repository = UserItemListRepositoryImpl.shared
        let useCase = UserItemListUseCaseImpl(repository: repository, userId: userId)
        let presenter = UserItemListPresenterImpl(useCase: useCase)
        
        let navigationController = UIStoryboard(name: "ItemListScreen", bundle: Bundle(for: ItemListViewController.self)).instantiateInitialViewController() as! UINavigationController
        let vc = navigationController.topViewController as! ItemListViewController
        
        let routing = UserItemListRoutingImpl()
        routing.viewController = vc
        vc.injection(presenter: presenter, routing: routing)
        
        viewController?.navigationController?.pushViewController(vc, animated: true)
        
    }
    
}

ここではpushでタップしたユーザーの一覧画面に遷移するよう指示しています

次に、[ユーザー投稿一覧]→[詳細]に遷移してからユーザーのリンクをタップした際の挙動を記載したRoutingです

import Foundation
import DataLayer
import DomainLayer
import PresentationLayer

class UserItemRoutingImpl: ItemRoutingImpl {
    
    override func segueItemList(userId: String) {
        let _ = viewController?.navigationController?.popViewController(animated: true)
    }
    
}

ここでは詳細画面でユーザー名をタップした際に1つ前の画面に戻るようにしています

一覧画面から記事を選択した際の詳細画面のViewControllerにRepository,UseCase,Presenter,RoutingをDIできるようになっているので上記のようなことが簡単に実現可能なのです

Clean Architectureを使ってみて

メリット

  • 各レイヤーの責務がしっかり分かれるのでどこに何を書くかとか迷わない
  • レイヤーをこれだけ細かく切るのでDIできる設計にすれば変更要求に強いプログラムがかける
  • スケールしやすい
  • テスト書きやすい

デメリット

  • 学習コスト
  • コード量が多くなる
  • とりあえずモノを世に出さないとというスピード最重視の0->1フェーズスタートアップでは使わないかも

個人的には使ってみて凄く良かった やはりViewを本来のレンダリングだけすれば良いといった役割にできること、変更要求の強さに魅力を感じています アプリはViewが一番変わると思うのでCleanArchitectureを導入しないにしても、PresentationLayerの概念だけは意識しながら分けてあげると良いなと思います

謝辞

今回の説明はほぼ

まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて

から引用させていただいてます

@koutalou 本当にありがとうございました

CleanArchitectureのサンプルコードを書く上での基盤や構造などを教えてくれたyukiasai ありがとうございました!

UITableViewCellが無い領域のUITableViewのSeparatorを消す方法

背景

TableViewCellが表示されている領域にはSeparatorあって良いんだけど、Cellが1個しかなくて後はUITableViewの領域で、その領域ではSeparatorを表示したくなかった

解決

TableViewのTableFooterViewに何か突っ込めばUITableView領域にSeparatorは出ない

tableView.tableFooterView = UIView(frame: CGRectZero)

SwiftTaskであるAPI処理の結果をもとにSuccessでTask.allを使う方法

背景

ある通信処理の成功結果をもとに複数通信をネストせずに書きたかった SwiftTaskを使うと簡単にできる

コード


private func generateTaskA() -> Task<Float, [String], NSError?> {

  return Task { (fulfill, reject) in
    //通信処理 成功時にはStringの配列が得られる
    fulfill(results)          
  }

}

private func generateTaskB(successA: String) -> Task<Float, Int, NSError?> {

  return Task<Float, DestinationPlaceProtocol, NSError?> { progress, fulfill, reject, configure in
      //通信処理 成功時にはInt型の値が得られる
      fulfill(result)
  }

}

func fetchData() {
  
  generateTaskA().success { [unowned self] results -> Task<(completedCount: Int, totalCount: Int), [Int], NSError?> in
    //TaskAの成功時 results(Stringの配列)をもとにTaskBを複数作成
    let taskListB = results.map(self.generateTaskB)
    return Task.all(taskListB)
  }.success { numberResults in
    //TaskBの配列のTaskが全て成功した場合にくる
   //TaskBの成功オブジェクトの配列なのでnumberResultsは[Int]
  }

}

1つ目のsuccessの返り値であるTaskはTaskBの配列の結果として何が得られるかを指定する(successの中で処理するTaskの結果)

Task.allの場合は

public class func all(tasks: [Task]) -> Task<BulkProgress, [Value], Error>

返り値が上記のようになるので合わせる (BulkProgressは(completedCount: Int, totalCount: Int)のエイリアス

Swiftのインスタンスから型メソッド(クラスメソッド)を呼び出す方法

背景

あるクラスのインスタンスから、そのクラスの型メソッド(クラスメソッド)を呼び出したいときにObjective-Cでは下記のようにやっていたけれどSwiftだとどう書くのか知りたかった

[[instance class] classMethod];

結論

dynamicTypeを使うことでインスタンスから型メソッド(クラスメソッド)へのアクセスが可能になる

class SampleClass() {

  //型メソッド(クラスメソッド)
  static func sampleClassMethod() {
    print("sampleClassMethod")
  }

}

//普通の型メソッド(クラスメソッド)の呼び出し方
SampleClass.sampleClassMethod()  //sampleClassMethod

//インスタンスからクラスメソッドへのアクセス
let sampleClass = SampleClass()
sampleClass.dynamicType.sampleClassMethod() //sampleClassMethod

dynamicTypeとはなんぞや?

dynamicTypeを使うとインスタンスのクラス自身(サブクラス化されていればサブクラス)が参照できるようです

let sampleClass = SampleClass()
print(sampleClass.dynamicType) //SampleClass

if sampleClass.dynamicType == SampleClass.self {
  print("true") //true
}

tableViewCell上にUISliderを載せてアニメーションしたらおかしくなる件

事象

カスタムのUITableViewCell上にUISliderをaddしてcellforRowで読み込まれると同時にSliderをアニメーションしてみたのだけれど、minimumTrackTintColorとmaximumTrackTintColor割合がおかしく、明らかに変なアニメーションをしだした。

解決方法

Sliderのアニメーションをdispatch_afterで遅らせてやるとうまくいった。

ちなみに[UIView animateKeyframesWithDuration...のdelay:をセットしてもダメだった。

下記サンプルではtag10にUISliderが紐付けられているものとする。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        UISlider *slider = (UISlider *)[cell viewWithTag:10];
        slider.value = 0;
        [UIView animateKeyframesWithDuration:1 delay:0 options:UIViewKeyframeAnimationOptionAllowUserInteraction animations:^{
            [slider setValue:0.8 animated:YES];
        } completion:^(BOOL finished) {
            
        }];
    });
    
    return cell;
}

うまくいかない時は遅延処理させると解決するのは結構あるけど、 delayじゃなくdispatch_after使わないとダメだったとは。。

かなりの時間を使ってしまったので久しぶりにメモ。

Android Studio0.8.14にしたらエラーしまくった

背景

Android Studioのverを0.8.14にしたらエラーになったので解決方法をメモ

解決方法

まず、アップデートしているとAndroid Studio配下にsdkフォルダを配置するなと怒られるので、sdkの場所をmvコマンドで適当な場所に移す。

sdkを移した後にリトライすれば0.8.14に上がる。

Android Studioを起動した際にsdkがないと言われるので、sdkを移動したパスを設定する。

その後に、新規プロジェクト作成したら今度はAndroid APIの最新である21を使うにはjdk1.7を使えというエラー。

OracleからJava SE Development Kit 7u71をダウンロードしてjdkをjdk1.7.0_71.jdk/Contents/Homeを使うように設定してようやく動いた。

Android Studioはまだbeta版だからいろいろと仕様が変わるのですね。

ありえん。。

追記 新規でAndroid Studio0.8.14をインストールする場合はここが大変参考になります。