👻

[iOS] SourceryとAspectsを組み合わせて自動でタップイベントをトラッキングする

2020/12/03に公開

画面内に有る青と赤の領域にあるボタンをタップするとトラッキングのログが吐き出されている様子

はじめに

継続的に機能改善を行うにはユーザの行動分析は欠かせません.そのため,多くの企業がユーザがアプリ内で開いた画面やタップしたボタンなどのインタラクションをトラッキングしているのではないでしょうか.

しかし,トラッキング処理は有益な一方でコードの見通しを悪くする要因の1つでもあります.なぜなら,トラッキングの処理はアプリの価値を表す主要な機能(以降,ビジネスルールと呼ぶことにします)とは一切関係が無いからです.それなのにも関わらず,トラッキング処理はビジネスルールが存在するのと同じ上位のレイヤーに現れることが多いため,尚更ノイズに感じてしまいます.

こういった乱雑な処理は,どうにか隠蔽して意識せず開発を進めたいです.この記事では,iOSアプリ上のボタンのタップイベントを対象にして,その方法を模索および提案します.

目的

あるViewControllerが持つUIButtonがタップされたときに,ログイベントを貯めているサーバに問い合わせを行います.このとき,実装者がトラッキングの処理を一切書かないことと見ないことを目指します.

実装

それでは,早速どのように実現するかについて説明します.

方針

先述した目的を実現するためには次の2つの処理が必要です.

1.ボタンのタップイベントを自動でハンドルして,タップイベントをサーバへ送信する.
2.対象とするViewController全てで1の処理を実行する.

1の処理はメタプログラミングによって実現できそうです.これは,対象とするViewControllerのプロパティとして保持している全てのUIButtonに対してaddTarget(_:action:for:)を呼び出すイメージです.
2の処理はAspectsをによって実現できそうです.これは,メタプログラミングをした1の処理をViewControllerの任意のライフサイクルにフックさせて実行させるイメージです.

実装例

方針だけではイメージしずらい部分もあると思うので,ここからはコードを交えつつ詳細な説明を行います.

メタプログラミングによるタップイベントのハンドリング

まず最初にメタプログラミングで実現したゴールを説明します.タップイベントをトラッキングしたいと思っている,2つのUIButtonを内部に持つAviewControllerを考えることにします.

class AViewController: UIViewController {
    let hogeButton = UIButton()
    let fugaButton = UIButton()
}

AviewControllerに対して次のようなコードを生成するのが一旦のゴールです.生成されたAviewController.registerTabEventLogger()は,ViewControllerとUIButtonの組み合わせによる文字列をパラメータとしたタップイベントをトラッキングするように各UIButtonに命令します.

