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

データ削除/追加 機能

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

上のスクリーンショットにもありますが、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

このように、操作のタイミングで送信する行動ログを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

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

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

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

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

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/

コードレビューの機械的な指摘はSideCIに任せる

コードレビューを自動化してくれるSideCIを導入しました。GitHubのプルリクエストを自動で解析して指摘してくれます。 主にRubyを使用しているのでRuboCopを筆頭に解析ツールが豊富に揃っているのは助かっています。

導入の経緯

もともとRuboCop, JSHint, ESLintは使用しており主にローカルで実行して個人個人で修正対応していました。 RuboCopはJenkinsのJOBを走らせていましたが、毎回リポジトリ全体に対して実行すると時間がかかっていたり、わざわざJenkinsの結果を確認する必要がありプルリクエストベースの開発プロセスから外れているためあまり効率が良いものではありませんでした。 そこで自前でやるよりサービスを利用したほうが開発にリソースを投入できるということもあり以前から気になっていたSideCIを導入してみることにしました。

導入してみて良かったところ

SideCIを数ヶ月運用してみて全体的に大きなメリットを感じています。

既存の開発プロセスに簡単に組み込めた

プルリクエストと連携しているので検出した問題やサマリへのリンクをコメントしてくれたりステータスが表示されるので効率よく確認と対応ができるようになりました。

f:id:lcl-engineer:20171124114558p:plain
指摘がない場合
f:id:lcl-engineer:20171124114604p:plain
指摘がある場合

無駄な指摘をする必要がなくなった

静的コード解析ツールによって検出されたコーディングスタイルに関する問題点がSideCIによってプルリクエスト上で可視化されるのでわざわざレビュアーがコメントする必要がなくなりました。 また、複雑度を数値で確認することができるので「このコードってちょっとわかりにくいよね。」ではなく「ここはこうした方がもっとわかりやすくなるよね。」のように複雑なのは数値で判明している前提で一歩進んだところから議論を始められるようになりました。

ナレッジが共有しやすくなった

これまでは実装者個人のみが解析結果を確認することが多かったのですが、レビュアーや他のメンバーの目にも触れやすくなり自分が実装する際にも意識してコードを書くことができるようになりました。対応に迷ったときにもコメントのURLをチャットで共有できるので議論の効率も上がりました。

メンテが楽になった

JenkinsのJOBの面倒を見たりGitHubとの連携を考える必要もなくなりました。チームの実情に合わせて解析ツールの設定ファイルを修正するだけで良く、メインの仕事であるサービスの開発に力を注ぐことができるようになりました。

ちょっと残念なところ

2017年10月31日にクラシックモードからアビシニアンモードへ完全移行となりました。これによりソースコードの自動修正機能が廃止されました。

SideCIでのクラシックモード廃止のお知らせ - SideCI Blog

自動修正機能とはRuboCopのauto-correct機能を利用して、自動修正可能な問題点を解消したプルリクエストをSideCIが作成してくれるというものです。

RuboCopによる自動修正の方法 | SideCI Documentation

自動修正されるのはコーディングスタイルに関する指摘がメインです。この機能のお陰でコーディングスタイルに関しては多少雑に実装してもPushして自動修正機能で対応するということができ、効率アップにつながっていました。 廃止の理由としてはツールの誤検出により誤った修正をしてしまうことを防ぐためかと想像できますが、プルリクエストとして確認できるのでオプションとして残っていてくれたら嬉しかったかな。という感想です。

アビシニアンモードでLINTツールの誤検出と闘う - SideCI Blog

まとめ

自前で仕組みを作るより断然効率的でコードレビューでも無駄な時間を使う必要がなくなったのでSideCIには満足しています。 サポートする言語やツールも増えていますのでこれからも期待大です。

InstabugのWeb版(Beta)導入方法

InstabugでWebサイトのフィードバックを送れるように設定しました。 すでにiOSでは使い始めていて、iOSの設定については以下の記事で軽く触れています。 techblog.lclco.com

Web版は現時点(2017/11/13)でBeta版のためか、少し導入でつまづいた点もありましたので、手順を記録しておきます。

Web版(Beta)の導入方法

