Команда The Browser Company активно использует фреймворк The Composable Architecture (TCA). Основываясь на опыте нашей команды и мнениях более широкого сообщества, я разработал новый набор best practices (лучших практик), которые могут принести пользу вашим проектам TCA.
Вот некоторые из ключевых практик, которые можно использовать в своих проектах.
Reducers (редукторы)
Чтобы придерживаться подхода boundaries approach (основанного на границах), лучше всего называть действия view (вью, вьюшка, представление) на основе того, «что произошло» (what happened), а не их ожидаемого эффекта. Например, вы должны использовать.didTapIncrementButton вместо.incrementCount. Этот подход позволяет вам добавить логику к действию, сохраняя при этом правильное название. Кроме того, эта практика поощряет сохранение бизнес‑логики в редукторе, а не позволяет ей проскальзывать на уровень view.
-
Старайтесь избегать выполнения дорогостоящих операций в Reducers. Reducers выполняются в основном потоке, и такие операции могут вызывать задержки или даже зависание UI (пользовательского интерфейса). Вместо этого рассмотрите возможность использования.task/EffectTask и environment clients (клиентов среды), которые позволяют выполнять эти операции вне основного потока. Таким образом, вы можете гарантировать, что ваше приложение останется отзывчивым и производительным.
return .task { .reducer(.onResultsUpdated(await self.search(query: query)) }
При обнулении state (состояния) важно отменить регистрацию любых длительных эффектов, чтобы предотвратить утечку памяти и ошибки. Для этого вы можете использовать метод.cancellable для отмены любых текущих эффектов до того, как state будет освобождено. Таким образом, вы можете гарантировать, что ваше приложение будет эффективно использовать память и оставаться стабильным.
При использовании методов concatenate и merge важно учитывать их различия. Метод concatenate будет ждать завершения предыдущих эффектов, прежде чем запускать следующие, в то время как метод merge запускает эффекты параллельно. Однако важно отметить, что использование concatenate может стать проблематичным при использовании других действий, которые могут привести к задержкам в будущем, таких как анимация. Это также может отсрочить любые последующие эффекты. Поэтому часто лучше использовать метод merge, чтобы обеспечить параллельное выполнение эффектов и предотвратить задержки, которые могут повлиять на взаимодействие с пользователем.
Избегайте высокочастотных действий, таких как Timers (таймеры), нажимающие на редуктор, чтобы что‑то проверить. Вместо этого выполняйте работу в задачах или клиентах env и отправляйте действия обратно только тогда, когда необходимо выполнить реальную работу над State. Примером могут быть обработчики перемещения мыши. Когда мы отслеживаем движение мыши, мы часто делаем это, ожидая выполнения некоторого условия. Лучше всего проверить это условие в коде views, а затем передать только edge condition/events (условие/события) в TCA.
Не используйте действия для обмена логикой. Это не методы. 1.Отправка действий не так проста, как вызов метода для типа. 2. Каждое (асинхронное) действие вызывает изменение области видимости и проверку на равенство в нашем приложении. 3. Единственное исключение на данный момент: действия .delegate(). 4. Вместо этого используйте изменяющие методы для объектов State. В настоящее время изучается альтернатива расширения реализации ReducerProtocol с помощью функций, которые принимают inout State variable (входную переменную состояния). Это позволяет получить доступ к зависимостям Reducer, не передавая их во вспомогательные методы.
По возможности лучше использовать в приложении state функции вместо проецируемого состояния (computed properties — вычисляемых свойств). Это может помочь вам избежать ненужных вычислений и повысить производительность вашего приложения. Например, в вашем AppReducer лучше использовать localWindows вместо вычисляемого свойства windows, если оно доступно. Поступая таким образом, вы можете избежать вычисления свойства windows каждый раз при доступе к нему, уменьшая нагрузку на основной поток и повышая общую производительность приложения.
При использовании редукторов onChange важно быть осторожным и помнить о том, где вы добавляете их в композицию редуктора. Это связано с тем, что редукторы onChange работают только на определенном уровне композиции редуктора. Если у вас возникли проблемы с неработающими редукторами onChange, скорее всего, они добавлены на неправильный уровень редуктора. Например, вы можете наблюдать на уровне функций, но изменение происходит на уровне приложений. Чтобы решить эту проблему, убедитесь, что вы добавили редукторы onChange на соответствующем уровне композиции редуктора, чтобы убедиться, что они функционируют должным образом.
При наблюдении за действиями дочерних функций используйте действия делегирования из нашего boundaries convention (соглашения о границах).
Моделирование State
-
Будьте особенно осторожны с кодом внутри scope (области видимости) функций, например, с вычислением дочернего state.
! Эти вызовы происходят для каждого действия, отправляемого в систему, поэтому они должны быть быстрыми.
! Старайтесь избегать вычислений. Даже сложность O(n) вызовет проблемы в hot paths (горячих путях) для больших боковых панелей.
! Либо предварительно вычислите heave data (тяжёлые данные), либо заставьте уровень view вычислить их, если это необходимо.
! Будьте особенно осторожны при использовании вычисляемых свойств, так как они могут быть дорогостоящими из‑за тех же проблем, что и обычная область. Помимо того, что они находятся в hot‑path, они часто могут воссоздавать объекты каждый раз, когда их вызывают.
Делайте state optional (необязательным), когда это возможно, и используйте ifLet и optional откаты, чтобы избежать выполнения ненужной работы. Например, command bar (панель команд) была not optional, поэтому её побочные эффекты state/reducer и уровня view работали, даже когда она не была видна.
Состояние UI (пользовательского интерфейса) не всегда должно сохраняться. Например, наведение на боковую панель раньше было свойством state, но оно должно жить только на View Layer.
Будьте осторожны при обновлении объектов persisted (постоянного) state. Они могут потребовать миграции. Обязательно добавьте тесты, чтобы избежать потери пользовательских данных.
Избегайте ссылок на userDefaults в области видимости функции. Они должны использовать текущее State и не ссылаться ни на что другое.
Инициализаторы проецируемого state должны быть максимально быстрыми, обычно это просто инициализаторы без каких‑либо вычислений.
Тестирование
Используйте prepareDependencies и всегда явно используйте initialState. Это позволяет инициализаторам state использовать тестовые значения @Dependency, например, UUID generator.
// Don't
let windows = WindowsState.stub(windows: [.init(window)], sidebar: .stub(globalSidebarContainer: sidebar))
let store = TestStore(
initialState: windows,
prepareDependencies: {
$0.uuid = .incrementing
}
)
// Do
let store = TestStore(
initialState: .stub(windows: ...),
prepareDependencies: {
$0.uuid = .incrementing
}
)
Dependencies (зависимости)
Используйте структуры для клиентов с mutablevar(изменяемой переменной) вместоprotocols(протоколов). Protocol-oriented (протокольно-ориентированные) интерфейсы для клиентов не рекомендуются в нашей кодовой базе.
// Don't
protocol AudioPlayer {
func loop(_ url: URL) async throws
func play(_ url: URL) async throws
func setVolume(_ volume: Float) async
func stop() async
}
// Do
struct AudioPlayerClient {
var loop: (_ url: URL) async throws -> Void
var play: (_ url: URL) async throws -> Void
var setVolume: (_ volume: Float) async -> Void
var stop: () async -> Void
}
Это позволяет нам описать абсолютный минимум зависимости в тестах. Например, предположим, что один пользовательский поток тестируемой функции вызывает play endpoint (конечную точку воспроизведения), но вы не думаете, что будет вызвана какая‑либо другая endpoint (конечная точка). Затем вы можете написать тест, который переопределяет только одну endpoint и использует версию.failing по умолчанию для всех остальных. Таким образом, вы можете быть уверены, что ваши тесты целенаправленны и эффективны, что упрощает выявление и решение любых возникающих проблем.
let model = withDependencies {
$0.audioPlayer.play = { _ in await isPlaying.setValue(true) }
} operation: {
FeatureModel()
}
Используйте заполнители в unimplemented failing stubs (неудачных заглушках), где endpoints возвращают значение. Это позволяет вашему набору тестов продолжать работу даже в случае сбоя. Если обнаружена проблема, о ней сообщается через XCTFail вместе с именем endpoint, вызвавшей сбой. Это предпочтительнее, чем fatalError (фатальная ошибка), которая может привести к сбою теста и падению в отладчике, прерывая остальную часть набора тестов.
// DON'T
searchHistory: unimplemented("\(Self.self).searchHistory")
// DO
searchHistory: unimplemented("\(Self.self).searchHistory", placeholder: [])
Уровень view
Используйте новый инициализатор observe: ViewState, так как он заставляет вас создавать ViewState
// DON'T
viewStore = .init(store.scope(state: \.overlayViewState))
// DO
viewStore = .init(store, observe: \.overlayViewState)
Нет необходимости создавать ViewStore, если вам необходимо отправлять только разовые действия, например, ViewStore(store.stateless).send(.action). Обратите внимание, что мы используем вариант.stateless, который не позволяет временному ViewStore участвовать в конвейере обновлений.
Всегда ограничивайте свои областиViewStoreдо минимального значения, необходимой вашемуView, вводя для него локальныйViewState, поскольку это позволяет избежать ненужного сравнения и перезагрузки view. Либо добавьте свойства активного наблюдения во ViewState, либо рассмотрите возможность создания нескольких разных ViewStore для более сложных вариантов использования.
Помимо действия view‑lifecycle (жизненного цикла) view (например, viewDidAppear или, что ещё лучше, привязать его к времени существования view через задачу), не должно быть никаких других действий, отправляемых в хранилище при отображении или загрузке. Особенно для ячеек таблицы это может привести к отправке множества действий в хранилище при появлении, например, при onHover(false).
Имейте в виду, что подписка на издателя ViewStore срабатывает синхронно. Поэтому в AppKit рассмотрите возможность добавления dropFirst при подписке на изменения в viewStore, если речь идёт только о реагировании на изменения.
Ошибки и обходные пути
Если вы прерываете auto‑completion (автозавершение) в телах ReducerProtocol, обходным путём является добавление явного типа к вашим определениям, например, Reduce {
Если такие же ошибки или ошибки компилятора возникают при использовании WithViewStore, либо добавьте явный тип в тело, например, WithViewStore(self.store) { (viewStore: ViewStoreOf in), либо введите @ObservedObject var viewStore: ViewStoreOf вместо обёртки WithViewStore.
Заключение
The Browser Company активно использует фреймворк The Composable Architecture (TCA). Мы поделились передовым опытом, основанным на опыте нашей команды и мнениях более широкого сообщества.
Эти методы разработаны, чтобы помочь вам оптимизировать производительность и стабильность ваших проектов TCA и сделать их более удобными в обслуживании в долгосрочной перспективе. Применяя эти методы, вы можете создавать высококачественные проекты TCA, отвечающие потребностям ваших пользователей и превосходящие их ожидания.
Ссылки: