20190403のSwiftに関する記事は7件です。

[図解]SwiftでARCのメモリ解放の仕組みを理解するために実施したこと(unowned版)

はじめに

前回に引き続きARCのメモリ解放の仕組みを、インスタンスの参照関係を操作可能なプログラムを作って動作確認を行いました。
今回はweak(弱参照)ではなくunowned(非所有参照)を含めた参照関係を作成してARCによるメモリ解放の流れを確認して結果を図解でまとめています。

関連記事

[図解]SwiftでARCのメモリ解放の仕組みを理解するために実施したこと(weak版)

環境

Xcode 10.1
Swift 4.2

作成したプロジェクト一式

https://github.com/sakamotoyuya/proj2
スクリーンショット 2019-04-02 2.10.04.png

このプログラムは(1)から(5)のメモリを以下の参照関係となるように作成して、いずれかのメモリをnilにしたとき、ARCのメモリ解放がどう動くのかを確認できるものとなっています。
色々試してみてこんな感じで動いたというのを図解でまとめました。

参照関係の図

スクリーンショット 2019-04-03 21.26.15.png

(2)にnilを入れたときのARCの動作

スクリーンショット 2019-04-03 21.26.21.png
スクリーンショット 2019-04-03 21.26.27.png
スクリーンショット 2019-04-03 21.26.34.png
スクリーンショット 2019-04-03 21.26.41.png

(3)にnilを入れた時のARCの動作

スクリーンショット 2019-04-03 21.26.51.png
スクリーンショット 2019-04-03 21.27.00.png
スクリーンショット 2019-04-03 21.27.06.png

(1)にnilを入れた時のARCの動作

スクリーンショット 2019-04-03 21.27.14.png
スクリーンショット 2019-04-03 21.27.21.png
スクリーンショット 2019-04-03 21.27.28.png
スクリーンショット 2019-04-03 21.27.35.png
スクリーンショット 2019-04-03 21.27.43.png
スクリーンショット 2019-04-03 21.27.52.png

ここまでやってみて

今回の実験からweakとunownedについて「(2)にnilを入れたときのARCの動作」のみ結果に差分が出ました。

参照タイプ毎の動作の差分内容は以下の通りです。

参照タイプ 動作差分
weak
(弱参照)
弱参照先から自分への強参照が外れた場合はnilとなる。
※アクセスした場合であってもnilアクセスでなければアプリが落ちない。
unowned
(非所有参照)
非所有参照先から自分への強参照が外れた場合はメモリ解放されてアクセス不可となる。
※アクセスした場合はアプリが落ちる。

weakもunownedもどちらも参照先のリファレンスカウンタをカウントアップしない性質ですので、これらの参照タイプを使用する目的は「循環参照させないようにすること」だと思います。
どちらの参照タイプを使用するにしても、上記表の動作差分について意識して設計したいものです。

次回はUIAlertControllerのクロージャーを含めた参照関係を作成してARCによるメモリ解放の流れを確認していきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CATransitionはアニメーションさせたいとき毎回追加する必要がある

UILabelでテキストを変更した際に遷移アニメーションをさせたくて、以下のようなクラスを作成しました。

class AnimatedLabel: UILabel {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        let animation = CATransition()
        animation.type = .reveal
        animation.subtype = .fromBottom
        animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
        animation.duration = 1
        layer.add(animation, forKey: "transition")
    }
}

これでテキストを変更するたびにアニメーションが実行されるだろうと思っていたのですが、アニメーションが実行されませんでした。

テキストを変更するコードの直前でアニメーションオブジェクトを確認したところ、nilとなっていました。

print(label.layer.animation(forKey: "transition"))  // nil

ドキュメントで確認ができていないのですが、一度アニメーションを実行したらオブジェクトは削除されてしまうのでしょうか。

仕方ないのでアニメーションさせたいときは以下のように毎回CATransitionオブジェクトを作ってレイヤーに追加するようにしました。

let animation = CATransition()
animation.type = .reveal
animation.subtype = .fromBottom
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.duration = 1
label.layer.add(animation, forKey: nil)

label.text = "Changed!"
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] Double(Float)型の数値を整数部と小数部で分けて取り出す

10.125100.125に分けて取り出したい。

let value = 10.125
let integer = Int(value)
let fraction = value.truncatingRemainder(dividingBy: 1)

print(integer)  // 10
print(fraction) // 0.125

value.truncatingRemainder(dividingBy: 1)value % 1と同じで1で割った余りの数を計算する。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UITableViewCellを例とするDI(dependency injection)開発[Swift]

DIとは

DI(dependency injection)というのは簡単にいうと
インスタンス変数(cellなどの容器)を呼び出すときに挿入するデータを呼び出してtestしやすいコード書こうぜ
的なものだと認識しています。

ここで覚えて欲しいのは

  • インスタンス変数(cellなどの容器)と同時に挿入する情報も呼び出している
  • testしやすいコードである

の2つです。
これだと一見メリットしかないように見えますが強いてデメリットを挙げるとすると、

  • storyboardに直接書く記述には使えない(必要がない)
  • リリース前とかのとりあえずリリースしないといけない時期には必要性が薄い

あとは1つのファイルで管理するというよりも複数のファイルで可読性よく管理するので何個ものファイルを行き来するのがめんどい人とかはそれもデメリットになるかもです。
(小さいプロジェクトにおいてはめんどいだけかもしれませんし。)

ここからは実際にコードで説明していきます。

サンプルコード