以下の2つの手順で動作します。

  1. Instabugの管理画面に貼ってある2つのscriptタグを</body>の直前に貼ります。 f:id:lcl-engineer:20171109083946p:plain ※Bower経由でも入れられるようです
    ※開発環境のみに導入する場合は、上記のタグを開発環境のみで出力するように記述する必要があります。

  2. scriptを貼ってWebサイトを表示させると右下にInstabugのボタンが出てきます。

    ※注意※

    Instabugを使うドメインで最初に表示させる必要がありそうです。 私は、先にローカル環境で動作確認をしてからテストサーバーへ反映しましたが、以下のようなエラーが出てテストサーバーでは動作しませんでした。

Failed to load https://api.instabug.com/api/sdk/v3/features?application_token=xxxxxxxxxxxxxxxxxxxxxxxx: The 'Access-Control-Allow-Origin' header has a value 'http://localhost:8080' that is not equal to the supplied origin. Origin 'https://xxxxxx.xxx.xxxx(ドメイン)' is

管理画面ではドメインを設定するところはなかったので、最初に表示させたところをホームとして認識するような仕様なのかもしれません。 この辺は正式版リリースまでに変わるかもしれませんね。

以下のようなアイコンが出てくれば成功です。 f:id:lcl-engineer:20171109084352p:plain

Chrome拡張機能を入れるとスクリーンショットを添付できますが、入れなくてもテキストのみで報告が可能です。 f:id:lcl-engineer:20171109084613p:plain

導入手順は、以上です。

少し使ってみましたが、LocalStorageやconsoleの値等、いろいろな情報が見えるのが良いです。 翻訳機能があるのもおもしろいです。 f:id:lcl-engineer:20171113155810p:plain

必要な機能は揃っていそうですが、気になる点は以下のとおりです。

  • 現時点(2017/11/13)では、スマホ(実機)で報告できない(ボタンは表示されますが、押しても反応しない)
  • 動画が撮れないので、特定のステップのときのみに起こるバグの報告はしにくい
  • 画面に書き込むのもマウス操作のみなので、きれいに書けない

なお、スマホについては、Chromeデベロッパーツールで以下の設定にしたときだけは動きました。 「Responsiveモード かつ、幅が800px以上の時」 f:id:lcl-engineer:20171114071437p:plain

スマホで報告できないのは、とても大きなマイナス点です。 正式リリースまでに改善されることを望みます。

ChatWorkのWebhookをRuby/Railsで受信する

先日、ChatworkがWebhookに対応されました。弊社では普段からChatworkを利用しており、早速検証しました。

ビジネスチャット「チャットワーク」がWebhookとOAuthに対応、オープンβ版の提供開始〜2018年春までにクラウドサービス17社18サービスとデータ連携開始予定〜 ~ ChatWorkニュースリリース

Webhook対応でできること

ChatworkがWebhookに対応したことで、「自分宛のメッセージ受信」「特定のルームでの発言」等をトリガーに、任意のURLを呼び出し処理を実行できるようになります。

今までも、APIで特定のルームのメッセージをポーリングすることで、同様のことは実現できました。ただし、APIリクエスト数の「5分あたり100回」という制限もあり、リアルタイム性に課題がありました。

Webhookの利用方法

公式ドキュメントに詳しく記載されていますが、簡単に紹介したいと思います。

Webhook - チャットワークAPIドキュメント

Webhookの受信側の準備

AWS EC2上にRailsアプリケーションを構築し、Webhookから呼び出すコントローラを準備します。

class Chatwork::WebhookController < ApplicationController    
  def test
    # 処理を実装
  end
end

Webhookの作成

Chatworkの設定画面で、Webhookメニューが新しく追加されており、そこからWebhookが作成できます。

  • Webhook名
    • 任意です
  • Webhook URL
    • httpsから始まる必要があります。
  • イベント
    • トリガーとなるイベントを設定します。
    • アカウントに対するイベントか、特定のルームでのイベントのどちらかが選択できます。
    • ルームイベントの場合は、自身が所属するルームIDしか入力できません。

f:id:lcl-engineer:20171104171523p:plain

