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

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容