viewController.swift
import UIKit

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

  @IBOutlet weak var stationList: UITableView!
  var stations:[Station] = [Station]()


  override func viewDidLoad() {
    super.viewDidLoad()
    stationList.dataSource = self
    stationList.delegate = self
    stationList.register(UINib(nibName: "StationTableViewCell", bundle: nil), forCellReuseIdentifier: "StationTableViewCell")
    self.setupStations()
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }

  func setupStations() {
    stations = [Station(name: "飯田橋", prefecture: "東京都新宿区"), Station(name: "九段下", prefecture: "東京都千代田区"), Station(name: "御茶ノ水", prefecture: "東京都文京区") ];
  }

  func numberOfSectionsInTableView(tableView: UITableView) -> Int {
    return 1
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return stations.count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "StationTableViewCell", for: indexPath ) as! StationTableViewCell

    cell.setCell(station: stations[indexPath.row])

    return cell
  }
}
stationTableViewCell.swift
import UIKit

class StationTableViewCell: UITableViewCell {

  @IBOutlet weak var name: UILabel!
  @IBOutlet weak var prefecture: UILabel!

  override func awakeFromNib() {
        super.awakeFromNib()
    }

  func setCell(station: Station) {
    self.name.text = station.name as String
    self.prefecture.text = station.prefecture as String
  }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }

}

今上に載せたのは所謂cutomCellを作成しようとしたときに出てくる普通のコードです。

今回はこれをいじることでDIのコードを作っていきたいと思います。

(今回サンプルコードを作成するにあたってInstantiateというパッケージを導入させて頂きました。そのため、少し記法が異なるかもしれません。ただ、使ってない方は便利なので皆さんも使って欲しいです。)

viewController.swift
stationList.register(UINib(nibName: "StationTableViewCell", bundle: nil), forCellReuseIdentifier: "StationTableViewCell")

stationList.registerNib(type: StationTableViewCell.self)
viewController.swift
let cell = tableView.dequeueReusableCell(withIdentifier: "StationTableViewCell", for: indexPath ) as! StationTableViewCell

let cell = StationTableViewCell.dequeue(from: stationList, for: indexPath)

Instantiateを使うことでこれだけ簡潔に書くことができます。(上が元のコード、下がInstantiateを使ったとき)特にここはDIにも関係している部分なのでぜひ試して欲しいです。

( 注意点:これを行うにはStationTableViewCellReusableNibTypeを呼んでいる必要があります。最後に乗っているサンプルコードを読んでみてください。)

完成形

viewController.swift
import UIKit

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    @IBOutlet weak var stationList: UITableView!
    var stations:[Station] = [Station]()

    override func viewDidLoad() {
        super.viewDidLoad()
        stationList.dataSource = self
        stationList.delegate = self
        stationList.registerNib(type: StationTableViewCell.self)
        self.setupStations()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    func setupStations() {
        stations = [Station(name: "飯田橋", prefecture: "東京都新宿区"), Station(name: "九段下", prefecture: "東京都千代田区"), Station(name: "御茶ノ水", prefecture: "東京都文京区") ];
    }

    func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return stations.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = StationTableViewCell.dequeue(from: stationList, for: indexPath, with: stations[indexPath.row])
        return cell
    }
}
StationTableViewCell.swift
import UIKit
import Instantiate
import InstantiateStandard

class StationTableViewCell: UITableViewCell {

    @IBOutlet weak var name: UILabel!
    @IBOutlet weak var prefecture: UILabel!

    override func awakeFromNib() {
          super.awakeFromNib()
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
    }
}

extension StationTableViewCell: Reusable & NibType {
  typealias Dependency = Station

  func inject(_ dependency: Dependency) {
    let station = dependency
    name.text = station.name
    prefecture.text = station.prefecture
  }
}

という形になります。実際に確認したい場合はこちら

viewController.swift
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = StationTableViewCell.dequeue(from: stationList, for: indexPath, with: stations[indexPath.row])
        return cell
    }

に注目するとcellを定義した後にsetCellというfunctionの記述が省略されています。これが

  • インスタンス変数(cellなどの容器)と同時に挿入する情報も呼び出している

に該当している部分なのです。with:以降で挿入する情報も呼び出しているのです!!!

ViewController内部の記述が少なくなりスッキリ見えますね。

最後にこちらからコードの全体像を確認できるのでみてみてください。
https://github.com/takumaosada/customCellTutorial/tree/feature/DI

関連性があるもの

https://github.com/takumaosada/customCellTutorial/tree/feature/DI (今回のサンプルコード)
https://github.com/Swinject/Swinject
https://github.com/tarunon/Instantiate

参考にした記事

1分で分かる Dependency Injection in Swift

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CarthageやCocoaPodsの最新バージョン情報をSlackへ通知するfastlaneプラグインを作成しました

 二番煎じ、もしくは車輪の再発明かも知れないですが、自分の(狭い)観測範囲で見当たらなかったのと、ちょっとしたツール作成にちょうど良さそうな規模感だったので作ってみました。

 機能としてはCocoaPodsCarthageでインストールしているライブラリの最新バージョンのリリース状況を取得して、必要なら結果をSlackへ通知するものです。

 プロジェクトで利用しているライブラリのアップデートはリグレッションテスト(回帰テスト)が必要となったりとなかなか腰が重い作業ですが、とりあえず新しいバージョンがリリースされていることだけでも気付けるようにして、多少プレッシャーを与えられるような状況を作れればいいかなと思いました。
 JenkinsなどのCIツールに設定して定期的にSlackへ通知するだけでもいいかなと、そんなゆるふわな使い方を想定しています。

 また、実行した最新バージョンの結果を出力変数にセットしているので、他のアクションで利用することも可能です。
 例えばこちらの記事のように、GitHubのissueやPRを作るCIフローでも利用できるかなと思います。

 cocoapods_outdatedアクションの方はlane_context[SharedValues::COCOAPODS_OUTDATED_LIST]