Webhookの確認

該当のルームでメッセージを投稿すると、指定したWebhook URLが呼び出され、Chatworkから以下のデータがPOSTされます。 ポーリングとは異なり、即座に反応します。

 {"webhook_setting_id"=>"xxxxxxxxxxxx", 
 "webhook_event_type"=>"message_created", 
 "webhook_event_time"=>1509724361, 
 "webhook_event"=>{"message_id"=>"xxxxxxxxxxxx", 
 "room_id"=>xxxxxxxxxxxx, 
 "account_id"=>xxxxxxxxxxxx, 
 "body"=>"テストメッセージ", 
 "send_time"=>1509724361, 
 "update_time"=>0}, 
 "room_id"=>"xxxxxxxxxxxx", 
 "webhook"=>{"webhook_setting_id"=>"xxx", 
 "webhook_event_type"=>"message_created", 
 "webhook_event_time"=>1509724361, 
 "webhook_event"=>{"message_id"=>"xxxxxxxxxxxx", 
 "room_id"=>xxxxxxxxxxxx, 
 "account_id"=>xxxxxxxxxxxx, 
 "body"=>"テストメッセージ", 
 "send_time"=>1509724361, 
 "update_time"=>0}}}

これでWebhookの設定は完了です。あとはパラメータの内容を解析して、任意の処理を実装すれば、Chatworkでの投稿をトリガーとしたBotが作成できます。

リクエストの署名検証

実装上必須ではありませんが、Webhookリクエストの送信元がChatworkであることを、確認する方法が用意されています。

  1. トークンをBASE64デコードしたバイト列を秘密鍵として、HMAC-SHA256アルゴリズムによりリクエストボディのダイジェスト値を得ます
  2. ダイジェスト値をBASE64エンコードした文字列が、リクエストヘッダに付与されたsignature(X-ChatWorkWebhookSignatureヘッダの値)と一致することを確認します

上記の説明だけでは少し複雑ですが、Ruby/Railsでは、以下のように実装しました。

require 'openssl'
require 'base64'

def test
  if chatwork_signature.present? && chatwork_signature == Base64.strict_encode64(digest)
    render text: 'success', status: 200
  else
    render text: 'invalid', status: 403
  end
end

private

def secret_key
  token = '[トークン]'
  Base64.decode64(token)
end

def request_body
  request.body.read
end

def chatwork_signature
  request.headers[:HTTP_X_CHATWORKWEBHOOKSIGNATURE]
end

def digest
  OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), secret_key, request_body)
en

※ トークンは、Webhookの設定画面から確認できます。

f:id:lcl-engineer:20171104171542p:plain

まとめ

Webhookを利用すると、簡単にリアルタイム性の高いやりとりが実現できることが確認できました。

弊社では、既にChatworkとHubot連携してデプロイ等を行っていますが、Webhookを利用して更に自動化を進めていきたいと思います。

techblog.lclco.com

LCLではエンジニア・インターン募集中です。 サービス開発はもちろん、開発プロセスの自動化も積極的に推進しています。 少しでも興味がある方は、ご連絡お願い致します。

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

Baltoからの移行先としてInstaBugとBugseeを比較してみました

先日、Baltoがサービスを終了するという悲しいお知らせがありました。

【重要】フィードバックツール「Balto」サービス終了について

日々のフィードバックや開発の中盤から終盤の確認フェーズにかけて、
大変お世話になり、Baltoのおかげで開発スピードが何倍にも短縮されていました。

Baltoを利用して素早くサービスを改善する
面倒だったフィードバックが簡単に!Baltoを使ったサービス改善。 LCL

しかし、サービス終了とSwift4の未対応を理由に
残念ではありますが、他のサービスへ移行をすることにしました。

今回はBaltoから移行するサービスとして以下の2つを比較してみました。

どちらもメインはユーザ向けのフィードバックとバグレポートのサービスですが
Baltoの代替目的として、以下の項目を比較することとします。

  • プラットフォーム、ライブラリ管理などの対応
  • フィードバックから送信可能な要素(画像、動画 ..etc)
  • フィードバックの送りやすさ(iOSのみ)
  • レポート一覧の閲覧性とアクション(コメント ..etc)

