2022年9月1日(金)〜3日(日)の3日間にわたって開催された、iOSDC Japan 2023 にてLINEはプラチナスポンサーを務めました。
会期中はLINEのブースにたくさんの方にお越し頂き、誠にありがとうございました。
本ブログでは、イベント当日2日間実施した「Code Review Challenge」の中から3問の解説をご紹介いたします。
「Code Review Challenge」に参加してくださった方も、そうでない方も、ぜひ本ブログで再チャレンジしてみて下さい。
Code Review Challengeの概要
iOSDC Japan 2023 のイベント当日、「Bad Code」を「Good Code」に変える、Code Review Challengeを実施しました。
この問題は、LINEのエンジニアが一から考えたオリジナルの問題※になります。
2日間、来場される方に何度も楽しんでいただけるように全部で5つの問題を出題しました。
Schedule
Day1
・10:00-12:30 出題1
・12:30-15:00 出題2
・15:00-18:00 出題3
Day2
・10:00-14:00 出題4
・14:00-18:00 出題5
※この企画のために、LINEのエンジニアが考えたコードです。LINEで提供されるサービスの実際のコードではありません。
問題1
API呼び出しをして、フォロワーの数を取得し、それをUIに表示させています。 エラーが起きた際は、CustomAlert
という独自アラートを出しています。 画面が表示された時もしくはReloadボタンが押された時にAPIの呼び出しが始まります。
import SwiftUI
import UIKit
struct FollowerView: View { // Followerの数が確認できる画面
@State var followerText = "Follower: -"
var body: some View {
VStack {
Text(followerText)
Button("Reload") { reload() }
}
.task { reload() }
}
private func reload() {
Task {
do {
followerText = "Loading..."
let number = try await ApiClient.getFollowerCount()
followerText = "Follower: \(number)"
} catch {
CustomErrorAlertControllerPresenter().present(error: error)
followerText = "Follower: -"
}
}
}
}
class CustomErrorAlertControllerPresenter {
func present(error: any Error) {
// 独自アラートをwindowSceneの上に表示する
let windowScene = UIApplication.shared.connectedScenes.first as! UIWindowScene
CustomAlert.present(title: error.localizedDescription, on: windowScene)
}
}
class ApiClient {
static func getFollowerCount() async throws -> Int {
// 以下3行はこの問題のためのmockでありレビュー対象外
try await Task.sleep(nanoseconds: 1_000_000_000)
guard Bool.random() else { throw ClientError() }
return Int.random(in: 0..<100)
}
}
class ClientError: Error {}
このコードが抱えている問題
このコードにはUIと非同期処理が絡んだ以下の問題があります。
・followerTextの状態管理
・API呼び出し中にAPI呼び出しができてしまう
・UIWindowSceneの取得方法
followerTextの状態管理
まず、followerTextの状態管理です。 followerText
の状態は以下の三つがあります
・初期値、およびエラーが発生した時の Follower: -
・読み込み中の Loading...
・読み込みに成功した場合の Follower: \(number)
これらを毎回手動で更新するとバグの元になるので、以下のようにenumを定義し、その値を元に文字列を準備するやり方が良さそうです。
enum FollowerViewState {
case empty, loading, loaded(number: Int)
}
struct FollowerView: View {
@State private var viewState: FollowerViewState = .empty
var followerText: String {
switch viewState {
case .empty: return "Follower: -"
case .loading: return "Loading..."
case .loaded(let number): return "Follower: \(number)"
}
// ...
}
API呼び出しの重複呼び出し
さて、まだまだ問題はあります。 ユーザーがReloadボタンを連打した時のことを考えてみましょう。 この時、API呼び出しが同時に複数走ってしまうという問題があります。 今回のAPI呼び出しの場合、(おそらく) 単なるgetなので大きな問題には繋がりませんが、これがpostなどの場合はサーバーに複数同じ値の書き込みをしてしまうことになり、バグの元です。
このような問題にはいくつかの対応方法が考えられます。
- API呼び出しをしている間は新規のAPI呼び出しをブロックする
- すでにAPI呼び出しをしている場合は既存のAPI呼び出しを中断してから新しくAPI呼び出しをする
どちらの方法が良いかはケースバイケースですが、どちらの方法を取るにしても「読み込み中」という状態が必要です。 ここで注意が必要なのは「読み込み中を表す状態に変更するのはTaskの中でしない」ということです。 Taskの中で状態を更新してしまうと、同期的に状態が更新されないため、ボタンがタップされてから「読み込み中の状態」に変更するまでにはわずかな時差が生じてしまいます。
Button("Reload") {
Task {
// ボタンがタップされてからこの処理が呼ばれるまでに時差がある
guard viewState != .loading else { reutrn }
viewState = .loading
}
}
これは、短い時間ではあるものの、ボタンがタップされてからTaskの処理が始まるまでの間に再度ボタンをタップすることでAPI呼び出しが複数回呼べてしまいます。 そのため、同期的に値を更新する必要があります。
Button("Reload") {
guard viewState != .loading else { reutrn }
viewState = .loading
Task {
// ...
}
}
UIWindowSceneの取得方法
UIWindowSceneの取得方法として以下のようなコードが書かれていました。
let windowScene = UIApplication.shared.connectedScenes.first as! UIWindowScene
会場でのコードレビューのコメントにはここのas!
を心配するコメントが多数ついていました。 そのコメントは正しく、connectedScenes
にはUIWindowScene
以外の UIScene
が入る可能性があります。 UIScene
のサブクラスとして圧倒的に目にするのはUIWindowScene
ではあるものの、CarPlayのためのCPTemplateApplicationScene
といったサブクラスが渡される可能性もCarPlayのサポートをしているとあります。
では、以下のようにforce unwrappingを避ければ良いのでしょうか?
let windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
まだ安心できないです。iPadアプリの場合マルチウインドウの考慮が必要です。
マルチウインドウの場合、各ウインドウに対応するUIWindowScene
がこのconnectedScenes
に入っています。
今回の場合、エラーを表示するためのUIWindowScene
なのでユーザーが操作したUIWindowScene
と同じ UIWindowScene
を取得する必要がありますが、この方法ではそのUIWindowScene
を取得できているとは限りません。
また、iPhoneであっても安心はできません。
iPhoneを外部ディスプレイに繋いでいる場合、iPhoneのウインドウとは別に繋がっている外部ディスプレイもUIWindowScene
として扱われます。
ではどうするのがいいのかですが、もともとのコードはSwiftUIなのでSceneDelegate
からUIWindowScene
をEnvironment
に流すのが良さそうです。
その他の問題点
・taskの中でTaskの発行をしていて、重複しています。
・ApiClient
はDIした方がいいです
・ApiClient
ではなくて、APIClient
にした方が命名として自然です
・ClientErrorのlocalizedDescriptionが使われていますが、使用用途がUIのアラートなのでLocalizedErrorに準拠しても良さそうです
・その他、細かい問題 (アクセス修飾子、classよりstructやenumの方が良い、命名などなど)
問題1のまとめ
UIと非同期にまつわる問題のコードが抱えている課題を紹介しました。 大きな改善として、以下のようなことが考えられます。
・Viewの状態をenumで管理する
・ボタンのタップなど、ユーザーの動作をトリガーにした非同期処理をする場合、非同期処理が重複して呼ばれないようにする
・UIWindowSceneの取得にconnectedScenesからとる方法は避ける
どれも実際によく出てくるパターンだと思いますので、みなさまの日々の開発でも気をつけてみてください。
問題2
Core DataのNSManagedObject
の変更を検知して、最新のNSManagedObject
をViewに表示させています。
class ManagedPerson: NSManagedObject {
@NSManaged var name: String
@NSManaged var postCount: Int // 投稿の数
}
class ManagedObjectObserver {
var object: NSManagedObject!
func observe(_ handler: @escaping (NSManagedObject?) -> Void) {
Task {
let notifications = NotificationCenter.default.notifications(
named: .NSManagedObjectContextObjectsDidChange,
object: nil
)
for await notification in notifications {
let userInfo = notification.userInfo!
let inserted_objects = userInfo[NSInsertedObjectsKey] as! Set<NSManagedObject>
let updated_objects = userInfo[NSUpdatedObjectsKey] as! Set<NSManagedObject>
let refreshed_objects = userInfo[NSRefreshedObjectsKey] as! Set<NSManagedObject>
let deleted_objects = userInfo[NSInsertedObjectsKey] as! Set<NSManagedObject>
if inserted_objects.contains(object)
|| updated_objects.contains(object)
|| refreshed_objects.contains(object) {
handler(object)
} else if deleted_objects.contains(object) {
handler(nil)
}
}
}
}
}
class PersonViewModel: ObservableObject {
@Published var person: ManagedPerson
private var Observer: ManagedObjectObserver
init(person: ManagedPerson) {
self.person = person
Observer = ManagedObjectObserver()
Observer.object = person
Observer.observe { [weak self] object in
self?.person = object as! ManagedPerson
}
}
}
struct PersonView: View {
@ObservedObject var viewModel: PersonViewModel
init(person: ManagedPerson) {
self.viewModel = PersonViewModel(person: person)
}
var body: some View {
VStack {
Text(viewModel.person.name)
Text("\(viewModel.person.postCount)") + Text("Posts")
}
}
}
このコードが抱えている問題
今回のユースケースのように、SwiftUIのViewに最新のNSManagedObject
を表示したいだけなら、実はManagedObjectObserver
というクラスは必要ありません。NSManagedObject
はObservableObject
なのでSwiftUIのView
から監視できるので、出題されたコードのうち、ほとんどが実は不要なコードでした。
ただ、そこに目を瞑ってもまだまだ致命的な問題を抱えています。
ここから先は「何かしらの事情で、ManagedObjectObserver
が必要」という前提で解説をしていきます。
このコードが抱えている致命的な課題としては以下があります。
・NSManagedObject
がスレッドを跨いで共有されている
・Task
の中が無限ループしている
NSManagedObjectがスレッドを跨いでいる
NSManagedObject
はスレッドセーフではないです。 このコードは、外から渡されたNSManagedObject
をTaskを超えて渡しています。 スレッドを超えて、NSManagedObject
を共有してしまうと、意図しない挙動をしたり最悪の場合クラッシュしたりします。 ここでは、NSManagedObject
を共有するのではなくNSManagedObjectID
を代わりに共有するべきです。
Taskが無限ループしている
次にTaskの中でNotificationCenter
を監視していますが、このfor await
が終わることはありません。
外から適切なタイミングでTaskをキャンセルしてこの監視を終了する必要があります。
ちなみに、このTaskの中でobject
を参照していますが、これはManagedObjectObserver
のプロパティです。
Taskのクロージャーの中でプロパティを参照した際self
を暗黙的に強参照するので、Taskが解放されるまで、このManagedObjectObserver
が解放されず、メモリーリークが発生してしまう、というバグもここにはありました。
その他の問題点
その他にも命名やforce unwrap、キャストなど、アクセス修飾子など様々な改善の余地を抱えています。
ちょっとした意地悪としてNSDeletedObjectsKey
を使うべきところを、NSInsertedObjectsKey
にしたのですが、会場でそれを見抜いた人がいてとてもびっくりしました。
問題2のまとめ
Core DataとSwift Concurrencyを併用した際に発生しやすい間違いを問題に含めてみました。
特にfor await
が出てきた時には、そのループが終了するかどうかを意識してコードを書く必要があります。
もし無限ループするものであれば、どこかでTaskをキャンセルすることを考えてみてください。
問題3
ToDoの一覧を表示する画面です。 ナビゲーションバーのボタンを押すことで「未完了」だけ表示するか、全てのToDoを表示するか変更できます。
struct CellInformation: Hashable, Equatable {
let id = UUID()
let title: String
}
protocol ToDoRepository {
func fetchUnfinishedToDo() -> [ToDo]
func fetchAllToDo() -> [ToDo]
}
class ToDoListViewModel: ObservableObject {
let repository: any ToDoRepository
@Published var isFiltering = true
@Published var cellInfos: [CellInformation] = []
init(repository: any ToDoRepository) {
self.repository = repository
reload()
}
func reload() {
let todos = isFiltering ? repository.fetchUnfinishedToDo() : repository.fetchAllToDo()
cellInfos = todos.map { todo in CellInformation(title: todo.title) }
}
}
class TODOListViewController: UIViewController {
typealias DataSource = UITableViewDiffableDataSource<Int, CellInformation>
let tableView = UITableView()
let viewModel: ToDoListViewModel
var dataSource: DataSource?
var cancellable: [AnyCancellable] = []
// initは省略
override func viewDidLoad() {
super.viewDidLoad()
setUpLayout() // <- tableViewをaddしたり、auto layoutを貼ったり(省略)
tableView.register(ToDoCell.self, forCellReuseIdentifier: "Cell")
dataSource = DataSource(tableView: tableView) { tableView, _, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! ToDoCell
cell.apply(info: item)
return cell
}
viewModel.$cellInfos.sink { [weak self] _ in self?.reload() }.store(in: &cancellable)
viewModel.$isFiltering.sink { [weak self] isFiltering in
let action = UIAction({ _ in
self?.viewModel.isFiltering = !isFiltering
self?.viewModel.reload()
})
let item = UIBarButtonItem(title: isFiltering ? "未完了" : "全て", primaryAction: action)
self?.navigationItem.rightBarButtonItem = item
}.store(in: &cancellable)
}
private func reload() {
var snapshot = NSDiffableDataSourceSnapshot<Int, CellInformation>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.cellInfos)
dataSource?.apply(snapshot)
}
}
このコードが抱えている問題
このコードが抱えている主要な問題として以下がありました。
・TableView
の更新タイミング
・cell
の同一性
TableViewの更新タイミング
問題となっているのはこのコードです。
viewModel.$cellInfos.sink { [weak self] _ in
self?.reload()
}.store(in: &cancellable)
cellInfo
の値が更新されたら tableView
を更新する処理です。
一見正しそうに見えますが、実はこの実装では正しく動きません。cellInfos
は@Published
が添えて宣言してありますが、Published
は値が更新される直前にイベントが発行されるのでsink
が呼ばれた時点では、まだcellInfos
の値は更新されていません。
更新された値は、sink
に渡されますが、reload
関数内では渡された値ではなくviewModel
の値を直接参照して更新しています。
その結果、古い値をもとにtableView
を更新してしまっています。
ここでは、以下のようにsink
に渡される値を使って更新する必要がありました。
viewModel.$cellInfos.sink { [weak self] cellInfos in
self?.reload(cellInfos) // クロージャーに渡されたcellInfosを用いてtableViewを更新する
}.store(in: &cancellable)
cell
の同一性
UITableViewDiffableDataSource
では前後のitemが同一の場合更新しない、いわゆる差分更新をサポートしています。CellInformation
の中でUUID
を発行する実装になっていますが、そのような実装ではtableView
を更新するたび全てのセルが削除されて、挿入されるアニメーションになってしまいます。
この実装を修正するにはいくつかの修正案が考えられます。
例えば、ToDo
に識別子になるような値があればそれを使う方法が考えられます。
ただ、そういった実装にしてしまうと、セルの識別子が同じ時に中身が更新されないという別の問題も発生させてしまいます。
どういった方法が良いのかはケースバイケースですが、iOS16以上をサポートしている場合は、UIHostingConfiguration
との組み合わせが良いかも知れません。
詳しくはWWDCのセッション動画Use SwiftUI with UIKitをご確認ください。
その他の問題点
その他にもこのコードにはたくさんの問題点があります。
・ToDoRepositoryがどのような方法で値を取得するのかはこのコードからはわかりませんが、一般的にはAPI呼び出しやDBアクセスがあるのならasync interfaceが良さそうに思います
・全体的にアクセス修飾子がないです
・ViewModelのinitではsomeが使えそうです
・DataSectionのSectionがIntなのは少し雑に感じます。適切なenumを定義するのが良さそうです
・UITableView
を使っていますが、最近ではUICollectionView
でも同様のことができそうです
・dataSource
の型が DataSource?
になっていますが、lazy var
を使うことで、DataSource
にできそうです
lazy var dataSource = DataSource(tableView: tableView) { tableView, _, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! ToDoCell
cell.apply(info: item)
return cell
}
・cellのregistationのidentifierを毎回ハードコーディングしています。どこかで定数として宣言した方が良いでしょう
問題3のまとめ
UIKitとCombineの組み合わせで陥りやすいバグを問題に仕込んでみました。 @Published
は便利ですが、イベントが発火するタイミングはwillSetなので、注意しながら実装する必要があります。 @Published
に対してsink
しているのに、その値を使っていないコードがあったら、少し注意が必要そうです。
最後に
イベント当日は多くの方に参加していただき、多くのレビューコメントが寄せられました。その中には予想してないかったアイデアや意外な視点からのコメントもあり、その度参加者、出題者ともに盛り上がりました。 参加者との意見交換を通じて、どのような実装がベストであるか、こういうケースにはどう対処するのかなど、どうしても会社の中で閉じてしまいがちな議論をiOSDCの参加者とオープンに議論できました。 このようなフィードバックや議論は出題者側であった私たちもにとっても非常に価値のある学びとなりました。 今回の「Code Review Challenge」に参加してくださった全ての方に心より感謝しています。