美文网首页
APP旧版本数据升级实践 - 回忆功能 - 使用Realm

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

作者: 拾识物者 | 来源:发表于2017-07-25 23:20 被阅读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;
    }
    

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

    • 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

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/wormhxtx.html