基于依赖关系的 Android 页面启动时间统计思路

1,241 阅读5分钟

我正在参加「掘金·启航计划」

1、简介

通常,当我们统计一个 Android 页面的启动时间的时候,我们可以通过覆写 View 的 dispatchDraw() 方法实现,比如,

override fun dispatchDraw(canvas: Canvas?) {
    super.dispatchDraw(canvas)
    // 在这里统计时间
}

但是因为 dispatchDraw() 被调用的时候页面可能没有真正地被渲染。比如,当我们页面的数据是异步加载的时候, dispatchDraw() 可能会先于接口返回被调用。这个时候通过 dispatchDraw() 统计到的时间是不准确的。因为虽然页面渲染完了,但是实际上真正的数据并没有展示出来。此时,我们就可以考虑引入依赖关系进行时间统计。

所谓的依赖关系,以上面的例子为例,我们可以将网络返回数据作为一个事件,将 dispatchDraw() 被回调作为一个事件。此时,我们可以定义 dispatchDraw() 事件依赖于网络接口返回的事件。只有当网络接口返回的事件完成之后,再次获取到 dispatchDraw() 事件的时候,我们才判定 dispatchDraw() 为完成状态。这在非常规的渲染情况中比较有用,比如从网络和缓存中拿到结果渲染事件都存在的时候。这可以让我们很方便地计算两个事件之间的时间差。

以上是该方案的背景,下面我们看具体的代码实现。

2、实现

首先,我们定义了一个事件模型。该模型中定义了两个方法,分别用来指定事件之间的依赖关系和当事件完成的时候上报的方法,

/** 事件配置接口 */
interface IEventConfiguration {

    /** 依赖的事件 */
    fun dependsOn(): List<String>

    /** 上报到服务器 */
    fun report(events: Map<String, Long>)
}

然后,我们定义了一个 Manager 实例用于管理事件。然后,定义了一个 register 方法用来向该管理类中注册时间的配置。其中的 name 参数是事件名,

class PerfManager private constructor(private val name: String) {

    /** 事件列表 */
    private val events = ConcurrentHashMap<String, Long>()
    
    /** 事件名到事件的映射关系 */
    private val configurations = ConcurrentHashMap<String, IEventConfiguration>()

    /** 上报状态记录 */
    private val reportStates = ConcurrentHashMap<String, Boolean>()

    /** 注册事件及其名称 */
    fun register(name: String, configuration: IEventConfiguration) {
        if (!configurations.containsKey(name)) {
            configurations[name] = configuration
            cycled = checkDependencies()
            if (cycled) {
                if (BuildConfig.DEBUG) {
                    throw IllegalStateException("cycled dependencies found")
                } else {
                    loge { "Cycled dependencies found for [$name] of ${this.name}" }
                }
            }
        } else {
            logd { "Failed to add configuration for event [$name] of [${this.name}], due: already exists." }
        }
    }
}

考虑到事件之间的依赖关系在配置不当的时候可能会导致循环依赖而使程序进入死循环,这里我们使用 checkDependencies() 方法来检查事件之间是否存在循环依赖。

这里用的是 BFS(广度优先搜索算法)来遍历事件的依赖树。下面的两个方法同理。

/** 循环依赖关系检查 */
private fun checkDependencies(): Boolean {
    val checked = mutableListOf<String>()
    configurations.entries.forEach {
        if (checked.contains(it.key)) {
            // 已经检查过,不用二次检查
            return@forEach
        }
        val checking = mutableListOf<String>()
        checking.add(it.key)
        checked.add(it.key)
        val nodes = mutableListOf<String>()
        nodes.addAll(it.value.dependsOn())
        while (nodes.isNotEmpty()) {
            val child = nodes.removeAt(0)
            if (checking.contains(child)) {
                return true // 发现循环依赖
            }
            if (checked.contains(child)) {
                continue
            }
            checking.add(child)
            checked.add(child)
            val childConfiguration = configurations[child]
            if (childConfiguration != null) {
                nodes.addAll(childConfiguration.dependsOn())
            }
        }
    }
    return false
}

然后,我们定义了 visit() 方法。当一个事件发生的时候,我们调用该方法并传入事件的名称,

/** 访问节点 */
fun visit(event: String) {
    if (cycled) {
        loge { "Ignore visit event [$event] for [$name], due: a cycled dependencies" }
        return
    }
    logd { "Trying visit event [$event] for [$name]" }
    if (!events.containsKey(event) && prepare(event)) {
        bfs(event)
        logd { "Event [$event] added for [$name]" }
    } else {
        logd { "Failed to add [$event] for [${this.name}], due: already exists." }
    }
}

每一个事件,当它所依赖的事件都发生之后,我们会将它的时间信息存储到 Manager 的 events 字段中。这里我们会先判断该事件是否已经存在,当它不存在的时候我们会调用 prepare() 方法。该方法会遍历事件的配置信息,找到其依赖的所有事件,并判断是否所有事件都已完成。当其依赖的所有事件都完成的时候,我们判断为当前事件已经满足依赖条件并记录其时间。