対応項目

項目 InstaBug Bugsee
プラットフォーム Web (beta), iOS, Android, Cordova, React Netive, Xamarin Web(Chrome), iOS, Android, Cordova, React Netive, Xamarin, Unity
ライブラリ管理 Carthage,CocoaPods,Manual CocoaPods,Manual

フィードバックの送りやすさ

Instabug

送信画面を出す方法が複数用意されています。
Baltoと同様に表示したい場合はfloatingButtonがおすすめです。

case none                // 表示は自作する
case shake               // デバイスを振る
case twoFingersSwipeLeft // 2本指で左へスワイプ
case rightEdgePan        // デバイスの右端から左へスワイプ
case floatingButton      // 右下にボタンを表示

Baltoと同様にボタンはいろいろなところへ移動させることができます。
ボタンを押すとメニューが表示され、項目ごとにレポートが送信できます。

全ての機能で画像と動画が添付可能であり、複数枚同時に送ることもできます。
フィードバックを行う際にBaltoは予めユーザログインが必要でしたが、
こちらはユーザ向けなので、メールアドレスを入力すれば誰でも送信できます。
一度入力されたメールアドレスは記憶されているので再入力の必要はありませんでした。

画像の編集では線、モザイク、拡大スコープを入力が可能です。

f:id:lcl-engineer:20171104025703p:plain

Bugsee

デフォルトでは通常のスクリーンショットと同様の
Home button + Lock button で送信画面を表示することができます。
また、以下のようにオプションを設定することで振って表示することも可能です。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 
    let options: [String: Any] = [ 
        BugseeMaxRecordingTimeKey : 60,
        BugseeShakeToReportKey: true,
    ] 
    Bugsee.launch(token: "XXXXXXXXXXXXX", options: options) 
    return  true 
}

Bugseeの録画機能は全てのレポートで行われるようで、
送信者が手動で録画をするのではなく勝手に手前の行動が録画されています。
上記のBugseeMaxRecordingTimeKeyが録画時間となるようです。

f:id:lcl-engineer:20171104025729p:plain

こちらもメールアドレスを入力すれば誰でも送信できます。
画像の編集では線のみ入力が可能です。

レポート一覧の閲覧性とアクション

Instabug

前述の通り、Instabugは送信元が3パターン存在します。
管理画面はその中からチャットとその他2つで振り分けられます。
今回はチャット機能については関係がないので割愛します。

レポートは「Bugs」メニューから一覧をみることが可能です。
「問題を報告」、「改善を提案」はそれぞれbugfeedbackタグが付けられます。

一覧にタグが表示されないのが残念ではありますが、
上部のFilterから絞り込むことは可能で、条件を保存しておけます。

f:id:lcl-engineer:20171104025744p:plain

Baltoと同様にステータス管理もできます。
コメントはチャットのようなUIではないですが、かわりなく使えそうです。

f:id:lcl-engineer:20171104025801p:plain

とても高機能でデバイス情報やログなども取得して同じ画面に載っているので、
エンジニア以外の方だと情報が多すぎるのが見づらく感じるかもしれません。

Bugsee

こちらは比較的シンプルな作りとなっており、「ISSUES」に一覧が表示されます。
ちなみに「FEEDBACK」は別途にチャット機能があり、そのためのページです。

f:id:lcl-engineer:20171104025813p:plain

個別ページはInstaBugと似ていますが、こちらはログの詳細が
ボタンで切り替わるようになっているため、ごちゃごちゃした雰囲気はありません。

下部のシークバーで動画を再生している際のデバイス、アプリの状態を確認できます。
秒単位でViewのサイクルやメモリなどが見れるのはエンジニアとしては助かりますね。

f:id:lcl-engineer:20171104025826p:plain

終わりに

今回、InstaBugの方は実際に数日前から運用しているのですが
やはりごちゃごちゃしているという意見がでています。

Baltoが開発者向けのフィードバックツールであり、
必要最低限の機能でスムーズに使えた良さを改めて感じました。

ごちゃごちゃしているとの意見はありますが、
Baltoの移行先としてはやはり似た振る舞いのInstaBugが良さそうです。

