1. 前言
在 App 的运营活动中,对用户进行弹窗提示,是一种常见的运营方式。例如:用户已经下单但未付款的时候,可以给用户一个优惠券的弹窗提示。
神策 Android 弹窗 SDK[1] 主要针对的就是上述运营场景,运营人员可以在神策智能运营中配置弹窗的 UI 以及触发弹窗的一些条件,当用户满足配置的条件时,集成了弹窗 SDK 的 App 会展示弹窗。UI 效果如图 1-1 所示:
图 1-1 弹窗 UI 效果图
2. 弹窗的实时性
在很多场景下,弹窗需要很高的实时性。如果弹窗的计算规则通过后端处理,符合条件时再下发给客户端,实时性将得不到保障。
为了解决这一问题,把计算逻辑等放在了客户端。简单来说,就是触发埋点事件之后会判断是否触发弹窗。
此外,弹窗 SDK 为了保证实时性,也做了很多工作,下面就逐一为大家进行介绍。
3. 方案演进
3.1. 方案一:共用埋点数据采集线程
触发弹窗的时机,取决于运营同学在神策智能运营中的配置,那弹窗 SDK 如何来决定是否弹窗呢?
举个例子,运营同学的配置条件是:有商品页面的浏览数据就弹窗。因此,在 App 端监控到有商品页面浏览的埋点数据产生就会弹窗。
埋点数据采集任务是在埋点数据采集线程中执行的。因此,最初的想法是在埋点数据采集线程中监控埋点数据,如果有符合弹窗条件的数据,那么就展示弹窗。示例代码如下:
//判断是否弹窗
PlanManager.ensureShowDialog(data);
//数据缓存到数据库
enqueueEventMessage(data);
这种做法很简单,也能满足需求。它的优点如下:
从代码上来看,逻辑比较清晰;
判断是否弹窗和埋点数据采集都是在一个线程,方便维护。
但是,它的缺点也很明显:
如果在判断是否弹窗这一步,有阻塞或者异常,那么会影响埋点数据缓存到数据库;
耦合度非常高。现在是弹窗的业务需要监控数据,如果将来其他业务也需要监控数据,那么在埋点数据缓存之前,还需要增加更多的业务逻辑。
为了解决上述问题,我们拆分了埋点数据采集线程和弹窗判断线程。
3.2. 方案二:拆分埋点数据采集线程和弹窗判断线程
考虑到共用埋点数据采集线程的缺点,我们做了线程的拆分,如图 3-1 所示:
图 3-1 线程拆分示意图
此时,埋点数据采集线程和弹窗判断线程,是两个独立的线程。当埋点数据采集线程有新的数据时,会主动通知弹窗判断线程,让其处理弹窗业务。
这样做不仅降低了耦合度,并且弹窗业务不会影响到埋点数据采集。即使最极端的情况,比如弹窗判断线程因为某些原因出现了异常,埋点数据采集线程仍然能正常工作。
埋点数据采集线程只需要根据接口,回调给数据接收端就行,示例代码如下:
// 监控数据,并传给注册接口的地方
DataMonitorInterface.trackEvent(data);
// 数据缓存到数据库
mMessages.enqueueEventMessage(eventType.getEventType(), dataObj);
在弹窗判断线程中,收到数据后会缓存在队列,示例代码如下:
public class SFDataMonitorImpl{
public void trackEvent(String data){
mSFPlanTaskManager.addTriggerTask(new Runnable() {
//判断是否弹窗
PlanManager.ensureShowDialog(data)
}
}
}
在新的线程中去执行此队列的任务,示例代码如下:
public class SFPlanTriggerRunnable implements Runnable {
@Override
public void run() {
Runnable downloadTask = mSFPlanTaskManager.getTriggerTask();
mPool.execute(downloadTask);
}
}
这种方案看上去已经很完美了,降低了埋点数据缓存和弹窗业务之间的耦合,并且代码上也做了拆分,互相之间的影响很小,同时也能满足各种业务场景。
但是,在测试过程中,我们发现其实弹窗 SDK 的网络请求是最耗时的一步,为了提高实时性,需要进行优化。
这一步主要是为了请求后端,拿到业务同学配置的弹窗信息。要介绍这一部分的优化,首先需要对弹窗 SDK 运行的流程有所了解,如图 3-2 所示:
图 3-2 弹窗 SDK 运行流程图
大致流程如下:
弹窗 SDK 初始化后,首先会读取本地缓存的弹窗数据;
在 App 进入前台时,会请求后端的弹窗数据,请求完成后会把后端返回的弹窗数据和本地的弹窗数据做对比,只取更新的部分;
接着处理埋点数据的任务。
将埋点数据采集和弹窗判断放到各自的串行队列中,具有如下优点:
里面的任务会按照我们添加的顺序进行执行;
一般情况下,不需要考虑并发导致数据不安全的问题。
但是,也有缺点:
当串行队列中的某一个任务发生阻塞时,其后的任务都会延迟执行,特别是此队列中还存在网络请求的任务。因为需要使用网络请求的结果,所以当网络请求完成后才能继续处理其他任务;
所有的任务都由一个线程来执行,特别在初始化的时候,串行队列的负担会比较重,除了图 3-2 中的两次 IO 操作,还有其他的 IO 操作,以及弹窗判断等任务。
那么,在此基础上还可以优化吗?答案是肯定的。
3.3. 方案三:抽离数据加载线程
其实,仔细思考之后可知:弹窗数据请求的任务不必和弹窗判断在同一个串行队列。当完成本地弹窗数据读取之后,就可以启动弹窗判断线程。
至于网络请求,本身是不可靠的。在弹窗的业务中,如果有本地数据,那么就用本地数据,不必等到网络的数据返回后再处理弹窗业务。
基于以上的思路,将数据加载线程抽离,读取到本地数据后,就进行业务的处理,详情参考示意图 3-3:
图 3-3 抽离数据加载线程示意图
流程看上去比之前的方案要复杂,同时牵扯到三个线程,但只要捋清楚它们之前的关系,以及在什么时候进行通信,就比较好理解:
初始化 SDK 后,我们启动了两个线程,分别是数据加载线程和弹窗判断线程,并且让弹窗判断线程处于等待状态,而让数据加载线程去加载本地的弹窗数据;
加载数据完成后,如果数据不为空,那么启动弹窗判断线程;
当 App 进入前台时,通知数据加载线程,加载网络请求返回的数据。
这里有一个场景需要特别注意:弹窗数据正在加载中,同时产生了埋点数据。此时需要根据弹窗数据判断埋点数据是否应该弹窗,但是弹窗数据还没有加载成功,应该怎么办呢?
此时,应该让埋点的数据先缓存在队列中。只有当弹窗数据加载完成后,才会执行缓存队列中的任务,这也是弹窗判断线程启动后就让它等待的原因。另外,在数据更新的过程中,因为更新的是同一份数据,所以也需要对这一步加锁。
在初始化 SDK 内部,等到弹窗数据加载成功时,才会启动弹窗判断线程。示例代码如下:
readLocalPlanData(new Callback() {
@Override
public void onFailure(int code, String errorMessage) {
}
@Override
public void onResponse(String response) {
//弹窗判断线程启动
new TriggerThread().start();
}
});
其他的代码和方案二中的代码很类似,此处不再展示。
需要注意的是:如果使用了线程池,线程池也会缓存任务。如果有业务场景需要停止线程,那么就不能让线程池缓存任务,可以让线程阻塞执行:
public class SFPlanTriggerRunnable implements Runnable {
@Override
public void run() {
Runnable downloadTask = mSFPlanTaskManager.getTriggerTask();
mPool.submit(downloadTask).get();
}
}
这种方案将数据加载线程抽离,解决了网络请求阻塞弹窗判断的场景,它有两个优点:
使线程的职责更加清晰;
网络请求弹窗数据,不再阻塞弹窗线程。
当然,也有缺点:
代码变得比较复杂;
需要保证并发场景下数据的安全性。
不过,基于目前的方案,既能降低延迟,也能保证业务上的需求,目前看来是一次成功的改造。
4. 总结
本文讲述了为了保证弹窗的实时性,弹窗 SDK 线程相关方案的演进过程。
弹窗 SDK 的实时性,经过不断努力终于取得了一定的成果。但在实际使用过程中,我们仍然碰到了不少难点:前后台判断的准确性、弹窗被遮盖等问题。不过,我们有信心在不久的未来一定会突破这些难点。
很多场景,只要我们朝着目标不断前行,不断优化自己的方案与代码,总能达成目标。
仔细斟酌,勇于尝试,犹如心有猛虎,细嗅蔷薇。
5. 参考文献
[1]https://github.com/sensorsdat...
文章来自公众号——神策技术社区
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。