[{:name=>"SDWebImage", :current=>"4.4.5", :available=>"4.4.6", :latest=>"5.0.0-beta6"}, {:name=>"SVProgressHUD", :current=>"2.2.4", :available=>"2.2.4", :latest=>"2.2.5"}]

この様な感じで値がセットされており、carthage_outdatedアクションの方にもlane_context[SharedValues::CARTHAGE_OUTDATED_LIST]にこれと同じ構造で値がセットされています。

注意点

 現状はまだ作成したばかりであるのと、他のソースコードを調べながら実装していて、利用できそうなロジックをパクって参考にした部分も多く、実際に挙動を確認できていない箇所が多いです。特にSlackのオプション周り。
 

Actions

  • cocoapods_outdated

    CocoaPodsの最新バージョンのリリース状況を取得して、必要ならSlackへ通知する。
    内部的にはpod outdatedを実行しているだけ。

  • carthage_outdated

    Carthageの最新バージョンのリリース状況を取得して、必要ならSlackへ通知
    内部的にはcarthage outdatedを実行しているだけ。

  • dependency_manager_outdated

    上記2つを実行

Slackへの通知

fastlane-plugin-dependency_manager_outdated.png

インストール

$ fastlane add_plugin dependency_manager_outdated

アクションの実行

$ bundle exec fastlane run cocoapods_outdated
$ bundle exec fastlane run carthage_outdated

Fastlaneへの組み込み

lane :test_cocoapods do |options|
  cocoapods_outdated(
    no_repo_update: true,
    slack_url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
  )
end
lane :test_carthage do |options|
  carthage_outdated(
    project_directory: "path/to/xcode/project",
  )
end

プラグインのヘルプ

+-----------------------------------------+---------+------------------------------+
|                                   Used plugins                                   |
+-----------------------------------------+---------+------------------------------+
| Plugin                                  | Version | Action                       |
+-----------------------------------------+---------+------------------------------+
| fastlane-plugin-dependency_manager_out  | 0.2.1   | cocoapods_outdated           |
| dated                                   |         | dependency_manager_outdated  |
|                                         |         | carthage_outdated            |
+-----------------------------------------+---------+------------------------------+

+-----------------------------------------+-----------------------------------------+------------------+
|                                      Available fastlane actions                                      |
+-----------------------------------------+-----------------------------------------+------------------+
| Action                                  | Description                             | Author           |
+-----------------------------------------+-----------------------------------------+------------------+
|                                            ...                                                       |
| carthage_outdated                       | Check outdated Carthage dependencies    | matsuda          |
|                                            ...                                                       |
| cocoapods_outdated                      | Check outdated CocoaPods dependencies   | matsuda          |
|                                            ...                                                       |
+-----------------------------------------+-----------------------------------------+------------------+

cocoapods_outdatedアクション

$ bundle exec fastlane action cocoapods_outdated

############################ 省略 ##############################

+-------------------+------------------------------+------------------------------+------------------------------+
|                                           cocoapods_outdated Options                                           |
+-------------------+------------------------------+------------------------------+------------------------------+
| Key               | Description                  | Env Var                      | Default                      |
+-------------------+------------------------------+------------------------------+------------------------------+
| project_directory | The path to the root of the  | DEPENDENCY_MANAGER_PROJECT_  |                              |
|                   | project directory            | DIRECTORY                    |                              |
| use_bundle_exec   | Use bundle exec when there   | DEPENDENCY_MANAGER_COCOAPOD  | true                         |
|                   | is a Gemfile presented       | S_USE_BUNDLE_EXEC            |                              |
| no_repo_update    | Skip running `pod repo       | COCOAPODS_OUTDATED_REPO_UPD  |                              |
|                   | update` before install       | ATE                          |                              |
| slack_url         | Create an Incoming WebHook   | DEPENDENCY_MANAGER_SLACK_UR  |                              |
|                   | for your Slack group to      | L                            |                              |
|                   | post results there           |                              |                              |
| slack_channel     | #channel or @username        | DEPENDENCY_MANAGER_SLACK_CH  |                              |
|                   |                              | ANNEL                        |                              |
| slack_username    | Overrides the webhook's      | DEPENDENCY_MANAGER_SLACK_US  | fastlane                     |
|                   | username property if         | ERNAME                       |                              |
|                   | slack_use_webhook_configure  |                              |                              |
|                   | d_username_and_icon is       |                              |                              |
|                   | false                        |                              |                              |
| slack_icon_url    | Overrides the webhook's      | DEPENDENCY_MANAGER_SLACK_IC  | https://s3-eu-west-1.amazon  |
|                   | image property if            | ON_URL                       | aws.com/fastlane.tools/fast  |
|                   | slack_use_webhook_configure  |                              | lane.png                     |
|                   | d_username_and_icon is       |                              |                              |
|                   | false                        |                              |                              |
| skip_slack        | Don't publish to slack,      | DEPENDENCY_MANAGER_SKIP_SLA  | false                        |
|                   | even when an URL is given    | CK                           |                              |
+-------------------+------------------------------+------------------------------+------------------------------+
* = default value is dependent on the user's system

+-------------------------+-------------------------------------------------------+
|                       cocoapods_outdated Output Variables                       |
+-------------------------+-------------------------------------------------------+
| Key                     | Description                                           |
+-------------------------+-------------------------------------------------------+
| COCOAPODS_OUTDATED_LIST | List of the outdated pods in the current Podfile.lock |
+-------------------------+-------------------------------------------------------+

############################ 省略 ##############################

