- 投稿日:2019-04-03T22:51:59+09:00
[図解]SwiftでARCのメモリ解放の仕組みを理解するために実施したこと(unowned版)
はじめに
前回に引き続きARCのメモリ解放の仕組みを、インスタンスの参照関係を操作可能なプログラムを作って動作確認を行いました。
今回はweak(弱参照)ではなくunowned(非所有参照)を含めた参照関係を作成してARCによるメモリ解放の流れを確認して結果を図解でまとめています。関連記事
[図解]SwiftでARCのメモリ解放の仕組みを理解するために実施したこと(weak版)
環境
Xcode 10.1
Swift 4.2作成したプロジェクト一式
https://github.com/sakamotoyuya/proj2
このプログラムは(1)から(5)のメモリを以下の参照関係となるように作成して、いずれかのメモリをnilにしたとき、ARCのメモリ解放がどう動くのかを確認できるものとなっています。
色々試してみてこんな感じで動いたというのを図解でまとめました。参照関係の図
(2)にnilを入れたときのARCの動作
(3)にnilを入れた時のARCの動作
(1)にnilを入れた時のARCの動作
ここまでやってみて
今回の実験からweakとunownedについて「(2)にnilを入れたときのARCの動作」のみ結果に差分が出ました。
参照タイプ毎の動作の差分内容は以下の通りです。
参照タイプ 動作差分 weak
(弱参照)弱参照先から自分への強参照が外れた場合はnilとなる。
※アクセスした場合であってもnilアクセスでなければアプリが落ちない。unowned
(非所有参照)非所有参照先から自分への強参照が外れた場合はメモリ解放されてアクセス不可となる。
※アクセスした場合はアプリが落ちる。weakもunownedもどちらも参照先のリファレンスカウンタをカウントアップしない性質ですので、これらの参照タイプを使用する目的は「循環参照させないようにすること」だと思います。
どちらの参照タイプを使用するにしても、上記表の動作差分について意識して設計したいものです。次回はUIAlertControllerのクロージャーを含めた参照関係を作成してARCによるメモリ解放の流れを確認していきます。
- 投稿日:2019-04-03T22:51:10+09:00
Xcodeの意味不明のコンソール出力
- 投稿日:2019-04-03T19:18:28+09:00
Bitriseでtag打ち〜リリースノート作成まで自動化する
今回のゴール
- releaseブランチにマージされたら、それをhookにBitriseのリリースビルドのワークフローを起動
- 自動でtag打ちし、リリースページをGitHub上に作成する
- リリースノートにはそのリリースで実装したコミットログが列挙されており、どのリリースでなんの対応をしたか後から把握できる
Bitriseの公式ブログ「Create release notes and versioning with the release workflow」でタグ打ちをトリガーにリリースノートを自動生成する記事があるが、
タグ打ちまで自動化するといくつかカスタマイズが必要だったので記事にした。完成形
最終的にこんな感じでiTunes Connectデプロイ後、自動でリリースノートが生成される
タグに紐づいたコミットログとそのリンクが一覧で確認できる手順
Stepの手順はこんな感じ。
※実際はDeploy to iTunes Connectの前にビルド番号をインクリメントしたり色々Stepがある事前準備としてPUSHトリガーの設定が必要
適宜GitHubのPersonal Access Tokenなど設定してwebhookを有効にする1. タグ名を環境変数としてセットしておく
後工程のリリースノート作成のステップ
Github Release
でタグ名が必要になるが、そのステップでデフォルト指定されている$BITRISE_GIT_TAG
はタグ打ちをトリガーにしないと値が格納されない。
今回はタグ打ちがトリガーではなく、Pushトリガーのため自前で定義する自前の環境変数の設定の仕方
Bitriseの環境変数の管理はBitrise社製のenvmanを使用している。
script
のステップ内でenvman
コマンドを使えば自前の環境変数が設定できる参考: Exposing env vars and using them in another step
独自環境変数
GIT_TAG
を設定envman add --key GIT_TAG --value "${XPI_VERSION}(${BITRISE_BUILD_NUMBER})"※
XPI_VERSION
はXcode Project Info
のステップで生成される2.タグ打ちをする
Step:
Git tag
を追加
Tag to set on current commit
に、1.で指定した環境変数をセットする3.CHANGE LOGを作る
Step:
Generate Changelog
を追加このステップで生成されるチェンジログは
$BITRISE_CHANGELOG
として次のリリースノート作成のステップで使える4.リリースノートを作る
Step:
Github Release
を追加
1. の手順で設定したGIT_TAG
をTag, Release nameに設定yamlはこんな感じ
- github-release: inputs: - username: "$GITHUB_USERNAME" - name: "$GIT_TAG" - body: "$BITRISE_CHANGELOG" - tag: "$GIT_TAG" - api_token: "$GITHUB_PERSONAL_ACCESS_TOKEN"メモ
Stepで生成される環境変数は、各ステップ詳細の最下部にある
This step will generate these output variables:
の項目で確認できる
- 投稿日:2019-04-03T19:11:00+09:00
Interface BuilderでUITextViewのテキストに改行を入力する
- 投稿日:2019-04-03T18:31:37+09:00
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!"
- 投稿日:2019-04-03T15:18:30+09:00
UITableViewCellを例とするDI(dependency injection)開発[Swift]
DIとは
DI(dependency injection)というのは簡単にいうと
インスタンス変数(cellなどの容器)を呼び出すときに挿入するデータを呼び出してtestしやすいコード書こうぜ
的なものだと認識しています。ここで覚えて欲しいのは
インスタンス変数(cellなどの容器)
と同時に挿入する情報
も呼び出している- testしやすいコードである
の2つです。
これだと一見メリットしかないように見えますが強いてデメリットを挙げるとすると、
- storyboardに直接書く記述には使えない(必要がない)
- リリース前とかのとりあえずリリースしないといけない時期には必要性が薄い
あとは1つのファイルで管理するというよりも複数のファイルで可読性よく管理するので何個ものファイルを行き来するのがめんどい人とかはそれもデメリットになるかもです。
(小さいプロジェクトにおいてはめんどいだけかもしれませんし。)ここからは実際にコードで説明していきます。
サンプルコード
viewController.swiftimport 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.swiftimport 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.swiftstationList.register(UINib(nibName: "StationTableViewCell", bundle: nil), forCellReuseIdentifier: "StationTableViewCell") stationList.registerNib(type: StationTableViewCell.self)viewController.swiftlet cell = tableView.dequeueReusableCell(withIdentifier: "StationTableViewCell", for: indexPath ) as! StationTableViewCell let cell = StationTableViewCell.dequeue(from: stationList, for: indexPath)Instantiateを使うことでこれだけ簡潔に書くことができます。(上が元のコード、下がInstantiateを使ったとき)特にここはDIにも関係している部分なのでぜひ試して欲しいです。
( 注意点:これを行うには
StationTableViewCell
がReusable
とNibType
を呼んでいる必要があります。最後に乗っているサンプルコードを読んでみてください。)完成形
viewController.swiftimport 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.swiftimport 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.swiftfunc 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参考にした記事
- 投稿日:2019-04-03T15:14:49+09:00
[iOS] Named colors do not work prior to iOS 11.0
Tipsです
ググっても出てこなかったので事象:
最低バージョンiOS12で作っているのに、"Named colors do not work prior to iOS 11.0"というビルドエラーが出る。原因:
StoryboardのBuildTargetが低いいじったつもりはないんですが、何かの拍子に下がったんですかね?
- 投稿日:2019-04-03T10:54:38+09:00
CarthageやCocoaPodsの最新バージョン情報をSlackへ通知するfastlaneプラグインを作成しました
二番煎じ、もしくは車輪の再発明かも知れないですが、自分の(狭い)観測範囲で見当たらなかったのと、ちょっとしたツール作成にちょうど良さそうな規模感だったので作ってみました。
機能としては
CocoaPods
やCarthage
でインストールしているライブラリの最新バージョンのリリース状況を取得して、必要なら結果を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 add_plugin dependency_manager_outdatedアクションの実行
$ bundle exec fastlane run cocoapods_outdated $ bundle exec fastlane run carthage_outdatedFastlaneへの組み込み
lane :test_cocoapods do |options| cocoapods_outdated( no_repo_update: true, slack_url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", ) endlane :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
省略
- 投稿日:2019-04-03T09:18:17+09:00
Cordova の iOS で .focus() を使ってもソフトウェアキーボードが表示されない
ページにアクセスした段階で input や textarea にフォーカスをあててソフトウェアキーボードを表示させ、ユーザがすぐにテキストを入力できるようにしたいとする。
<input id="input" type="text" /> <script> document.querySelector('#input').focus() </script>Android の場合は上記で問題ない。
iOS の場合 cordova-plugin-wkwebview-engine プラグインを使用していて iOS 11.3 なら cordova-plugin-wkwebview-inputfocusfix プラグインが必要になり、さらに "KeyboardDisplayRequiresUserAction" を "false" に設定しないとソフトウェアキーボードが表示されない。config.xml<platform name="ios"> <preference name="KeyboardDisplayRequiresUserAction" value="false" /> </platform>これで問題なしと思いきや、iOS 12.2 にアップデートした途端これでは動作しなくなった。
ひとまずプラグインをフォークして修正したので困っている人がいればこちを利用してください。
※本家にはプルリクエスト送信済み。cordova plugin add https://github.com/amatakasap/cordova-plugin-wkwebview-inputfocusfix
- 投稿日:2019-04-03T04:24:35+09:00
【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になってしまったと自覚することは多々あります)Massive View Controllerを作ってみた
そこで
まず今回のベース教材として
Massive View Controllerを作成してみました。機能としては下記のようなことをしています。
- 初期処理(DelegateやGesture の登録など) - 画面の状態管理 - 通信時のローディング表示 - エラー時の画面表示 - データがなかった場合の画面表示 - ユーザ情報取得 API 呼び出し機能(現状はダミーでローカルのjsonファイルから取得) - 一覧を画面に表示 - リモートからユーザプロフィール画像取得(現状はダミーでドキュメントディレクトリから取得) - 取得したユーザプロフィール画像のキャッシュ機能 - ユーザの会員種別でのフィルター機能(ポップアップを表示してAPI通信を行う) - テキスト入力名前検索機能 - ユーザ新規登録機能(ポップアップ表示から一覧に行追加) - ユーザ削除機能(セルスワイプから行削除) - ページング - リフレッシュ - 画面遷移機能(現状はセルタップしてconsoleに表示するのみ) - ユーザプロフィール画像変更のための画像選択機能(アクションシートでカメラかフォトライブラリを選択) - カメラ権限チェック機能 - フォトライブラリ権限チェック機能 - ユーザプロフィール画像保存機能(現状はダミーでドキュメントディレクトリへ保存)実装はViewControllerの部分だけ示します。
全体は下記のリポジトリにあります。
https://github.com/stzn/MassiveViewControllerimport 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になるなどございましたら
ぜひご意見ください
(何を言っているのかよくわからないですねw)そもそももっと良い勉強法があるなどの
ご意見もありましたら
教えていただけると嬉しいです