If an action is triggered during other action’s mutate, the action is handled in other loop.

case .tapDeleteButton(let model):
          let deleteHandler: () -> Void = { [weak self, weak listAdapter] in
            self?.action.onNext(.deletePositionCell(model?.userCash))

            listAdapter?.performUpdates(animated: true, completion: { [weak self] _ in // line 1
              self?.action.onNext(.delete(model?.userCash))
            })
          }
          self?.action.onNext(.showDeleteAlert(deleteHandler))
        }
case .showDeleteAlert(let deleteAction):
            guard let deleteAction = deleteAction else { return .empty() }
            deleteAction()
            return .empty()
case .deletePositionCell(let userCash):
            return .just(.deletePositionCell(userCash)) // line 2

line 1 is called before line 2.

IGListKit reorder by drag and drop

  1. return true to canMoveItem in cell sectionController
    override func canMoveItem(at index: Int) -> Bool {
        return true
    }

2. detect longPressGesture on View Controller

    let collectionViewUpdateQueue = BlockableTaskQueue()

func bind() {
        collectionView.rx
            .longPressGesture(configuration: { _, delegate in
                delegate.simultaneousRecognitionPolicy = .never
                delegate.beginPolicy = .custom { [weak self] gesture -> Bool in
                    guard let collectionView = self?.collectionView else { return false }
                    let touchLocation = gesture.location(in: collectionView)
                    guard let selectedIndexPath = collectionView.indexPathForItem(at: touchLocation) else { return false }
                    guard let cell = collectionView.cellForItem(at: selectedIndexPath) as? PositionListPositionCell else { return false }
                    return cell.isMovable
                }
            })
            .subscribe(onNext: { [weak self] gesture in
                self?.handleGesture(gesture)
            })
            .disposed(by: disposeBag)
}

extension PositionListStockViewController {
    func handleGesture(_ gesture: UILongPressGestureRecognizer) {
        switch gesture.state {
        case .began:
            let touchLocation = gesture.location(in: collectionView)
            guard let selectedIndexPath = collectionView.indexPathForItem(at: touchLocation) else { return }
            collectionViewUpdateQueue.block()
            collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
        case .changed:
            let position = gesture.location(in: collectionView)
            collectionView.updateInteractiveMovementTargetPosition(position)
        case .ended:
            collectionView.endInteractiveMovement()
            collectionViewUpdateQueue.unblock()
        default:
            collectionView.cancelInteractiveMovement()
            collectionViewUpdateQueue.unblock()
        }
    }
}

3. limit removable index from start to end

init() {
         let (collectionView, layout) = ViewFactory.collectionViewAndLayout(isHorizontal: false)
        layout.reorderingDelegate = self
}

extension PositionListStockViewController: CollectionViewFlowLayoutWithReorderingDelegate {
    func collectionViewFlowLayoutWithReorderingOverrideTargetIndex(moveFrom originalIndexPath: IndexPath, to computedTargetIndexPath: IndexPath) -> IndexPath {
        guard let reactor = reactor else { return originalIndexPath }

        guard let minIndexPath = reactor.getStartIndexPathOfPositions(),
              let maxIndexPath = reactor.getEndIndexPathOfPositions() else { return originalIndexPath }
        return computedTargetIndexPath.clamped(min: minIndexPath, max: maxIndexPath)
    }
}
extension PositionListStockViewReactor {
    func getStartIndexPathOfPositions() -> IndexPath? {
        guard let positions = currentState.positions,
              positions.isNotEmpty,
              let sectionIndex = makeListSection().firstIndex(where: {
                  guard let model = $0 as? DiffableBox<PositionListPositionModel> else {
                      return false
                  }
                  return true
              })
        else {
            return nil
        }
        return IndexPath(row: 0, section: sectionIndex)
    }

    func getEndIndexPathOfPositions() -> IndexPath? {
        guard let startIndex = getStartIndexPathOfPositions() else { return nil }
        let numberOfPositions = currentState.positions?.count ?? 0
        return IndexPath(row: 0, section: startIndex.section + numberOfPositions)
    }
}

4. Change list object first and then reorder request to server

init() {
        adapter.moveDelegate = self
}

extension PositionListStockViewController: ListAdapterMoveDelegate {
    func listAdapter(_ listAdapter: ListAdapter, move object: Any, from previousObjects: [Any], to objects: [Any]) {
        guard let object = object as? DiffableBox<PositionListPositionModel> else {
            return
        }
        let previous = previousObjects
            .compactMap { object in
                return object as? DiffableBox<PositionListPositionModel>
            }
        let current = objects
            .compactMap { object in
                return object as? DiffableBox<PositionListPositionModel>
            }

        reactorNotNil?.action.onNext(.onlyChangePositionsTo(current.compactMap(\.value.userStock))) 
        reactorNotNil?.action.onNext(.reorder(newList: current.compactMap(\.value.userStock)))
    }
}

5. When updating the collection view, enque to the collectionViewUpdateQueue

