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

PlanGrid iOS Refactor Presentation

PlanGrid iOS Refactor Presentation

Internal Presentation discussing iOS Refactor project.

Benjamin Encz

April 06, 2016
Tweet

More Decks by Benjamin Encz

Other Decks in Programming

Transcript

  1. Warning: The Slides Contain Larger Code Examples & Handwri;ng Download

    Slides Here: bit.ly/pg-ios-refactor iOS Rewrite | @benjaminencz | PlanGrid, April 2016 1
  2. iOS Refactor Rewrite Goals - Concepts and Tools - Usage

    iOS Rewrite | @benjaminencz | PlanGrid, April 2016 2
  3. Goals Move Fast & Don't Break Too Many Things -

    Comprehensible Code Base - Less Code - Test Coverage - Best Prac9ces for Faster Onboarding Performance - Provide Fast Defaults (Infrastructure) - Make Bad Choices Hard iOS Rewrite | @benjaminencz | PlanGrid, April 2016 3
  4. Part 1: UI & UI State Part 2: Persistence Part

    3: Sync iOS Rewrite | @benjaminencz | PlanGrid, April 2016 7
  5. Part 1: UI & UI State Flux + Reac+ve View

    Layer iOS Rewrite | @benjaminencz | PlanGrid, April 2016 8
  6. Store • Most new code for a feature lives in

    here • Defines State for a scene • Updates State when Actions come in • Exposes Signal for UI layer to observe state • Communicates with ObjectHaven to read & write data iOS Rewrite | @benjaminencz | PlanGrid, April 2016 13
  7. Explicit State as Data public struct TeamManagementState { /// Alphabetically

    sorted list of collaborators and admins on the project public var users: [TeamMember] public var selectedUsers: [TeamMember] public var selectionEnabled: Bool = false public var multiselectEnabled: Bool = false public var invitationsEnabled: Bool = false } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 14
  8. Ac#ons as Data struct TeamManagementActions { ///Action that asks the

    store to filter users based on provided search term struct SearchTeamMembers: ProjectSpecificAction { let projectUid: String let searchTerm: String let cursorPosition: Int } /// Action that asks the store to invite a team member struct InviteTeamMember: ProjectSpecificAction { let projectUid: String let userEmail: String } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 15
  9. Store Changes State in Response to Ac2ons func _handleActions(action: AnyAction)

    { switch action { // ... case let inviteAction as TeamManagementActions.InviteTeamMember: self._inviteTeamMember(inviteAction.userEmail) case let selectAction as TeamManagementActions.SelectTeamMember: self._selectTeamMember(selectAction.userEmail) // ... } } private func _inviteTeamMember(email: String) { let userExists = self._state.value.users.contains { $0.email == email } if !userExists { let newMember = TeamMember(email:email) self._state.value.insertUser(newMember) dispatch_background_high({ self._digest.userProvider.addCollaborator(email) }) } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 16
  10. View Layer Observes Signal and Updates Itself // The edit

    button is enabled when the state allows purging self.navigationItem.rightBarButtonItem!.racEnabled <~ self.store.state .map { $0.isPurgable } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 18
  11. Ideal World (aka React): func render(state: State) -> UI iOS

    Rewrite | @benjaminencz | PlanGrid, April 2016 20
  12. Real World: • UIKit cannot be updated based on state

    • All state updates need to be incremental • This is the root cause of much horrible UI code; all state lives in UIViewController, all state is implicitly part of the UI components itself • ReactiveCocoa is our bridge from the ideal world to the real world iOS Rewrite | @benjaminencz | PlanGrid, April 2016 21
  13. Building Reac-ve Bridges: // The edit button is enabled when

    the state allows purging self.navigationItem.rightBarButtonItem!.racEnabled <~ self.store.state .map { $0.isPurgable } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 22
  14. Building Reac-ve Bridges: // When the download/purge button gets enabled/disabled,

    reload the cell // so that the table view changes its appearance correctly self.projectStorageTableViewDataSource.tableViewModel.producer .ignoreNil() .skip(1) .map { ($0[NSIndexPath(forRow: 0, inSection: 2)] as? CenteredLabelCellViewModel)?.selectionStyle } .ignoreNil() .skipRepeats() .startWithNext { [weak self] _ in guard let `self` = self else { return } self.tableView.reloadRowsAtIndexPaths( [NSIndexPath(forRow: 0, inSection: 2)], withRowAnimation: .None ) } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 23
  15. Store Summary • Store defines State for a scene •

    Store updates State when Actions come in • Store exposes Signal for UI layer to observe state • Store communicates with ObjectHaven to read & write data iOS Rewrite | @benjaminencz | PlanGrid, April 2016 24
  16. ViewProvider • Take State from store • Returns view models

    • Our React-like layer • Pure Func:on: func tableViewModelForState(state: ProjectStorageState) -> FluxTableViewModel iOS Rewrite | @benjaminencz | PlanGrid, April 2016 27
  17. ViewProvider: View Code as Data /// Generates `FluxTableViewModel` that represent

    the current `ProjectStorageState` func tableViewModelForState(state: ProjectStorageState) -> FluxTableViewModel { return FluxTableViewModel(sectionModels: [ FluxTableViewModel.SectionModel(headerTitle: "project.sheets".translate(), headerHeight: 50, cellViewModels: [ self._cellViewModelForCategoryDownloadState( state.currentSet, viewState: state.viewState, initialized: state.downloadStateInitialized ), self._cellViewModelForCategoryDownloadState( state.pastSet, viewState: state.viewState, initialized: state.downloadStateInitialized ) ]), FluxTableViewModel.SectionModel(headerTitle: nil, headerHeight: 20, cellViewModels: [ state.viewState == .EditMode ? self._purgeCellViewModelForState(state) : self._downloadCellViewModelForState(state) ]) ]) iOS Rewrite | @benjaminencz | PlanGrid, April 2016 28
  18. UI Layer in Detail ViewModel to UIKit Transla3on iOS Rewrite

    | @benjaminencz | PlanGrid, April 2016 29
  19. ViewModel to UIKit Transla3on • Takes view model and turns

    it into UIKit components / proper9es • Simpler bindings are implemented manually; complex bindings are done via components (e.g. FluxTableViewModel) // The edit button is enabled when the state allows purging self.navigationItem.rightBarButtonItem!.racEnabled <~ self.viewModel .editingEnabled iOS Rewrite | @benjaminencz | PlanGrid, April 2016 30
  20. ObjectHaven • We iden(fied that our persistence code is highly

    redundant • Wanted to share persistence code among all types • Provide hooks for customiza(on • Now types only need to adopt ObjectHavenElement protocol iOS Rewrite | @benjaminencz | PlanGrid, April 2016 32
  21. Making a Type Persistable public struct ReportTemplatesTable: SqlTable { public

    static let name = "report_templates" public static let primaryKey = PrimaryKey(Columns.uid) public struct Columns { public static let uid = Column<String>("uid") public static let reportType = Column<String>("report_type") public static let filename = Column<String>("filename") public static let size = Column<Int>("size") public static let urlFull = OptionalColumn<NSURL>("url_full") public static let urlThumbnail = OptionalColumn<NSURL>("url_thumbnail") } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 34
  22. Making a Type Persistable public final class PGReportTemplateModel: NSObject, ProjectSpecificModel,

    UIDIdentifiable { public convenience init?(resultSet: FMResultSet) { guard let project = resultSet.project else { return nil } let columns = ReportTemplatesTable.Columns.self let file = PGReportFileJsonModel( uid: columns.uid.value(resultSet), filename: columns.filename.value(resultSet), size: columns.size.value(resultSet) ) public static var table: SqlTable.Type { return ReportTemplatesTable.self } public var columnRepresentation: [(SqlColumn, SqlTypeConvertible?)] { let columns = ReportTemplatesTable.Columns.self return [ (columns.uid, self.uid), (columns.filename, self.file.filename), (columns.size, self.file.size), (columns.urlFull, self.file.urlFull), (columns.urlThumbnail, self.file.urlThumbnail), (columns.reportType, self.reportTypeUid) ] } public var primaryKeyValue: String { return self.file.uid } } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 35
  23. Making a Type Persistable • A#er adoping protocols all basic

    persistence features are available to that type: • Saving, dele;ng, querying, reference coun;ng, asset management, change tracking, etc. • Bonus: Free Test Coverage through our Generic Test Suite describe("Test Object Havens") { testHaven(FieldReportTemplateTestCase.self) testHaven(FieldReportTypeTestCase.self) testHaven(FieldReportFileTestCase.self) testHaven(FieldReportTestCase.self) } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 36
  24. Sync • Same idea as ObjectHaven: Code is provided as

    infrastructure • Adop6ng types just configure that code iOS Rewrite | @benjaminencz | PlanGrid, April 2016 40
  25. Download Sync: DownloadSyncable public protocol DownloadSyncable { /// Uniquely identifies

    saved `SyncRequestState` in the database. static var requestStateKey: String { get } /// Defines API endpoint from which to sync this entity static func apiEndpoint() -> NSURLComponents /// Creates the first in a potential chain of paged requests given the previously saved parameters. /// - note: For requests like `ProjectDigest` that don't use paging parameters will be `nil`. static func startingRequest(state: SyncRequestState?) -> SyncRequest? /// Process the server's response to a request. /// - returns: The list of changes and optionally a request if there is another paginated request. /// The parameters on the returned request will be saved as the "previous parameters". static func processServerResponse(response: SyncResponse, request: SyncRequest, state: SyncRequestState?) -> (changes: [ModelChange], newState: SyncRequestState?, nextRequest: SyncRequest?) } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 42
  26. Download Sync: DownloadSyncable Types that adopt the protocol get the

    following for free: • Network Requests for described endpoints • Pagina8on • Persistence of request state in DB (for next sync) • Persistence of downloaded data in ObjectHaven or other PersistenceProvider iOS Rewrite | @benjaminencz | PlanGrid, April 2016 43
  27. Download Sync Example: Conten4ul API extension AnnouncementEntry: DownloadSyncable { public

    static var requestStateKey: String { return "ContentfulContentRequestKey" } public static func apiEndpoint() -> NSURLComponents { return NSURLComponents(string: "spaces/\(Announcement.space.uid.value)/sync")! } public static func startingRequest(state: SyncRequestState?) -> SyncRequest? { let urlComponents = self.apiEndpoint() if let syncedState = state { urlComponents.queryItems = [NSURLQueryItem(name: "sync_token", value: syncedState.payload["sync_token"].string)] } else { let params = [ "initial": "true", ] urlComponents.queryItems = params.map { NSURLQueryItem(name: $0, value: $1) } } return SyncRequest(relativeUrl:urlComponents, headers:[:]) } // ... } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 44
  28. Download Sync Example: Conten4ul API Kicking synchroniza/on off: public static

    func syncAnnouncements(db: GlobalDatabase = _defaultDb) { let persistenceProvider = AnnouncementsSyncPersistenceProvider( globalDB: db ) let syncManager = SyncManager( persistenceProvider: persistenceProvider, HTTPAuthenticationHeader: ["Authorization": "Bearer \(ContentfulManager._authToken)"], baseURL: ContentfulManager._contentfulBaseURL ) syncManager.sync([[AnnouncementEntry.self]]) { result in if result == .Failure { DDLogError("contentful sync failed!") } } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 45
  29. Sync in Detail: Upload Sync • Wanted to conceptualize the

    modifica3on of local data to reduce redundant code, reduce room for error and enable new sync features, e.g. rollback of changes iOS Rewrite | @benjaminencz | PlanGrid, April 2016 47
  30. Sync in Detail: Upload Sync Opera3ons • Objects can't be

    mutated directly anymore instead changes are modelled as MutationOperation • MutationOperation describes a change and provides relevant data for push request • CreationOperation and DeletionOperation are created implicitly by ObjectHaven iOS Rewrite | @benjaminencz | PlanGrid, April 2016 48
  31. Upload Sync: Muta0on Opera0ons let exampleModel = PGExampleObjectHavenModel() exampleModel =

    exampleModel.mutate(PGExampleObjectHavenModelChangeType("New Test Type")) objectHaven.saveLocalChanges(exampleModel) iOS Rewrite | @benjaminencz | PlanGrid, April 2016 49
  32. Sync in Detail: Rollbacks • Will provide customiza3on hooks; but

    by default rollbacks will be implemented by common infrastructure • Note: v1 will only do complete rollback of an object (a@er first failed opera3on). v2 will support demonstrated par3al rollback. iOS Rewrite | @benjaminencz | PlanGrid, April 2016 55
  33. describe("Storing the initial server version of an entity") { context("When

    the ObjectHaven saves an entity via `saveFromServer`") { beforeEach { OperationDeserializerRegistry.operationTypes.append(PGExampleObjectHavenModelChangeType.self) haven = ObjectHaven() try! haven.database.context.executeUpdate(PGExampleObjectHavenModel.createTable()) operationDAO = SyncOperationDAO(database: haven.database) exampleModel = PGExampleObjectHavenModel() try! haven.saveFromServer(exampleModel) } it("stores a JSON blob that represents the object in the database") { let serverVersion = try haven.latestServerVersion(exampleModel.uid) expect(serverVersion).to(equal(exampleModel)) } context("when a deletion operation is performed") { beforeEach { try! haven.removeLocally(exampleModel) } it("removes the object from the local db") { let object = haven[exampleModel.uid] expect(object).to(beNil()) } } } iOS Rewrite | @benjaminencz | PlanGrid, April 2016 57