Upgrade to Pro — share decks privately, control downloads, hide ads and more …

SwiftUI in an Async World

Pierluigi Cifani
February 16, 2024
30

SwiftUI in an Async World

Pierluigi Cifani

February 16, 2024
Tweet

Transcript

  1. UIKit is not going anywhere • UIKit is one of

    the most successful UI framework in the history of software. • Incredibly versatile, scaling from the iPhone to the Mac. • Is the backbone of iOS and will be for many years to come.
  2. • We have a huge investment in UIKit infrastructure •

    This is what has allowed us to build complex apps with a relatively small team. • A rewrite is unrealistic. UIKit is not going anywhere
  3. SwiftUI Transition goals • Better interoperability with Swift Concurrency. •

    Better developer experience. • Better user experience. • Do not reinvent the wheel!
  4. • Most of “Data” or “State” is remote. • It's

    behind some kind of HTTP call • This should be first class citizen. • No unnecessary optionals! SwiftUI Transition goals 􀽑 􀟜
  5. Let ’ s build something! • A View that displays

    a List of Books. • The data is in a remote web server. • Loading, Error and List mode should be available.
  6. @MainActor class DataModel: ObservableObject { @Published var isLoading: Bool @Published

    var error: Swift.Error? @Published var books: [Book] init(isLoading: Bool = false, error: Error? = nil, books: [Book] = []) { } }
  7. struct BookList: View { @ObservedObject var dataModel: DataModel var body:

    some View { if dataModel.isLoading { ProgressView() } else if let error = dataModel.error { ErrorView(error: error) } else { List(dataModel.books, id: \.id) { book in VStack { Text(book.title) .foregroundStyle(.primary) Text(book.author) .foregroundStyle(.secondary) } } } } }
  8. extension DataModel { func populateData() async { self.isLoading = true

    let request: URLRequest = ... do { let response = try await URLSession.shared.data(for: request).0 let books = try JSONDecoder().decode([Book].self, from: response) self.books = books } catch { self.error = error } self.isLoading = false } }
  9. struct BookList: View { var body: some View { {

    ... } .task { await dataModel.populateData() } } }
  10. Let ’ s look back Several code smells • The

    if {} else if {} else {} branch in the view's body will sure become more and more complex as we add features to this view. • Cyclomatic Complexity will spiral out of control • Could be improved with an enum but the core issue remains. • Previews will hit the actual network. • These should be treated as Unit Tests: should work regardless of the network status.
  11. Async Initialization The first breakthrough • The first breakthrough in

    the way we architecture SwiftUI apps was that we could add two initializers to our DataModel objects: • An async initalizer for when the view is run regularly. • A sync initializer when the view is Previewed
  12. extension DataModel { init(urlSession: URLSession) async throws { self.books =

    try await urlSession.fetchBooks() } init(books: [Book]) { self.books = books } }
  13. Async Initialization What have we achieved with this? • We

    can remove those pesky properties: • var isLoading: Bool • var error: Error? • We are instead using the Swift way: async throws. • SwiftUI Previews work now by using the sync initialiser. • If Previews don ’ t work, it ’ s not SwiftUI!
  14. Async Initialization What have we achieved with this? • But

    how do we use this new initialiser from production code? • We can ’ t set the @ObservedObject of a view asynchronously • Enter AsyncView
  15. struct BookListAsync: View { let urlSession: URLSession @State var loadingPhase

    = LoadingPhase.loading enum LoadingPhase { case loading case loaded(DataModel) case failed(Swift.Error) } var body: some View { contentView .task { self.loadingPhase = .loading do { let dataModel = try await DataModel(urlSession: urlSession) self.loadingPhase = .loaded(dataModel) } catch { self.loadingPhase = .failed(error) } } }
  16. @MainActor @ViewBuilder private var contentView: some View { switch loadingPhase

    { case .loading: ProgressView() case .failed(let error): ErrorView(error: error) case .loaded(let dataModel): BookList(dataModel: dataModel) } }
  17. struct BookList: View { @ObservedObject var dataModel: DataModel var body:

    some View { List(dataModel.books, id: \.id) { book in VStack(alignment: .leading) { Text(book.title) .foregroundStyle(.primary) Text(ListFormatter.localizedString(byJoining: book.authors)) .foregroundStyle(.secondary) } } } }
  18. What have we gained? • The BookList is only created

    when there is actual data to show. • It made no semantical sense for it to be holding an empty array • Reduced complexity • For example, any error that occurs after BookList is on screen is handled (marking a book as read for example) is handled in a different context
  19. What have we gained? • Automatic cancellation! • By using

    .task {}, if the user navigates away, the Task is automatically canceled
  20. How can we make it better? • BookListAsync is still

    tightly coupled to BookList and it ’ s DataModel. • Options to make it reusable are: • Use Swift Macros to auto-generate the code • Use Swift Generics to inject the View.
  21. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } }
  22. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } id: A String to the identify this view.
  23. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } dataGenerator: The function that generates the data that is required for your HostedView
  24. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } hostedViewGenerator: The function that creates the HostedView
  25. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } errorViewGenerator: The function that creates the ErrorView
  26. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } loadingViewGenerator: The function that creates the LoadingView
  27. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } } The actual dependencies of the View 👻
  28. AsyncView struct Async: View { let urlSession: URLSession var body:

    some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) }, errorViewGenerator: { error, onRetry in ErrorView(error: error) }, loadingViewGenerator: { ProgressView() }) } }
  29. But there ’ s more Skeleton loading /// A protocol

    that de fi nes types that return placeholder data to be used for Previews or loading states. public protocol PlaceholderDataProvider { associatedtype Data static func generatePlaceholderData() -> Data }
  30. But there ’ s more Skeleton loading /// A protocol

    that de fi nes types that return placeholder data to be used for Previews or loading states. public protocol PlaceholderDataProvider { associatedtype Data static func generatePlaceholderData() -> Data } extension BookList: PlaceholderDataProvider { static func generatePlaceholderData() -> DataModel { .init(books: [Book(…), Book(…), …]) } }
  31. But there ’ s more Skeleton loading struct Async: View

    { let urlSession: URLSession var body: some View { AsyncView( id: "book-list", dataGenerator: { try await DataModel(urlSession: urlSession) }, hostedViewGenerator: { BookList(dataModel: $0) } ) } }
  32. Async Actions • Actions that happen after the data is

    loaded can also be async • For example, marking a book as read or subscribing to a Reading Club
  33. Takeaways • These abstractions do not work against SwiftUI. •

    Do not reinvent the wheel! • Lightweight. • Dramatically improved Developer Experience • Deterministic Previews help a lot!
  34. Takeaways • How to inject dependencies is up to the

    developer • @EnvironmentObject is fine • Interoperability with UIKit is done via UIHostingController. • Rewrites of specific screens have drastically reduced the codebase size. • You can always “drop down” to UIKit if SwiftUI doesn ’ t provide what you need.
  35. Takeaways • As engineers, we should focus on delivering value

    to our customers. Always. • Make the right decision for you and your team after carefully assessing. • Keep iteration cycles short!