reactor.state.distinctUntilChanged { $0.listVersion }
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
self?.collectionViewUpdateQueue.enqueue(
BlockableTaskQueue.Mutation(
type: .wholeUpdate,
mutate: { [weak self] in self?.adapter.performUpdates(animated: false, completion: nil) }
)
)
})
.disposed(by: disposeBag)

Monitor server response errors using Crashlytics

  1. Throw various errors when parsing response
class ModelUtil {
    // 1. 응답이 JSONObject나 JSONArray가 아님
    // 2. 응답의 전체를 감싸는 key값으로 parsing이 안됨
    // 3. key값인 Array 각각이 JSONObject가 아님
    @discardableResult
    static func apiFormatError() -> NSError{
        let error = NSError(domain:"com.companyname.appname.API JSON Format Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    @discardableResult
    static func apiFormatError(userInfo: Dictionary<String, Any>) -> NSError{
        let error = NSError(domain:"com.companyname.appname.API JSON Format Error", code:0, userInfo:userInfo)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    
    // 필수로 받아와야 하는 값이 parsing되지 않음
    @discardableResult static func apiEssentialValueParsingError() -> NSError {
        let error = NSError(domain:"com.companyname.appname.API JSON Essential Value Parsing Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
        return error
    }
    
    // 1. parsing된 값이 empty임
    // 2. parsing된 값이 상황상 null이면 안 되는데 null임
    static func apiEmptyValueError() {
        let error = NSError(domain:"com.companyname.appname.API JSON Empty Parsed Value Error", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
    }
    
    // 요구사항에서 받기로 했던 length와 API 결과 length가 다를 때
    static func apiLengthError() {
        let error = NSError(domain:"com.companyname.appname.API JSON Length is Not Expected", code:0, userInfo:nil)
        Crashlytics.sharedInstance().recordError(error)
    }
}

enum ModelError: Error {
    case invalidFormat(data: [String: Any])
}

Model class

extension IngredientPack {
    static func getIngredientPack(_ requestManager: RequestManager, type: PackType, completion: @escaping (RequestResult<IngredientPack>) -> Void) {
        let url = CustomRequest.domainAppend("APIPath")!
        requestManager.request(url: url, parameters: ["type": type.rawValue]) { (result) in
            switch result {
            case .success(let data):
                do {
                    let ingredientPacks = try JSONDecoder().decode([IngredientPack].self, from: data)
                    guard let ingredientPack = ingredientPacks.first else {
                        throw ModelUtil.apiFormatError()
                    }
                    completion(.success(ingredientPack))
                } catch {
                    completion(.failure(.others))
                }
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

2. Throw errors when the status code is not normal

func parseResponse(request: URLRequest, _ error: Error?, _ response: URLResponse?, _ data: Data?, completion: @escaping (RequestResult<Data>) -> Void) {
        // Network Indicator Control.
        self.networkIndicatorCount -= 1
        
        // Handling Error.
        if let error = error {
            ResponseError.cocoaError(request: request, error: error)
            completion(.failure(.others))
            
            // Handling Response Data.
        } else {
            if let httpURLResponse = response as? HTTPURLResponse {
                if httpURLResponse.statusCode != 403
                    && (httpURLResponse.statusCode < 200
                        || httpURLResponse.statusCode >= 400){
                    let statusCodeError = ResponseError.statusCodeError(statusCode: httpURLResponse.statusCode, request: request)
                    Crashlytics.sharedInstance().recordError(statusCodeError)
                    completion(.failure(.others))
                    return
                }
            }
            
            // (1) Data 형식이 JSON형태가 아닐 경우 Error 처리.
            // (2) Data 형식이 JSON형태이고 값이 UserAuthError인 경우 Error 처리.
            // (3) Data 형식이 JSON형태이고 값이 UserAuthError가 아니면 success처리.
            if let data = data, let response = response {
                if (data.count > 0) {
                    // TODO: UserAuthError를 확인하는 방법으로 Result접근이 아니라 StatusCode로 분별하기.
                    do {
                        let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
                        
                        // 인증 에러 인 경우 JSON형식으로 UserAuthError라는 Key에 Message값을 보냄.
                        if let json = jsonObject as? [String: Any],
                            let authErrorMessage = json["UserAuthError"] as? String {
                            ResponseError.userAuthError(request: request, response: response, json: json, authErrorMessage: authErrorMessage)
                            
                            completion(.failure(.authenticationFailed(message: authErrorMessage)))
                            return // .success가 호출되지 않도록 return
                        }
                    } catch {
                        let nsError = error as NSError
                        var userInfo = nsError.userInfo
                        let responseData = String(data: data, encoding: .utf8).or("nil")
                        userInfo.updateValue(responseData, forKey: "responseData")
                        let error = NSError(domain: nsError.domain, code: nsError.code, request: request, userInfo: userInfo)
                        Crashlytics.sharedInstance().recordError(error)
                        completion(.failure(.others))
                        return // .success가 호출되지 않도록 return
                    }
                }
                completion(.success(data))
                // Handling No Data.
            } else {
                ResponseError.noData(request: request)
                completion(.failure(.others))
                
                // TODO: CustomRequest.
                // 정상적인 상황에서도 response가 없는 요청도 있기 때문에 completeHandler가 있는 요청에 대해서만 (response를 바라는데 error 가 발생 한 경우) 에러를 리포팅 한다.
                // Response가 없는 요청 알아둘 것.
            }
        }
    }

3. Third party login error

@objc func login(on viewController: UIViewController) {
        if let currentToken = AccessToken.current {
            if let tracker = loginEventTracker {
                let loginResult = LoginManagerLoginResult(token: currentToken, isCancelled: false, grantedPermissions: [], declinedPermissions: [])
                tracker(loginResult, nil)
            } else {
                fetchUser(from: viewController)
            }
        } else {
            loginManager.logIn(permissions: MyFacebook.permissions, from: viewController) { (result, error) in
                if let error = error {
                    Alert.showConfirmAlert(withTitle: self.errorTitle, message: error.localizedDescription, viewController: viewController)
                    Crashlytics.sharedInstance().recordError(error)
                    self.thirdPartyLoginDelegate?.logFail(registerType: MyFacebook.registerType, message: self.errorMessage)
                } else if result?.isCancelled == false {
                    self.fetchUser(from: viewController)
                }
            }
        }
    }

How to evaluate the result of a feature

When making features of a application, it’s important to evaluate the result of the feature. It’s beneficial to keep track of how well the team is working and motivate teammates in the perspective of their impact on the users.

The most important tool is AB Test. Now I’m using Statisig for AB Testing tool. There are some categories of things to measure.

  1. How much the feature is tracked
    • For example, it can be tracked by the numbers of new button tap events.
  2. How the feature increased the use of the screen it is embeded.
  3. How the feature increased the other key feature
    • For example, for finance app, the number of stocks user registered as their asset can be increased by adding a feature that improve the analysis of their asset.
  4. DAU

However AB Test is not always possible to be accompanied by all features. For this case, there are some sideways. But because the result is dependent on the time, it can be affected by other things aside the feature. For finance app, some events that occurred during the time of the measurement can affect the results. And also if there were other features released in the meantime of the measurement, the result can be affected. Anyway, there are some ways to measure in this case. It’s quite similar to the things when there was an AB Test.

  1. Divide the timeline. If the release date is 5/3/23, the period to be compared as the period without feature is two weeks before the 5/3/23 to 5/3/23. It’s important not to vary the day of the week between the to period, because usually user app behaviors are often affected by the day of the week.
  2. Now compare the four things above.
  3. If the result does not seem to work, there’s other way to measure. It’s to measure the things done by only users who experienced the new feature. You can do this by limiting the user to whom performed the new feature after the release date. And add one more period. It’s 4 weeks before the release date to 2 weeks before the release date. It’s to limit to users who were already used the app before the period to compare. Limit the users who did any active event during the period. If you don’t, the result will be biased, because the result of the period after the release will include the activities who joined after the released. Then the statistics will show the users’ activity changed after they experience the new feature.

++

For making statistics requirement, it’s important to have consistency of what to log. Plus it’s good to collect all users tapping data from start. At least if you have the logs of tap events, it can imply any screen exposures.

Add ///@mockolo to certain file of the API repository

Method 1: Make mockable annotation to certain files

  1. make bash file
#!/bin/bash
sed -i '' '12i\
/// @mockable' File.swift

12 is line number

2. edit Makefile

all: mockable

mockable:
	./mockable.bash

3.

$PROJECT_DIR/third_party/mockolo -s Pods/Backend -d Neo/Generated/MockAPI.swift

Method 2: Make mockable annotations to all files and that mockolo to read certain files

  1. Edit api.mustache file
/// @mockable you need to include the file to ios/scripts/generate-mock.bash to make Mock.
public protocol {{apiPrefix}}{{classname}}Type {

2.

$PROJECT_DIR/third_party/mockolo -srcs Pods/Backend/social/Classes/Swaggers/APIs/CertainAPI.swift -d [project]/Generated/MockAPI.swift

Make swift enum cases snake_case

I have a special enum only for analytics. The analytics requirements are all in snake_case. I want to make enum cases by copy and paste the requirements preserving snake_case. But sometimes I habitually make cases camelCase when I forget to copy and paste. Therefore I made a script that makes cases snake_case.

SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"

#go to project directory
 cd $SCRIPT_DIR/.. 

 sed -i '' '/^ *case [^\.]*$/s/\([a-z0-9]\)\([A-Z]\)/\1_\2/g' [projet_name]/[path]/[filename].swift

“-i” is to edit the file

“^” starts with

” *” multiple blanks

[^\.]*$ does not contain .

s/A/B substitute A to B

/g is to use \1 and \2

” it’s needed for Mac