LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

iOSアプリ開発で動作確認をする際にあると捗るTips

この記事はiOS2 Advent Calendar 2017の9日目の記事です。

開発中やリリース前に、手動での動作確認を行うことは多いと思います。

弊社では、リリースの前にテスト配布を行い、他のエンジニアメンバーや 担当のデザイナー、プロジェクトマネージャーに動作確認をしてもらう体制を取っています。

そしてその際に、”特定の条件で発生する状態”の確認が必要なことは稀に存在します。

例えば、以下のような場面です。

  • インストールから○回起動でダイアログを表示をチェックしたい
  • とある設定データの値が存在しない状態をチェックしたい

これらのパターンはアプリを再インストールしてもらうことで実現が可能ですが、 再インストールに多少の時間がかかってしまうのが勿体無いです。

また、KeyChainなどで特定のデータを取り扱っている場合は アプリをアンインストールしても、データがDB側に存在し DBから個別に削除する必要があるといったパターンもあります。

その際に、バージョンで削除用の処理を埋め込んだり、 複数台ある端末の特定のIDを個別に、手動で対応するのは現実的ではないですし 時間やコミュニケーションコストが掛かってしまいます。

例え開発者だけだったとしても、都度データを削除するのは面倒ですよね

そこで弊社ではテスト配布版のみ「秘密のアクション」という社内向けの機能を実装しています。

今回は実際のコードと共に機能の紹介をしたいと思います。

テスト版でのみ表示する画面を用意

まず、追加する機能へアクセスするための機能一覧画面が必要です。
Other Swift Flagsを設定し、テスト環境でのみ表示するようにすると良いです。

弊社ではDEBUGとTEST環境でのみ、タイトルロゴを長押しで表示するようにしています。

class HomeViewController: UIViewController {
    override func viewDidLoad() {
        #if DEBUG || TEST
            let titleViewGesture = UILongPressGestureRecognizer(target: self, action: #selector(HomeViewController.longTapTitleView))
            titleViewGesture.minimumPressDuration = 0.5
            logoImageview.addGestureRecognizer(titleViewGesture)
            logoImageview.isUserInteractionEnabled = true
        #endif
    }
...

    @objc private func longTapTitleView() {
        let nextViewController = InhouseViewController()
        let nextNavigationController = navigationController(rootViewController: nextViewController)
        present(nextNavigationController, animated: true, completion: nil)
    }
}

以下のようなシンプルなテーブルビューを用意しています。

f:id:lcl-engineer:20171209234554p:plain:w300

データ削除/追加 機能

前述の通り、特定の状態でのみ動作する機能を確認するために 関連するデータを削除する必要があります。

上のスクリーンショットにもありますが、4つの項目にリセット機能を設けています。

これらの項目はEnumで管理しています。

import Foundation

enum InhouseActionType {
    case none
    case reset(ResetActionType)
    case .log

    enum ResetActionType {
        case activityLog, readNotification
        case resentSearchCondition, savedSearchCondition
    }

    var cases: [InhouseActionType] {
        return [.reset(.activityLog), .reset(.readNotification), .reset(.resentSearchCondition), .reset(.savedSearchCondition), .log]
    }

    var displayName: String {
        switch self {
        case .reset(.activityLog): return "アクティビティログ"
        case .reset(.readNotification): return "検索履歴"
        case .reset(.resentSearchCondition): return "お知らせの開封履歴"
        case .reset(.savedSearchCondition): return "条件保存"
        case .log:  return "ログ"
        default: return ""
        }
    }

    var cellText: String {
        switch self {
        case .reset: return "\(self.displayName)をリセット"
        case .log: return "(self.displayName)の一覧を表示"
        default: return ""
        }
    }

    func run() {
        switch self {
        case .reset(.activityLog): /* resetFunction() */
        case .reset(.readNotification): /* resetFunction() */
        case .reset(.resentSearchCondition): /* resetFunction() */
        case .reset(.savedSearchCondition): /* resetFunction() */
        default: break
        }
    }
}

社内向けの機能であり、UIに装飾は必要ないので
機能一覧画面ではInhouseActionType取り扱うだけです。

import UIKit

final class InhouseViewController: UIViewController {

    private var inhouseActionType: InhouseActionType = .none

    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "InhouseTableViewCell")
        tableView.alwaysBounceVertical = false
        tableView.rowHeight = 44
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
}


// MARK: - Private Methods
extension InhouseViewController {
    private func setupUI() {
        navigationItem.title = "秘密のアクション"
        navigationItem.backBarButtonItem = UIBarButtonItem(title: "戻る", style: .plain, target: self, action: nil)

        tableView.dataSource = self
        tableView.delegate = self
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
    }

    private func displayAlertView(inhouseAction: InhouseActionType) {
        let alertController = UIAlertController(title: "秘密のアクション", message: "\(inhouseAction.cellText)しますか?", preferredStyle: .alert)
        let cancelAction = UIAlertAction(title: "いいえ", style: .default, handler: nil)
        let successAction = UIAlertAction(title: "はい", style: .default) { _ in
            inhouseAction.run()
        }
        alertController.addAction(cancelAction)
        alertController.addAction(successAction)
        present(alertController, animated: true, completion: nil)
    }
}

