kurotyannの覚え書き

iOSのこととか、たまにRailsとか。

GithubKitForSampleを参考にしてQiitaKitForSampleを作ってみた

これはなに

この記事はiOS2 Advent Calendar 2017の5日目の記事です。

今年のiOSDCで注目を集めた iOSDesignPatternSamples を参考にしてiOSデザインパターンを勉強してる。このiOSDesignPatternSamplesは、GithubKit というライブラリを使って開発されている。

今回は、このGithubKitが面白かったので自分も真似して作ってみたという話。しかし、ただ真似するだけではコピペになるし学びが薄いので、APIをQiitaに変更して開発してみた。

github.com

github.com

先に作った感想

結論から先に言うと、とても勉強になったし、@marty-suzukiさんには感謝しかない。

具体的に良かったのは、「SwiftやiOSの新機能をさっと試すのに適当なサイズのサンプルアプリが欲しいとき、◯◯Kitというライブラリをフレームワーク化しておくとサンプルアプリを作りやすい」 ことに気づけたところ。

今までは個人で開発したアプリで新機能を試してたが、一度リリースしてしまうと既存ユーザーへの影響やデザインパターンとの調整を考えて、新機能を試すのが面倒になり何もしないことが多かった。

かといって、ゼロからサンプル用のアプリを作るのも面倒で、サンプルに適した形にするのもまた面倒だ。

結局、なにも試してない状態になってた。要するに、サボってた。

でも、この〇〇Kitとかを作るとcarthageでプロジェクトに取り込んでささっと適当なサイズのアプリにして新機能を試せていいかなと思い始めてる。

もちろん、試したい機能によって下準備とか別に必要だけど、それもフレームワーク化して 「新機能をすぐに試せる環境作りをしておくのが大切なんだ」 と実感した。

QiitaKitForSampleについて

github.com

本題に入る。GithubKitはSwift3の時代に開発されてたようで、Swift4の機能は使われていない。

というわけで、QiitaKitForSampleではSwift4の新機能の Codable を使ってAPIのリクエスト、レスポンス、モデルを書き換えてみた。

リクエスト(Requestable)

まずは、リクエストの部分。ResponseTypeをCodableにしてリクエストして返ってきたデータをレスポンスにデコードする static func decode(with data: Data, response: HTTPURLResponse?) throws -> Response<ResponseType> を定義した。

public protocol Requestable {
    associatedtype ResponseType: Codable

    static var baseURL: URL { get }
    static var notFoundText: String { get }
    
    var allHTTPHeaderFields: [String: String]? { get }
    var endpoint: URL { get }
    var path: String { get }
    var method: HttpMethod { get }
    var body: [String: Any] { get }
    
    static func decode(with data: Data, response: HTTPURLResponse?) throws -> Response<ResponseType>
}

レスポンス(Response<T: Codable>)

次にレスポンスではリクエストの static func decode(with data: Data, response: HTTPURLResponse?) throws -> Response<ResponseType> で処理するレスポンスのデコードメソッドを定義した。

singleはその名のとおりネストのないJSON(ex: {})の場合、unkeyedContainerは配列(ex: {},{})の場合に使う感じを想定して定義した。

ちなみに、.formatted(DateFormatter.ISO8601).iso8601 でもOKだが、iOS9~11の対応を想定して開発したので独自で定義したDateFormatterを呼んで渡してる。

public struct Response<T: Codable> {
    public let totalCount: Int
    public let values: [T]

    init(single data: Data, response: HTTPURLResponse?) throws {
        let strTotalCount: String = response?.allHeaderFields["Total-Count"] as? String ?? ""
        self.totalCount = Int(strTotalCount) ?? 0
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .formatted(DateFormatter.ISO8601)
        self.values = [try decoder.decode(T.self, from: data)]
    }
    
    init(unkeyedContainer data: Data, response: HTTPURLResponse?) throws {
        let strTotalCount: String = response?.allHeaderFields["Total-Count"] as? String ?? ""
        self.totalCount = Int(strTotalCount) ?? 0
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .formatted(DateFormatter.ISO8601)
        self.values = try decoder.decode([T].self, from: data)
    }    
}

モデル(User: Codable)

そして、レスポンスとなるモデルはスネークケースのキー名のみ、独自で定義すればOKなので簡潔になった。

public struct User: Codable {
    public let id: String
    public let name: String?
    public let profileImageUrl: URL
    public let followeesCount: Int
    public let followersCount: Int
    public let itemsCount: Int
    public let websiteUrl: String?
    public let location: String?
    public let organization: String?
    public let description: String?

