はじめまして、モバイルアプリチームの佐々木です。普段は主に求人飲食店ドットコムアプリ (以下、求人アプリ)のiOS開発を担当しています。
「求人飲食店ドットコム」では飲食店専門で求人情報を掲載しており、アプリで求人情報の閲覧から応募、応募先とのやりとりまで行えます。
今回は、初めて調査からリリースまでを一貫して担当した、求人アプリのローディングUIを変更する対応でスケルトンビューを導入したので、その過程で悩んだことや学んだことについて共有します。
スケルトンビューとは
そもそもスケルトンビューとはどのようなものかご存知でしょうか?
アプリやウェブサイトでデータを読み込んでいる間にページのレイアウトだけを先に表示する、ローディングUIの一種です。スケルトンスクリーン(Skeleton Screen)やシマーエフェクト(Shimmer Effect)など、様々な呼び方があります。
スケルトンビュー
このUIのメリットとして、レイアウトが先に表示されることでユーザーはこれから表示される内容について心の準備をしやすくなり、待ち時間を短く感じさせる効果があると言われています。
導入の経緯
スケルトンビュー導入の話が出たきっかけは、WEB履歴書という画面でバグが見つかったことでした。
WEB履歴書とは応募に必要な情報をまとめた画面で、「プロフィール」「職務経歴」「経験職種」「自己PR」の四つのタブから構成されています。
WEB履歴書
この画面を開く時は各タブに対応するAPIから情報を取得しています。
求人アプリではAPI通信時にSVProgressHUD を使ってローディングUIを表示しており、WEB履歴書画面でも同様でした。
この画面では四つのタブの情報を取得するAPIへの通信を同時に並列で行い、全てが完了した時点でSVProgressHUDを非表示にする、という仕組みになっていました。
しかし、このSVProgressHUDの表示・非表示処理は、各APIで個別に制御していたり、PromiseKit を使用して一括で制御していたりと、複数APIを扱う際の実装が複雑になりがちで、今回のバグもその複雑さに起因するものでした。
そのため、以前から議論のあった別形式のローディングUIへの移行について、今回バグが見つかったことをきっかけに正式に対応することが決まりました。
WEB履歴書以外にも複数のAPIに対して並列に通信を行っている画面がいくつかあるため、これを機に求人アプリ全体で新しいローディングUIを取り入れようという話になり、影響の大きさを考えて、まずは調査から始めることになりました。
実装前の調査
スケルトンビューの導入に向けて、どういった対応が必要になるのか、どのくらい工数がかかりそうか、などを見積もるためにまず調査を行いました。
ライブラリの選定
まず最初に、ライブラリの選定を行いました。iOSで広く知られているJuanpe/SkeletonView を候補に挙げつつ、他に適したライブラリがないかを洗い出しました。
その結果、他にも似たようなUIを実現できそうなライブラリはいくつか見つかりましたが、「Swiftで実装可能であること」「最近のリリースが古すぎないこと」「利用者が多く信頼性が高いこと」という観点から、最初に挙がったSkeletonViewが最も適していると判断し、仮実装へと進めることにしました。
仮実装での苦戦
SkeletonViewを使用した仮実装では、大きく二つの点で悩むことがありました。
①ライブラリのインストール方法
求人アプリではライブラリ管理ツールとしてCocoaPodsとCarthageを併用しており、新しいライブラリを導入する際にはビルド時間の短縮を重視してCarthageの使用を優先しています。
SkeletonViewはCocoaPods、Carthage、SPMに対応しているということで、まずCarthageでインストールを試みました。
しかし、インストール後にStoryboardを確認してもREADMEにあるようにisSkeletonable
プロパティが表示されませんでした。
isSkeletonable
は、UIViewのExtensionとして@IBInspectable
で定義されたプロパティです。プロジェクト側で同様の@IBInspectable
を追加した場合は問題なく機能するということは確認できましたが、何故かSkeletonView側で定義されたものはStoryboardに反映されませんでした。
コード上で設定する方法もありますが、SkeletonViewではスケルトン表示したい要素に対して親子すべての階層にこの設定をする必要があります。求人アプリでは、Storyboard上にある項目は可能な限りStoryboardで設定するようにしているため、再帰的にコード上で設定するのは避けたい方法でした。
調べると同様の事例が報告されており、CocoaPodsでインストールすることでisSkeletonable
がStoryboardに表示されたとの情報を見つけ、CocoaPodsでのインストールを試したところ、この問題は解決しました。
②isSkeletonable
の設定方法
ライブラリのREADMEを参考に、showSkeleton()
を呼び出す要素の下にある全階層に対してisSkeletonable
をOnに設定しましたが、期待通りにスケルトン表示が動作しませんでした。showSkeleton()
を呼び出す要素を変更したり、isSkeletonable
を設定する階層を調整したり、実装をいくつか試した結果showSkeleton()
を呼び出す要素自体もisSkeletonable
をOnにする必要があることが分かりました。
こうして、READMEの記載通りに実装してもうまくいかない点があり苦戦しましたが、試行錯誤を経てスケルトンビューの仮実装を行うことができました。
仮実装を進めた結果
仮実装はWEB履歴書画面の「プロフィール」と「職務経歴」の二つのタブで行いました。
まず、比較的シンプルな構造の「プロフィール」でスケルトンビューが動作することを確認した後、UITableViewを使用した画面でも実装できるか「職務経歴」で検証しました。
まず「プロフィール」についての処理を簡易化したものが以下の通りです。
class ProfileViewController : UIViewController {
private var profile : Profile!
override func viewDidLoad () {
super .viewDidLoad()
view.showSkeleton()
}
...
func getDataByAPI () -> Promise < Void > {
return Promise< Void> { resolver in
ProfileAPI.fetchData { [weak self ] result in
guard let self else { return }
view.hideSkeleton()
switch result {
case .success(let profile ):
self .profile = profile
resolver.fulfill(())
case .failure(let error ):
resolver.reject (error)
}
}
}
}
}
class ResumeTabViewController : ButtonBarPagerTabStripViewController {
private var profileVc : ProfileViewController!
private var workCareerVc : WorkCareerViewController!
private var workExperienceVc : WorkExperienceViewController!
private var selfPrVc : SelfPrViewController!
...
func getChildrenTabData () {
SVProgressHUD.show()
firstly {
when(fulfilled:
[
profileVc.getDataByAPI () ,
workCareerVc.getDataByAPI () ,
workExperienceVc.getDataByAPI () ,
selfPrVc.getDataByAPI ()
]
)
}
.done {
...
}
.catch { _ in }
}
}
「プロフィール」ではデータをセットする前にhideSkeleton()
を呼ぶことと、isSkeletonable
の設定に気をつければshowSkeleton()
とhideSkeleton()
を記述するだけで、スケルトン表示ができました。
UITableViewを使用した「職務経歴」でも、下記のようにデータの読み込み前後で処理を行うことでスケルトンを表示しました。
class WorkCareerViewController : UIViewController {
@IBOutlet weak var tableView : UITableView!
@IBOutlet weak var detailLabel : UILabel!
var workCareers : [ WorkCareer ] ?
...
func getDataByApi () -> Promise < Void > {
return Promise< Void> { resolver in
WorkCareerAPI.fetchData { [weak self ] result in
guard let self else { return }
tableView.hideSkeleton()
switch result {
case .success(let workCareers ):
self .detailLabel.numberOfLines = 0
self .detailLabel.sizeToFit()
self .workCareers = workCareers
resolver.fulfill(())
case .failure(let error ):
resolver.reject (error)
}
}
}
}
}
extension WorkCareerViewController : UITableViewDataSource {
func tableView (_ tableView: UITableView , numberOfRowsInSection section: Int ) -> Int {
return workCareers?.count ?? 5
}
func tableView (_ tableView: UITableView , cellForRowAt indexPath: IndexPath ) -> UITableViewCell {
...
if workCareers == nil {
cell.detailLabel.numberOfLines = 3
cell.detailLabel.sizeToFit()
cell.showSkeleton()
} else {
cell.workCareer = workCareers[indexPath.row]
}
}
}
tableView
単位でのshowSkeleton()
が機能しない点で疑問を抱きつつ、SkeletonViewの実装方法として同様の方法を紹介しているサイトがいくつかあり、スケルトン表示も一応実現できたため、この方法で問題ないだろうと仮実装を終えました。
しかし、実際にはこの実装方法にはいくつかの大きな問題がありました。
実装時に判明した問題
前述の調査を踏まえて正式に対応することが決まりました。
最初はローディングUIによる問題が特に大きかったWEB履歴書画面に導入して、リリース後も特に問題が起きないようであれば他の画面にも徐々に導入していくという方針で設計をして、実装に取り掛かりました。
実装を一通り終えた段階で、レビュー時に指摘された内容も含め、仮実装の方法にいくつか問題があることが判明しました。
前章の実装例を元に、どのような問題があったかを見ていきましょう。
コードが複雑で可読性が低い
「職務経歴」の実装例ではスケルトン表示用に、UILabelの行数とセル数に関する設定をしています。
登録されたデータの長さによって行数が変わるdetailLabel
について、スケルトン表示時は三行に固定したいと、viewDidLoad()
の初期表示処理の際にdetailLabel.numberOfLines = 3
と設定し、データ読み込み完了時にdetailLabel.numberOfLines = 0
と設定し直しています。
また、セル数についてはtableView(_:numberOfRowsInSection:)
でデータ取得前には五つのセルを表示するように指定しています。
これを踏まえて全体を見ると、セルやdetailLabel
という同じ項目に対する設定がコード上で離れた場所に定義されており、スケルトン表示に関する設定としても同様に離れているため、どこで何に対してどのような設定をしているのかが把握しにくい、可読性の低い実装になっています。
表示・非表示処理で単位が一致しない
「職務経歴」の実装例ではshowSkeleton()
はセル単位で、hideSkeleton()
はtableView
単位で呼び出しています。
理由としては、実装例にも記載したようにshowSkeleton()
がセル単位でしか機能しなかったことに加えて、もう一つありました。
セル単位でhideSkeleton()
を行うと、データ読み込み時にshowSkeleton()
を五つのセルで呼び出しているため、実際の登録データが五件未満だった場合に一部のセルでhideSkeleton()
を呼ぶタイミングがなくスケルトン表示が残ってしまいます。
この問題を回避するため、showSkeleton()
と異なりhideSkeleton()
はtableView
単位で行う実装にしました。
しかし、このように根本的な原因を解明せずに対処療法的な方法を取っているため、意図が分かりにくく、特に初めて実装を見た人には奇妙に感じる実装になっています。
タブを移動するとスケルトン表示が消えない
これは問題のある実装方法に加え、私自身のUIViewControllerのライフサイクルに対する理解不足も原因となって発生した問題でした。
WEB履歴書画面は、その他メニューで選択した項目がデフォルトのタブとして設定されて表示されます。
例えば、以下のGIFでは「経験職種」を選択したのでWEB履歴書画面が表示されたときには「経験職種」タブが開いた状態になっています。
「プロフィール」の実装例ではviewDidLoad()
でshowSkeleton()
を呼び出しているため、WEB履歴書画面表示時にデフォルトのタブでない場合、以下の順序で処理が行われます。
画面上に表示されていない状態でデータ読み込みを開始
データ読み込みが完了してhideSkeleton()
を実行
「プロフィール」タブを開いたタイミングでviewDidLoad()
が呼ばれ、showSkeleton()
を実行
このように、スケルトンの非表示処理が先に実行されてしまい、データの読み込みが完了した状態で表示処理が実行されるため、スケルトン表示を終了することができなくなります。
原因と修正
仮実装で起きた問題の原因
結局これらの問題の原因はSkeletonTableViewDataSourceプロトコルに準拠していなかったこと でした。SkeletonViewのREADMEには、以下のようにUITableViewでスケルトン表示を行うための方法がしっかり記載されていました。
If you want to show the skeleton in a UITableView, you need to conform to SkeletonTableViewDataSource protocol.
一応言い訳をすると、調査時にこのプロトコルの存在は認識しており、実装も試したのですが、当時はなぜかうまく動作しなかったため、この方法を使わない方針で進めました。
しかし、前述の問題に直面して改めて試してみたところ、驚くほどすんなり動作しました。
おそらく、当時はisSkeletonable
など他の設定に問題があり、それが原因で正しく動作しなかったのを誤解したのだと思います。(余談ですがSkeletonViewのissue にも「原因はわからないがやり直したら上手く行った」というコメントがあり、とても親近感を覚えました)
修正後の実装
原因を踏まえて修正した、最終的な実装は以下の通りです。
class WorkCareerViewController : UIViewController {
@IBOutlet weak var tableView : UITableView!
@IBOutlet weak var detailLabel : UILabel!
var workCareers : [ WorkCareer ] ?
override func viewWillLayoutSubviews () {
if workCareers == nil {
tableView.showSkeleton()
} else {
tableView.hideSkeleton()
}
}
...
func getDataByAPI () -> Promise < Void > {
return Promise< Void> { resolver in
WorkCareerAPI.fetchData { [weak self ] result in
guard let self else { return }
switch result {
case .success(let workCareers ):
self .workCareers = workCareers
self .tableView.hideSkeleton()
self .tableView.reloadData()
resolver.fulfill(())
case .failure(let error ):
resolver.reject (error)
}
}
}
}
}
extension WorkCareerViewController : SkeletonTableViewDataSource {
func collectionSkeletonView (_ skeletonView: UITableView , numberOfRowsInSection section: Int ) -> Int {
return 1
}
func collectionSkeletonView (_ skeletonView: UITableView , skeletonCellForRowAt indexPath: IndexPath ) -> UITableViewCell? {
cell.detailLabel.skeletonTextNumberOfLines = 3
cell.detailLabel.sizeToFit()
return cell
}
func collectionSkeletonView (_ skeletonView: UITableView , cellIdentifierForRowAt indexPath: IndexPath ) -> ReusableCellIdentifier {
return "CellIdentifier"
}
func tableView (_ tableView: UITableView , numberOfRowsInSection section: Int ) -> Int {
return workCareers?.count ?? 0
}
func tableView (_ tableView: UITableView , cellForRowAt indexPath: IndexPath ) -> UITableViewCell {
...
if self .workCareers != nil {
cell.workCareer = workCareers[indexPath.row]
}
}
}
ご覧の通り、スケルトン表示に関する処理が一箇所にまとめられたことで、コードの可読性が大幅に向上しました。それだけでなく、既存の処理にはほとんど手を加えていないため、今後職務経歴の仕様が変更されて修正が必要になった場合でも、スケルトンビューの実装には影響を与えることなく対応できるようになっています。
改善されたUX
紆余曲折はありましたが、スケルトンビューを導入したことで、当初のバグが解消されただけでなく、UXが大きく二つの点で改善されました。
①読み込み中に操作ができる
これまで、SVProgressHUDの表示中は画面の操作ができないようになっていたため、四つのタブすべてのローディングが完了するまでアプリの操作が阻害されていました。
特に、四つのタブのうち一つだけ確認したい場合でも、関係のないタブの読み込みに時間がかかるとユーザーに本来必要のない待ち時間を強いていました。
今回のスケルトンビュー導入によって、タブごとに独立してローディングを表示するようになったため、各タブの読み込みが完了次第、そのタブの操作が可能になりました。
before
after
②エラーが起きても画面が閉じない
スケルトンビュー導入前は四つのタブについて、データ取得からエラー表示までをまとめて行っていたため、一つでもエラーが発生するとアラートを表示してWEB履歴書画面ごと閉じるという動きになっていました。この場合、ユーザーは再度WEB履歴書画面を開いて表示し直すという操作が必要で、少し手間のかかる仕様でした。
今回の対応でタブごとに処理できるようになったため、エラー時の動きについても従来のアラート表示から、各タブごとにエラー画面を表示するように改修しました。
エラー表示
これにより、一つのタブでエラーが発生しても、目的のタブが正常に読み込めていればそのまま利用することができます。
さらに、エラー画面に再読み込みボタンを設置しているため、目的のタブがエラーになった場合でもタブ単位でリロードすることが可能になりました。これまでのようにモーダルを閉じて再度開くといった余分な操作が不要になり、利便性が向上しました。
まとめ
WEB履歴書画面にスケルトンビューを導入してから一ヶ月以上が経過しましたが、今のところクラッシュなどのバグ報告はありません。苦労して試行錯誤を繰り返した分、安定して動作する機能をリリースできたことはとても嬉しいです。
失敗の原因を振り返ると単純なものでしたが、当初はCarthageでの導入やisSkeletonable
の設定が上手くいかなかったことで、SkeletonViewのREADMEよりも、検索で見つけたサイトの実装例を過信してしまいました。
今回の経験を通じて、不慣れな対応をするときは特に、一度冷静に情報を整理することが大切だと実感しました。また、UITableViewのライフサイクルや求人アプリの仕様など、基本的な部分を改めて理解し直す良い機会にもなりました。
今後はWEB履歴書以外の画面にもスケルトンビューの導入を進めていく予定ですが、今回の失敗や学びをしっかりと活かしながら、より良い実装ができたらと思います。
このブログがスケルトンビューの導入を検討している方やローディングUIに悩みを抱える方にとって、少しでも参考になれば幸いです。
最後までお読みいただき、ありがとうございました。