carthage_outdatedアクション

$ bundle exec fastlane action carthage_outdated

############################ 省略 ##############################

+-------------------+------------------------------+------------------------------+------------------------------+
|                                           carthage_outdated Options                                            |
+-------------------+------------------------------+------------------------------+------------------------------+
| Key               | Description                  | Env Var                      | Default                      |
+-------------------+------------------------------+------------------------------+------------------------------+
| project_directory | The path to the root of the  | DEPENDENCY_MANAGER_PROJECT_  |                              |
|                   | project directory            | DIRECTORY                    |                              |
| use_ssh           | Use SSH for downloading      | DEPENDENCY_MANAGER_CARTHAGE  |                              |
|                   | GitHub repositories          | _USE_SSH                     |                              |
| slack_url         | Create an Incoming WebHook   | DEPENDENCY_MANAGER_SLACK_UR  |                              |
|                   | for your Slack group to      | L                            |                              |
|                   | post results there           |                              |                              |
| slack_channel     | #channel or @username        | DEPENDENCY_MANAGER_SLACK_CH  |                              |
|                   |                              | ANNEL                        |                              |
| slack_username    | Overrides the webhook's      | DEPENDENCY_MANAGER_SLACK_US  | fastlane                     |
|                   | username property if         | ERNAME                       |                              |
|                   | slack_use_webhook_configure  |                              |                              |
|                   | d_username_and_icon is       |                              |                              |
|                   | false                        |                              |                              |
| slack_icon_url    | Overrides the webhook's      | DEPENDENCY_MANAGER_SLACK_IC  | https://s3-eu-west-1.amazon  |
|                   | image property if            | ON_URL                       | aws.com/fastlane.tools/fast  |
|                   | slack_use_webhook_configure  |                              | lane.png                     |
|                   | d_username_and_icon is       |                              |                              |
|                   | false                        |                              |                              |
| skip_slack        | Don't publish to slack,      | DEPENDENCY_MANAGER_SKIP_SLA  | false                        |
|                   | even when an URL is given    | CK                           |                              |
+-------------------+------------------------------+------------------------------+------------------------------+
* = default value is dependent on the user's system

+--------------------------+-------------------------------------------------------+
|                        carthage_outdated Output Variables                        |
+--------------------------+-------------------------------------------------------+
| Key                      | Description                                           |
+--------------------------+-------------------------------------------------------+
| CARTHAGE_OUTDATED_LIST   | List of the outdated dependencies in the current      |
|                          | Cartfile.resolved                                     |
| CARTHAGE_REPOSITORY_LIST | List of dependencies in the current Cartfile.resolved |
+--------------------------+-------------------------------------------------------+

############################ 省略 ##############################

dependency_manager_outdated

省略

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】MVCC(Massive View Controller Challenge)

これは個人的な勉強用として使っているものですが
(悪い例として)何かのネタで活用できることがあったら良いなと思い公開してみました。

MVCとは?

プレゼンテーションアーキテクチャーの1つに
MVCがあります。

本来は

Model View Controller

の略ですが

iOS界隈では

Massive View Controller

と言われることの方が多いかもしれません。

これは
MVCのC(Controller)の実装を

1つのクラスに全ての機能を詰め込む

ような形にしてしまうことで
Controllerが巨大化してしまっている状態のことを揶揄した表現です。
※iOSですとUIViewControllerのことを指します。

何が良くないのか?

こうなってしまうと

  • 他のプロジェクトでも再利用できそうな機能が再利用できない
  • コードが長くなるので見つけたいメソッドや変数などが探しづらい(スクロール量が増える)
  • ある機能の修正のせいで他の修正のない機能の再ビルドやリリースが必要になる
  • チームで開発をしている場合などはコンフリクトがよく起きる
  • モジュールの分割がしづらく大規模になるとビルド時間やテストの時間が長くなる

などなど色々困ったことが生じます。

ではどうすれば良いのか?

簡単に言ってしまえば

適切な単位に分割しましょう

となります。

でもどうやるのか適切なのか・・・

とは言うものの
実際にやってみるとやり方はたくさんあり
何が適切で何が適切でないのかという判断を下すのはすごい難しいことだと思います。

じゃあ検証してみよう

↓のような設計パターンの良書もあり
これを読むだけでも充分学べるのですが
https://peaks.cc/books/iOS_architecture

その上で実際に自分で書いてみて試行錯誤しないと
理解や納得感が得られない性格のため
あれこれと実装してみようと思いました。

そもそもMassive View Controllerってどういうもの?

この試みを行う上で

「そもそもMassive View Controllerってなんだ?」

という疑問が最初に湧きました。

正直、Massive View Controllerというワードは前から知っていたため
実装の最初から分割できるものは分割していこうと考えているため

「これがMassive View Controllerだ」といったイメージを持っていませんでした。
(結果的にMassive View Controllerになってしまったと自覚することは多々あります:fire::innocent::smiling_imp::fire:)

Massive View Controllerを作ってみた

そこで
まず今回のベース教材として
Massive View Controllerを作成してみました。

機能としては下記のようなことをしています。

