1. 前言

对于一个数据采集 SDK 而言,数据的完整性和准确性都尤为重要,而实现这些目标的基石正是数据存储功能。因此,如何选择合适的存储方案是数据采集 SDK 需要面临的核心问题。

神策 Android SDK 基于多种数据存储方案的对比和实验,实现了适用于数据采集的存储方案。下面针对神策 Android SDK 数据存储方案进行详细的介绍,希望能够给大家提供一些有益的参考。

2. SDK 存储模块简介

2.1. Android 系统中的数据存储方式

Android 系统中提供的常用数据存储方式大概有五种,即 SharePreferences、SQLite、ContentProvider 、 File 和 NetWork。每一种存储方式具体说明如下:

  • SharedPreference:比较常用的数据存储方式,用于在内存中记录配置信息、标识位等。其本质就是一个 xml 文件,常用于存储简单的设置。
  • SQLite:一个轻量级的数据库,支持基本的 SQL 语法,是常被采用的一种数据存储方式。Android 为此数据库提供了一个名为 SQLiteDatabase 的类,封装了一些操作数据库的 API。
  • ContentProvider:所有应用程序共享的一种数据存储方式。例如,音频、视频、图片和通讯录,一般都可以采用此种方式进行存储。每个 ContentProvider 都会对外提供一个公共的 Uri(包装成 Uri 对象),如果应用程序有数据需要共享时,就使用ContentProvider 为这些数据定义一个 Uri,然后其他的应用程序就通过 ContentProvider 传入 Uri 来对数据进行操作。
  • File: 即常说的文件存储方式,可用于存储大量数据,缺点是更新数据将是一件困难的事情。
  • NetWork:通过网络来存储数据,可以向服务端发送请求获取数据。

2.2. SDK 中的数据存储方式

考虑到性能和数据完整性等原因,缓存到本地的埋点数据采用 SQLite 存储,可以实现高效的增删等操作。SDK 会采集大量的埋点数据,这些数据会首先缓存到本地,然后按照预定的发送策略上报数据。

除了 SDK 采集的埋点数据需要缓存外,在 SDK 实现过程中,还存在一些其他的数据需要缓存。例如,需要记录已启动的 Activity 数量、App 启动时间和 App 退出时间等数据,这些单一且数据量较小的数据适合 SharedPreferences 存储。

为了提高数据操作的便捷性和支持跨进程访问,SDK 采用 ContentProvider 提供统一的数据访问接口。通过 ContentProvider 的封装,可隔离具体的数据存储操作,通过 ContentResolver 便可实现数据的增删改查操作。具体流程如图 2-1 所示:

图 2-1 数据操作流程图

3. SQLite 数据库

3.1. 简介

SQLite 是轻量级嵌入式数据库引擎,它支持 SQL 语言,只利用很少的内存就能获得较好的性能。现在的主流移动操作系统,例如 Android、iOS 等都使用 SQLite 作为复杂数据的存储引擎。在开发应用程序时,可以使用 SQLite 来存储大量的数据。它使用单个文件存储数据,运算速度快,占用资源少,因而特别适合 Android 这样的移动操作系统。

Android 标准库包含 SQLite 库以及配套的一些 Java 辅助类。Android 提供的 SQLiteOpenHelper 是一个数据库操作的抽象类,借助这个类我们可以简单有效的对数据库进行创建和升级。两个重要的抽象方法为:onCreate() 和 onUpgrade() 。顾名思义,分别是在数据库创建和升级时进行调用。两个重要的实例方法:getReadableDatabase() 和 getWritableDatabase()。前者打开或创建一个可读的数据库,后者打开或创建一个可读可写的数据库。SQLiteDatabase 类为我们提供了多种方法,基本上囊括了大部分的数据库操作。对于添加、更新和删除来说,我们都可以使用如下方法:

//使用 SQL 语言操作数据库
db.executeSQL(String sql); 
db.executeSQL(String sql, Object[] bindArgs);
//除了使用 SQL 语句操作外,同时支持非 SQL 语言形式各种操作方法
db.insert(String table, String nullColumnHack, ContentValues values); 
db.update(String table, Contentvalues values, String whereClause, String whereArgs); 
db.delete(String table, String whereClause, String whereArgs);

3.2. 存储策略

实现 SDK 中的数据库时,为了保证数据的完整性和准确性,采用了较为完善的存储策略:

  1. 开发者在初始化 SDK 时,可以根据需要通过 setMaxCacheSize() 方法设置本地缓存文件的最大值。本地缓存默认上限值为 32M,最小上限值为 16M。当用户设置的最大缓存值小于 16M 时,则使用最小上限值。
  2. 执行数据采集任务时,采集的数据首先缓存到本地数据库。数据写入时,会判断数据库文件大小是否超过限定的最大缓存值。若超过最大缓存值,则删除距离当前时间最远的 100 条历史数据,然后再执行入库操作。
  3. 入库后满足上报策略会将数据上报到服务端,上报成功后删除已上报的数据,上报失败则不删除。

3.3. 具体实现

3.3.1. 数据表设计

SDK 采集了事件名称、预制公共属性和用户自定义属性等数据。数据中包含的属性较多,存储数据无需关心具体的细节。将一个事件的数据存储到数据表的一个字段中,可以提高数据的操作效率。

例如,数据存储在 events 表中,为了加快数据查询,针对 events 表 “created_at” 列创建了索引。events 表结构设计如表 3-1 所示:

表 3-1 events 表结构

3.3.2. 数据库操作

通过继承 SQLiteOpenHelper 实现数据库的创建、更新等操作,具体代码参考如下:

class SensorsDataDBHelper extends SQLiteOpenHelper {
    private static final String TAG = "SA.SQLiteOpenHelper";
    //创建 events 表语句
    private static final String CREATE_EVENTS_TABLE =
            String.format("CREATE TABLE %s (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                                  "%s TEXT NOT NULL, %s INTEGER NOT NULL);",
                    DbParams.TABLE_EVENTS, DbParams.KEY_DATA, DbParams.KEY_CREATED_AT);
   //创建 events 表索引
    private static final String EVENTS_TIME_INDEX =
            String.format("CREATE INDEX IF NOT EXISTS time_idx ON %s (%s);",
                    DbParams.TABLE_EVENTS, DbParams.KEY_CREATED_AT);
    SensorsDataDBHelper(Context context) {
        super(context, DbParams.DATABASE_NAME, null, DbParams.DATABASE_VERSION);
    }
    @Override
    public void onCreate(SQLiteDatabase db) {
        SALog.i(TAG, "Creating a new Sensors Analytics DB");
        db.execSQL(CREATE_EVENTS_TABLE);
        db.execSQL(EVENTS_TIME_INDEX);
    }
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        SALog.i(TAG, "Upgrading app, replacing Sensors Analytics DB");
        db.execSQL(String.format("DROP TABLE IF EXISTS %s", DbParams.TABLE_EVENTS));
        db.execSQL(CREATE_EVENTS_TABLE);
        db.execSQL(EVENTS_TIME_INDEX);
    }
}

3.3.3. 数据库封装

数据库相关操作通过 SensorsDataContentProvider 进行封装。SensorsDataContentProvider 继承自 ContentProvider 抽象类,并覆写以下方法:

  • onCreate:在创建 ContentProvider 时使用,系统启动时初始化,SDK 中主要做 Uri 创建及数据初始化相关工作。
  • query:查询指定 Uri 的数据,返回一个 Cursor。
  • insert:向指定 Uri  ContentProvider 中添加数据。
  • delete:删除指定 Uri 的数据。
  • update:更新指定 Uri 的数据。
  • getType:返回指定 Uri 中的数据 MIME 类型。

SensorsDataContentProvider 类除了封装 SQLite 相关数据库操作外,还封装了一些通过 SharedPreference 存储数据的操作。在初始化时通过构建不同的 Uri 来区分具体的数据操作类型。想了解具体实现过程,可参考 SDK 源码学习。