開発側としてはBugseeの機能も捨てがたいので、
こちらも解析ツールとして導入したままにしようと思いました。

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

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

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

モバイルアプリエンジニアとして入社して3ヶ月が経ちました

2017年8月よりモバイルアプリエンジニアとして入社しました 山下です。
入社後1ヶ月ほどから下書きを書きつつ、あっという間に3ヶ月が経っていました。

新しい環境にも慣れてきたので、今回は転職の経緯や入社までの所感、
入社後3ヶ月経った上での会社の紹介を書いていきたいと思います。

なぜLCLを選んだか

(※ 自己紹介要素が多めなので飛ばしてもらって構いません)

前職は受託開発を行う小さな会社に所属していました。
そこでは営業と開発サイドの2極化となっており、
クライアント対応(メール)、クライアント向けのドキュメント作成、
簡単なデザイン、開発 、 運用 ..等、できることが増えると、
開発以外のことも次々と仕事が任されるという感じでした。

新卒で入社したのでとても良い経験となりましたが、
業務に余裕ができた辺りでクライアント相手の業務に気持ちが入らず、
その先のユーザに対してアプローチしたいという気持ちが強くなりました。

さらに、小規模でしたがWEBフロントエンド・バックエンド、
モバイルアプリ、デスクトップアプリなど幅広くやらせてもらう中で
モバイルアプリエンジニアとしてメインのスキルを伸ばしたいと思い始めました。

ちょうどその頃、エンジニア業界で転職活動の雰囲気が強く出始め
色々な企業の方とお話させていただく機会があり、そのうちの1社がLCLでした。

転職を意識するに辺り、転職先の基準を以下のように決めており、
LCLはぴったりマッチしていたことから、面談機会を設けていただきました。

  • 自社開発でカスタマー向けのサービスを運営している
  • 会社としてユーザファーストの意識がある
  • モバイルアプリ専業で仕事ができる(けれど裁量は限定的過ぎない)
  • 開発環境が整っている

入社までの所感

連絡や次回面談の決定などを即時に返して頂いたので好感を得ました。
面接においても堅苦しさはなく、自然と素に近い雰囲気で話ができていました。

正直、面接の機会をいただくまでLCLのことは知らなかったのですが
面接時にしっかりと現状のことや今後の計画などをお話していただき、
社風や開発スタイルの雰囲気が自分に合っていると感じました。

中途社員で構成されており、社員の年齢層は落ち着いているのも
まだ下の世代として多くを吸収したい自分にとっては魅力的でした。

オフィス内を案内していただいた際は、明るくて広々とした雰囲気のスペースや
トリトンタワー40階からの眺めをみて、ここで仕事したいと思える光景でした。

f:id:lcl-engineer:20161014230926j:plain f:id:lcl-engineer:20161014223720j:plain

内定をいただいた後日、用意していただくMacをiMacかMBPか決める際に
WWDCの発表を待ってもらえたのも嬉しかったです。

入社後3ヶ月が経って

冒頭でも書きましたが、11月で3ヶ月が経ちました。
業務としては専属でiOS/Androidの新規機能追加や改善を進めています。

業務自体の改善も好きなのでDangerを導入したり、細かい改善をやっています。
こうしたツールや開発プロセスなどを臨機応変に試せる環境であるため
新しいもの好きな自分としてはとてもやりがいを感じます。

ここからは社内全体と開発部に分けてざっくり紹介します。

社内全体

社内全体でコミュニケーションツールとしてChatworkを使っています。
そのため、オフィスは落ち着いた静かな雰囲気なので
集中して業務を進めることができます。

DevOpsがしっかりと構築されており、
社員数は多くないですが 個々の役割がしっかりと分けられていて、
エンジニアが雑務をやるということも一切ありません。

男女比も半々だったり、長期インターンシップも複数人いたりと
全体としてすごくバランスが取れていることに驚きました。

大江戸線-トリトンは人が多いので通勤が大変かと心配していましたが
勤務時間が10-19時で他の会社とずれているので問題ありませんでした。

フリードリンクなので毎日無限コーヒーしてます。

