My iOS Journey
Wei Wen
13 July 2018
App Review Manager

Problem
- Not many people rate AirPay
- People usually give bad reviews, not good
- Some rate poorly because of just one bad experience
Solution
@available(iOS 10.3, *)
open class SKStoreReviewController : NSObject {
/** Request StoreKit to ask the user for an app review.
* This may or may not show any UI.
*
* Given this may not succussfully present an alert to
* the user, it is not appropriate for use from a button
* or any other user action. For presenting a write
* review form, a deep link is available to the App
* Store by appending the query params
* "action=write-review" to a product URL.
*/
open class func requestReview()
}
Solution
Prompt users to rate our app when they make a "satisfactory purchase":
- Straightaway successful purchase
- No processing/refunding/refunded orders
- Initiated by user
- Welcome gift/discount/rebate/coupon
- Loyal user (registered for at least 20 days)
- Will not prompt again in 30 days
- Will not exceed 3 times per user within 365-day period
1. Straightaway successful purchase
Trivial
// BPOrderViewController
typedef enum : NSUInteger {
BPOrderEntryContextFromDirectPayment,
BPOrderEntryContextFromHistoryList,
BPOrderEntryContextFromNotificationMessage,
BPOrderEntryContextFromMerchantPayment,
} BPOrderEntryContext;
2. No processing/refunding/refunded orders
Trivial
// BPOrderObject
- (BOOL)isFinalized
{
return self.status == BPOrderObjectStatusCompleted || self.status == BPOrderObjectStatusCompleting;
}
3. Initiated by user
Same as criteria 1 (straightaway successful purchase)
4. Welcome gift/discount/rebate/coupon
- Welcome Gift?
BPOrderObject.isWelcomeGift()
returns whether it is a free gift- Track the
giftID
corresponding to the order as the user goes through the purchase flow
- Track the
- Discount?
topupPayableAmount < paymentPayableAmount
- Rebate?
paymentCashAmount != 0
- Coupon? Covered by above cases
5. Loyal user (registered for at least 20 days)
Trivial?
- API doesn't provide registration date
- We can track when the user first launched the app, but what about existing users?
- Since we cache orders, we use the date of the oldest purchase
- It's OK since we don't mind false negatives
6. Will not prompt again in 30 days
Trivial
7. Will not exceed 3 times per user within 365-day period
Actually guaranteed by Apple
Mistakes
-
Sloppy code style
- not using trailing closures
- not breaking long lines
- testing all cases in one test
- magic numbers
- typos in variable names (!)
-
Misuse of language features
-
guard
semantics - mutating getters
-
guard
isDirectPurchase && order.isFinalized(),
giftIDs[order.orderID].map({ $0 != 0 }) ?? false
|| order.isWelcomeGift()
|| order.isDiscounted()
|| order.hasRebate(),
isLoyalCustomer(dateFirstOpenedApp: dateFirstOpenedApp),
!hasSeenPromptRecently(datelastReviewed: dateLastReviewed)
else {
return false
}
if !order.isFinalized() {
return false
}
if !(isWelcomeGift(order: order)
|| order.isFreeGift()
|| order.isDiscounted()
|| order.hasRebate()) {
return false
}
if !isLoyalCustomer() {
return false
}
if hasSeenPromptRecently() {
return false
}
Current Status
Tutorial Kit

