building a testable mixed-codebase ios framework · pdf filefrom localytics to google...
TRANSCRIPT
§
Building a testable mixed-codebase iOS Framework
Nikos MaounisiOS Developer @Psy2k
Christos KaraiskosiOS Developer @karaiskc
Outline
• Introduction and Motivation
• Distributing Libraries and Resources
• Design Considerations
• Testability
• Swift and ObjC Coexistence
Introduction
The App Store evolution
Introduction
The App Store evolution
Introduction
New Horizons
• Extend beyond the traditional app experience
• Quick interactions with your app throughout the OS and throughout different devices
IntroductionShared Code
LocationLogging
Networking
ReachabilityAnalytics Resources
LocationLogging
Networking
ReachabilityAnalytics Resources
Motivation
• Be at the edge of innovation, follow changes in the app and device landscape (widget, watch, notifications, Siri, Maps)
• Lay the foundations for the future, with focus on code reuse, testability and self-documentation for new team members
• Prepare for complete and safe transition to Swift, with unit-test assurance
Distributing Libraries and Resources
Static Libraries
• Names of the form libname.a • Archives of object files • Statically linked objects become part of executable: Increase in size • Separate distribution of headers and resources • Changes require re-linking • Swift does not support them
Dynamic Libraries
• Names of the form dynamicLibrary.dylib • Single copy of objects is shared among different applications • Symbol binding performed at runtime. Not being able to find and load a required dylib
will crash your app. • Not part of main executable: Static linker only makes note of the install names of
dylibs that are referenced by symbols in your app • Versioned: dylibs may be updated independently of their consumers (i.e., minor
version updates)
Frameworks
• Names of the form frameworkName.framework • Act as dylib wrappers, inherit all their characteristics • Bundle format, relocatable • Include dynamic library, headers, resources, versioning info • Resources include: NIB files, images, localised strings, etc.
User Frameworks in iOS
• Introduced in iOS 8, support Swift • The official way to package reusable code with resources, without requiring hacks (e.g. static frameworks)
• Due to sandbox restrictions, each application must embed its own copy (along with any further dylib/framework dependencies)
• Frameworks cannot be updated alone, even bumping the minor version requires resubmitting the whole application
• Conversely: They are easier to handle, with the guarantee that the correct version of the framework will run, new versions won’t break anything in previous versions
Design Considerations
Swift vs ObjC in Taxibeat
23.5% 76.5%
26.2% 73.8%
Github Linguist
Passenger
Driver
Our Approach
• Use both ObjC and Swift. Migrate to pure Swift when framework is stable and tested. New classes should be written in Swift.
• Focus on model part of MVC. Determine parts heavily shared among apps and extensions (e.g. location management, networking, logging)
• Treat Framework as a clean room: warnings as errors, unit tests, code reviews, design patterns, double-think before adding functionality
• Code organization. Different project, different repo, same workspace
• Don’t risk client stability. If migration of a class from client to framework is not straightforward, keep both so that nothing breaks and replace only when tests pass.
Project Organization
Extensions
• Check “Allow app extension API only”
• Compiler-enforced restriction on what you can include in framework
• Ensures that what you build can be used in extensions.
ld: warning: linking against a dylib which is not safe for use in application extensions
Architectural Differences
• A framework target can only be built for a single platform, and the respective supported architectures
Solution for deploying to watchOS or tvOS: • Separate framework target for each platform • Use Swift directives (e.g., #if os(watchOS)), different
platforms support different system API subsets
Testability in Mind
The Importance of Tests
• Confidence to refactor
• Test assisted design promotes good API design. Find flaws early and iterate.
• Modularity and loose coupling by definition
• Simulate edge scenarios using fake objects
• Dynamic documentation
Xcode Server Coverage Stats
We created an Xcode Server to “force” ourselves to write tests
Example 1: Unit Testing the Location Manager
Example 1: Unit Testing the Location Manager
• Need to simulate location and authorization scenarios • Override the need for depending on actual hardware
measurements (GPS, A/GPS) • Override the need for user input (Permissions etc)
Example 1: Unit Testing the Location Manager
• Continuous user tracking not always required throughout app lifecycle, battery drain
• iOS >= 9 provides requestLocation() using delegation
• Closure/block-based approach: ease of use, compact, code not scattered around
• Execute callback when sufficiently accurate location is acquired
@objc(TXBLocationManager) public class LocationManager: NSObject {
public func requestLocationOnce(completion: @escaping ((CLLocation) -> Void)) { // LocationManager saves closure, internally handles inaccurate locations, lack of authorization etc.
// when sufficiently accurate location is retrieved, closure is called } }
Example 1: Unit Testing the Location Manager
extension LocationManagerTests { public class MockRequestOnceSuccessLocationManager: CLLocationManager { override class func locationServicesEnabled() -> Bool { return true } override class func authorizationStatus() -> CLAuthorizationStatus { return .authorizedWhenInUse } override public func startUpdatingLocation() { delegate?.locationManager?(self, didUpdateLocations: [CLLocation(latitude: 10.0, longitude: 10.0)]) } } }
In order to unit test our LocationManager we need to “control” the CLLocationManager which is used internally
Step 1: Subclass CLLocationManager (or define a protocol with common functions)
Example 1: Unit Testing the Location Manager
locMgr = LocationManager(locationManagerType: MockRequestOnceSuccessLocationManager.self) // TEST
Step 2: Inject dependency to our LocationManager
init(locationManagerType:CLLocationManager.Type = CLLocationManager.self) { … }
Init for LocationManager
self.locationManagerType.authorizationStatus()
Step 3: Remove hardcoded references of CLLocationManager within LocationManager
CLLocationManager.authorizationStatus()
instead of
Create new instance:
Example 1: Unit Testing the Location Manager
Step 4: Write an asynchronous test
class LocationManagerTests: XCTestCase { var locMgr: LocationManager! var exp: XCTestExpectation!
func testRequestOnceAllowedWhenInUse() {
exp = self.expectation(description: "Request once callback") locMgr = LocationManager(locationManagerType: MockRequestOnceSuccessLocationManager.self) locMgr.requestLocationOnce(completion: { (location) in XCTAssertEqual(location.coordinate.latitude, 10.0) XCTAssertEqual(location.coordinate.longitude, 10.0) self.exp.fulfill() }) self.waitForExpectations(timeout: 2.0, handler: nil)
} }
Test case for successful scenario
Example 2: From Localytics to Google Analytics
• Be careful not to become too coupled with 3rd-party applications
• Unit testing revealed that as consumers we just want to log events
• Consumers don’t care if it’s Google Analytics or Localytics under the hood
- (IBAction)favoriteButtonPressed:(id)sender { [Localytics tagEvent:@"favoriteButtonPressed" attributes:@{……}]; // handle action }
- (IBAction)favoriteButtonPressed:(id)sender { id<GAITracker> tracker = [[GAI sharedInstance] defaultTracker]; [tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"someCategory" action:@"favoriteButtonPressed" label:@"someLabel" value:nil] build]]; }
Tight coupling with
3rd party libraries
Example 2: From Localytics to Google Analytics
• Create an abstraction layer, hide actual implementation details
• Migration transparent to consumer (besides the API key initializer)
• Changes required within single class, not scattered throughout project
+ (void)sendHitToProviderWithName:(NSString *)eventName andParameters:(NSDictionary *)params { [Localytics tagEvent:eventName attributes:params]; }
+ (void)sendHitToProviderWithName:(NSString *)eventName andParameters:(NSDictionary *)params { id<GAITracker> tracker = [[GAI sharedInstance] defaultTracker]; [tracker send:[[GAIDictionaryBuilder createEventWithCategory:@"ui_action" action:eventName label:@"app action" value:nil] build]]; }
- (IBAction)favoriteButtonPressed:(id)sender { [BKAnalyticsHelper sendHitToProviderWithName:@"favoriteButtonPressed" andParameters:@{}]; // handle action }
Swift and ObjC Coexistence
Exposing ObjC Symbols
• Umbrella header, named MyFramework.h • Exposes Objective-C/C classes as public • Similar to Bridging header of mixed-codebase app target
• Should include all the ObjC headers you want disclosed in your public API
• Be sure to mark the header file as Public in the inspector
Exposing Swift Symbols
• Use access control to restrict visibility to parts of your code from code in other source files and modules
• Explicitly mark classes and methods as public if you want them exposed (default is internal)
Namespaces
• Swift supports namespaces • ObjC does not. Convention is 3-character prefixing Solution for exposing Swift symbols:
Seen in ObjC as:
@objc(TXBLogger) public class Logger: NSObject {
public func logDebug(_ params:Any...) { // .... } }
SWIFT_CLASS_NAMED("Logger") @interface TXBLogger : NSObject + (void)logDebug; - (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER; @end
Pure Swift Features
• As long as there is ObjC we cannot fully take advantage of Swift’s powerful features
• We could not use optional primitives, associated value enums, structs, tuples, nested classes, default function params etc.
Solution: • Use pure Swift features only for internal Swift classes that do not
interact with ObjC. • If at some point they need to, write wrapper methods (e.g. to expose
a String enum to ObjC)
Delegation in Swift (1/2)
• Handling weak references in delegates
Solution(??): declare delegate var weak, as in ObjC
strong reference to A
Delegation in Swift (2/2)
Solution: Declare the protocol to inherit from class and property weak
delegate released when it goes out of method scope
Being Swifty
• Use guard to exit functions early • Use type inference • Use the trailing closure syntax • Follow case conventions. Names of types and protocols are
UpperCamelCase. Everything else is lowerCamelCase. • Default to structs unless you really need a class • Favor immutable variables
https://swift.org/documentation/api-design-guidelines/
Being Swifty Example 1
•Map reduce filter vs C-style loop NSArray<NSNumber *> *sampleArray = @[@1, @2, @3, @4, @5]; NSMutableArray *finalArray = [[NSMutableArray alloc] init]; for (NSInteger i = 0; i <= sampleArray.count; i++) { NSNumber *iDoubled = [NSNumber numberWithInteger: sampleArray[i].integerValue * 2]; [finalArray addObject:iDoubled]; }
let sampleArray = [1, 2, 3, 4, 5]
let finalArray = sampleArray.map { return $0*2 }
Being Swifty Example 2
Naming conventions
typedef NS_ENUM(NSInteger,TXBStatusViewState) { kStatusViewStateNone, kStatusViewStateWaiting, kStatusViewStateNoAvailableDrivers, kStatusViewStateUnsupportedArea, kStatusViewStateMain, kStatusViewStateAddress, kStatusViewStateBlocked, kStatusViewStateNoInternet };
let statusViewMode: TXBStatusViewState = .statusViewStateBlocked //NOT SWIFTY
typedef NS_ENUM(NSInteger,TXBStatusViewState) { TXBStatusViewStateNone, TXBStatusViewStateWaiting, TXBStatusViewStateNoAvailableDrivers, TXBStatusViewStateUnsupportedArea, TXBStatusViewStateMain, TXBStatusViewStateAddress, TXBStatusViewStateBlocked, TXBStatusViewStateNoInternet };
let statusViewMode: TXBStatusViewState = .blocked //SWIFTY
😭
😍
Being Swifty Example 3
Type Safety[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_badRequestNotification:) name:@"kRequestBadNotification" object:nil];
NotificationCenter.default.addObserver(self, selector: #selector(self.badRequestNotification), name: Notification.Name.requestBad, object: nil)
Coming Soon…
Taxibeat Widget
iOS.Conf app
The .Conf app is built using embedded frameworks for the Watch App
and…
we made it open source on Github https://github.com/taxibeat/ios-conference
iOS.Conf app
we made it open source on Github https://github.com/taxibeat/ios-conference
Questions? enum SupportedLanguages { case # case $ }
Questions
CocoaPods
•Single Podfile for both projects, same workspace
•pod update acts on both projects
source 'https://github.com/CocoaPods/Specs.git' workspace 'MyWorkspace' platform :ios, '9.0' use_frameworks!
target 'MyProject' do project 'MyProject.xcodeproj' pod 'FBSDKCoreKit' pod 'FBSDKLoginKit' pod 'FBSDKShareKit' pod 'FBSDKMessengerShareKit' pod 'Fabric' pod 'Crashlytics' end
target 'MyFramework' do project ‘../iOS-Framework/MyFramework/MyFramework.xcodeproj' pod 'CocoaLumberjack' end
Warnings as errors
•Static Analyzer (ObjC): Localized Strings missing comments
•Treat warning as errors
API Design Example 1: Request Location Once
[[TXBLocationManager sharedInstance] requestLocationOnceWithCompletion:^(CLLocation * _Nonnull location) {
// center map to user location
}];
Mock URLSession
class MockSession: URLSession { var completionHandler:((Data?, URLResponse?, Error?) -> Void)? static var mockResponse: (data: Data?, urlResponse: URLResponse?, error: NSError?) override class var shared: URLSession { return MockSession() } override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { self.completionHandler = completionHandler return MockTask(response: MockSession.mockResponse, completionHandler: completionHandler) } class MockTask: URLSessionDataTask { typealias Response = (data: Data?, urlResponse: URLResponse?, error: NSError?) var mockResponse: Response let completionHandler: ((Data?, URLResponse?, Error?) -> Void)? init(response: Response, completionHandler:((Data?, URLResponse?, Error?) -> Void)?) { self.mockResponse = response self.completionHandler = completionHandler } override func resume() { completionHandler!(mockResponse.data, mockResponse.urlResponse, mockResponse.error) } } }
Organization
•Separate project and git repo
•Same workspace (already setup due to CocoaPods)
•Alternatives: Subproject, different target [1]
[1] https://developer.apple.com/library/content/technotes/tn2435