Как заставить вызовы API загружаться один раз… и только один раз.
UIKit и UIViewControllers предоставили нам несколько вариантов управления событиями жизненного цикла: viewDidLoad
, viewWillAppear
, viewDidAppear
, viewWillDisappear
, viewDidDisappear
и так далее.
SwiftUI, с другой стороны, в основном дает нам onAppear
и onDisappear
. Итак, если мы хотим загрузить некоторые данные для представления, мы обычно делаем что-то вроде следующего:
struct MyAccountListView: View { @StateObject var viewModel = MyAccountListViewModel() var body: some View { List { ForEach(viewModel.accounts, id: \.id) { account in NavigationLink(destination: Details(account)) { AccountListCellView(account: account) } } } .onAppear { viewModel.load() } } }
Просто позвоните load()
в onAppear
, и с миром все в порядке. Верно?
Что ж, если вы какое-то время работали со SwiftUI, то, вероятно, знаете, что ответ совсем не так прост. И хотя большинство из следующих решений относительно просты, я видел достаточно вопросов (и сомнительных решений) в Интернете, чтобы предположить, что они не так уж очевидны.
Итак, давайте начнем. Во-первых, нам нужно разобраться в рассматриваемой проблеме.
Туда и обратно
Первая и наиболее очевидная проблема связана с нашими навигационными ссылками. Нажмите на «учетную запись» в списке, и вы перейдете на страницу сведений о новой учетной записи. Но что происходит, когда вы возвращаетесь с этой страницы?
Правильный. Ваше представление «появляется» снова, и поэтому запрос на загрузку ваших данных также выполняется снова.
Та же проблема может возникнуть при создании форм с большим количеством переходов вперед и назад со списками выбора и подчиненными формами ввода данных. Нажмите на элемент, который представляет список выбора на новом экране. Выберите элемент, и список вернется к исходному экрану… который появится снова.
Все эти проблемы могут усугубляться тем фактом, что SwiftUI (по причинам, известным только SwiftUI) также может вызывать обработчики onAppear
и onDisappear
более одного раза во время данного перехода. С этим стало лучше в 3.0, но это все еще может произойти.
И на самом деле не имеет значения, почему, не так ли? У нас все еще есть проблема с навигацией, и мы по-прежнему хотим загрузить наши данные один раз и только один раз.
Итак, что нам с этим делать?
Пометить
Что ж, если вы программируете больше пары дней, первое (и самое очевидное решение) — достать молоток в нашем наборе инструментов и установить флажок. Учитывать.
class MyAccountListViewModel: ObservableObject { @Published var accounts: [Account] = [] private var shouldLoad = true func load() { guard shouldLoad else { return } shouldLoad = false // load our data here } }
Дело закрыто. Задача решена. Но это решение, как и решения, оставляет желать лучшего, поскольку мы должны объявить переменную в нашей модели представления, защитить ее, а затем не забыть сбросить наш флаг.
И это настолько привередливо, что мы, вероятно, захотим написать несколько дополнительных модульных тестов только для того, чтобы убедиться, что мы все сделали правильно.
В общем, как-то… ну, скажем так, не очень элегантно. Можем ли мы сделать лучше?
Атомикс
Что ж, мы могли бы импортировать новую библиотеку Atomics и исключить дополнительный оператор присваивания.
private var loading = ManagedAtomic(true) func load() { guard loading.exchange(false, ordering: .relaxed) else { return } // load our data here }
Функция exchange
для атомарного значения установит загрузку в новое значение (false), но вернет исходное значение для оценки. Это устраняет необходимость в дополнительной строке кода, но это достигается за счет некоторой сложности и использования библиотеки, с которой многие разработчики Swift могут быть не знакомы.
В этой ситуации это также излишне, поскольку маловероятно, что этот код будет реентерабельным и будет вызываться в нескольких потоках.
dispatch_once
В прежние времена, когда массивные программы на Objective-C еще ходили по земле, мы могли использовать GCD и dispatch_once
, чтобы гарантировать, что данный блок кода будет вызываться один раз и только один раз.
var token: dispatch_once_t = 0 func load() { dispatch_once(&token) { // load our data here } }
К сожалению, dispatch_once
устарела в Swift 3.0, и попытка использовать dispatch_once_t
сегодня выдает ошибку, говорящую вам вместо этого использовать ленивые переменные. Мы могли бы написать собственную версию, чтобы справиться с такой ситуацией, но… ленивые переменные?
Давайте подумаем об этом.
Ленивые переменные
Ленивые переменные не создаются до тех пор, пока они не будут использованы, и Swift гарантирует, что указанная инициализация произойдет только один раз. Звучит точно так же, как поведение, которое нам нужно.
А что, если мы заменим нашу функцию загрузки функцией с ленивой загрузкой?
class MyAccountListViewModel: ObservableObject { @Published var accounts: [Account] = [] lazy var load: () -> Void = { // load our data here return {} }() }
Здесь мы создаем ленивую переменную с замыканием, которое выполняет нашу функцию загрузки, а затем возвращает пустое замыкание. ()
, добавленное в конец, гарантирует, что само закрытие оценивается при доступе к переменной.
Таким образом, с этим решением наш загрузочный код вызывается при первом вычислении нашей ленивой функции, а затем при каждом повторном вызове load()
будет использоваться пустое замыкание.
Обратите внимание, что мы все еще можем передать значение функции загрузки, если это необходимо, отметив, конечно, что возвращаемое замыкание-заглушка также должно отражать пустое, неиспользуемое значение { _ in }
.
Это решение… неплохо. Это устраняет дополнительную переменную флага и защиту за счет того, что это немного сложно и вызывает нашу процедуру загрузки исключительно как побочный эффект первоначального ленивого вычисления.
Звонок один раз
Конечно, лучший способ убедиться, что наш код выполняется только один раз, — это вызвать его только один раз. Рассмотрим следующие изменения в нашей модели представления.
class MyAccountListViewModel: ObservableObject { enum State { case loading case loaded([Account]) case empty(String) case error(String) } @Published var state: State = .loading @Injected(Container.userServiceType) var service: private var cancellables = Set<AnyCancellable>() func load() { service.fetch() .receive(on: DispatchQueue.main) .sink { [weak self] completion in switch completion { case .failure(let error): self?.state = .error(error.localizedDescription) case .finished: break } } receiveValue: { [weak self] (users) in if users.isEmpty { self?.state = .empty("No users found...") } else { self?.state = .loaded(users) } } .store(in: &cancellables) } }
Обратите внимание на наше перечисление состояний и тот факт, что теперь мы обрабатываем ошибки, пустые состояния и тому подобное. Что, если честно, все, что нам, вероятно, придется делать в реальной жизни.
Теперь проверьте соответствующее изменение в нашем представлении.
struct MyAccountListLoadingView: View { @StateObject var viewModel = MyAccountListViewModel() var body: some View { switch viewModel.state { case .loaded(let accounts): AccountListView(accounts: accounts) case .empty(let message): MessageView(message: message, color: .gray) case .error(let message): MessageView(message: message, color: .red) case .loading: ProgressView() .onAppear { viewModel.load() } } } }
Здесь мы отображаем разные представления в зависимости от состояния нашей модели представления, и этот onAppear
теперь присоединен к нашему ProgressView
. Поскольку начальное состояние нашей модели представления — .loading
, «появляется» ProgressView
и вызывается наша функция загрузки.
Как только учетные записи загружены, представление прогресса удаляется и заменяется нашим представлением списка учетных записей (либо сообщением об ошибке, либо пустым сообщением).
Но в любом случае представление с модификатором onLoad
удаляется, и поэтому load()
больше никогда не будет вызываться.
Я подробно писал об этом методе в статье «Использование протоколов модели представления в SwiftUI? Ты делаешь это неправильно." Там я также объяснил, как этот метод можно использовать с протоколами, чтобы помочь с тестированием и имитацией данных. Проверьте это.
Конечно, если вы параноик, вы можете использовать этот метод, и один из более ранних методов просто для того, чтобы быть абсолютно положительным, load будет вызываться только один раз. (Что-то вроде ремня и подтяжек.)
Потяните, чтобы обновить
Еще одна приятная особенность нашего окончательного подхода заключается в том, что он делает реализацию таких действий, как «вытягивание для обновления», простой и легкой.
Просто снова вызовите load()
в модели представления, и когда она завершится, загрузка снова обновит состояние результата новыми данными, ошибкой или сообщением.
Вы могли сбросить состояние до .loading
, но это показало бы исходное представление прогресса, а также индикатор обновления, который, вероятно, не самый лучший пользовательский интерфейс.
Модификатор представления OnAppearOnce
Наконец, хотя я предпочитаю метод состояния, также может быть удобно вернуться к нашему исходному методу, основанному на флагах… с изюминкой. Мы поместим флаг в представление, а не в модель представления.
extension View { func onAppearOnce(_ action: @escaping () -> ()) -> some View { self.modifier(OnAppearOnceModifier(action: action)) } } private struct OnAppearOnceModifier: ViewModifier { let action: () -> () @State private var appearOnce = true func body(content: Content) -> some View { content .onAppear { if appearOnce { appearOnce = false action() } } } }
Создание нашего собственного модификатора представления onAppearOnce
и добавление нашего собственного расширения View для его вызова позволяет нам использовать наш код на основе флагов во всем нашем приложении во многих местах и ситуациях.
var body: some View { List { ... } .onAppearOnce { viewModel.load() } }
Что, я думаю, замыкает круг.
Блок завершения
Итак, у вас есть это. Несколько способов решить нашу проблему.
У тебя есть свой? Расскажите мне об этом в комментариях. И, конечно же, хлопайте и подписывайтесь, если хотите увидеть больше.
До скорого.