Pro Yearly is on sale from $80 to $50! »

Making Meetup 6.0

Making Meetup 6.0

This month, Paul Bruneau and Matthew Bischoff will talk about "Making Meetup 6.0". They'll walk us through the architecture underlying the new Meetup iOS app, rewritten entirely in Swift, as well as how they employed view models, protocols, generics, unit tests, and other patterns to build an app that's fun to work on and easy to change.

23ad4e437c3ece1b7a04696321036c30?s=128

Matthew Bischoff

November 15, 2016
Tweet

Transcript

  1. None
  2. MAKING MEETUP

  3. Matthew Bischoff (@mb) • Lickability Paul Bruneau (@ethicalpaul) • Meetup

  4. THE HISTORY

  5. None
  6. MEETUP 6.0

  7. None
  8. THE GOAL COMPLETELY REDESIGN THE IOS EXPERIENCE OF MEETUP

  9. REWRITE OR RE-SKIN?

  10. WHY REWRITE? > Legacy Objective-C codebase > Completely new design

    > New APIs (v 3.0) > New team > Swift
  11. HOW LONG?

  12. HOW LONG? LONGER THAN WE THOUGHT

  13. HOW LONG? LONGER THAN WE THOUGHT A FEW TIMES

  14. WAS IT THE RIGHT CALL? WE THINK SO.

  15. THE CODE

  16. MacBookRetina:Meetup mb$ cloc . 1250 text files. 1202 unique files.

    339 files ignored. http://cloc.sourceforge.net v 1.64 T=4.36 s (208.9 files/s, 21111.0 lines/s) ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- Swift 771 20479 20124 48891 JSON 134 0 0 2194 Objective C 2 46 15 137 C/C++ Header 4 61 74 59 ------------------------------------------------------------------------------- SUM: 911 20586 20213 51281 -------------------------------------------------------------------------------
  17. USE LIBRARIES BUT ISOLATE THEM

  18. platform :ios, '9.0' source 'https://github.com/CocoaPods/Specs.git' use_frameworks! target 'Meetup' do pod

    'RealmSwift', '~> 1.1.0' pod 'Alamofire', '~> 3.5.0' pod 'SocketRocket', '~> 0.5.0' pod 'SwiftyJSON', :git => 'https://github.com/Navdy/SwiftyJSON.git', :branch => 'xcode8b6' pod 'BuddyBuildSDK', '~> 1.0.12' pod 'RMStore', '~> 0.7' pod 'Static', git: 'https://github.com/venmo/Static.git' pod 'CLTokenInputView', '~> 2.3.0' pod 'KeychainAccess', '~> 2.4.0' pod 'XLForm', '~> 3.1.1' pod '1PasswordExtension', '~> 1.8' pod 'FormatterKit/TimeIntervalFormatter', '~> 1.8' pod 'Nuke', '~> 3.2.0' pod 'KVOController', '~> 1.1.0' pod 'BRYHTMLParser', '~> 2.1.0' pod 'AcknowList', :git => 'https://github.com/vtourraine/AcknowList.git', :branch => 'swift-2-3' pod 'ReachabilitySwift', '~> 2.4.0' pod 'NSAttributedString+CCLFormat', '~> 1.2.0' pod 'NYTPhotoViewer/Core', '~> 1.1.0' pod 'RSKGrowingTextView', '~> 1.2.0' pod 'Google/SignIn', '~> 2.0.3' pod 'FBSDKLoginKit', '~> 4.16' pod 'UIColor_Hex_Swift', :git => 'https://github.com/yeahdongcn/UIColor-Hex-Swift.git', :branch => 'Swift-2.3' pod 'DeepLinkKit', '~> 1.2.1' target 'MeetupTests' do inherit! :search_paths pod 'OHHTTPStubs', '~> 5.0.0' end end
  19. KEY DEPENDENCIES > Persistence > RealmSwift > Networking > Alamofire

    > JSON Parsing > SwiftyJSON
  20. PATTERNS > Storyboards, size classes, stack views, auto layout >

    private and final by default > Document anything internal or public > Protocols define interfaces > Dependencies are injected > Logic is unit tested (1403 tests)
  21. TYPES MANY SMALL TYPES THAT DO FEW THINGS

  22. MODEL HOLDS DATA AND MODELS BUSINESS LOGIC

  23. /// Model representing a location where Meetups may take place.

    final class Location: Object { // MARK: - Public API /// The name of the city. dynamic var name = "" /// The full name of the city, as returned by query search, if applicable. dynamic var fullName: String? // MARK: - LocationGenerating var city: String { return name } /// The state which contains the city, if applicable. dynamic var state = "" /// The ISO_3166-1 country code for the country which contains the city. dynamic var country = "" /// Localized name of country based on request's language information. dynamic var localizedCountryName: String? /// The latitude of the city. dynamic var latitude: Double = kCLLocationCoordinate2DInvalid.latitude /// The longitude of the city. dynamic var longitude: Double = kCLLocationCoordinate2DInvalid.longitude /// The zip code of the city. For cities in countries without ZIP codes, a placeholder will be returned. dynamic var zipCode = "" } extension Location: NSCoding { … }
  24. REQUEST DESCRIBES AN API REQUEST IN TERMS OF THE DOMAIN

  25. /// An enum encapsulating types of location requests. enum LocationRequest:

    MeetupRequest { /// Request locations nearby a specified set of coordinates. case ByCoordinate(coordinate: CLLocationCoordinate2D, bearerToken: String?, pageSize: Int) /// Request locations corresponding to a queried city name or zip code. case ByQuery(query: String, bearerToken: String?) /// Request the authenticated or pre-registation user's current country. case Country(bearerToken: String?) // MARK: - MeetupRequest var path: String? { switch self { case .ByCoordinate, .ByQuery: return "find/locations" case .Country: return "members/self" } } var parameters: [String: AnyObject]? { switch self { case let ByCoordinate(coordinate, _, pageSize): return ["lat": coordinate.latitude, "lon": coordinate.longitude, "page": pageSize] case let ByQuery(query, _): return ["query": query] case .Country: return ["only": "country"] } } var URLRequest: NSURLRequest { if let bearerToken = bearerToken { return defaultRequestWithPreRegistrationAuthorizationHeader(bearerToken: bearerToken) } else { return defaultURLRequest() } } }
  26. UPDATER MAKES A NETWORK REQUEST, PERSISTS INFORMATION, CALLS COMPLETION

  27. /// Downloads objects related to nearby location requests. final class

    LocationUpdater { typealias Completion = (result: Result<[Location]>) -> Void /// The bearer token used to create authenticated requests. private let bearerToken: String? init(bearerToken: String? = nil) { self.bearerToken = bearerToken } func fetchLocationsNearby(coordinate: CLLocationCoordinate2D, pageSize: Int = 10, completion: Completion) { let request = LocationRequest.ByCoordinate(coordinate: coordinate, bearerToken: bearerToken, pageSize: pageSize) performLocationRequest(request, completion: completion) } func fetchLocationsMatchingQuery(query: String, completion: Completion) { let request = LocationRequest.ByQuery(query: query, bearerToken: bearerToken) performLocationRequest(request, completion: completion) } func fetchCountry(completion: (result: Result<Location>) -> Void) { let request = LocationRequest.Country(bearerToken: bearerToken) NetworkController.request(request).responseObject { (request, response, result: Result<Location>) in completion(result: result) } } private func performLocationRequest(locationRequest: LocationRequest, completion: Completion) { NetworkController.request(locationRequest).responseObject { (_, _, result: Result<[Location]>) in completion(result: result) } } }
  28. PARSER TRANSFORMS JSON INTO MODELS

  29. /// Parses a `Location` object from a JSON dictionary, or

    `nil` if no identifier is found. struct LocationParser: Parser { static func parse(dictionaryRepresentation: [String : AnyObject]) -> Location? { let JSONObject = JSON(dictionaryRepresentation) let location = Location() location.name = JSONObject["city"].stringValue location.fullName = JSONObject["name_string"].string location.localizedCountryName = JSONObject["localized_country_name"].string location.state = JSONObject["state"].stringValue location.country = JSONObject["country"].stringValue location.zipCode = JSONObject["zip"].stringValue if let latitude = JSONObject["lat"].double, longitude = JSONObject["lon"].double { location.latitude = latitude location.longitude = longitude } return location } } extension Location: Parseable { typealias ParserType = LocationParser }
  30. VIEW MODEL MODELS ONLY THE VIEW NEEDS TO DISPLAY AND

    FORMAT
  31. /// The view model containing all view-related data necessary to

    lay out an `LocationCell`. struct ViewModel { /// The configuration style for the cell based on a specified location status. enum LocationStatus { /// Cell configuration for an unknown location. case Unknown case Known(location: Location?, style: DisplayStyle) private var title: String { switch self { case .Unknown: return NSLocalizedString("Current location", comment: "…") case let .Known(_, style): return style.title } } private var image: UIImage { switch self { case .Unknown: return UIImage(named: "LocationServices")! case let .Known(_, style): return style.image } } } /// The location status for which to configure the cell. let locationStatus: LocationStatus }
  32. DATA SOURCE TABLE AND COLLECTION VIEW DATA SOURCES AS SEPARATE

    OBJECTS
  33. /// A data source object that acts as the table

    view data source for nearby locations, or optionally a loading interface. final class LocationDataSource : NSObject, UITableViewDataSource { /// An enum representing the table section. Do not initialize directly. Instead, call `sectionForSectionIndex`. enum Section { /// The section when a loading indicator is displayed. case Loading /// The section containing a cell displaying the user's current device location. case CurrentLocation(rows: [Meetup.LocationDataSource.CurrentLocationRow]) /// The section containing locations nearby the user's current location. case NearbyLocations(rows: [Meetup.LocationDataSource.LocationRow]) /// The number of rows in the section. var numberOfRows: Int { get } } typealias CellConfiguration = (tableView: UITableView, indexPath: NSIndexPath, section: Section, authorizationStatus: CLAuthorizationStatus?) -> UITableViewCell init(loading: Bool, cellConfiguration: CellConfiguration) func sectionForSectionIndex(index: Int) -> Meetup.LocationDataSource.Section? }
  34. CELL CREATOR TAKES MODELS AND PRODUCES CELLS FOR THE DATA

    SOURCE
  35. /// Sets up the table view and creates configured cells

    for location editing. final class LocationCellCreator { init(tableView: UITableView) { tableView.registerNibForClass(LoadingCell.self, reuseIdentifier: self.dynamicType.LoadingCellIdentifier) … } func cell(tableView: UITableView, indexPath: NSIndexPath, section: LocationDataSource.Section, authorizationStatus: CLAuthorizationStatus?) -> UITableViewCell { let cell: UITableViewCell? switch section { case .Loading: cell = loadingCell(tableView: tableView, indexPath: indexPath) } return cell } } // MARK: - Private methods private func currentLocationCell(tableView tableView: UITableView, indexPath: NSIndexPath, authorizationStatus: CLAuthorizationStatus?, location: Location?, isNearOtherLocations: Bool, isOnboarding: Bool) -> UITableViewCell { … } private func onboardingLocationCell(tableView tableView: UITableView, indexPath: NSIndexPath) -> UITableViewCell { … } }
  36. WHAT WE LEARNED > The best way to learn Swift

    is to write a lot of it > Lack of refactoring makes architecture more important > Doing all-swift removed inter-op challenges > Swift 2.2 -> 2.3 migration went smoothly > Compile times are still terrible
  37. AND FINALLY...

  38. YOU CAN HIRE CAN HIRE YOU

  39. THANK YOU WE’D LOVE TO ANSWER YOUR QUESTIONS — @MB

    AND @ETHICALPAUL