SensorsDataContentProvider 组件定义之后需要在 AndroidManifest.xml 文件中进行注册。 SDK 早期版本需要开发者手动在项目的 AndroidManifest.xml 文件中注册声明,为了便于 SDK 集成,现已将该配置放在 SDK 内部注册。

完成了 SensorsDataContentProvider 封装之后,访问数据接口需要通过 ContentResolver 实现。为了减少代码的耦合性,对数据的访问接口封装在 DbAdapter 中。DbAdapter 采用单例模式创建,里面封装了数据的添加、更新、删除等操作。DbAdapter 中封装的主要方法如图 3-1 所示:

图 3-1 DbAdapter 方法列表

3.3.4. 数据流程

SDK 采集数据过程中,会频繁的执行存储数据、上报数据和删除数据等耗时工作。为了保证 SDK 的数据采集不影响用户的 App 性能,这些耗时的操作会在子线程中完成。SDK 执行数据存储和上报会涉及到 TrackTaskManager、TrackTaskManagerThread 和 AnalyticsMessages 等几个关键的类。其中,TrackTaskManager 用于维护数据采集的任务队列; TrackTaskManagerThread 用于任务的执行调度; AnalyticsMessages 用于事件的存储和上报操作。

当 SDK 调用 track 相关的方法时,会创建一个任务用于执行数据采集、存储和上报等工作。该任务通过 TrackTaskManager 添加到任务队列中,TrackTaskManagerThread 会轮询 TrackTaskManager 管理的任务队列,然后取出任务执行。任务的具体工作是进行预置属性的采集、合并、拼装和入库。数据入库后,根据上报策略判断是否上报,如果数据上报成功则删除已上报的数据。工作流程如下图 3-2 所示:

图 3-2 数据操作流程图

4. SharedPreference 存储

4.1. 简介

SharedPreferences  是用来存储一些简单配置信息的工具,SDK 中有一些业务数据需要频繁的更新,通常数据量较小,适合使用 SharedPreferences 存储。

4.2. 存储策略

SDK 中需要频繁更新的数据主要包含以下几种:

  • activity_started_count:启动的页面个数,用于 $AppStart 触发时机的判断。
  • app_start_time:Activity Start 的时间戳,用于计算 App 使用时长。
  • app_end_time:Activity Pause 的时间戳,用于计算 App 使用时长。
  • app_end_data:当前的 $AppEnd 事件关键信息,例如 App 版本、SDK 版本等。
  • events_login_id:用户登录 ID。
  • session_interval_time:Session 的时长。

SharedPreferences 提供了存储轻量级数据的能力,它的存储策略是采用 XML 格式将数据存储到设备中,存储文件保存在 /data/data/<package name>/shares_prefs 目录下。这里要注意的是,SharedPreferences 只能在创建它的应用中使用。

4.3. 具体实现

由于 SDK 数据采集的频繁,SharedPreferences 操作也比较频繁 ,操作使用不当会导致 App 卡顿,甚至会出现 ANR 问题。对于 SDK 来说,性能和稳定性尤为重要。为了解决以上问题,SDK 做了一些优化操作,下面进行详细介绍。

首次使用 getSharedPreferences() 时,内存中不存在 SharedPreferences 以及 SharedPreferences Map 缓存,需要创建 SharedPreferences 并添加到 ContextImpl 的静态成员变量(sSharedPrefs)中。从首次初始化到读取数据会存在延迟,因为读文件操作会阻塞调用的线程直到文件读取完毕。如果在主线程调用,可能会对 UI 流畅度造成影响,因此将 getSharedPreferences() 调用放在子线程中完成。 SDK 中使用 FutureTask 创建多线程任务,LoadSharedPreferences 实现了Callable 接口,在实现的 call() 方法中获取 SharedPreferences 实例。FutureTask 执行  call() 方法来获得 SharedPreferences 实例。为了解决多线程同时访问造成的性能问题,可以通过  Executors.newSingleThreadExecutor() 创建单一线程的线程池,来执行 FutureTask 任务。线程的创建和线程池的添加封装在 SharedPreferencesLoader 中。