f:id:lcl-engineer:20161014230917j:plain

開発部

開発メンバーは現在7名で、フロントエンド・バックエンド・iOS/Androidアプリ、
インフラ構築・運用など それぞれがメインの領域を持ちつつ幅広く担当しています。

自分は現在、専属でモバイルアプリをやっていますが、
他の技術をやりたいとなればそちらを選択することができる環境なのもいいです。

前述の通り、社内の役割がしっかりと分けられているため、
仕様から実装までエンジニア任せに全てを決めるといったことはなく、
事業開発を促進する社員がしっかりと企画・検討・分析を行ってくれます。
発信は両者から行われ、ユーザにとって良いものにしていこうという意欲のもと、
意見交換や仕様決定が行われるので全員に裁量があります。

開発環境

全社員にハイスペックのMacとセカンドディスプレイが用意されています。
そのため机も広く、両手を広げられるくらいのスペースが確保されています。
椅子はバロンで長時間座っていても身体に負担が少なく感じます。

f:id:lcl-engineer:20161017235536j:plain

f:id:lcl-engineer:20171102111701p:plain

業務

入社してから電話やメールは一切なく、
業務中もエンジニアとして全く関係ないタスクをすることもありません。
リファクタなどの改善タスクもしっかりと工数を確保することができます。

残業

社内全体として定時で直ぐに退社する雰囲気があり、
開発部では30分もするとほとんど誰も残っていません。

リモートワーク

開発部はコアタイム・MTG以外でのリモートワークが許可されています。
午前は在宅で午後から出社するメンバーも居たり、
前日までにチャットで一言連絡をすれば自由に取ることが可能です。
子持ちの方は業務の合間を縫って子供の送り迎えをしています。

関連記事: リモートワークへの取り組み

また、オフィス内でも防音材で囲まれた集中スペースがあったり、
土禁のスペース、ハイデスクがあるため 色々なところで作業ができます

関連記事: 働き方を工夫して効率UP

チャット文化

チャット文化なので終日対面で話さないという日もあるため、
メンバーとの付き合いが難しそうに思いますが、そういう雰囲気は全く無いです。

ほどよく上下関係がないので、たまにランチをしたり、
チャット上でも気軽にコミュニケーションがとれます。
絵文字を使う文化もあるので、チャット文化特有の気まずさなどもないです。

入社1ヶ月間はチャットを追うのが大変に感じましたが、
慣れてくると既読が必要なものも判別できるようになり、
今ではまったく苦にならなくなりました。

ドキュメント文化

大きな作業内容や変更などは、Qiita:Teamにドキュメントを残す文化があります。
入社後は環境構築なども記事を見ながらをスムーズ行うことができました。

自分はドキュメントを積極的に書いていくタイプなのですが、
やはり自分以外の方の投稿もあるとモチベーションに繋がります。

参考書籍

参考書籍などは会社負担で購入することができるため技術書籍が多くあります。
WEB+DBや東洋経済、新聞を定期購読していたり
サービスの関係上、カフェスペースには旅行雑誌もたくさんあります。

最後に

エンジニアにとって、とても仕事がしやすい環境です。
毎日のやりがいと共に3ヶ月があっという間に感じました。

今はiOSの既存のコードのSwift4対応やリファクタ等を行っていたり、
AndroidもKotlin移行など まだまだやりたいことも沢山あるので
この環境を活かして、良いプロダクトをユーザへ届けたいと思います。

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

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

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

Bitriseで動作するFastlane/Gymでビルドが失敗する問題の解決方法

Xcode9 / Swift4の対応後、とあるブランチを配布しようとした際に PodsでインストールしたライブラリでProvisioning Profileが参照できずビルドが失敗する現象が発生しました。

以下はBitrise上のログです。(Provisioning Profile等の情報は伏せてあります)

[02:35:57]: Detected provisioning profile mapping: {:"XXX"=>"XXX"}