/**
 * 计算事件的加入时间
 *
 * @return 返回 true 表示事件已加入,否则表示未加入
 */
private fun prepare(event: String): Boolean {
    val current = System.currentTimeMillis()
    val configuration = configurations[event]
    if (configuration == null) {
        events[event] = current
        return true
    }
    val nodes = mutableListOf<String>()
    nodes.addAll(configuration.dependsOn())
    while (nodes.isNotEmpty()) {
        val child = nodes.removeAt(0)
        if (!events.containsKey(child)) {
            // 存在子节点未记录
            return false
        }
        val childConfiguration = configurations[child]
        if (childConfiguration != null) {
            nodes.addAll(childConfiguration.dependsOn())
        }
    }
    events[event] = current
    return true
}

当一个新的事件被标记为完成,我们就需要再次遍历整个事件的依赖树,找到所有满足依赖条件的事件,并调用其上报方法,完成信息上报。

/** 使用 BFS 遍历,查找某个节点依赖的所有子节点是否存在 */
private fun bfs(root: String) {
    val configuration = configurations[root] ?: return
    val reported = reportStates.containsKey(root)
    val nodes = mutableListOf<String>()
    nodes.addAll(configuration.dependsOn())
    while (nodes.isNotEmpty()) {
        val child = nodes.removeAt(0)
        if (!events.containsKey(child)) {
            // 存在子节点未记录
            return
        }
        val childConfiguration = configurations[child]
        if (childConfiguration != null) {
            nodes.addAll(childConfiguration.dependsOn())
        }
    }
    // 上报数据
    if (!reported) {
        configuration.report(events)
        reportStates[root] = true
    }
}

最后,我们定义了 reset() 方法,用来恢复当前 Manager 内的状态标记。以便于下次统计使用,

/** 重置 */
fun reset() {
    events.clear()
    reportStates.clear()
}

3、使用

按照上述方式实现我们的代码思路之后,可以按照如下方式来使用。这里,我们将一个事件的 Manager 定义为单例的,以便于确保每次调用它的时候其事件配置已经注册完毕,

interface Perf {
    /** 播放页 */
    private const val PLAYER_NAME = "PLAYER"
    const val PLAYER_EVENT_CREATED = "PLAYER_EVENT_CREATED"
    const val PLAYER_EVENT_GOT_INFO = "PLAYER_EVENT_GOT_INFO"
    const val PLAYER_EVENT_RENDERED = "PLAYER_EVENT_RENDERED"

    @Volatile private var playerPerfManager: PerfManager? = null

    /** 用于播放页的管理类 */
    @JvmStatic fun ofPlayer() : PerfManager {
     synchronized(this) {
         if (playerPerfManager == null) {
             playerPerfManager = PerfManager(PLAYER_NAME)
             playerPerfManager?.register(PLAYER_EVENT_GOT_INFO,
                 VoicePerfManager.EventConfiguration(listOf(PLAYER_EVENT_CREATED))
             )
             playerPerfManager?.register(PLAYER_EVENT_RENDERED,
                 VoicePerfManager.EventConfiguration(listOf(PLAYER_EVENT_GOT_INFO)) { events ->
                     val frameRenderCost = (events[PLAYER_EVENT_RENDERED] ?: 0) - (events[PLAYER_EVENT_CREATED] ?: 0)
                     val gotInfoCost = (events[PLAYER_EVENT_GOT_INFO] ?: 0) - (events[PLAYER_EVENT_CREATED] ?: 0)
                     if (BuildConfig.DEBUG) {
                         Log.e("VOICE_PERF_$PLAYER_NAME", "got_voice_info_cost[$gotInfoCost ms], " +
                                 "first_frame_render_cost[$frameRenderCost ms]")
                     }
                     // ... 上报服务器
                 }
             )
         }
         return playerPerfManager!!
     }
}

这里我们定义了三个事件,分别是页面创建、获取到数据和渲染完成事件。依赖关系是,渲染完成事件依赖获取到数据事件,获取到数据事件依赖页面创建事件。

然后,在页面的创建和从服务器中获取到数据的时候添加事件,

Perf.ofPlayer().visit(Perf.PLAYER_EVENT_CREATED);
Perf.ofPlayer().visit(Perf.PLAYER_EVENT_GOT_INFO);

最后,我们将页面的根节点换成自定义控件,并在 dispatchDraw(canvas: Canvas?) 中添加页面渲染的事件,

class PlayerProgramFrameLayout: FrameLayout {

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun dispatchDraw(canvas: Canvas?) {
        super.dispatchDraw(canvas)
        Perf.ofPlayer().visit(Perf.PLAYER_EVENT_RENDERED)
    }
}

按照上述方式,我们便可以记录页面数据请求完毕到渲染完成的耗时时间。

总结

以上是基于依赖关系的冷启动时间统计方案的设计思路和实现逻辑。

这个方案比较适合自动化的 APM 无法覆盖的场景。考虑到页面可能多次渲染,这里基于 dispatchDraw(canvas: Canvas?) 未必绝对准确,只能说是相对更接近用户感知。