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

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.

Matthew Bischoff

November 15, 2016
Tweet

More Decks by Matthew Bischoff

Other Decks in Technology

Transcript

  1. View Slide

  2. MAKING MEETUP

    View Slide

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

    View Slide

  4. THE HISTORY

    View Slide

  5. View Slide

  6. MEETUP 6.0

    View Slide

  7. View Slide

  8. THE GOAL
    COMPLETELY REDESIGN THE IOS
    EXPERIENCE OF MEETUP

    View Slide

  9. REWRITE OR
    RE-SKIN?

    View Slide

  10. WHY REWRITE?
    > Legacy Objective-C codebase
    > Completely new design
    > New APIs (v 3.0)
    > New team
    > Swift

    View Slide

  11. HOW LONG?

    View Slide

  12. HOW LONG?
    LONGER THAN WE
    THOUGHT

    View Slide

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

    View Slide

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

    View Slide

  15. THE CODE

    View Slide

  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
    -------------------------------------------------------------------------------

    View Slide

  17. USE LIBRARIES
    BUT ISOLATE
    THEM

    View Slide

  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

    View Slide

  19. KEY DEPENDENCIES
    > Persistence
    > RealmSwift
    > Networking
    > Alamofire
    > JSON Parsing
    > SwiftyJSON

    View Slide

  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)

    View Slide

  21. TYPES
    MANY SMALL TYPES THAT DO FEW THINGS

    View Slide

  22. MODEL
    HOLDS DATA AND MODELS BUSINESS LOGIC

    View Slide

  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 {

    }

    View Slide

  24. REQUEST
    DESCRIBES AN API REQUEST IN TERMS OF THE DOMAIN

    View Slide

  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()
    }
    }
    }

    View Slide

  26. UPDATER
    MAKES A NETWORK REQUEST, PERSISTS INFORMATION, CALLS COMPLETION

    View Slide

  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) -> Void) {
    let request = LocationRequest.Country(bearerToken: bearerToken)
    NetworkController.request(request).responseObject { (request, response, result: Result) in
    completion(result: result)
    }
    }
    private func performLocationRequest(locationRequest: LocationRequest, completion: Completion) {
    NetworkController.request(locationRequest).responseObject { (_, _, result: Result<[Location]>) in
    completion(result: result)
    }
    }
    }

    View Slide

  28. PARSER
    TRANSFORMS JSON INTO MODELS

    View Slide

  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
    }

    View Slide

  30. VIEW MODEL
    MODELS ONLY THE VIEW NEEDS TO DISPLAY AND FORMAT

    View Slide

  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
    }

    View Slide

  32. DATA SOURCE
    TABLE AND COLLECTION VIEW DATA SOURCES AS SEPARATE OBJECTS

    View Slide

  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?
    }

    View Slide

  34. CELL CREATOR
    TAKES MODELS AND PRODUCES CELLS FOR THE DATA SOURCE

    View Slide

  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 { … }
    }

    View Slide

  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

    View Slide

  37. AND FINALLY...

    View Slide

  38. YOU CAN HIRE
    CAN HIRE YOU

    View Slide

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

    View Slide