4.3.1. 业务数据抽象封装

为了保证不同数据获取接口的统一性,SDK 对存储的业务数据进行了统一的封装,抽象出 PersistentSerializer 接口。接口中定义了读取数据、保存数据、创建默认值等方法,存储数据类型在初始化时通过范型传入。PersistentIdentity 是所有业务数据类的抽象类,封装了业务数据类公用的 get() 和 commit() 方法。其中,get() 方法用于获取存储数据的值。为了避免数据读取的并发问题,获取数据操作通过 synchronized 同步锁来实现。commit() 方法也采用了同样的方式。

每一种业务数据类都继承自 PersistentIdentity,以 DISTINCT_ID 为例来具体介绍下具体实现,代码如下:

public class PersistentDistinctId extends PersistentIdentity<String> {
    public PersistentDistinctId(Future<SharedPreferences> loadStoredPreferences, final Context context) {
        super(loadStoredPreferences, PersistentLoader.PersistentName.DISTINCT_ID, new PersistentSerializer<String>() {
            @Override
            public String load(String value) {
                return value;
            }
            @Override
            public String save(String item) {
                return item == null ? create() : item;
            }
            @Override
            public String create() {
                String androidId = SensorsDataUtils.getAndroidID(context);
                if (SensorsDataUtils.isValidAndroidId(androidId)) {
                    return androidId;
                }
                return UUID.randomUUID().toString();
            }
        });
    }
}

对于数据的读取和写入操作等可复用方法封装在父类 PersistentIdentity 中,子类只需要覆写构造方法即可。在构造方法中传入要存储数据的 key ,以及实现 PersistentSerializer 接口的匿名内部类。通过该内部类实现默认值的创建,以及对获取的数据或者要保存的数据进行处理。最后,PersistentLoader 实现了所有 SharedPreferences 持久化数据的唯一入口。通过 loadPersistent() 方法,传入需要保存或读取数据的 key,即根据 key 来实例化数据实体对象。

4.3.2. 跨进程访问

SharedPreferences 原则上不支持多进程访问,即使使用 MODE_MULTI_PROCESS 这个字段也并不可靠。因为 Android 内部并没有合适的机制去防止多个进程所造成的冲突。为了解决 SharedPreferences 跨进程访问,可以通过 SensorsDataContentProvider 封装数据的读写方法。根据前面的介绍可知, SensorsDataContentProvider 继承自 ContentProvider,给要存储的数据创建不同的 Uri。然后,通过 Uri 来区分要操作的数据,进而实现写入或读取操作。

SensorsDataContentProvider 实现了数据访问封装,DbAdapter 中封装了 ContentResolver 对 SensorsDataContentProvider 的访问,具体实现可参考 SDK 源码。

5. 总结

本文介绍了 SQLite 以及 SharedPreferences 存储数据的具体实现。为了接口的统一性以及多进程访问的需要,都通过 SensorsDataContentProvider 提供对外访问接口。然后,通过 DbAdapter 对业务层曝露统一的数据接口。这样,SDK 开发过程中对数据的存取操作即可通过 DbAdapter 实现。

最后,希望通过这篇文章,大家能够对神策 Android SDK 存储模块有一个较为全面的了解。

大家如果有什么好的想法,或者发现我们的这个项目有 bug,欢迎大家去 GitHub 上提 issues 或者 Pull Requests,我们会第一时间处理,也希望我们 SDK 能在大家的一起努力下,做得更加完善。
如果大家觉得我们这个项目还可以的话,点上一颗 star 吧。
Sensors Data Android SDK 项目地址:https://github.com/sensorsdata/sa-sdk-android

6、交流合作

本文著作权归神策数据开源社区所有。商业转载请联系我们获得授权;非商业转载请注明出处,并附上神策数据开源社区公众号二维码。
你还可以扫描二维码,加入社区交流群,与大家一同讨论。
也欢迎关注我们的公众号,博客更新尽在掌握。