// MARK: - UITableViewDataSource
extension InhouseViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return inhouseActionType.cases.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "InhouseTableViewCell") else {
            fatalError("InhouseTableViewCell is not found.")
        }
        let inhouseAction = inhouseActionType.cases[indexPath.row]
        cell.textLabel?.text = inhouseAction.cellText
        return cell
    }
}

// MARK: - UITableViewDelegate
extension InhouseViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let inhouseAction = inhouseActionType.cases[indexPath.row]
        switch inhouseAction {
        case .log: displayLogListTableView()
        default: displayAlertView(inhouseAction: inhouseAction)
        }
    }
}

ここで一つポイントなのは、アラートビューを挟んであげることです。
誤タップ防止にも繋がりますし、社内向けの機能とはいえ削除機能なので ワンクッション設けることをおすすめします。

気をつけたいこと

API経由での削除機能に限り、気をつけるべき点が一つあります。
それは、万が一本番APIへ接続した場合に、削除処理を行わないようにすることです。
稀に、テスト版で本番APIに接続するということもあるかと思います。
やはり、本番データを操作してしまうと思わぬ事故が発生しかねないので しっかりとAPI側でフィルタリングしてあげましょう。

ログの可視化

社内向けの動作確認の中でもう一つあるとよい機能があります。
それはログを画面に表示してあげることです。

例えば、以下のような場面があると思います。

  • 行動ログを埋め込むタイミングが正しいか不安
  • エラーが起きた際に具体的な内容を確認したい

行動ログを埋め込む際に、別の機能のライフサイクルの関係で 意図したタイミングで送信できていなかったということがあるかもしれません。

また、行動ログを埋め込みを開発者のみしか確認できないのは誤設置のリスクもあります。

そのようなことを防ぐためにログを送信するタイミングでToastを表示しています。

f:id:lcl-engineer:20171209234633p:plain:w300

このように、操作のタイミングで送信する行動ログを3秒間だけ表示します。

実際のコードは以下になります。

import UIKit

final class ToastView: UIView {

    static let shared = ToastView()

    private(set) var logs = [(body: String, createdAt: String)]()

    /// 表示秒数
    private let displayDurationInSeconds: Double = 3.0
    /// 表示/非表示時のアニメーションの秒数
    private let fadeInOutDurationInSeconds: Double = 0.4
    /// 表示/非表示時のアニメーションのタイプ
    private let transition: UIViewAnimationOptions = .transitionCrossDissolve
    /// 非表示用ののタイマー
    private var hideTimer = Timer()

    private func startTimer() {
        hideTimer = Timer(timeInterval: displayDurationInSeconds, target: self, selector: #selector(hideView(_:)), userInfo: nil, repeats: false)
        let runLoop: RunLoop = .current
        runLoop.add(hideTimer, forMode: .defaultRunLoopMode)
    }

    /// Toastを非表示にする
    @objc internal func hideView(_ timer: Timer) {
        if timer.isValid {
            timer.invalidate()
        }

        UIView.transition(with: self, duration: fadeInOutDurationInSeconds, options: transition, animations: nil) {
            if $0 {
                self.removeFromSuperview()
            }
        }
        isHidden = true
    }

    /// Toastを表示する
    func show(text: String, backgroundColor: UIColor = UIColor.black.withAlphaComponent(0.8)) {
        guard let keyWindow = UIApplication.shared.keyWindow, let targetView = keyWindow.rootViewController?.view else { return }

        let offset = CGPoint(x: 8, y: 8)
        let frame = CGRect(x: keyWindow.frame.origin.x + offset.x, y: keyWindow.frame.origin.y + offset.y, width: keyWindow.frame.size.width - offset.x, height: keyWindow.frame.size.height - offset.y)
        let toast = ToastView(frame: frame)
        toast.translatesAutoresizingMaskIntoConstraints = false

        let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: toast.frame.size.width, height: toast.frame.size.height))
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        backgroundView.backgroundColor = backgroundColor
        backgroundView.layer.cornerRadius = 4

        let textLabel = UILabel(frame: CGRect(x: 0, y: 0, width: toast.frame.size.width, height: toast.frame.size.height))
        textLabel.translatesAutoresizingMaskIntoConstraints = false
        textLabel.font = UIFont.systemFont(ofSize: 14)
        textLabel.text = text
        textLabel.numberOfLines = 0
        textLabel.sizeToFit()
        textLabel.textColor = UIColor.white
        textLabel.textAlignment = .left

        backgroundView.addSubview(textLabel)
        toast.addSubview(backgroundView)

        targetView.addSubview(toast)

