Slide 1

Slide 1 text

No content

Slide 2

Slide 2 text

MAKING MEETUP

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

THE HISTORY

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

MEETUP 6.0

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

THE GOAL COMPLETELY REDESIGN THE IOS EXPERIENCE OF MEETUP

Slide 9

Slide 9 text

REWRITE OR RE-SKIN?

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

HOW LONG?

Slide 12

Slide 12 text

HOW LONG? LONGER THAN WE THOUGHT

Slide 13

Slide 13 text

HOW LONG? LONGER THAN WE THOUGHT A FEW TIMES

Slide 14

Slide 14 text

WAS IT THE RIGHT CALL? WE THINK SO.

Slide 15

Slide 15 text

THE CODE

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

USE LIBRARIES BUT ISOLATE THEM

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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)

Slide 21

Slide 21 text

TYPES MANY SMALL TYPES THAT DO FEW THINGS

Slide 22

Slide 22 text

MODEL HOLDS DATA AND MODELS BUSINESS LOGIC

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

REQUEST DESCRIBES AN API REQUEST IN TERMS OF THE DOMAIN

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

UPDATER MAKES A NETWORK REQUEST, PERSISTS INFORMATION, CALLS COMPLETION

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

PARSER TRANSFORMS JSON INTO MODELS

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

VIEW MODEL MODELS ONLY THE VIEW NEEDS TO DISPLAY AND FORMAT

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

DATA SOURCE TABLE AND COLLECTION VIEW DATA SOURCES AS SEPARATE OBJECTS

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

CELL CREATOR TAKES MODELS AND PRODUCES CELLS FOR THE DATA SOURCE

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

AND FINALLY...

Slide 38

Slide 38 text

YOU CAN HIRE CAN HIRE YOU

Slide 39

Slide 39 text

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