一、背景
本文想介绍介绍最近几年做的项目中所踩过的一些坑以及对其的一些反思。这里的坑不是要介绍项目中出现了崩溃。而是集中在项目中本地数据较多或逻辑较复杂的背景情况下,作为上层App业务开发我们遇到了哪些问题,又是怎么推进优化的,希望能够在这里抛砖引玉。
二、联系人相关
1、联系人模块背景
在我们的项目主页中存在一个联系人Tab,页面整体和微信的通信录Tab页面很相似。在工程中我们将用户信息封装成了UserInfo对象,在联系人仓库单例中维护了UserInfo的List集合。同时基于这个List暴露了一些接口:
- 是否是我的好友
- 添加好友
- 移除好友
- ...等等
基于以上接口逻辑,我们在仓库中维护了联系人相关逻辑。项目上线初期有用户向我们反馈,在使用App的时候存在卡顿、不流畅。由于是项目初期各种基建不完善,我们接收到用户的反馈仅有通过查看日志一种路径方式。
2、集合维护不当导致卡顿
查看用户日志,发现用户存在6000+好友,而在仓库中我们维护好友数据是一个List集合List<UserInfo>。其实现方法大致如下:
public boolean isFriend(String uid) {
for (UserInfo info : friendsList) {
if (TextUtils.equals(info.uid, uid)) {
return true;
}
}
return false;
}
以上接口方法当数据较少时,没有任何问题,但是当好友数据达到一定数量级,这个方法就变成了耗时方法。
优化方案
找到问题之后对于我们来讲,就比较好解决了,我们调整了仓库中维护联系人的集合,由List<UserInfo> 改为 Map<String, UserInfo> ,isFriend方法调整为:
public boolean isFriend(String uid) {
return mFriendsMap.containsKey(uid);
}
上层需要好友列表数据时,直接通过mFriendsMap.values()提供。
3、对象数量未复用
在Review联系人模块代码时,发现除了在仓库中维护的好友数据集合之外,在上层联系人ViewModel中,由于UserInfo对象用于展示在UI层还缺少了两个字段,所以在ViewModel中又构建了一个 List<ContactModel> 用于在RecyclerView中展示。
联系人Tab展示在主页,主页在整个App生命周期是一直存在的,所以相当于关于好友数据对象,在内存中维护了两份。如果你有10000个联系人,那么内存就维护了20000个相关对象(分别是10000个UserInfo对象,10000个ContactModel对象),这显然是不合理的。
经过审慎的评估我们发现,实际上ViewModel层中构建的ContactModel是不必要的,其内部新增用于在UI层展示的字段,可以在列表展示时动态获取,所以将这部分逻辑进行了优化。
4、联系人标签动态加载
在上一节中有提到,有的字段可以在展示时动态加载,不需要在展示UI之前就将数据完全准备好。一般这类数据都是有单独的接口的,不是跟随联系人接口统一下发下来,其中就有用于展示联系人在职状态的标签数据。经过优化,我们封装了一套标签的动态的加载方案,可以更加优化的解决问题,具体实现可以看看这篇:以展示用户标签需求举例,看优雅技术方案设计
三、系统服务相关
1、多次调用系统接口导致ANR
在项目中我们不可避免的要和系统服务打交道,例如使用ConnectivityManager获取当前设备网络状态,使用AudioManager获取音量,播放模式相关数据。
如在我们的项目中某个业务逻辑会存在频繁的切换当前语音的播放模式,即在听筒与扬声器之间快速切换或者调整音量,实践过程中我们发现在主线程频繁调用AudioManager的接口,会引发卡顿甚至ANR。
基于此我们做了以下优化:
- 非必要不要频繁调用系统服务。在一些接口请求中,尤其是项目工程中的埋点接口,需要上报当前网络等状态,添加状态缓存逻辑,同时为这个状态添加超时时间,超时时间内的系统状态数据通过缓存获取。
- 一定要频繁调用系统服务的逻辑,通过特定的线程池去访问,规避对主线程的影响。
2、更新角标未读数:存在卡顿问题
线上卡顿框架监控发现我们App在华为手机上,更新桌面未读数图标时存在偶现的卡顿。虽然是偶现,但是每周也会有几条上报,拉取日志排查发现上报卡顿堆栈时,同时都是用户接收消息较为频繁,未读数变更也同样较为频繁。
华为手机上更新未读数方法:
public static void setBadgeNumber(Context context, int number) {
try {
Bundle bundle = new Bundle();
bundle.putString("package", context.getPackageName());
String launchClassName = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()).getComponent().getClassName();
bundle.putString("class", launchClassName);
bundle.putInt("badgenumber", number);
context.getContentResolver().call(Uri.parse("content://com.huawei.android.launcher.settings/badge/"), "change_badge", null, bundle);
} catch (Exception e) {
e.printStackTrace();
}
}
基于此我们优化更新未读数逻辑,之前是只要未读数有变更就调用接口更新,改为当App在前台时不调用接口更新未读数,当App切换到后台之后统一调用华为更新未读数接口更新。调成策略之后卡顿堆栈基本消失。
(这里的逻辑实际上当App在后台时,收到新消息还是会频繁变更,但是由于切换到后台在未设置白名单的情况下,App短时间就会长连接就会断开,所以后台复现这个问题的概率非常小,特此说明。)
四、数据库相关
1、搜索数据较慢
由于笔者所在的项目中是IM类应用。在本地存在大量IM相关的数据,如联系人、群、群成员、最近消息列表、消息、组织架构等等。按照目前笔者项目中监控的数据来看,线上联系人数据较多的有近10000个好友,群数量有在7000左右,组织架构如果具有全公司的权限的话数量也非常多,消息最多的用户其消息数据库有10G以上,几千万条消息。
为此就出现了一个搜索困难问题,在项目初期我们主要以本地搜索为主,因此本地进行了大量的搜索逻辑优化。我们将搜索能力基于技术方案大致规划为两类:
- 搜索消息
- 搜索联系人、群等其他数据
搜索消息 针对搜索消息我们主要是引入全文检索,具体的实现可以看这篇:Android 数据库系列二:全文检索踩坑记录与相关思考
搜索联系人、群等 搜索联系人、群引入了搜索树的概念,具体实现可以看这篇:Android 如何实现中文全拼、简拼搜索
2、SQL 执行次数较多
由于项目中大量应用了数据库逻辑,所以当App上线后,数据库问题一度非常困恼我们,例如其中就有数据库SQL语句执行较多引发的崩溃问题。由于前面有详细写过文章,这里就不过多赘述了,感兴趣的同学可以去看看:Android 数据库系列三:复杂项目SQL治理与数据库的优化总结
五、锁相关
笔者这里专门将锁相关的问题提出来,原因在于项目初期我们有遇到过多个场景,开发人员经验不足使用锁的场景选择不当,最终导致App性能一般,同时相关问题隐藏较深,较难排查。
在这里我们将锁的优化方向简单总结为两个方向:锁的粗化、锁的细化
1、锁粗化
示例代码:
public void iterateInfo(List<String> infos) {
for (String info : infos) {
synchronized (Info.class) {
execute(info);
...
}
}
}
private void execute(String info) {
...
}
通过示例代码,可以看到在iterateInfo方法中,由于synchronized关键字在for循环内部,每一次For循环都是一次获取锁、释放锁的过程,这显然是不合理的。经过评估完全可以将以上调整为:
public synchronized void iterateInfo(List<String> infos) {
for (String info : infos) {
execute(info);
...
}
}
private void execute(String info) {
...
}
2、锁细化
锁细化和锁粗化正好相反,将加锁的操作放在一个非常外层的一个方法中,实际上Review整段逻辑会发现,我们可以将锁细化到其中一个非常小的方法中,其他部分即使多线程并发访问也没有关系,仅关心最终数据部分就可以了。 示例代码:
public synchronized void iterateInfo(List<String> infos) {
//大段代码
...
execute("");
//大段代码
...
}
private void execute(String info) {
...
}
优化后:
public void iterateInfo(List<String> infos) {
//大段代码
...
execute("");
//大段代码
...
}
private synchronized void execute(String info) {
...
}
六、数据增量同步加载
在项目中由于部分接口数据量较大,同时产品希望数据有任何变更都能够在下次登录时同步到本地。所以开发过程中面对这一类数据我们采用都是分页 + 增量同步的方式。
同步方案
调用服务端接口同步数据,在首次登录时传入时间戳 0,即同步线上所有的数据。此时客户端会进行分页同步,当没有更多数据后,保存接口返回的时间戳。再次登录时传入上次保存的时间戳,仅同步一段时间内变化的数据。这样就不需要每次登录后都同步所有的数据了。
方案评价
增量同步的好处显而易见,就是可以做到不必每次都同步全量的数据。但在实践过程中我们也遇到了几个问题:
客户端Bug:时间戳已更新但本地缺少数据 之所以出现这个问题我们排查下来基本都是接口数据返回之后,优先更新了本地时间戳,然后再调用数据接口去同步。在这个过程中由于进程被杀、客户端逻辑Bug等原因,就导致虽然时间戳更新了,但数据并没有保存到本地。后面我们针对此类接口统一进行了梳理,确保一定是数据保存完成之后,才更新本地时间戳
服务端Bug:返回了时间戳但是数据没有提供 我们还遇到过服务端出现的Bug,接口返回了最新的时间戳但是没有提供变化的数据,导致客户端本地缺少变更的数据。
针对以上两类问题的发现是比较困难的,例如用户在PC端上退出了一个群,其打开移动端App大概率也不会特意去群列表中搜索一下是否还有这个群,一般都是正好有一些事情发现的 : “哎,这个群我不是退出了么,怎么在手机上还存在”,然后向我们反馈Bug。今年我们针对这种情况完善了业务埋点方案,旨在通过埋点监控的方案发现逻辑上Bug。
总结
以上问题实际上都不是特别复杂的问题,但是基本都是我们在开发初期编程时没有注意引入的Bug,如果在项目初期,开饭人员本身具有类似的经验,在逻辑设计之初就考虑到以上这些点,那么代码在健壮性、用户体验会等方面都会提高很多。所以这里在此罗列一部分希望能够给读者提供一些参考,少踩一些坑。