+--------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
| Summary for gym 2.62.1 |
+--------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
| clean | true |
| xcargs | DEVELOPMENT_TEAM="XXX" PROVISIONING_PROFILE_SPECIFIER="XXX" CODE_SIGN_IDENTITY="iPhone Distribution" |
| scheme | XXX |
| export_method | ad-hoc |
| export_options.provisioningProfiles.XXX | XXX |
| workspace | ./XXX.xcworkspace |
| destination | generic/platform=iOS |
| output_name | XXX |
| build_path | /Users/vagrant/Library/Developer/Xcode/Archives/2017-10-30 |
| output_directory | . |
| silent | false |
| skip_package_ipa | false |
| buildlog_path | /var/folders/90/5stft2v13fb_m_2dsfafd0gn/T/fastlane_logs222222/gym |
| skip_profile_detection | false |
| xcode_path | /Applications/Xcode-beta.app |
+--------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+

[02:35:57]: $ set -o pipefail && xcodebuild -workspace ./XXX.xcworkspace -scheme XXX -destination 'generic/platform=iOS' -archivePath /Users/vagrant/Library/Developer/Xcode/Archives/2017-10-30/XXX\ 2017-10-30\ 02.35.57.xcarchive DEVELOPMENT_TEAM="XXX" PROVISIONING_PROFILE_SPECIFIER="XXX" CODE_SIGN_IDENTITY="iPhone Distribution" clean archive | tee /var/folders/90/5stft2v13fb_m_gv3c8x9asdfagn/T/fastlane_logs23ddd6627/gym/XXX.log | xcpretty

[02:36:02]: ▸ ❌ error: AWSCore does not support provisioning profiles. AWSCore does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'AWSCore')

[02:36:02]: ▸ ❌ error: AWSSNS does not support provisioning profiles. AWSSNS does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'AWSSNS')

...

[02:36:02]: ▸ ❌ error: Pods-XXX does not support provisioning profiles. Pods-XXX does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'Pods-XXX')

[02:36:02]: ▸ Target Pods-XXX product Pods_XXX cannot link framework Foundation.framework

[02:36:02]: ▸ Target Pods-XXX product Pods_XXX cannot link framework Foundation.framework

[02:36:02]: ▸ unexpected duplicate creator 'PhaseScriptExecution [CP]\ Copy\ Pods\ Resources /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheukvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/IntermediateBuildFilesPath/XXX.build/Test-iphoneos/XXX.build/Script-C604611C209DA5CF0468AAE9.sh' for node '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheukvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' with prior creator 'MkDir /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheukvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

[02:36:02]: ▸ ignoring duplicated output file: '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheukvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

[02:36:02]: ▸ ** ARCHIVE FAILED **

❌ error: AWSCore does not support provisioning profiles. AWSCore does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'AWSCore')

❌ error: AWSSNS does not support provisioning profiles. AWSSNS does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'AWSSNS')

❌ error: DZNWebViewController does not support provisioning profiles. DZNWebViewController does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'DZNWebViewController')

❌ error: OALayoutAnchor does not support provisioning profiles. OALayoutAnchor does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'OALayoutAnchor')

❌ error: SimulatorRemoteNotifications does not support provisioning profiles. SimulatorRemoteNotifications does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'SimulatorRemoteNotifications')

❌ error: TTRangeSlider does not support provisioning profiles. TTRangeSlider does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'TTRangeSlider')

❌ error: Pods-XXX does not support provisioning profiles. Pods-XXX does not support provisioning profiles, but provisioning profile XXX has been manually specified. Set the provisioning profile value to "Automatic" in the build settings editor. (in target 'Pods-XXX')

Target Pods-XXX product Pods_XXX cannot link framework Foundation.framework

Target Pods-XXX product Pods_XXX cannot link framework Foundation.framework

unexpected duplicate creator 'PhaseScriptExecution [CP]\ Copy\ Pods\ Resources /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfhefdsfgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/IntermediateBuildFilesPath/XXX.build/Test-iphoneos/XXX.build/Script-C604611C20EEE0468AAE9.sh' for node '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikadfeglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' with prior creator 'MkDir /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikasdfavzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

ignoring duplicated output file: '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheukvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

** ARCHIVE FAILED **

[02:36:02]: Exit status: 65