- 初期処理(DelegateやGesture の登録など)
- 画面の状態管理
- 通信時のローディング表示
- エラー時の画面表示
- データがなかった場合の画面表示
- ユーザ情報取得 API 呼び出し機能(現状はダミーでローカルのjsonファイルから取得)
- 一覧を画面に表示
- リモートからユーザプロフィール画像取得(現状はダミーでドキュメントディレクトリから取得)
- 取得したユーザプロフィール画像のキャッシュ機能
- ユーザの会員種別でのフィルター機能(ポップアップを表示してAPI通信を行う)
- テキスト入力名前検索機能
- ユーザ新規登録機能(ポップアップ表示から一覧に行追加)
- ユーザ削除機能(セルスワイプから行削除)
- ページング
- リフレッシュ
- 画面遷移機能(現状はセルタップしてconsoleに表示するのみ)
- ユーザプロフィール画像変更のための画像選択機能(アクションシートでカメラかフォトライブラリを選択)
- カメラ権限チェック機能
- フォトライブラリ権限チェック機能
- ユーザプロフィール画像保存機能(現状はダミーでドキュメントディレクトリへ保存)

実装はViewControllerの部分だけ示します。

全体は下記のリポジトリにあります。
https://github.com/stzn/MassiveViewController

import UIKit
import AVFoundation
import Photos