extension AViewController {
    fileprivate func registerTabEventLogger() {

        hogeButton.addTarget(self, action: #selector(didTaphogeButton), for: .touchUpInside)
        fugaButton.addTarget(self, action: #selector(didTapfugaButton), for: .touchUpInside)
    
    }

    @objc private func didTaphogeButton() {
	Logger.send(event: .tap(button: "AViewController.hogeButton"))
    }
    
    @objc private func didTapfugaButton() {
	Logger.send(event: .tap(button: "AViewController.fugaButton"))
    }
}

ゴールを明示したところで,早速実装の説明に移ります.Swiftにおいて,こういったメタプログラミングをするのにはSourceryが有用です.Sourceryを使って上記のようなコード生成を行うために,次のようなProtoclとテンプレートファイルを記述しました.SourceryのREADMEに従ってセットアップを行いビルドを走らせると,TapEventLoggableControllerに準拠したクラスに対応するコードが自動生成されます.これが,タップイベントをサーバへ伝える処理を自動で生成する処理です.

protocol TapEventLoggableController { }

// 全てのUIViewControllerへ適応させる
extension UIViewController: TapEventLoggableController {}
// あるいは特定のUIViewControllerへ適応させる
extension AViewController: TapEventLoggableController {}
import UIKit

<%_ for vc in types.classes.filter { $0.implements["TapEventLoggableController"] != nil } { -%>
extension <%= vc.name %> {
    fileprivate func registerTabEventLogger() {

    <%_ for button in vc.variables.filter { $0.typeName.name == "UIButton" } { -%>
        <%= button.name %>.addTarget(self, action: #selector(didTap<%= button.name %>), for: .touchUpInside)
    <% } -%>

    }
    <%_ for button in vc.variables.filter { $0.typeName.name == "UIButton" } { -%>

    @objc private func didTap<%= button.name %>() {
        Logger.send(event: .tap(button: "<%= vc.name %>.<%= button.name %>"))
    }
    <% } -%>
}
<% } -%>

Aspectsによるタップイベントの監視の開始

registerTabEventLogger()を呼び出すことによって,タップイベントがサーバへ伝達されるようになりました.それではこのメソッドは誰が呼ぶのでしょうか?ViewControllerの中で決まりごととして毎回呼びますか?それは呼び忘れのミスに繋がるし,トラッキング処理の隠蔽がしきれていません.

今回はAspectsを使ってViewControllerの.viewDidAppear(_:)をhookしてregisterTabEventLogger()を呼び出すようにしました.結果が次のコードです.

// Generated using Sourcery 1.0.0 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT

import UIKit
import Aspects

extension AViewController {    
    fileprivate func registerTabEventLogger() {

        hogeButton.addTarget(self, action: #selector(didTaphogeButton), for: .touchUpInside)
        fugaButton.addTarget(self, action: #selector(didTapfugaButton), for: .touchUpInside)
    
    }

    @objc private func didTaphogeButton() {
        Logger.send(event: .tap(button: "AViewController.hogeButton"))
    }
    
    @objc private func didTapfugaButton() {
        Logger.send(event: .tap(button: "AViewController.fugaButton"))
    }
}

enum AspectTracker {
    private static var token: AspectToken?

    static func setup() {
        guard token == nil else { return }

        token = try? UIViewController.hook(
            #selector(UIViewController.viewDidAppear(_:)),
            with: .positionBefore,
            using: { info, animated in
		// 抽象化したい気持ちが湧いてきますが,型の継承関係は暗黙的に変えたくないので,やっていません.
                if let vc = info?.instance() as? AViewController {
                    vc.registerTabEventLogger()
                }
            }
        )
    }
}

import UIKit
import Aspects

<%_ for vc in types.classes.filter { $0.implements["TapEventLoggableController"] != nil } { -%>
extension <%= vc.name %> {
    fileprivate func registerTabEventLogger() {

    <%_ for button in vc.variables.filter { $0.typeName.name == "UIButton" } { -%>
        <%= button.name %>.addTarget(self, action: #selector(didTap<%= button.name %>), for: .touchUpInside)
    <% } -%>

    }
    <%_ for button in vc.variables.filter { $0.typeName.name == "UIButton" } { -%>

    @objc private func didTap<%= button.name %>() {
        Logger.send(event: .tap(button: "<%= vc.name %>.<%= button.name %>"))
    }
    <% } -%>
}
<% } -%>

enum AspectTracker {
    private static var token: AspectToken?

    static func setup() {
        guard token == nil else { return }

        token = try? UIViewController.hook(
            #selector(UIViewController.viewDidAppear(_:)),
            with: .positionBefore,
            using: { info, animated in
                <%_ for vc in types.classes.filter { $0.implements["TapEventLoggableController"] != nil } {-%>
                if let vc = info?.instance() as? <%= vc.name %> {
                    vc.registerTabEventLogger()
                }
                <% } -%>
            }
        )
    }
}

これでAspectTracker.setup()を任意の場所で一回呼べば,それ以降は自動トラッキングが走るようになりました.

デモ

以上までで説明した処理を結合すると動画に示すような処理が可能となります.これは,画面内に有る青いボタンをタップするとトラッキングのログがコンソールへ吐き出されている様子を表しています.

画面内に有る青と赤の領域にあるボタンをタップするとトラッキングのログが吐き出されている様子

改善

実運用するにあたって必要となってきそうなことと,その解決方法の考えを明記しておきます.

タップ時にサーバへ送信するイベントを柔軟に変えたい

ポリモーフィズムを使えば良い

protocol TapEventLoggableController {
    var tapEventMap: [UIButton: Event] { get }
}

extension TapEventLoggableController {
    var tapEventMap: [UIButton : Event] {
        [:]
    }
}

class AViewController: UIViewController {
    let hogeButton = UIButton()
    let fugaButton = UIButton()

    var tapEventMap: [UIButton : Event] {
        [hogeButton: .tapHogeButton]
    }
}

@objc private func didTaphogeButton() {
	if let event = tapEventMap[hogeButton] {
	    Logger.send(event: event)
	} else {
	    Logger.send(event: .tap(button: "BViewController.hogeButton"))
	}
}

サーバへイベントを送信するクラスをDIしたい

ViewControllerが保持すれば良い.

protocol TapEventLoggableController {
     var logger: Logger { get }
}

おわりに

この記事では自動でタップイベントをトラッキングする方法を紹介しました.この方法を応用すれば,タップイベント以外のイベント,例えばインプレッションのイベントも容易にトラッキングすることが可能だと思います.ぜひ試してみてください.

参考文献

  • iOSのトラッキング実装ベストプラクティスを考えるhttps://qiita.com/horimislime/items/71702594363b17483567#aranalytics%E3%82%92%E4%BD%BF%E3%81%86%E4%BE%8B

Discussion