    private enum CodingKeys: String, CodingKey {
        case id
        case name
        case profileImageUrl = "profile_image_url"
        case followeesCount = "followees_count"
        case followersCount = "followers_count"
        case itemsCount = "items_count"
        case websiteUrl = "website_url"
        case location
        case organization
        case description
    }
}

GET /api/v2/users/:user_id/followees

最後に、リクエスプロトコル(Requestable)とレスポンス(Response<T: Codable>)とモデル(User: Codable)を組み合わせて、フォローしているユーザーの一覧を返すリクエストは下記のようになった。

このUserFolloweeRequestを見るだけで、どのようなリクエストでレスポンスとして何が取得できるのか、はっきりするのが良いですね。

public struct UserFolloweeRequest: Requestable {
    
    public typealias ResponseType = User
    
    public static let notFoundText: String = "フォロー中のユーザーがいません"
    public let endpoint: URL
    public let path: String = "users/%@/followees"
    public let method: HttpMethod = .get
    public let body: [String: Any] = [:]
    
    public init(page: Int, perPage: Int, userId: String) {
        let basePathURL: URL = UserFolloweeRequest.baseURL.appendingPathComponent(String(format: path, userId))
        var components: URLComponents? = URLComponents(url: basePathURL, resolvingAgainstBaseURL: true)
        components?.queryItems = [URLQueryItem(name: "page", value: String(page)),
                                  URLQueryItem(name: "per_page", value: String(perPage))]
        self.endpoint = components?.url ?? basePathURL
    }
    
    // MARK: - ResponseType decode
    
    public static func decode(with data: Data, response: HTTPURLResponse?) throws -> Response<User> {
        return try .init(unkeyedContainer: data, response: response)
    }
}

Codableを使うことで、Swift3のときにResponseTypeに準拠させていたJsonをdecodeするためのプロトコル(JsonDecodableやJsonDecodeError)は不要となり、Codableが全てを処理してくれるようになった。

Codableへの移行が簡単だったのは、GithubKitの設計が良かったからだと思う。

終わりに

まだまだ発見がたくさんあったけども、それはまた別の機会にする。

当初の目的は、iOSDesignPatternSamplesでiOSデザインパターンを学ぶことだったので。

本家のiOSDesignPatternSamplesはGitHubKitなので、QiitaKitをつかってiOSDesignPatternSamplesの理解を深めていきたい。

ただコードを眺めたり、コピペするだけではやっぱり理解できないですね。

しかし、年内に終えるつもりだったのに間に合わない気がしてたぞ!!!!!!

xibとコードの両方から利用できるカスタムビューを作る

xibのイニシャライザと、コードのイニシャライザ

引き続き、下記のアプリで行ったことをアウトプット。

ボルダー

ボルダー

  • masashi sutou
  • スポーツ
  • 無料

xibのイニシャライザは init?(coder aDecoder: NSCoder) で、コードのイニシャライザは init(frame: CGRect) または独自で定義したイニシャライザになる。

上記のアプリを作成したとき、xibとコードの両方から利用できるカスタムViewが欲しくなった。

具体的に説明すると、Cellで画像を表示するViewはCellがStoryboardで定義されているのでxib経由で初期化したい。 一方、上記のアプリは画像や動画を複数同時にアップロード可能で画像Viewの数が一定ではないため、コードで初期化をコントロールしたい。

つまり、UIImageViewを継承したカスタムなイメージビューをxibとコードの両方から呼べるようにして、一つのカスタムビューで対応したいということ。

カスタムビューのイニシャライザ

MediaViewという名前のカスタムビューを作成して対応した。 xibとコードの両方から利用できるように下記のようなイニシャライザにした。 コードはSwift3です。

import UIKit

final class MediaView: UIImageView {