final class UserListViewController: UIViewController, UISearchBarDelegate,
    UITableViewDataSource, UITableViewDelegate, UIImagePickerControllerDelegate,
    UINavigationControllerDelegate, UIScrollViewDelegate {

    // MARK: typealias

    typealias ImageSaveCompletion = Completion<ImageError?>
    typealias ImageDeleteCompletion = Completion<ImageError?>
    typealias ImageFetchCompletion = Completion<UIImage>
    typealias UserFetChCompletion = Completion<Result<[User], APIError>>

    // MARK: IBOutlet

    @IBOutlet private var tableView: UITableView!
    @IBOutlet private var searchBar: UISearchBar!
    @IBOutlet private var filterButton: UIButton!

    // MARK: ページ表示情報

    private var users: [User] = []
    private var pageStatus = PageStatus.initail

    private final class PageStatus {
        var pageNo: Int
        var hasNext: Bool
        var searchKeyword: String?
        var filterUserType: UserType?

        init(pageNo: Int, hasNext: Bool,
             serachKeyword: String?,
             filterCategory: UserType?) {

            self.pageNo = pageNo
            self.hasNext = hasNext
            self.searchKeyword = serachKeyword
            self.filterUserType = filterCategory
        }

        static let initail = PageStatus(pageNo: 1, hasNext: false,
                                        serachKeyword: nil, filterCategory: nil)
    }

    private func resetPage() {
        pageStatus.pageNo = 1
        users = []
    }

    // MARK: 画面状態管理

    private var state: DisplayState<[User]> = .empty {
        didSet {

            hideAll()

            switch state {
            case .loading:
                showLoading(self.tableView)
            case .showingData(let users):
                hideLoading()
                pageStatus.hasNext = true
                self.users.append(contentsOf: users)
                tableView.reloadData()
                tableView.isHidden = false
            case .empty:
                hideLoading()
                pageStatus.hasNext = false
                if self.users.isEmpty {
                    emptyView.isHidden = false
                }
            case .error(let error):
                hideLoading()
                pageStatus.hasNext = false
                errorMessageLabel.text = error.localizedDescription
                errorMessageLabel.sizeToFit()
                errorView.isHidden = false
            }
        }
    }

    private func hideAll() {
        tableView.isHidden = true
        emptyView.isHidden = true
        errorView.isHidden = true
        hideLoading()
    }

    private func changeState(by result: Result<[User], APIError>) {
        switch result {
        case .success(let users):
            if users.isEmpty {
                state = .empty
                return
            }
            state = .showingData(users)
        case .failure(let error):
            state = .error(error)
        }
    }

    // MARK: ローディング表示

    private let indicatorViewTag = 9999

    private func showLoading(_ view: UIView) {
        let indicator = makeIndicatorView()
        self.view.addSubview(indicator)
        NSLayoutConstraint.activate([
            indicator.topAnchor.constraint(equalTo: view.topAnchor),
            indicator.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            indicator.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            indicator.trailingAnchor.constraint(equalTo: view.trailingAnchor)
            ])
    }

    private func hideLoading() {
        guard let indicator = view.viewWithTag(indicatorViewTag) else {
            return
        }
        indicator.removeFromSuperview()
    }

    private func makeIndicatorView() -> UIView {
        if let view = self.view.viewWithTag(indicatorViewTag) {
            return view
        }

        let view = UIView()
        view.backgroundColor = .white
        view.translatesAutoresizingMaskIntoConstraints = false
        view.tag = indicatorViewTag
        let indicator = UIActivityIndicatorView(style: .gray)
        indicator.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(indicator)
        NSLayoutConstraint.activate([
            indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            indicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            ])
        indicator.startAnimating()
        return view
    }

    // MARK: データなし表示

    private lazy var emptyView: UIView = {
        let view = UIView(frame: self.view.bounds)
        let label = UILabel()
        label.text = "データがありません。"
        view.addSubview(label)
        label.sizeToFit()
        label.center = view.center
        self.view.addSubview(view)
        return view
    }()

    // MARK: エラー表示

    private var errorMessageLabel: UILabel!
    private lazy var errorView: UIStackView = {

        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.distribution = .fillEqually
        stackView.spacing = 10

        do {
            let label = UILabel()
            label.numberOfLines = 0
            label.textAlignment = .center
            stackView.addArrangedSubview(label)
            errorMessageLabel = label
        }

        do {
            let button = UIButton()
            button.setTitle("リトライ", for: .normal)
            button.backgroundColor = .lightGray
            button.addTarget(self, action: #selector(retry), for: .touchUpInside)
            stackView.addArrangedSubview(button)
        }
        view.addSubview(stackView)
        stackView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stackView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8, constant: 0)
            ])
        return stackView
    }()

    // MARK: リトライ機能

    @objc func retry() {
        load(completion: self.changeState)
    }

    // MARK: リフレッシュ機能

    private let refreshControl = UIRefreshControl()

    @objc func refresh() {
        resetPage()
        load(completion: changeState)
        refreshControl.endRefreshing()
    }

    // MARK: ライフサイクルイベント

    override func viewDidLoad() {
        super.viewDidLoad()
        setTableView()
        setSearchBarDelegate()
        navigationItem.title = "ユーザー一覧"
        load(completion: self.changeState)
    }

    // MARK: View初期化

    private func setSearchBarDelegate() {
        searchBar.delegate = self
    }

    private func setTableView() {
        tableView.dataSource = self
        tableView.delegate = self
        refreshControl.addTarget(self, action: #selector(self.refresh), for: .valueChanged)
        tableView.refreshControl = refreshControl
        tableView.tableFooterView = UIView()
    }

    // MARK: ユーザー情報取得機能

    func load(completion: @escaping UserFetChCompletion) {
        state = .loading
        dummyFetchListAPI(completion: completion)
    }

    func loadNextPage(completion: @escaping UserFetChCompletion) {
        dummyFetchListAPI { [weak self] result in
            self?.isNextPageLoading = false
            completion(result)
        }
    }

    private func dummyFetchListAPI(completion: @escaping UserFetChCompletion) {
        let fileName = "UserPage\(pageStatus.pageNo)"
        guard let path = Bundle.main.path(forResource: fileName, ofType: "json") else {
            completion(.success([]))
            return
        }

        let url = URL(fileURLWithPath: path)
        do {
            let data = try Data(contentsOf: url)

            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase

            var users = try decoder.decode([User].self, from: data)
            if let keyword = pageStatus.searchKeyword {
                users = users.filter { $0.name.contains(keyword) }
            }
            if let type = pageStatus.filterUserType {
                users = users.filter { $0.type == type }
            }
            asyncAfter {
                completion(.success(users))
            }
        } catch {
            asyncAfter {
                completion(.failure(.parseError(error)))
            }
        }
    }

    // MARK: ユーザープロフィール画像取得機能

    private lazy var imageFetchQueue: DispatchQueue = {
        let queue = DispatchQueue(label: "shiz.massiveViewControler.ImageFetch", attributes: .concurrent)
        return queue
    }()

    private func fetchImage(
        with userId: String, completion: @escaping ImageFetchCompletion) {

        if let image = cachedImages[userId] {
            DispatchQueue.main.async {
                completion(image.image)
            }
            return
        } else {
            cachedImages[userId] = nil
        }

        imageFetchQueue.async { [weak self] in
            self?.getProfileImage(with: userId) { [weak self] image in
                self?.handleFetchedImage(image, userId: userId, completion: completion)
            }
        }
    }

    private func handleFetchedImage(_ image: UIImage?, userId: String,
                                    completion: @escaping ImageFetchCompletion) {

        DispatchQueue.main.async { [weak self] in
            guard let image = image else {
                completion(UIImage(named: "default")!)
                return
            }
            self?.cache(id: userId, image: image)
            completion(image)
        }
    }

    // MARK: リモート画像取得機能(ダミーとしてドキュメントフォルダへ保存)

    private func getDocumentsURL() -> URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
    }

    private func filePathInDocumentsDirectory(fileName: String) -> String {
        let fileNameWithExt = "\(fileName).png"
        return getDocumentsURL().appendingPathComponent(fileNameWithExt).path
    }

    private func getProfileImage(with fileName: String,
                                 completion: @escaping Completion<UIImage?>) {
        asyncAfter(1.0) { [weak self] in
            guard let strongself = self else {
                return
            }
            let path = strongself.filePathInDocumentsDirectory(fileName: fileName)
            completion(UIImage(contentsOfFile: path))
        }
    }

    private func saveImage(_ image: UIImage, name: String,
                           completion: @escaping ImageSaveCompletion) {
        let path = filePathInDocumentsDirectory(fileName: name)
        let url = URL(fileURLWithPath: path)

        do {
            guard let data = image.pngData() else {
                throw ImageError.invalidData
            }
            try data.write(to: url)

            asyncAfter {
                completion(nil)
            }
        } catch {
            asyncAfter {
                completion(.failure(error))
            }
        }
    }

    private func deleteImage(name: String, completion: @escaping ImageDeleteCompletion) {
        let path = filePathInDocumentsDirectory(fileName: name)

        let manager = FileManager.default
        do {
            if manager.fileExists(atPath: path) {
                try manager.removeItem(atPath: path)
            }
            asyncAfter {
                completion(nil)
            }
        } catch {
            asyncAfter {
                completion(.failure(error))
            }
        }
    }

    // MARK: 画像キャッシュ機能

    private struct CachedImage {
        let image: UIImage
        let cachedDate: Date
    }

    private var cachedImages: [String: CachedImage] = [:]

    private func cache(id: String, image: UIImage) {
        cachedImages[id] = nil
        cachedImages[id] = CachedImage(image: image, cachedDate: Date())
    }

    // MARK: フィルター機能

    @IBAction func filter(_ sender: Any) {
        showUserTypeFilterModal()
    }

    func showUserTypeFilterModal() {
        let controller = UIAlertController(title: "絞り込み",
                                           message: "選択してください", preferredStyle: .actionSheet)

        let normal = UIAlertAction(title: "通常会員", style: .default) { [weak self] _ in

            guard let strongSelf = self else { return }

            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = .normal
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(normal)

        let preminum = UIAlertAction(title: "プレミアム会員", style: .default) { [weak self] _ in

            guard let strongSelf = self else { return }

            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = .preminum
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(preminum)

        let none = UIAlertAction(title: "絞り込みしない", style: .default) { [weak self] _ in

            guard let strongSelf = self else { return }

            strongSelf.resetPage()
            strongSelf.pageStatus.filterUserType = nil
            strongSelf.load(completion: strongSelf.changeState)
            controller.dismiss(animated: true)
        }
        controller.addAction(none)

        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)
        self.present(controller, animated: true)
    }

    // MARK: 名前検索機能

    // MARK: UISearchBarDelegate textDidChange

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.search(_:)), object: searchBar)
        perform(#selector(self.search(_:)), with: searchBar, afterDelay: 0.75)
    }

    @objc func search(_ searchBar: UISearchBar) {

        var searchKeyword: String? = nil
        if let keyWord = searchBar.text,
            keyWord.trimmingCharacters(in: .whitespaces) != "" {
            searchKeyword = keyWord
        }
        resetPage()
        pageStatus.searchKeyword = searchKeyword
        load(completion: self.changeState)
    }

    // MARK:  新規ユーザー登録機能

    @IBAction func registerNewUser(_ sender: Any) {
        showRegistrationModal()
    }

    func showRegistrationModal() {
        let controller = UIAlertController(
            title: "新規登録",
            message: "入力して登録ボタンを押してください",
            preferredStyle: .alert)

        let register = UIAlertAction(title: "登録", style: .default) { [weak self] _ in

            guard let name = controller.textFields?[0].text else {
                self?.showErrorAlert("名前を入力してください")
                return
            }
            self?.users.insert(User(id: UUID().uuidString, name: name, type: .normal), at: 0)
            self?.insertNewUser()
            controller.dismiss(animated: true)
        }
        controller.addAction(register)

        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)

        controller.addTextField { (textField) in
            textField.placeholder = "名前を入力してください"
        }
        self.present(controller, animated: true)
    }

    private func insertNewUser() {
        let indexPath = IndexPath(row: 0, section: 0)
        tableView.insertRows(at: [indexPath], with: .none)
    }

    private func showErrorAlert(_ message: String) {
        let controller = UIAlertController(title: "エラー", message: message, preferredStyle: .alert)
        let ok = UIAlertAction(title: "OK", style: .default) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(ok)
        present(controller, animated: true)
    }

    // MARK: ページング機能

    // MARK: UIScrollViewDelegate scrollViewDidScroll

    private var isNextPageLoading = false

    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        let contentSizeHeight = scrollView.contentSize.height
        let offset = scrollView.contentOffset.y
        let height = scrollView.frame.size.height
        let didReachBottom = contentSizeHeight - 20 <= (offset + height)
        let needLoadNextPage = didReachBottom && pageStatus.hasNext && !isNextPageLoading

        if needLoadNextPage {
            isNextPageLoading = true
            pageStatus.pageNo += 1
            loadNextPage(completion: changeState)
        }
    }

    // MARK: 一覧表示機能

    // MARK: UITableViewDataSource

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard indexPath.row <= users.count - 1 else {
            return UITableViewCell()
        }
        return setUserCell(at: indexPath)
    }

    // MARK: UITableViewDelegate willDisplay

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {

        guard let cell = cell as? UserCell else {
            return
        }
        let user = users[indexPath.row]
        setImage(to: cell, id: user.id)
    }

    // MARK: UITableViewDelegate heightForRowAt

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 120
    }

    // MARK: セル設定

    @discardableResult
    private func setUserCell(at indexPath: IndexPath) -> UserCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: UserCell.identifier) as! UserCell
        let user = users[indexPath.row]

        cell.configure(user: user)
        setTagToProfileImage(cell.profileImage, with: indexPath.row)
        setImageTapRecognizer(to: cell)
        return cell
    }

    private func setTagToProfileImage(_ image: UIImageView, with rowNumber: Int) {
        image.tag = rowNumber
    }

    private func setImageTapRecognizer(to cell: UserCell) {
        cell.profileImage.isUserInteractionEnabled = true
        let tap = UITapGestureRecognizer(target: self, action: #selector(selectImage(_:)))
        cell.profileImage.addGestureRecognizer(tap)
    }

    private func setImage(to cell: UserCell, id: String) {

        fetchImage(with: id) { [weak self] image in

            guard let strongSelf = self else { return }

            strongSelf.setImageIfCellVisible(to: cell, image: image)
        }
    }

    private func setImageIfCellVisible(to cell: UserCell, image: UIImage) {
        let visibleCells = tableView.visibleCells
        if visibleCells.contains(cell) {
            cell.setImage(image)
        }
    }

    @objc func selectImage(_ tapGesture: UITapGestureRecognizer) {
        guard let image = tapGesture.view as? UIImageView,
            let cell = self.tableView.cellForRow(at: IndexPath(row: image.tag, section: 0)) as? UserCell
            else {
                return
        }
        pickedCell = cell
        presentImageSelectActionSheet()
    }

    // MARK: 画面遷移

    // MARK: UITableViewDelegate didSelect

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)
        let user = users[indexPath.row]
        print("go to detail of \(user.name)")
    }

    // MARK: ユーザー削除

    // MARK: UITableViewDelegate willDisplay

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            let user = users.remove(at: indexPath.row)
            cachedImages[user.id] = nil
            deleteImage(name: user.id) { error in

                guard error == nil else { return }

                DispatchQueue.main.async { [weak self] in
                    self?.tableView.deleteRows(at: [indexPath], with: .fade)
                }
            }
        }
    }

    // MARK: 画像選択機能

    private func presentImageSelectActionSheet() {

        let controller = UIAlertController(title: "画像選択", message: "下記から画像を選択してください", preferredStyle: .actionSheet)
        let camera = UIAlertAction(title: "カメラ", style: .default) { [weak self] _ in
            self?.startCameraAction()
            controller.dismiss(animated: true)
        }
        controller.addAction(camera)
        let library = UIAlertAction(title: "ライブラリ", style: .default) { [weak self] _ in
            self?.startPhotoLibraryAction()
            controller.dismiss(animated: true)
        }
        controller.addAction(library)

        let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { _ in
            controller.dismiss(animated: true)
        }
        controller.addAction(cancel)

        present(controller, animated: true)
    }


    private func startCameraAction() {
        checkCameraPermission()
    }

    private func startPhotoLibraryAction() {
        checkPhotoLibraryPermission()
    }

    // MARK: カメラ権限チェック

    private func checkCameraPermission() {
        let status = AVCaptureDevice.authorizationStatus(for: .video)
        switch status {
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { success in
                if success {
                    self.presentImagePicker(type: .camera)
                }
            }
        case .restricted:
            showErrorAlert("カメラの利用が制限されています。")
        case .denied:
            showErrorAlert("カメラの利用が禁止されています。")
        case .authorized:
            presentImagePicker(type: .camera)
        @unknown default:
            fatalError()
        }
    }

    // MARK: フォトライブラリ権限チェック

    private func checkPhotoLibraryPermission() {
        switch PHPhotoLibrary.authorizationStatus() {
        case .notDetermined:
            PHPhotoLibrary.requestAuthorization { status in
                if case .authorized = status {
                    self.presentImagePicker(type: .photoLibrary)
                }
            }
        case .restricted:
            showErrorAlert("フォトライブラリの利用が制限されています。")
        case .denied:
            showErrorAlert("フォトライブラリの利用が禁止されています。")
        case .authorized:
            presentImagePicker(type: .photoLibrary)
        @unknown default:
            fatalError()
        }
    }

    // MARK: 画像選択ImagePicker表示

    private func presentImagePicker(type: UIImagePickerController.SourceType) {
        let picker = UIImagePickerController()
        picker.sourceType = type
        picker.delegate = self
        present(picker, animated: true)
    }

    // MARK: ImagePickerDelegate

    private var pickedCell: UserCell? = nil

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {

        defer { dismiss(animated:true) }

        guard let cell = pickedCell else {
            return
        }

        let index = cell.profileImage.tag
        let user = users[index]
        let chosenImage = info[UIImagePickerController.InfoKey.originalImage] as! UIImage

        showLoading(self.tableView)

        saveImage(chosenImage, name: user.id) { [weak self] error in

            guard let strongSelf = self else { return }

            defer { strongSelf.hideLoading() }

            if let error = error {
                strongSelf.state = .error(error)
                return
            }
            strongSelf.cache(id: user.id, image: chosenImage)
            strongSelf.setImageIfCellVisible(to: cell, image: chosenImage)
        }
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        dismiss(animated: true)
    }
}

