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

  1. Straightaway successful purchase
  2. No processing/refunding/refunded orders
  3. Initiated by user
  4. Welcome gift/discount/rebate/coupon
  5. Loyal user (registered for at least 20 days)
  6. Will not prompt again in 30 days
  7. 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
  • 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

Made with Slides.com