APP旧版本数据升级实践 - 回忆功能 - 使用Realm

2017-07-25  本文已影响394人  拾识物者

本篇文章讨论旧版本升级到新版本时,新功能如何处理旧数据的场景。

代码属于这个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;
}

以上模型有很多冗余的字段:

这个功能的特点是更新频率低,而且没有大量的数据批量插入和更新,读取数据是显示在列表中的,没有复杂的搜索功能,简单直接。于是加几个冗余的字段会省很多事,这个省事的部分是查询,插入和删除稍微费点事,需要同步更新冗余的字段。

这些冗余使得查询会变得非常简单直接,这也是使用多少冗余,或者说冗余到什么程度的标杆。这里要提一下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后并不会删除这个存放文件的目录,之前安装的版本产生的文件对新安装的程序也是可见的,并不一定卸载的就是旧版本。如果新版本产生了文件,然后卸载,然后再安装新版本,这个时候,就需要把之前产生的文件导入到数据库中。

如果文件本身提供不了所有的信息,那么就只能按照旧数据一样去导入了。在创建文件的时候,是可以把这个功能需要的所有信息都保存到文件本身中的:

导入数据还有个问题需要考虑,就是导入的时机,一般有两种做法,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

上一篇下一篇

猜你喜欢

热点阅读