// MARK: Helper

private func asyncAfter(_ deadline: TimeInterval = 1.0, action: @escaping () -> Void) {
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + deadline, execute: action)
}

実装が乱暴になってしまっている部分もありますが
だいたい750行くらいになりました。

1人で実装したのでチーム開発で生じる問題を感じることはありませんでしたが
スクロール量が増え
メソッドや変数を探すのにXcodeのジャンプ機能などを使ったとしても
探しづらいなという印象を強く持ちました。

MVCC(Massive View Controller Challenge)

名前をつけた方が記憶に残るかなと思い
こう名付けてみました。

これをベースに色々な実装方法を検討していき

  • 何が良い実装で何が良くない実装なのかの判断基準をより明確にする
  • 同じような問題に直面した時の実装方法の選択肢を増やす

といった部分を強化できたら良いなと思っています。

最後に

もっと良いMassive View Controllerの例や
こうした方がもっとMassiveになるなどございましたら
ぜひご意見ください:smiley:
(何を言っているのかよくわからないですねw)

そもそももっと良い勉強法があるなどの
ご意見もありましたら
教えていただけると嬉しいです:bow_tone1:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TableViewCellのスタイルについて

Swiftで開発をしていて、いざビルドするとstoryboardでエラーが発生しました。
解決に少し時間がかかったので、まとめたいと思います。