        let constraints = [
            NSLayoutConstraint(item: toast, attribute: .bottom, relatedBy: .equal, toItem: backgroundView, attribute: .bottom, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .top, relatedBy: .equal, toItem: backgroundView, attribute: .top, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .leading, relatedBy: .equal, toItem: backgroundView, attribute: .leading, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .trailing, relatedBy: .equal, toItem: backgroundView, attribute: .trailing, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: backgroundView, attribute: .bottom, relatedBy: .equal, toItem: textLabel, attribute: .bottom, multiplier: 1.0, constant: 10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .top, relatedBy: .equal, toItem: textLabel, attribute: .top, multiplier: 1.0, constant: -10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .leading, relatedBy: .equal, toItem: textLabel, attribute: .leading, multiplier: 1.0, constant: -10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .trailing, relatedBy: .equal, toItem: textLabel, attribute: .trailing, multiplier: 1.0, constant: 10.0),
            NSLayoutConstraint(item: toast, attribute: .bottom, relatedBy: .equal, toItem: targetView, attribute: .bottomMargin, multiplier: 1.0, constant: -12.0),
            NSLayoutConstraint(item: toast, attribute: .leading, relatedBy: .equal, toItem: targetView, attribute: .leadingMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .trailing, relatedBy: .equal, toItem: targetView, attribute: .trailingMargin, multiplier: 1.0, constant: 0.0),
        ]
        NSLayoutConstraint.activate(constraints)

        targetView.layoutIfNeeded()
        toast.alpha = 0

        logs.append((body: text, createdAt: now()))

        UIView.animate(withDuration: fadeInOutDurationInSeconds, delay: 0, options: transition, animations: {
            toast.alpha = 1
        }, completion: { _ in
            toast.startTimer()
        })
    }

    private func now() -> String {
        let now = Date()
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
        return formatter.string(from: now)
    }
}

ここでポイントなのはToastを表示する際に、背景色を設定できるようにしておくところです。

上のスクリーンショットでは行動ログのJSONを表示していますが、 弊社ではその他にもエラー等を表示させています。

f:id:lcl-engineer:20171209234650p:plain:w300

背景色を変更することで、表示したログのカテゴリがわかりやすくなります。

画面上に表示することで、エンジニア以外の方がエラーなどに遭遇した場合でも 実際のログを共有してもらいやすくなり、修正等の対応がしやすくなります。

コードをみてお気づきの方もいるかと思いますが、ログのデータを日時とともに保持しており、これらのログは一覧画面から確認できるようにしています。

f:id:lcl-engineer:20171209234659p:plain:w300

import UIKit

final class ToastViewController: UIViewController {

    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(ToastTableViewCell.self, forCellReuseIdentifier: ToastTableViewCell.className)
        tableView.alwaysBounceVertical = false
        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 44
        tableView.separatorInset = .zero
        if #available(iOS 11.0, *) {
            tableView.contentInsetAdjustmentBehavior = .never
        }
        return tableView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }
}

// MARK: - Private Methods
extension ToastViewController {
    private func setupUI() {
        navigationItem.title = "ログリスト"

        tableView.dataSource = self
        tableView.delegate = self
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
    }
}

// MARK: - UITableViewDataSource
extension ToastViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return ToastView.shared.logs.reversed().count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ToastTableViewCell.className, for: indexPath) as? ToastTableViewCell else {
            fatalError("ToastTableViewCell is not found.")
        }
        let log: (body: String, createdAt: String) = ToastView.shared.logs.reversed()[indexPath.row]
        cell.dateLabel.text = log.createdAt
        cell.bodyLabel.text = log.body
        return cell
    }
}

// MARK: - UITableViewDelegate
extension ToastViewController: UITableViewDelegate {}
final class ToastTableViewCell: UITableViewCell {
    internal let dateLabel: PaddingLabel = {
        let label = PaddingLabel()
        label.backgroundColor = UIColor.gray
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = UIColor.white
        label.numberOfLines = 1
        label.textAlignment = .left
        return label
    }()

    internal let bodyLabel: PaddingLabel = {
        let label = PaddingLabel()
        label.backgroundColor = UIColor.white
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = UIColor.black
        label.numberOfLines = 0
        label.textAlignment = .left
        return label
    }()

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        selectionStyle = .none

        addSubview(dateLabel)
        dateLabel.translatesAutoresizingMaskIntoConstraints = false
        dateLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        dateLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        dateLabel.heightAnchor.constraint(equalToConstant: 25).isActive = true

        addSubview(bodyLabel)
        bodyLabel.translatesAutoresizingMaskIntoConstraints = false
        bodyLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor).isActive = true
        bodyLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        bodyLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        bodyLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

ここでポイントなのはreversed()で配列を逆順にして表示してあげることです。
最新のものが上に表示されるようにすると確認がしやすくなります。

最後に

削除/追加 機能があれば、削除用にコードを埋め込んでビルドをし直したり データを追加するために同じ操作を繰り返すようなこともなくなるので スムーズに開発を行うことができます。

ログ表示に関しても、具体的なエラー内容が知れると対応がとてもしやすいです。
行動ログもこのタイミングでこのデータがほしいということがより具体的にわかるようになりました。

このような補助系の実装は後回しになりがちですが、 紹介した機能を活用することで、開発速度があがることは間違いなしです。

今回は実際のコードも紹介していますので、是非活用してください。

エンジニアを募集しています

LCLではエンジニア積極的に募集中です。
興味のある方はお気軽にご連絡よろしくお願いいたします。
インターンも募集しています。

https://www.lclco.com/recruit/