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

SwiftUI in an Async World

Avatar for Pierluigi Cifani Pierluigi Cifani
February 16, 2024
48

SwiftUI in an Async World

Avatar for Pierluigi Cifani

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!