Giriş & Sonuçlar
Az önce The Composable Architecture (TCA) 0.35.0 sürümüyle oluşturulmuş RemafoX uygulamamı yeni 1.0 tarzı API’lere taşıdım. 0.35.0’ın yayınlanmasıyla şu anki 1.0 betası arasındaki süre bir yıldan kısa olsa da, bu kısa sürede bazen önemli değişiklikler içeren 27 özellik sürümünün yapıldığını belirtmek gerekir. Point-Free ekibi gerçekten Swift geliştiricileri için uygulama geliştirmeyi iyileştirmek üzerine tam gaz çalışıyor ve TCA, çalışmalarının büyük bölümünün doruk noktası. Harika ileri düzey Swift video serisinde tartıştıkları neredeyse her konunun TCA üzerinde bir etkisi var. Mevcut 0.52.0 sürümüne kadar tüm değişiklikleri hemen hemen kaynak uyumlu tutmayı başarmış olsalar da, 1.0 sürümü bir süredir deprecated olarak işaretlenmiş birçok eski tarz API’den kurtulacak.
Uygulamalarımın mimarisi veya konvansiyonları hakkında verdiğim her kararı sürekli sorgulamayı çok verimsiz bulduğum için, son aylarda TCA’ya yapılan tüm iyileştirmeleri takip etmeye çok zaman harcamadım — genel yönden haberdar olmak için bir gözümü açık tuttum tabii. Ama kütüphanenin dönüm noktası olan 1.0 sürümüne yaklaşması ve RemafoX için bazı büyük özellikler üzerinde çalışmayı planlıyor olmam, bundan sonra uygulamalarımı en iyi nasıl yapılandıracağımı yeniden değerlendirmek ve öğrenmek için iyi bir zaman.
Neyse ki TCA’nın genel konsepti hiç değişmedi. Ama özelliklerin nasıl bağlandığı, navigasyonun nasıl çalışacağı, asenkron işin nasıl tanımlanacağı ve hatta bağımlılıkların nasıl aktarılacağı konularındaki API’ler, en son Swift özelliklerini kullanarak önemli değişiklikler aldı — hepsi daha iyiye doğru. Bu yüzden benim için çözülecek ve taşınacak çok şey vardı. Tüm bu değişiklik alanlarını aynı anda ele aldım, ama işleri yönetilebilir kılmak için değişiklikleri SwiftPM ile modülerleştirilmiş uygulamamın 33 UI özelliğine modül modül uyguladım.
İşte geçiş sürecinin ana çıkarımları:
Geçişi tamamlamam tam bir iş haftası (~5 gün) sürdü.
Kod tabanım 2.500 satır kod küçüldü, bu yaklaşık %7’lik bir azalma.
Birkaç navigasyon hatası, threading sorunu ve SwiftUI aksaklığı artık düzeltildi.
Kodum çok daha kolay anlaşılır, gezinilebilir ve üzerinde akıl yürütülebilir hale geldi.
Uygulamamın test hikayesine gelince, tüm testlerim aslında hala geçiyor ve test kodunda herhangi bir değişiklik yapmam gerekmedi. Bunun nedeni, şu anda sadece veri ayrıştırma, dosya arama veya Strings dosyalarında değişiklik yapma gibi UI dışı özellikler için testlerim olması — ve bazı kısımlarda oldukça kapsamlı testler. Ama UI için de test yazmayı düşündüğümde, uygulamayı yayınlama zaman çizelgemin zaten aylar gerisindeyim ve ek olarak TCA, kapsamlı olmayan test desteğinden birkaç hafta uzaktaydı. Bu yüzden UI testleri eklemeye karar vermedim çünkü genel davranışı gerçekten değiştirmeyen UI katmanındaki bazı yeniden düzenlemeler yüzünden testlerde ne kadar sık değişiklik yapılması gerektiğinden memnun değildim — kapsamlı yapıları yüzünden ilgili testlerin yeniden yazılması gibi hissediyordu. Ama artık kapsamlı olmayan test mevcut olduğuna göre, en önemli olanlardan başlayarak adım adım uygulamamın UI testlerini yazmayı planlıyorum: En çok iş mantığı yoğun özelliğim ve tüm Onboarding özelliklerim. Bunu ileride bir makalede yazabilirim.
Ama şimdilik, uygulamamın kod tabanını nasıl taşıdığıma odaklanalım.
Geçiş Öncesi (Örnek Vaka)
Hangi değişikliklerin gerekli olduğunu ve işleri daha da kolaylaştırmak için başka hangi değişiklikleri yaptığımı açıklamanın en iyi yolunun gerçek kod göstermek olduğunu düşünüyorum. Bu yüzden aşağıda uygulamamın en basit özelliğinin geçiş öncesi gerçek kodunun nasıl göründüğünü ve bunu yeni TCA 1.0 tarzına nasıl evrimleştirdiğimi göstereceğim.
Özellik, kod tabanımda AppInfo olarak adlandırılıyor ve uygulamada şöyle görünüyor:

“About RemafoX” ekranı (Cmd+I).
Geçiş öncesinde, özelliğin kodu 7 farklı dosyaya bölünmüştü:

Özellik parçaları: Action, ActionHandler, Error, Event, Reducer, State ve View.
AppInfoState ve AppInfoAction verileri ve mümkün olan etkileşimleri tanımlıyor:
import AppFoundation
import AppUI
public struct AppInfoState: Equatable {
public typealias Action = AppInfoAction
public typealias Error = AppInfoError
@BindingState
var showEnvInfoCopiedToClipboard: Bool = false
var selectedAppIcon: AppIcon
var errorHandlingState: ErrorHandlingState?
public init() {
self.selectedAppIcon = Defaults[.selectedAppIcon]
}
}AppInfoState.swift
import AppFoundation
import AppUI
public enum AppInfoAction: Equatable, BindableAction {
public typealias State = AppInfoState
public typealias Error = AppInfoError
case onAppear
case onDisappear
case selectedAppIconChanged
case copyEnvironmentInfoPressed
case binding(BindingAction<State>)
case errorOccurred(error: Error)
case setErrorHandling(isPresented: Bool)
case errorHandling(action: ErrorHandlingAction)
}AppInfoAction.swift
Özelliğin ilgili kısımlarına referans verebilmek için her zaman typealias tanımladığımı not et — bunları kullanmasam bile kolaylık olsun diye. Ayrıca, daha sonra sheet ile sunmak istediğim her alt view için ekstra bir set<alt adı>(isPresented:) action’ım vardı. AppFoundation ve AppUI import’larının ne olduğunu merak ediyorsan, bu makalede açıkladım. Uygulamadaki import sayısını azaltmaya yardımcı oluyorlar.
Sonra, AppInfoView dosyasının neye benzediğine bakalım:
import AppFoundation
import AppUI
public struct AppInfoView: View {
public typealias State = AppInfoState
public typealias Action = AppInfoAction
let store: Store<State, Action>
public init(store: Store<State, Action>) {
self.store = store
}
public var body: some View {
WithViewStore(self.store) { viewStore in
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .center, spacing: 10) {
viewStore.selectedAppIcon.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.onChange(of: Defaults[.selectedAppIcon]) { newValue in
viewStore.send(.selectedAppIconChanged)
}
Text(Constants.appDisplayName)
.font(.system(size: 33, weight: .light, design: .rounded))
Text("Copyright © 2022 Cihat Gündüz")
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
Divider()
VStack(alignment: .center, spacing: 10) {
Text("Environment Info")
.font(.headline)
Text("Provide these info when reporting bugs or use Help menu.")
.frame(maxWidth: .infinity, alignment: .leading)
.font(.subheadline)
.padding(.bottom, 5)
HStack {
Text("App Version:").foregroundColor(.secondary)
Spacer()
Text(Bundle.main.versionInfo)
}
HStack {
Text("System Version:").foregroundColor(.secondary)
Spacer()
Text(ProcessInfo.processInfo.operatingSystemVersionString.replacingOccurrences(of: "Version ", with: ""))
}
HStack {
Text("System CPU:").foregroundColor(.secondary)
Spacer()
Text(KernelState.getStringValue(for: .cpuBrandString))
}
HStack {
Text("Tier:").foregroundColor(.secondary)
Spacer()
Text(Plan.loadCurrent().tier.displayName)
}
Button {
viewStore.send(.copyEnvironmentInfoPressed)
} label: {
Label("Copy", systemSymbol: .docOnClipboard)
}
.padding(.top, 10)
.popover(isPresented: viewStore.binding(\.$showEnvInfoCopiedToClipboard), arrowEdge: Edge.top) {
Text("Copied!").padding(10)
}
}
}
.frame(width: 320)
.padding()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
.sheet(
isPresented: viewStore.binding(
get: { $0.errorHandlingState != nil },
send: Action.setErrorHandling(isPresented:)
)
) {
IfLetStore(
self.store.scope(state: \State.errorHandlingState, action: Action.errorHandling(action:)),
then: ErrorHandlingView.init(store:)
)
}
}
}
}
#if DEBUG
struct AppInfoView_Previews: PreviewProvider {
static let store = Store(
initialState: .init(),
reducer: appInfoReducer,
environment: .mocked
)
static var previews: some View {
AppInfoView(store: self.store)
}
}
#endifAppInfoView.swift
Sheet sunmak için 11 satırdan az olmayan kod yazmam gerektiğini ve setErrorHandling(isPresented:) action’ını sisteme manuel olarak geri göndermem gerektiğini not et. Ayrıca deneyimli geliştiriciler, view kodumda Plan.loadCurrent() gibi global bağımlılıklar kullandığımı fark edebilir — bu da UI kodumu çok test edilebilir yapmıyor. Ama UI testleri yazmaya başladığımda bunları düzgün bağımlılıklar olarak tanıtacağım, şimdilik görmezden gelelim.
Bulmacının TCA’daki bir özellik için son eksik parçası AppInfoReducer:
import AppFoundation
import AppUI
public let appInfoReducer = AnyReducer.combine(
errorHandlingReducer
.optional()
.pullback(
state: \AppInfoState.errorHandlingState,
action: /AppInfoAction.errorHandling(action:),
environment: { $0 }
),
AnyReducer<AppInfoState, AppInfoAction, AppEnv> { state, action, env in
let actionHandler = AppInfoActionHandler(env: env)
switch action {
case .onAppear, .onDisappear:
return .none // for analytics only
case .selectedAppIconChanged:
return actionHandler.selectedAppIconChanged(state: &state)
case .copyEnvironmentInfoPressed:
return actionHandler.copyEnvironmentInfoPressed(state: &state)
case .binding:
return .none // assignment handled by `.binding()` below
case .errorOccurred, .setErrorHandling, .errorHandling:
return actionHandler.handleErrorAction(state: &state, action: action)
}
}
.binding()
.recordAnalyticsEvents(eventType: AppInfoEvent.self) { state, action, env in
switch action {
case .onAppear:
return .init(event: .onAppear)
case .onDisappear:
return .init(event: .onDisappear)
case .copyEnvironmentInfoPressed:
return .init(event: .copyEnvironmentInfoPressed)
case .errorOccurred(let error):
return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])
case .binding, .setErrorHandling, .errorHandling, .selectedAppIconChanged:
return nil
}
}
)Öncelikle appInfoReducer‘ın global düzeyde tanımlandığını not et, bu zaten yanlış hissettiriyor. Sonra, alt özellik ErrorHandling‘i bu özelliğe bağlamak için 7 satır gerekiyor. Ve fark edeceksin ki, reducer’ın asıl mantığını tutan AppInfoActionHandler adında bir tür daha tanımlamışım. Bunun nedeni, bazı reducer mantıklarımın oldukça uzun olması ve tüm mantığı switch-case içinde tutsam, çok sayıda case’de çok fazla kod olacak olması. Ama Xcode, switch case’leri arasında bulmayı ve gezinmeyi kolaylaştıracak herhangi bir özellik sunmuyor. Bu yüzden mantığı başka bir türdeki fonksiyonlara çıkardım. Son olarak, analitik amaçlı AnyReducer türüne bir extension fonksiyonu tanımladığımı fark edeceksin:
import ComposableArchitecture
extension AnyReducer {
/// Returns a `Result` where each action coming to the store first attempts to record an analytics event.
/// In the implementation, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
public func recordAnalyticsEvents<Event: AnalyticsEvent>(
eventType: Event.Type,
event toAttributedEvent: @escaping (State, Action, Environment) -> Analytics.AttributedEvent<Event>?
) -> Self {
.init { state, action, env in
guard let attributedEvent = toAttributedEvent(state, action, env) else { return self.run(&state, action, env) }
return .concatenate(
.fireAndForget { Analytics.shared.record(attributedEvent: attributedEvent) },
self.run(&state, action, env)
)
}
}
}AnyReducerExt.swift
Bunun yaptığı tek şey, kaydetmek istediğim action’lar için TelemetryDeck tarafından desteklenen Analitik motorumda bir olay kaydetmek. Bunu çok faydalı buluyorum çünkü AppInfoAction enum’una eklediğim her yeni action için, yeni olayı tamamen anonim bir şekilde analiz etmek isteyip istemediğimi her zaman düşünmem gerektiğini hatırlatıyor. Bunun düzgün çalışması için, her özellik için bir tür daha tanımlamam gerekiyor, burada AppInfoEvent:
import AppFoundation
enum AppInfoEvent: String {
case onAppear
case onDisappear
case copyEnvironmentInfoPressed
case errorOccurred
}
extension AppInfoEvent: AnalyticsEvent {
var idComponents: [String] {
["AppInfo", self.rawValue]
}
}AppInfoEvent.swift
Bu enum toplamak istediğim tüm olayları tanımlıyor ve idComponents property’si Analitik sağlayıcıma olay adı geçerken otomatik String oluşturmaya yardımcı oluyor. AnalyticsEvent protokolü biraz konu dışı, ama ilgileniyorsan sadece şu:
import Foundation
public protocol AnalyticsEvent: Identifiable where ID == String {
var idComponents: [String] { get }
}
extension AnalyticsEvent {
public var id: String {
self.idComponents.joined(separator: ".")
}
}AnalyticsEvent.swift (Analytics adlı bir yardımcı modülün parçası)
Environment için kullandığım AppEnv türünü de fark etmiş olabilirsin. Bu aslında sadece temel bir mainQueue’a ihtiyaç duyduğum her yerde yeniden kullandığım ve uygulamam boyunca her yere aktarılan paylaşımlı bir tür:
import CombineSchedulers
import Defaults
import Foundation
public struct AppEnv {
public let mainQueue: AnySchedulerOf<DispatchQueue>
public init(mainQueue: AnySchedulerOf<DispatchQueue>) {
self.mainQueue = mainQueue
}
}
#if DEBUG
extension AppEnv {
public static var mocked: AppEnv {
.init(mainQueue: DispatchQueue.main.eraseToAnyScheduler())
}
}
#endif7 dosyanın sonuncusu AppInfoError ve bu tür aslında bu çok basit özellik için boş. Ama amacını, hata işleme yaklaşımımı detaylı olarak ele alacağım ilerideki bir makalede açıklayacağım. Bu makale için bilmen gereken tek şey, beklenmedik bir şey olduğunda, özellik bağlamında doğrudan faydalı bilgiler içeren bir sheet göstermek istediğim.
Point-Free tüm türlerini tek bir dosyada tutma eğiliminde, bu AppInfo gibi küçük bir özellik için işe yarayabilir. Ama benim tipik bir özelliğim, tüm türler birleştirildiğinde yaklaşık 500 ile 1.500 satır kod tutuyor. Dosyalarımı genellikle 400 satırlık esnek bir üst sınır ve 1.000 satırlık kesin bir üst sınırla küçük tutma eğilimindeyim (SwiftLint kural varsayılanlarına bak). 314 satırla bile bu çok basit özellik zaten esnek üst sınıra yaklaşıyordu ve bazı özelliklerim kesin üst sınırı bile aşabilirdi. Yani hepsini tek dosyada tutmak benim için mümkün değil. Bu yüzden her türü kendi dosyasına koymaya karar verdim. Ama bundan da hiçbir zaman %100 memnun kalmadım çünkü her şey çok dağınık görünüyor. En iyi durumda, ilişkili kod yine birlikte olurdu ama özellik 7’den daha az dosyaya eşit olarak dağıtılırdı. Haydi geçiş sonrasında işlerin nasıl göründüğüne bakalım.
✨ Reklamını burada görmek ister misin? Benimle iletişime geçmek için [email protected] adresine yaz.
Geçiş Sonrası (Örnek Vaka)
TCA 1.0 betasında, Point-Free bir özellik için kapsam veya ad alanı görevi gören özel bir struct oluşturma konseptini tanıttı ve State ile Action gibi yardımcı türleri bu ad alanı türüne alt tür olarak koydu. Bu ad alanına Feature adını verdiler ve onu Reducer’a uyumlu hale getirdiler, yaklaşık olarak şöyle:
struct Feature: Reducer {
struct State: Equatable { … }
enum Action: Equatable { … }
func reduce(into state: inout State, action: Action) -> Effect<Action> { … }
}Benim için bu yapı iki nedenden dolayı kafa karıştırıcı:
Ad alanını
Featureson ekiyle adlandırırkenReducer’a uyumlu hale getirmek bana uygun gelmiyor. Birreducerparametresi geçirilmesi gereken her yerde,Featuregeçirirdik ki bu kafa karıştırıcı. O zaman ad alanı en baştanReducerolarak adlandırılmalıydı, ama o zamanReducer.StateveReducer.Actionüzerinden erişilen alt türlerimiz olurdu ki bu da doğru değil.Özellik ad alanı fikrini tamamen benimsersek, tutarlılık için
Featuretürü içinde birReduceralt türü beklerdim.
Bunun yerine, Feature’ı gerçekten bir ad alanı olarak kullanmayı ve içine Reducer’a uyumlu bir Reducer alt türü koymayı tercih ettim. Ama bu struct Reducer: Reducer ile sonuçlanırdı ki bu bir isim çakışması, bunu typealias ile çözelim:
import ComposableArchitecture
public typealias FeatureReducer: ReducerŞimdi Feature ad alanımızda Reducer: FeatureReducer alt türü tanımlayabiliriz. Ve bu arada, reducer’larım için public initializer yazmayı unutma eğilimim var (uygulama modülerleştirilmiş olduğu için bu gerekli), o yüzden public initializer gerektiren yeni bir public protokol tanımlayalım:
import ComposableArchitecture
public protocol FeatureReducer: Reducer {
init()
}Aslında, diğer TCA özellik türleri hakkında da unutma eğiliminde olduğum daha fazla şey var. Hepsini FeatureReducer gibi protokoller uygulayarak açık bir gereklilik haline getirelim:
import Analytics
import ComposableArchitecture
import ErrorHandling
import SwiftUI
public protocol FeatureState: Equatable {
var childErrorHandling: ErrorHandlingFeature.State? { get set }
}
public protocol FeatureAction: Equatable {
associatedtype ErrorType: FeatureError
static func errorOccurred(_ error: ErrorType) -> Self
static func childErrorHandling(_ action: PresentationAction<ErrorHandlingFeature.Action>) -> Self
}
public protocol FeatureEvent: AnalyticsEvent {}
public protocol FeatureError: HelpfulError {}
public protocol FeatureReducer: Reducer {
init()
}
public protocol FeatureView: View {
associatedtype Action: FeatureAction
}Feature.swift dosyasından alıntı (bir yardımcı modülün parçası)
FeatureState ve FeatureAction‘ın Equatable olmasını gerektirdiğimi not et — TCA’da test edilebilirlik için bu her zaman iyi bir fikir ve tüm state & action türlerim zaten buna uyuyor. Ek olarak, Analytics ve Hata İşleme ihtiyaçlarım için iki ekstra tür ve buna uygun bir FeatureView tanımladım. Ayrıca, önceki özellikte errorHandlingState ile yaptığım gibi tüm alt özelliklere State son eki eklemek yerine, child ön ekini kullanmaya karar verdim — childErrorHandling’deki gibi — bu, öznitelikleri yukarıdan aşağıya tararken alt özellikleri bulmayı kolaylaştırıyor.
Bu protokoller yerindeyken, artık tüm alt türleri gerektiren başka bir protokol tanımlayarak derleyiciye bir “özellik”in gerçekte ne olduğunu bile öğretebiliriz:
/// A namespace for a TCA feature with extra requirements for Analytics and Error Handling.
public protocol Feature {
associatedtype State: FeatureState
associatedtype Action: FeatureAction
associatedtype Event: FeatureEvent
associatedtype Error: FeatureError
associatedtype Reducer: FeatureReducer
associatedtype View: FeatureView
}
/// A helper to declare a `Store` of a `Feature` type.
public typealias FeatureStore<F: Feature> = Store<F.State, F.Action>Feature.swift dosyasından alıntı (bir yardımcı modülün parçası)
View’larımızdaki store’u tanımlamak için, Point-Free tarafından oluşturulan yeni StoreOf typealias’ına benzer ama Feature’a özgü bir typealias da tanımladım.
Tamam, bu ön çalışmayla, taşınmış Feature’ın nasıl göründüğüne bakalım:
import AppFoundation
import AppUI
public enum AppInfoFeature: Feature {
public struct State: FeatureState {
// see below
}
public enum Action: FeatureAction, BindableAction {
// see below
}
public enum Event: String, FeatureEvent {
// see below
}
public enum Error: FeatureError {
// ...
}
public struct Reducer: FeatureReducer {
// see below
}
public struct View: FeatureView {
// see below
}
}
extension AppInfoFeature.Event: AnalyticsEvent {
public var idComponents: [String] {
["AppInfo", self.rawValue]
}
}AppInfoFeature.swift’in genel görünümü
Bunun sadece bir ad alanını temsil ettiğini belirtmek için Feature olarak bir struct yerine enum tanımladığımı not et. Sonra, tüm alt türler tam olarak adlarının Feature ön ekli haliyle uyumlu, örneğin State: FeatureState. Bu, neye uyumlu olacağını hatırlamayı gerçekten kolaylaştırıyor ve kodu daha tutarlı kılıyor.
Daha iyi bir genel bakış için yukarıdaki kod örneğinde bıraktığım State gövdesi:
public struct State: FeatureState {
@BindingState
var showEnvInfoCopiedToClipboard: Bool = false
var selectedAppIcon: AppIcon
@PresentationState
public var childErrorHandling: ErrorHandlingFeature.State?
public init() {
self.selectedAppIcon = Defaults[.selectedAppIcon]
}
}Bu, orijinal AppInfoState’e oldukça benziyor ama bu sefer alt özellik errorHandlingFeature’dan childErrorHandling’e yeniden adlandırıldı. Ayrıca alt özelliğin kendisini de taşıdığım için tür ErrorHandlingState?’den ErrorHandlingFeature.State?’e değişti. Ayrıca, alt içinden @Dependency(\.dismiss) kullanarak self.dismiss() çağrısıyla kapatmayı destekleyen TCA 1.0’daki yeni navigasyon tarzı için @PresentationState özniteliğini ekledim.
Şimdi Action alt türümüze bakalım:
public enum Action: FeatureAction, BindableAction {
case onAppear
case onDisappear
case selectedAppIconChanged
case copyEnvironmentInfoPressed
case binding(BindingAction<State>)
case errorOccurred(Error)
case childErrorHandling(PresentationAction<ErrorHandlingFeature.Action>)
}Bu da orijinal AppInfoAction‘ın hemen hemen kopyası, ama alt action’ın artık farklı bir türü olduğuna dikkat et. HelpfulErrorAction‘dan PresentationAction<HelpfulErrorFeature.Action>‘a dönüştü — bu, tüm alt action’ları .presented adlı bir case’e koyan bir wrapper — diğer case .dismiss ise, ebeveynin buna tepki vermesi gerekirse alt özelliğin kapatıldığını geri bildirir. PresentationAction sayesinde setErrorHandling(isPresented:) action’ını tamamen kaldırabildim çünkü bu artık TCA tarafından sağlanan türlerde kapsüllenmiş durumda.
Şimdi View’ımızın nasıl göründüğüne bakalım:
public struct View: FeatureView {
let store: FeatureStore<AppInfoFeature>
public init(store: FeatureStore<AppInfoFeature>) {
self.store = store
}
}Gördüğün gibi, eski tarz Store<State, Action> veya TCA 1.0 tarzı StoreOf<Feature> yerine FeatureStore typealias’ını kullanıyorum. Ama body property’si gibi bir SwiftUI View‘ı tanımlayan diğer her şey nerede? Bir view’ın implementasyonu tipik olarak bir özelliğin en uzun kısımlarından biri, bu yüzden tüm alt türlerin yapısal kısımlarını bir arada tutmayı tercih etsem de, çok kod gerektiren protokol uyumlulukları extension dosyalarına çıkardım.
View’ın implementasyonu extension olarak ayrı bir dosyada:
import AppFoundation
import AppUI
extension AppInfoFeature.View: View {
public typealias Action = AppInfoFeature.Action
public var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack(alignment: .leading, spacing: 20) {
// same code as before
}
.frame(width: 320)
.padding()
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
.sheet(store: self.store.scope(state: \.$childErrorHandling, action: Action.childErrorHandling)) { childStore in
HelpfulErrorFeature.View(store: childStore)
}
}
}
}
#if DEBUG
struct AppInfoView_Previews: PreviewProvider {
static let store = Store(initialState: AppInfoFeature.State(), reducer: AppInfoFeature.Reducer())
static var previews: some View {
AppInfoFeature.View(store: self.store).previewVariants()
}
}
#endifAppInfoFeatureView.swift*
body property’sinin implementasyonu hemen hemen öncekiyle aynı. Ama 11 satırlık .sheet modifier’ının sadece 3 satıra küçüldüğüne dikkat et. Bu, @PresentationState ve PresentationAction kullanan yeni navigasyon araçları sayesinde. Bir diğer değişiklik PreviewProvider içindeki static let store‘da oldu: Store’a geçirilecek environment parametresi artık yok!
Nedenini öğrenmek için Reducer alt türüne bakalım:
public struct Reducer: FeatureReducer {
@Dependency(\.mainQueue)
var mainQueue
@Dependency(\.continuousClock)
var clock
public init() {}
}@Dependency özniteliğinin kullanımına dikkat et. SwiftUI’daki @Environment özniteliğini hatırlatabilir ve aslında tam olarak aynı şekilde çalışıyor. Bu yeni öznitelik, TCA 1.0’da artık Environment türüne ihtiyaç duyulmamasının nedeni. Bunun yerine tüm bağımlılıklar @Dependency özniteliğiyle tanımlanıyor. Bu sayede daha önce her yerde aktardığım AppEnv türünden tamamen kurtulabiliyorum.
Yine Reducer protokolünün asıl implementasyonunun eksik olduğunu fark etmiş olabilirsin. Bir özellikte uzayabilen kodun ikinci kısmı olan implementasyonu da kendi dosyasına çıkardım:
import AppFoundation
import AppUI
extension AppInfoFeature.Reducer: Reducer {
public typealias State = AppInfoFeature.State
public typealias Action = AppInfoFeature.Action
enum ShowEnvInfoCopiedId {}
public var body: some ReducerOf<Self> {
AnalyticsEventRecorderOf<AppInfoFeature> { state, action in
switch action {
case .onAppear:
return .init(event: .onAppear)
case .onDisappear:
return .init(event: .onDisappear)
case .copyEnvironmentInfoPressed:
return .init(event: .copyEnvironmentInfoPressed)
case .errorOccurred(let error):
return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])
case .binding, .childErrorHandling, .selectedAppIconChanged:
return nil
}
}
BindingReducer()
Reduce<State, Action> { state, action in
switch action {
case .onAppear, .onDisappear:
return .none // for analytics only
case .selectedAppIconChanged:
return self.selectedAppIconChanged(state: &state)
case .copyEnvironmentInfoPressed:
return self.copyEnvironmentInfoPressed(state: &state)
case .binding:
return .none // assignment handled by `BindingReducer()` above
case .errorOccurred, .childErrorHandling:
return self.handleHelpfulErrorAction(state: &state, action: action)
}
}
.ifLet(\.$childErrorHandling, action: /Action.childErrorHandling) {
HelpfulErrorFeature.Reducer()
}
}
private func selectedAppIconChanged(...)
private func copyEnvironmentInfoPressed(state: inout State) -> Effect<Action> {
Pasteboard.string = Constants.GitHub.environmentInfo
state.showEnvInfoCopiedToClipboard = true
return .run { send in
try await self.clock.sleep(for: Constants.toastMessageDuration)
try Task.checkCancellation()
await send(.set(\.$showEnvInfoCopiedToClipboard, false))
}
.cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)
}
private func handleHelpfulErrorAction(...)
}AppInfoFeatureReducer.swift*
Öncelikle tamamen farklı yapıya dikkat et. Artık global reducer değişkenlerine gerek yok. Bunun yerine, SwiftUI’daki View protokolüne çok benzer şekilde bir body property’si uygulanıyor. Ve benzerlik burada bitmiyor, yapı da çok SwiftUI benzeri — BindingReducer() dahil olmak üzere birlikte AppInfoFeature.Reducer‘ı oluşturan farklı reducer’ların bir listesi var, BindingReducer() eski .binding()‘in yerini alıyor. Ayrıca alt özelliği bağlayan 7 satır kodun yeni .ifLet API’si kullanılarak sadece 3 satıra küçüldüğüne dikkat et. Ek olarak, action’lara tepki verme mantığının implementasyonunu koyduğum özel bir ActionHandler türü tanımlamak yerine, artık bir tür içinde olduğumuz ve global düzeyde olmadığımız için, bu fonksiyonları kolayca Reducer türünün kendisine taşıyabildim. Ayrıca copyEnvironmentInfoPressed implementasyonu yeni async tarzı API’leri kullanıyor. Önceden, daha az okunabilir Combine tarzıyla uygulanmıştı:
Pasteboard.string = Constants.GitHub.environmentInfo
state.showEnvInfoCopiedToClipboard = true
return .init(value: .set(\.$showEnvInfoCopiedToClipboard, false))
.delay(for: Constants.toastMessageDuration, scheduler: env.mainQueue)
.eraseToEffect()
.cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)Eski AppInfoActionHandler.swift’den alıntı
Son olarak, yeni SwiftUI benzeri fonksiyon builder tarzı nedeniyle AnyReducer extension fonksiyonum recordAnalyticsEvents’i, yürütme mantığını property olarak saklayan basit bir Reducer’a dönüştürmek zorunda kaldım:
import ComposableArchitecture
import Foundation
/// Returns a `Reducer` where each action coming to the store attempts to record an analytics event.
public struct AnalyticsEventRecorder<State, Action, Event: AnalyticsEvent>: Reducer {
let toAttributedEvent: (State, Action) -> Analytics.AttributedEvent<Event>?
/// In the event closure, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
public init(event toAttributedEvent: @escaping (State, Action) -> Analytics.AttributedEvent<Event>?) {
self.toAttributedEvent = toAttributedEvent
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
if let attributedEvent = self.toAttributedEvent(state, action) {
Analytics.shared.record(attributedEvent: attributedEvent)
}
return .none
}
}
/// Convenient way to declare an `AnalyticsEventRecorder`, but requires `Reducer` to conform to `Feature`.
public typealias AnalyticsEventRecorderOf<F: Feature> = AnalyticsEventRecorder<F.State, F.Action, F.Event>AnalyticsEventRecorder.swift (bir yardımcı modülden)
Reducer’daki yukarıdaki analitik yardımcısının gövdesi hiç değişmedi, sadece önceki yardımcı fonksiyondan bu yeni reducer’ın initializer’ına kopyaladım.
Ve hepsi bu, tüm AppInfo özelliği TCA 1.0’a taşındı. Genel dosya yapısı artık 7 yerine sadece 3 dosyayla şöyle görünüyor:

Dosyanın bir alt türün ana kısmını içerdiğini belirtmek için ayırıcı olarak * kullandığımı not et. Doğal olarak . ayırıcı kullanarak adın AppInfoFeature.Reducer.swift gibi okunmasını sağlayabilirdik. Ama .Reducer’daki .R harfi .swift’teki .s’den önce sıralandığı için, alt tür dosyaları ana özellik dosyası AppInfoFeature.swift’in üzerinde görünürdü — bu yüzden noktaya benzeyen ama .’den daha düşük önceliğe sahip bir ayırıcı tercih ettim, bu da *’a yol açtı.
Tercih ettiğim SwiftLint kuralı file_name bu adlandırma tarzıyla bana bir uyarı gösterdi. Ama yapılandırma dosyasına şunu ekleyerek kolayca düzeltebildim:
file_name:
nested_type_separator: '*'Sonuç
Orta büyüklükteki uygulamamı yeni TCA 1.0 tarzı API’lere taşımak çok fazla iş oldu, ama çoğu dosya yapılarını kurmak, bul ve değiştir yapmak ve mevcut kodu başka yerlere taşımaktı. Ve zamanımın önemli bir kısmını beğendiğim iyi bir yapı bulmaya harcadım. Öğrendiklerimle başka bir uygulama için tekrar yapmam gerekseydi, muhtemelen 5 yerine 2-3 günde bitirirdim.
Sadece birkaç yerde gerçekten kod ayarlamam gerekti, çoğunlukla reducer’lardaki Combine tarzı efekt kodunu async-await tarzına taşırken. Ama harika dokümantasyon ve uyarılar sayesinde ne yapılacağı her zaman oldukça açıktı. Benzer bir geçiş yapan herkes için, en faydalı bulduğum 3 bağlantı:
Ayrıca, TCA 1.0‘daki ilerlemelerin iyi bir genel görünümünü veren tek bir bölüm varsa, o da Composable Navigation’ı başlatan bölüm #222’nin ilk ~35 dakikası. Geçen yıl işlerin nasıl değiştiğini hızlıca anlamak için izle.