①事象

ビルドすると失敗していて、エラーをみるとソースコードではないところでエラーが発生しているようでした

スクリーンショット 2019-04-02 23.44.30.png

TableView配下のTableViewCellに置いたラベルやボタン、イメージ全てで同様の事象が起こっていました
スクリーンショット 2019-04-02 23.48.37.png

②解決法

よく見ると、TableViewのContentがDynamic Prototypesに設定されていました。

スクリーンショット 2019-04-02 23.54.11.png

Dynamic Prototypesはセルを動的に作る設定で、ソースコードで設定は可能ですが、storyboardからは設定できないようです。
それが原因で、配下のラベルやボタン、イメージがうまく設定できていませんでした。
storyboardで設定したい場合は、static Cellsに設定を変更しないといけません。

スクリーンショット 2019-04-03 0.01.21.png

設定を変更すると配下の資材のエラーが解消されました。

③まとめ

storyboardで開発すると、原因を突き止めるのに細かな設定を確認しないといけない場合があるので時間がかかる可能性がある一方、動的にセルを作るとその分、実装量が増えます。
場合に合わせて、開発スタイルを選ばないといけないと思いました。
※今回はセルの個数が変わらない想定だったので、storyboardで開発を行いました。
セルの個数が増える場合は動的に作ったほうがいいと思いました。

参照サイト

Stack Overflow -- https://stackoverflow.com/questions/26561461/outlets-cannot-be-connected-to-repeating-content-ios

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む