    private(set) var url: URL?
    private let indicator: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray)
    private let playImageView: UIImageView = UIImageView(image: UIImage(named: "play_icon"))

    init() {
        super.init(frame: .zero)
        isUserInteractionEnabled = true
        contentMode = .scaleAspectFit
        clipsToBounds = true
        translatesAutoresizingMaskIntoConstraints = false
        initialize()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initialize()
    }
    
    private func initialize() {
        
        indicator.hidesWhenStopped = true
        indicator.isHidden = true
        indicator.translatesAutoresizingMaskIntoConstraints = false
        addSubview(indicator)
        
        playImageView.contentMode = .scaleAspectFit
        playImageView.isHidden = true
        playImageView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(playImageView)
        
        NSLayoutConstraint.activate([
            indicator.centerXAnchor.constraint(equalTo: centerXAnchor),
            indicator.centerYAnchor.constraint(equalTo: centerYAnchor),
            indicator.widthAnchor.constraint(equalToConstant: indicator.frame.width),
            indicator.heightAnchor.constraint(equalToConstant: indicator.frame.height),
            playImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
            playImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            playImageView.widthAnchor.constraint(equalToConstant: playImageView.frame.width),
            playImageView.heightAnchor.constraint(equalToConstant: playImageView.frame.height)
        ])
    }
    
// 省略

xibとコードの両方で利用されるものをinitialize()というメソッドにまとめた。

コードで利用する場合はframeではなく、AutoLayoutでレイアウトを定義したかったのでtranslatesAutoresizingMaskIntoConstraints = falseにした。

今回はアプリの仕様上、初期化後にレイアウトが不変だったのでinit()で済ましたが、override init(frame: CGRect)にしてframeを渡しても良い。

まとめ

  • xibのイニシャライザは init?(coder aDecoder: NSCoder) で、コードのイニシャライザは init(frame: CGRect) または独自で定義したイニシャライザになる。
  • 例えば、xibとコードの両方で利用されるものをinitialize()というメソッドにまとめて、両方のイニシャライザから呼べば良い。

iOSのDependency Injectionについてわかった気がした

iOSDependency Injectionとは

最近、下記のアプリを作って気づいたことをアウトプット。

ボルダー

ボルダー

  • masashi sutou
  • スポーツ
  • 無料

ネット上に転がってるiOS開発におけるDIの話は、だいたい以下のような内容だと思う。

  • ViewControllerの初期化の時に、必要なプロパティを持つモデルや値をViewControllerへ注入すること
  • ViewControllerから見ると、モデルや値に依存していることになる

DIにより何が嬉しいのかというと、以下のような点になる。

  • 必須のプロパティを漏らすことなく、安全にViewControllerを生成し利用できる
  • 必須のプロパティが明示的になることで、アプリケーション全体の保守性があがる

今なら「なるほど、便利だなー!」と思うのだが、iOS開発しか経験がない僕は当初、Dependency Injection がまったくわからなかった。 僕は今の業務ではStoryboardを使用しない。ブログやLT資料の多くは、Storyboardを前提として内容が構成されている。 なぜDependency Injectionについてたくさん発表が行われているんだろうと、最初は不思議に思っていた。

コードで書くDependency Injection

業務ではStoryboardを使用しないが、個人開発ではStoryboardを使用して開発している。 個人開発は 「業務ではやらないことをやる」 をモットーにしているからだ。 コードでiOSを開発するとき ViewControllerの初期化は下記のように書くと思う。

final class WebViewController: UIViewController, UIWebViewDelegate {
    
    private var webView: UIWebView?
    private let url: URL
    
