本篇文章讨论旧版本升级到新版本时,新功能如何处理旧数据的场景。
代码属于这个App: JusTalk,一个视频通话类的APP,本文说的是安卓。
所谓回忆功能,就是借(chao)鉴(xi)苹果系统中的系统相册的回忆模式,还有一点点对微信的致(chao)敬(xi)。回忆的内容是通话中产生的视频和涂鸦数据(可以简单的理解为一种特殊的视频)。原本是分开两个列表显示的,并且没有区别是与谁通话中产生的,而新的回忆功能是要根据对方的账号来分类显示的。
本质上,这个功能就是将原来的两个列表合并成一个,并用新的规则分类。
原来的两个列表的数据源都是文件,其实都在一个目录下,通过扩展名来区分属于哪个列表。目录在内部存储的JusTalk目录下,卸载也不会删除,可以在系统相册中看到这个目录。以mp4为扩展名的就是录制的视频,而涂鸦会有3个文件名相同扩展名不同的文件,只看jpg就好了。
原功能的弊端
从产品角度讲,所有的这些内容都是在与人通话过程中产生的,但内容生成后并没有以人为本,没有记录是与谁通话产生的,于是产生了孤立的一些文件,用户进入列表只能看到缩略图,没有与人关联。
从技术角度讲,原列表是直接遍历文件系统,并加以过滤,显示合适的内容,这在小数据量的时候并没有什么严重的问题。但最坏情况下,目录下的文件很多,遍历一遍也是很费时的,而且将文件列表保存在了内存里也加大了内存的使用。而且为了使列表能自动更新,监听文件系统变化,是一个很不优雅的行为。
新的模型
首先要保存通话对象的信息,其次要解决遍历文件的问题。这个APP中大量使用了Realm做为数据库使用,只需一个简单的数据模型就能解决以上问题。
对视频/涂鸦建立模型,创建一个数据表:
Name | Type | Description | Attributes |
---|---|---|---|
date | Date | 创建时间 | Indexed |
uri | String | 所属账号uri | Required |
type | String | 类型(涂鸦或视频) | Required |
fileKey | String | 文件名相关 | Required |
对应的Java类:
public class RecollectionItem extends RealmObject {
public static final String TABLE_NAME = "RecollectionItem";
public static final String FIELD_URI = "uri";
public static final String FIELD_DATE = "date";
public static final String FIELD_TYPE = "type";
public static final String FIELD_FILE_KEY = "fileKey";
public static final String TYPE_DOODLE = "doodle";
public static final String TYPE_VIDEO = "video";
@Index
private Date date;
@Required
private String uri;
@Required
private String type;
@Required
private String fileKey;
// getter and setters...
}
对用户建立模型,再创建一个数据表:
根据需求,要区分通话对象,类似于会话,表示每个用户(通话对象),模型如下:
Name | Type | Description | Attributes |
---|---|---|---|
uri | String | 账号uri | PrimaryKey |
latestDate | Date | 最后一条记录的日期 | Required |
latestItem | RecollectionItem | 最后一个Item | - |
items | RealmList<RecollectionItem> | 这个账号的所有Item | - |
对应的Java类:
public class RecollectionGroup extends RealmObject {
public static final String TABLE_NAME = "RecollectionGroup";
public static final String FIELD_URI = "uri";
public static final String FIELD_LATEST_DATE = "latestDate";
public static final String FIELD_NAME = "name";
public static final String FIELD_ITEMS = "items";
public static final String FIELD_LATEST_ITEM = "latestItem";
@PrimaryKey
private String uri;
@Required
private Date latestDate;
private String name;
private RealmList<RecollectionItem> items;
private RecollectionItem latestItem;
}
以上模型有很多冗余的字段:
- Item中的uri:按照Realm的方式,Item是直接链接在Group中的,不需要一个外键来表示自己是哪个Group的。这里加上uri没有强力的理由,可能的用途是只知道Item去查找Group,这个Realm也提供了内置的方案去解决,但需要新版本的Realm,顺便吐槽一下Realm更新速度非常快,现在最新版本已经3.6了,我们仍在使用2.3。
- Group中的latestDate和latestItem,这两个都是可以从items的第一条去获取的,不过Realm并没有提供SQL中复杂的表连接,想要用最新条目的日期对Group排序是没有直接的方法的,官方的说法是:你可以自己随便组合呀。
这个功能的特点是更新频率低,而且没有大量的数据批量插入和更新,读取数据是显示在列表中的,没有复杂的搜索功能,简单直接。于是加几个冗余的字段会省很多事,这个省事的部分是查询,插入和删除稍微费点事,需要同步更新冗余的字段。
这些冗余使得查询会变得非常简单直接,这也是使用多少冗余,或者说冗余到什么程度的标杆。这里要提一下Realm的特点,Realm查询到的RealmResult<T>,并不会预先将所有数据保存在内存里,占用的内存特别少,适合在滚动的列表中使用,滚动到哪里读取哪里。如果查询的时候要进行复杂的操作,就要把一个列表的所有对象引用都放到内存里根据规则过滤、排序和组合,来达到SQL中表连接的效果。简而言之,就是达到Adapter的数据源直接使用RealmResult<T>,而不遍历RealmResult<T>的效果。
// 插入
public static void addRecollectionItem(String uri, String type, String fileKey, String defaultName) {
RealmHelper.executeTransaction(realm -> addRecollectionItemRaw(realm, uri, type, fileKey, new Date(), defaultName));
}
private static void addRecollectionItemRaw(Realm realm, String uri, String type, String fileKey, Date date, String defaultName) {
RecollectionItem item = realm.createObject(RecollectionItem.class);
item.setFileKey(fileKey);
item.setDate(date);
item.setType(type);
item.setUri(uri);
RecollectionGroup group = realm.where(RecollectionGroup.class)
.equalTo(RecollectionGroup.FIELD_URI, uri)
.findFirst();
if (group == null) { // 没有对应的group自动创建一个新的
group = realm.createObject(RecollectionGroup.class, uri);
}
group.setName(defaultName);
// 这里按日期降序直接找到新插入的位置,插入后即排好序
int position = findPositionToInsert(group.getItems(), item);
if (position == 0) { // 注意更新latest
group.setLatestDate(item.getDate());
group.setLatestItem(item);
}
group.getItems().add(position, item);
}
private static int findPositionToInsert(RealmList<RecollectionItem> items, RecollectionItem item) {
int n = items.size();
for (int i = 0; i < n; i++) {
if (item.getDate().compareTo(items.get(i).getDate()) > 0) {
return i;
}
}
return n;
}
// 删除
public static void deleteRecollectionItem(RecollectionGroup group, RecollectionItem item) {
RealmHelper.executeTransaction(realm -> {
// 如果删除的是最新的一条,记得更新group中的冗余数据,Item没有主键,这里用FileKey来判断
boolean needUpdateLatest = item.getFileKey().equals(group.getLatestItem().getFileKey());
item.deleteFromRealm();
if (group.getItems().isEmpty()) {
group.deleteFromRealm(); // 没有item直接删掉,会省很多事
} else if (needUpdateLatest) {
RecollectionItem latest = group.getItems().get(0);
group.setLatestItem(latest);
group.setLatestDate(latest.getDate());
}
});
}
自动更新
Realm内置自动更新的机制,非常方便,只需要在界面中添加一个监听,发生变化时直接刷新列表即可,之前查询到的数据(RealmResult<T>)都会在触发监听后自动更新,也就是说在监听的回调中即可刷新Adapter。
// UI上有两级列表,一个是Group列表,一个是Item列表,本质上都是显示Item,故用同一个界面显示,使用参数区分一下。
if (isUserListMode()) {
mRecollectionGroups = mRealm.where(RecollectionGroup.class)
.findAllSorted(RecollectionGroup.FIELD_LATEST_DATE, Sort.DESCENDING);
setTitleForActivity(getString(R.string.Memories));
} else { // 对一个Group的所有条目也使用Group列表,方便监听变化
mRecollectionGroups = mRealm.where(RecollectionGroup.class)
.equalTo(RecollectionGroup.FIELD_URI, mUri)
.findAll();
mRecollectionItems = mRecollectionGroups.size() > 0 ? mRecollectionGroups.get(0).getItems() : null;
setTitleForActivity(getDisplayName(mRealm, mUri, mDefaultName));
}
// 只需要在Group列表上进行监听即可,因为每个Item的变化都会引起Group的变化(items字段)
mRecollectionGroups.addChangeListener(e -> {
mAdapter.notifyDataSetChanged();
configEmptyView();
});
旧数据导入引发的问题
原功能是没有数据库的,信息都保存在文件本身里,比如文件名保存了类型信息,文件的创建时间属性保存了文件的创建时间。而新版本需要的用户信息,并没有任何保存,对于之前版本产生的文件,是根本没有办法分类到正确的用户的,所以使用了一个特殊用户,将旧版本的数据都导入了进去。
旧版本产生的文件并不难处理,最有意思的是安卓删除app后并不会删除这个存放文件的目录,之前安装的版本产生的文件对新安装的程序也是可见的,并不一定卸载的就是旧版本。如果新版本产生了文件,然后卸载,然后再安装新版本,这个时候,就需要把之前产生的文件导入到数据库中。
如果文件本身提供不了所有的信息,那么就只能按照旧数据一样去导入了。在创建文件的时候,是可以把这个功能需要的所有信息都保存到文件本身中的:
- date:即文件创建的时间,读取文件属性可以得到。
- uri:保存到文件名里,从文件名读取。
- type:根据文件扩展名可以推测出来。
导入数据还有个问题需要考虑,就是导入的时机,一般有两种做法,app启动时导入;另一种是使用时导入。两种做法各有利弊,如果是启动时导入,势必要延长启动时间,或者占用AsyncTask线程;如果是在使用时导入,必然会延长第一次使用时操作的时间,或者出现短暂的数据不同步状态。
我们选用的方案是第二种,因为我们的app内(hen)容(duo)丰(la)富(ji),启动的时候做的事情非常多,这个功能不是必须启动时就使用,就不要添乱了。于是决定在第一次打开列表界面的时候创建新的线程导入。
这个策略后续也发现了一些问题,比如在导入之前就产生的新的数据(通过拨打电话),等到导入的时候(打开列表),就需要区分哪些文件是新生成的,从而不导入这些文件,以免产生两份相同的数据。
另一个问题,我们的数据库文件是按登录账号分隔的,一个账号登录看到的,换个账号是否还能看到?是不是还要导入一下。这个问题就涉及到产品定义了,最后根据“不是同一个账号,不应该看到另一个人的数据”的原则,不进行导入。(其实这个原则是经不起推敲的:P)
关于Picasso的一点使用心得
显示图片使用的是Picasso,对涂鸦生成的文件,显示的就是jpg文件,直接显示没有问题;而视频只有一个mp4文件,需要显示视频的缩略图,同样使用Picasso来显示就要自定义一些东西了。
Picasso的核心是加载(缓存)和显示,对于视频缩略图的问题,加载即通过视频文件计算第一帧的图像的过程,显示就与普通图片一样没有区别了。所以需要自定义加载的部分,Picasso提供了RequestHandler来自定义请求的处理,即加载过程。
// 使用的时候直接传视频文件,与图片一样,不区分类型
public void onBindViewHolder(MyViewHolder holder, int position) {
...
File file = new File(MyFavoriteManager.getFullPath(item.getFileKey()));
Picasso.with(getContext())
.load(file)
.into(holder.mImageViewThumbnail);
...
}
// 计算视频缩略图的RequestHandler
public class Mp4FileRequestHandler extends RequestHandler {
@Override
public boolean canHandleRequest(Request data) {
Uri uri = data.uri;
return uri.getScheme().toLowerCase().equals("file") && uri.getPath().toLowerCase().endsWith(".mp4");
}
@Override
public Result load(Request data, int networkPolicy) throws IOException {
Bitmap bitmap = getVideoFirstFrame(data.uri.getPath());
return bitmap != null ? new Result(bitmap, Picasso.LoadedFrom.NETWORK) : null;
}
public static Bitmap getVideoFirstFrame(String path) {
MediaMetadataRetriever media = new MediaMetadataRetriever();
try {
media.setDataSource(path);
return media.getFrameAtTime(1);
} catch (Exception e) {
return null;
}
}
}
// 添加RequestHandler到Picasso,在app初始化的时候设置。
private void initializePicasso() {
Picasso.setSingletonInstance(new Picasso.Builder(this)
.addRequestHandler(new Mp4FileRequestHandler())
.build());
}
趟过的坑
Nexus 5(API 22)手机上视频文件名含有特殊字符会无法用Intent播放,其他系统测试过没有问题,安卓各个版本系统都测试过也没有问题。经过实验,将文件名中的"@","[","]" 去掉之后就没问题了,文件路径是经过编码的,不是编码的问题,原因不详。
但在调试过程中发现了一些有趣log:
E/StrictMode: file:// Uri exposed through Intent.getData()
java.lang.Throwable: file:// Uri exposed through Intent.getData()
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1603)
at android.net.Uri.checkFileUriExposed(Uri.java:2341)
at android.content.Intent.prepareToLeaveProcess(Intent.java:7737)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1495)
at android.app.Activity.startActivityForResult(Activity.java:3745)
是 StrictMode 检查抛出的异常,然而并不是无法播放的原因,为了消灭这个异常可以使用FileProvider:
如果项目的 targetSdkVersion >=24,运行在Android N系统中,这里应该产生一个有身份的异常:FileUriExposedException,要用FileProvider东西来访问文件,具体参考这篇教你如何实现拍照的官方教程:Taking Photos Simply