Functional Requirements
- Animation
- Work with UITableView and UICollectionView
- Remember if tutorial has been shown before
- Automatically layout text and indicator
- Customizable
Non-functional Requirements
- Work with Objective-C code base
- Flexible
- Easy to use API
- Standalone
- Unit testing and UI testing
Considerations
- Swift or ObjC?
- How flexible?
Architecture
-
TutorialKit
checks whether user has seen tutorial already and shows -
TutorialView
rendering of steps and handles animations -
TutorialStep
stores data on each step (what to target, what to render) -
TutorialTarget
represents a target to highlight
Usage
let tutorial = TutorialKit(
name: "tutorial-demo-example",
makeSteps: { [weak self] in
guard let this = self else {
return []
}
return [
TutorialStepText(target: this.label, text: "This is a text view"),
TutorialStepText(
target: CGRect(x: 100, y: 100, width: 100, height: 100),
text: "This is an arbitrary frame"
),
TutorialStepText(
target: TutorialTargetTableViewCell(this.table, at: [0, 10]) {
return $0.detailTextLabel
},
text: "This is a table view cell's detail")
)
]
}
)
Usage
weakify(self);
_tutorial = [[TutorialKit alloc] initWithName:@"tutorial-kit-demo" makeSteps:^NSArray * {
strongify(self);
NSMutableArray *steps = [[NSMutableArray alloc] init];
[steps addObject:[[TutorialStepText alloc]
initWithTarget:self.label
text:@"This is a text view"]];
[steps addObject:[[TutorialStepText alloc]
initWithTarget: [[TutorialTargetCGRect alloc]
init:CGRectMake(100, 100, 100, 100)]
text:@"This is an arbitrary frame"]];
[steps addObject:[[TutorialStepText alloc]
initWithTarget: [[TutorialTargetTableViewCell alloc]
init:self.table
at: [NSIndexPath indexPathForRow:10 inSection:0]
targetForCell:^UIView * (UITableViewCell *cell) {
return cell.detailTextLabel;
}]
text:@"This is a table view cell's detail"]];
return steps;
}]
Implementation
public class TutorialKit: NSObject {
public init(name: String, makeSteps: @escaping () -> [TutorialStep]) { ... }
@available(swift, obsoleted: 1.0)
@objc public convenience init(name: String, makeSteps: @escaping () -> [Any]) { ... }
@objc public func start() { ... }
@objc public func forceStart() { ... }
@objc public func hasSeenTutorial() -> Bool { ... }
}
Implementation
public protocol TutorialStep {
var target: UIView { get }
var willEnter: (() -> Void)? { get set }
var didEnter: (() -> Void)? { get set }
var willExit: (() -> Void)? { get set }
var didExit: (() -> Void)? { get set }
/**
Returns a UIView that should be shown when the step is displayed
- Parameter frame: The frame the UIView should draw within
- Parameter targetFrame: The frame of the view the step is targeting
*/
func makeView(frame: CGRect, targetFrame: CGRect) -> UIView
}
public class TutorialStepPointing: NSObject, TutorialStep { ... }
public class TutorialStepText: TutorialStepPointing { ... }
Implementation
+ public protocol TutorialTarget { ... }
public protocol TutorialStep {
- var target: UIView { get }
+ var target: TutorialTarget { get }
var willEnter: (() -> Void)? { get set }
var didEnter: (() -> Void)? { get set }
var willExit: (() -> Void)? { get set }
var didExit: (() -> Void)? { get set }
/**
Returns a UIView that should be shown when the step is displayed
- Parameter frame: The frame the UIView should draw within
- Parameter targetFrame: The frame of the view the step is targeting
*/
func makeView(frame: CGRect, targetFrame: CGRect) -> UIView
}
Implementation
public protocol TutorialTarget {
/**
Will be called before the tutorial step is displayed
- Important: This function should not be animated as it will be wrapped in a UIView.animate()
- Parameter completion: should be called with the target rect after the target is in view
*/
func scrollToFrame(completion: @escaping (CGRect) -> Void)
}
extension UIView: TutorialTarget { ... }
extension CGRect: TutorialTarget { ... }
public class TutorialTargetTableViewCell: NSObject, TutorialTarget { ... }
public class TutorialTargetCollectionViewCell: NSObject, TutorialTarget { ... }
@objc public class TutorialTargetCGRect: NSObject, TutorialTarget { ... }
Implementation
public class TutorialTargetTableViewCell: NSObject, TutorialTarget {
public init(_ collectionView: UICollectionView,
at indexPath: IndexPath,
targetForCell: @escaping (UICollectionViewCell) -> TutorialTarget? = { $0 })
@available(swift, obsoleted: 1.0)
@objc public convenience init(_ collectionView: UICollectionView,
at indexPath: IndexPath,
targetForCell: @escaping (UICollectionViewCell) -> Any? = { $0 })
...
}
Limitations
- Need to define all the steps at once
- Difficult to continue through a navigation hierarchy
-
TutorialView
is not customizable
Demo

Improvements
- Customizable button
- Allow users to skip the tutorial completely
- Cleaner Objective-C interop
- Unit and UI tests
- Integrate into AirPay
End
My iOS Journey
By Wei Wen Goh
My iOS Journey
- 94