+---------------+------------------------------+
| Build environment |
+---------------+------------------------------+
| xcode_path | /Applications/Xcode-beta.app |
| gym_version | 2.62.1 |
| export_method | ad-hoc |
| sdk | iPhoneOS11.1.sdk |
+---------------+------------------------------+

[02:36:02]: ▸ Build system information

[02:36:02]: ▸ warning: unexpected duplicate creator 'PhaseScriptExecution [CP]\ Copy\ Pods\ Resources /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqasdfvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/IntermediateBuildFilesPath/XXX.build/Test-iphoneos/XXX.build/Script-C604611C2EEEE0468AAE9.sh' for node '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheasdfvzieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' with prior creator 'MkDir /Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfheudsafddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

[02:36:02]: ▸ Build system information

[02:36:02]: ▸ warning: ignoring duplicated output file: '/Users/vagrant/Library/Developer/Xcode/DerivedData/XXX-dwpikqfhadfdaieglddgbhcojmrj/Build/Intermediates.noindex/ArchiveIntermediates/XXX-dev/InstallationBuildProductsLocation/Applications/XXX.app' (in target 'XXX')

[02:36:02]:

[02:36:02]: ⬆️ Check out the few lines of raw `xcodebuild` output above for potential hints on how to solve this error

解決方法

Fastfileのgymメソッドに以下のオプションをを追加します。 PROVISIONING_PROFILE_SPECIFIERで使用するProvisioning Profileを指定します。

    gym(
        clean: true,
        xcargs: xcodebuild_args,
        scheme: options[:scheme],
        verbose: true,
        + export_options: {
        + provisioningProfiles: {
        +  BUNDLE_ID => PROVISIONING_PROFILE_NAME
        + }
        + },
        + xcargs: "PROVISIONING_PROFILE_SPECIFIER='#{PROVISIONING_PROFILE_NAME}'"
    )

又、CODE_SIGN_IDENTITYがProvisioning Profileに紐付いていないものが設定されているとエラーとなります。

         buildSettings = {
                ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
                CODE_SIGN_ENTITLEMENTS = "XXX/XXX.entitlements";
                CODE_SIGN_IDENTITY = "iPhone Distribution";
                - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer: 紐付いていない個人証明書 (XXX)";
                + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
                DEVELOPMENT_TEAM = XXX;
                FRAMEWORK_SEARCH_PATHS = (
                    "$(inherited)",

原因

gymのexport_optionsについては、xcode9からmatchを使っていない場合に明記する必要があるとのことです。

https://docs.fastlane.tools/codesigning/xcode-project/#xcode-9-and-up

またProvisining Profileを設定しないとxcode側で自動でAutomatically manage signingになる(?)とのことで PROVISIONING_PROFILE_SPECIFIERでの設定が必要となります。

CODE_SIGN_IDENTITY については、恐らくどこかのタイミングでAutomatically manage signingを設定してしまい 対象のビルド環境のCode Signing Identityの設定が証明書とProvisioning Profileの紐付かないものになっていました。

開発環境の証明書にはmatchを使っているのですが、リリースの証明書周りは以前から作られている証明書を使っているため 参照する証明書の設定がズレていたのが原因です。

ハマったポイント

  • Bitrise上のログ(特にProvisioning Profile等の文字列)は成功時のものと同じ
  • Xcode9が原因かと思われたが同環境のdevelopブランチは成功した
    • 動作するdevelopブランチのxcodeprojをチェックアウトしてきても発生した
  • 上のことからCacheが影響していた?(Podsフォルダが残っていて成功していた可能性があったのでPodsフォルダを都度削除するようにした)
  • Xcode 9で同様のIssueがたくさん立てられていたが、この問題は実はXcode9ではなくXcode8からある現象だった
  • Apple側がAutomatically manage signingを推奨している(設定しないと自動で"自動"に切り替わる)
  • Bitrise上でのみでしか実行を試せなかったので手間と時間が余計にかかった

終わりに

言語やXcodeの変わり目ではこういった変更と合併した設定ミスでハマりやすいですね。 ローカルで動作する環境を用意しなかったのも余計に時間がかかった原因でした。 自動化をすると疎かになりがちですが、個々の設定内容も理解しなければいけないと思います。