Как заставить вызовы 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()
    }
}

Что, я думаю, замыкает круг.

Блок завершения

Итак, у вас есть это. Несколько способов решить нашу проблему.

У тебя есть свой? Расскажите мне об этом в комментариях. И, конечно же, хлопайте и подписывайтесь, если хотите увидеть больше.

До скорого.