    init(url: URL, title: String) {
        self.url = url
        super.init(nibName: nil, bundle: nil)
        navigationItem.title = title.ex.localized
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

// 省略

カスタムイニシャライザを定義して、引数をセットするプロパティは private let で外部からの変更不可にする。 そして、親のイニシャライザを呼ぶ前に子のプロパティをセットすれば、プロパティにデフォルト値を書かずにプロパティを宣言できる。 これで初期化時に何かしらの値が設定されていないと、ビルドエラーなのでurlが初期化のときに設定されることが保証される。

これで「ああ、WebViewControllerはurlとtitleが必要でurlは初期化時に設定されるんだなー」てのが理解できる。 下記のスクショのように書けば、Xcodeが補完して呼び出し側も何が必要なのか理解しやすい。

f:id:kurotyann:20170912004721p:plain

僕は普段、このような初期化の書き方をDIとは呼んでなく、そういう呼び方があることを知らなかった。ただ 「ViewControllerの初期化の時に、モデルや値を必要なプロパティに設定したい」 と思って書いていた。 今考えれば、ブログやLT資料のDIが目指す内容に近いことをしていると思う。もちろん、Storyboardは使えないけど。

Storyboard で書くDependency Injection

さて、これをStoryboardでやろとするとできない。カスタムイニシャライザが定義できないからだ。 初期化の後にプロパティへ値やモデルを設定しないといけない。つまり、プロパティは internalpublicopen にしないといけない。 この場合、下記のデメリットが発生する。

  • プロパティの設定忘れ
  • プロパティが途中で変更される可能性
  • 初期化で必須なのか、それともある状態のもとで必要なのか、プロパティの意図が曖昧になる

だから、 DIが必要なんです という話になる。これで初心者のiOSエンジニアにDIの意図が伝えられる気がする。 Storyboard で書くDependency Injection はだいたい2パターンで、OSSのライブラリを使うか、独自の protocol 等を定義して実装するのどちらか。

いろいろ見て個人的に一番良かったのは、@motokiee さんのDI実装だと思った。 元の資料のリンク削除されていたけど(@motokieeさんに確認をとって資料のURLを教えてもらった【10/11更新】)、下記のような実装だったと思う。

protocol DependencyInjectable {
    associatedtype Dependency
    static func make(withDependency dependency: Dependency) -> Self
}
  • DependencyInjectable をViewControllerに継承させる
final class FaqViewController: UITableViewController, DependencyInjectable {

    private var faq: Faq!
    
    // MARK: - DependencyInjectable
    
    struct Dependency {
        let faq: Faq
    }
    
    static func make(withDependency dependency: Dependency) -> FaqViewController {
        let vc = Storyboard.faq.instantiate(FaqViewController.self)
        vc.faq = dependency.faq
        return vc
    }

// 省略
  • 呼び出し側
    // MARK: - FAQに遷移
    
    private func transitionToFaq(_ faq: Faq) {
        let dependency = FaqViewController.Dependency(faq: faq)
        let vc = FaqViewController.make(withDependency: dependency)
        navigationController?.pushViewController(vc, animated: true)
    }

このDI実装の良いところは、とにかく他のDIに比べて軽量でわかりやすいところ。 そして、Dependency構造体の表現力がとても豊かでよかった。 今後、プロパティが増えたり、ある時に初期化するときはnilだったり型が違う場合でも表現しやすい。 さらに、下記のようにXcodeで呼ぶときも補完が出て、コードで書いた時と同様に何が必要なのかわかりやすい。

f:id:kurotyann:20170912012937p:plain

個人開発したBoulderはこのDI実装をフル活用して開発した。 この DependencyInjectable をさらに用途を限定して型を縛ったりしていたが、正直このままの方が使い勝手は良いと思った。

終わりに

もっと良いDIの実装方法は、必ずあるのでインプットは続けるし教えて欲しい。 ブログとかQiitaではStoryboardを前提とした記事が多いので、コードとStoryboardの両方から説明するものがあると独自性だしていけるかもと思ったり。 両方から説明することで、あるデザインパターンを理解しやすくなることはありえそうなので、頑張って書こうかな。

FirebaseつかってボルダリングのiOSアプリをリリースしました

リリースしたアプリについて

  • Firebaseつかって何か作りたかったので、趣味がボルダリングだし作ってみた

技術的な話

  • Swift3です、4ではないです
  • FirebaseのDBとStorage使ってデータとか画像とか動画とか保存してます
  • 業務ではNo Storyboardだけど、このアプリではStoryboardで出来ることはStoryboardでやった
  • いつもと勝手が違うしStoryboardの進化に知識と体がついていけてなかったりで戸惑った
  • みんなStoryboardで出来ることと出来ないことの住み分けはどうしてるんですかね

今後はどうするか

  • 個人開発は「業務ではやっていないことをやる」というルールを決めている
  • なので、そのとおりに色々挑戦していく
  • 今は、Firebaseのコンソールから直接DBさわるの辛いので、HTMLとCSSとJSでWebの簡易なサポートツールを開発中
  • HTMLとCSSとJSとか久しぶり過ぎて何やったらいいのか全然なんですが、色々思い出しながら進めてる
  • そこら辺が出来てSwift4が出たら、MVVM + Rx みたいな流行りのアーキテクチャを導入してみたい

最後に

  • はてブには何か技術的なことや、まとまったこと書く
  • 日々の備忘録できなものは WikiHubの日報でやっていくことにした

UdemyのサーバーサイドSwiftのコースをやってみた

Udemy にある下記のコースをやってみました。

www.udemy.com

概要

  • 有料コース(セールで1200円のときに購入)
  • 英語
  • Kituraの基本的な使い方と、Swiftで書いたサーバーサイドのコードをDockerでテストしたり、Bluemixにデプロイする方法が丁寧に紹介されている
  • 使用するDBはCouchDBというNoSQL
  • コース公開時の最新バージョンがSwift3.0だったので、コードが少し古い

良かった点

  • Xcodeでサーバーサイド側のコードが書けるのはとても気持ち良い
  • サーバーサイドのコードを書いてる気がしないというか、使い慣れたXcodeで開発するのでサーバーサイドを書く負担がすごく下がる
  • iOS開発ではほとんど利用しない、Docker や Swift Package Manager の基本操作が分かるのも良い

悪かった点

  • 講師の方の本業がiOSエンジニアではないようでコードの書き方はあまり良くない
  • クライアント側の実装は参考にしないほうが良いと思う

全体を通した感想

途中でXcodeの使い方やSwiftの書き方のレクチャーがあるが、サーバーサイドSwiftだけ興味ある人は飛ばして良い。俺も飛ばした。クライアント側の実装はあまり参考にはならなかったが、サーバーサイド側は良かった。

講中にSwift3.1がリリースされたので、コースで紹介されているOSSライブラリが全てバージョンアップしなければならくなった。バージョンを上げてもそのままでは動かないコードもあり、コース外で調べないといけないことが増えてしまった。しかし、そのおかげでサーバーサイドSwiftの理解がより深まったし、こういう発展途上の分野を少しずつ対応していくのはとても面白いので結果的によかった。

で、面白かったけど、これでiOSのサーバーサイドを全てSwiftで書く気になるのかと言われると、簡単なJSONを返すぐらいならいいかもというのが正直なところ。今はAWSやFirebaseなど、そもそもサーバーサイドを気にせずにアプリを開発する方法もあるので、わざわざSwiftでサーバーサイドを実装して作業量を増やさなくてもいい。でも、面白いのは本当です。

これから受講したいと思っている人は、最新のXcodeではビルドできなくなっているので、ハマりそうなところのコードを下記にのせておく。

コースの内容をSwift3.1に対応させてみる

  • だいたいはXcodeのサジェスト通りにやればなんとかなると思う
  • docker pull ibmcom/swift-ubuntu とかしてイメージを最新にしないと docker上のテストが失敗します
  • Package.swiftを更新したら、.build と Package.pins と xcodeproj を削除してから、swift build して swift package generate-xcodeproj しないと更新状態が反映されない
  • Swift-cfenv がコースの内容と、かなり乖離があるのでハマる人が多い
  • UdemyのQ&Aにいくつか質問があがってるので参考にすると良い

Package.swift

import PackageDescription

let package = Package(
    name: "FoodTruckAPI",
    targets: [
      Target(
        name: "FoodTruckServer",
        dependencies: [ .Target(name: "FoodTruckAPI")]
      ),
      Target(
        name: "FoodTruckAPI"
      )
    ],
    dependencies: [
      .Package(url: "https://github.com/IBM-Swift/Kitura.git", majorVersion: 1, minor: 7),
      .Package(url: "https://github.com/IBM-Swift/Kitura-CouchDB.git", majorVersion: 1, minor: 7),
      .Package(url: "https://github.com/IBM-Swift/Swift-cfenv.git", majorVersion: 4, minor: 0),
    ]
)

main.swift

import Foundation
import Kitura
import HeliumLogger
import LoggerAPI
import Configuration
import CloudFoundryEnv
import FoodTruckAPI

HeliumLogger.use()

let trucks: FoodTruck

do {
    Log.info("Attempting init with CF enviroment")
    let service = try getConfig()
    Log.info("Init with Service")
    trucks = FoodTruck(service: service)
} catch {
    Log.info("Could not retreive CF env: init with defaults")
    trucks = FoodTruck()
}

let config: ConfigurationManager = ConfigurationManager()
let controller = FoodTruckController(backend: trucks)

let port = config.port
Log.verbose("Assigned port \(port)")

Kitura.addHTTPServer(onPort: port, with: controller.router)
Kitura.run()

Config.swift

import Foundation
import LoggerAPI
import CouchDB
import Configuration
import CloudFoundryEnv

struct ConfigError: LocalizedError {
    var errorDescription: String? {
        return "Could not retreive config info"
    }
}

func getConfig() throws -> Service {
    let config: ConfigurationManager = ConfigurationManager()
    config.load(.environmentVariables)  // これがないとBluemix 上で環境変数が取得できない

    Log.warning("Attempting to retreive CF Env")
    
    let services = config.getServices()
    let servicePair = services.filter { $0.value.label == "cloudantNoSQLDB" }.first
    guard let service = servicePair?.value else { throw ConfigError() }
    
    return service
}