Loading...
墨滴

掀乱书页的风

2021/10/16  阅读:23  主题:兰青

新版部落详情页来袭

新版部落详情页来袭(Android)

产品需求

部落是58的C端用户,基于业务主题的UGC社区,一直处于快速发展的阶段,为了方便迭代,页面为H5实现,发展至今,部落的详情页已趋于稳定,为了提升用户体验,进行了详情页native改版

AczIWq.pngAgS5He.pngAgSoAH.png

可以看到,页面结构及元素的信息表达更清晰,更统一,可玩性更强。

native的优势

  1. 原生的响应速度快
  2. 在无网络或者弱网的情况下体验好

方案设计

技术挑战

从效果图来看,整个详情页是一个复杂列表,顶部栏与底部栏固定,我们需要解决以下问题

AbTkHP.gifAbhdGd.gif

  1. 回复区需要与底部弹起的输入框,回复管理等进行交互,涉及插入,修改,删除,位移等操作,并实时刷新
  2. 页面中大量图片,也会有视频,视频播放控制与列表关联
  3. 标题栏的内容与列表中用户信息项(关注那一条)的滑动有交互效果
  4. 点赞、关注这种局部更新
  5. 由于列表内容较多,条目不一,滑动时对性能要求较高,需要处理可能出现的卡顿问题

整体框架

基于以上问题,我们采用了MVP + RecyclerView + ConstraintLayout的方案,那么这个方案的优势是什么呢

  1. 采用mvp架构,代码解耦,对于复杂的业务,使逻辑更加清晰
  2. ConstraintLayout是Google推荐使用的一种布局,能减少布局层次,减少重绘,使UI渲染更加高效
  3. 对于复杂的列表页面,涉及到多种形式的更新,删除,添加,更新区域等等,使用Recyclerview具有天然的优势,在更新的时候采用 notifyItemXXX()方法,能够实现区域更新而且封装了支持partial-bind(局部更新)的能力,比如只更新关注按钮。

RecyclerView-Adapter封装

在列表中,有的模块非常复杂,如果全部写在ViewHolder中,会显得很臃肿,而且难以维护,要解决这个问题,就必须对Adapter进行封装,满足我们的需要,这里我们拆分成了三部分

  • ViewHolder:UI展示
  • ItemBean:数据元素
  • Delegate:负责将数据展示到UI上,以及交互逻辑
Abn4T1.png
Abn4T1.png

这样就将列表中的每一项进行了解耦,而且带来了另外一个好处,我们是多人协同开发,不同的人开发不同的模块,基本不会受影响

partial-bind(局部更新)

对于列表的更新,上文已经提到过,我们会采用notifyItemXXX()方法,实现区域更新。但是如果我点击了点赞按钮,将点赞的这一项回复包括文字,头像都更新,这个时候点赞的这一项会闪一下,显然是不合理的,一是浪费性能,二是用户体验不好。看来我们还需要实现**partial-bind(局部更新)**的能力,那么我们继续对Adapter进行封装,通过调研,我们发现在Adapter中有一个三个参数的onBindViewHolder方法,其中第三个参数可以传递在某一项中具体的修改

@Override
    public void onBindViewHolder(DetailBaseViewHolder holder, int position, List<Object> payloads) {
        if (payloads.isEmpty()) {
            onBindViewHolder(holder, position);
        } else {
            Bundle payload = (Bundle) payloads.get(0);
            int viewType = getItemViewType(position);
            AbsViewHolderDelegate delegate = viewHolderDelegates.get(viewType);
            if (delegate != null) {
                delegate.setContentChange(mvpView, holder, payload, position);
            }
        }
    }

在Delegate中对应的方法

protected void setContentChange(Bundle payload) {
}

封装完毕,那么怎么使用呢,在数据源改变以后,我们可以调用notifyItemChanged(int position, @Nullable Object payload)方法,将变化的内容通过payload传递进去就解决了更新点赞按钮时闪动的问题,并且提高了刷新效率

Bundle payload = new Bundle();
payload.putInt("subscribe", subscribeBeen.subscribe);
mAdapter.notifyItemChanged(position, payload);

数据处理

详情页数据量比较大,嵌套比较深,在解析数据的时候,要考虑以下两点:

  1. 数据与列表中的每一项(ItemBean)一一对应,方便操作。
  2. 假如采用一个bean对应一个parser的方式,会使解析结构异常复杂,当过段时间再查看代码的时候,会花费很长时间来梳理,而且如果有数据修改,会很不方便。

要解决这两个问题,首先我们采用了模块解析的方式,对于列表中的模块分类解析,然后面向对象的思想来进行封装,对于深层次的数据解析,直接放在bean里面,使结构更加清晰,便于维护,下面的代码是一个较深层次的数据。

public class Topic {
        public String icon;
        public String text;
        public String action;
        public String color;
        public String background;

        public Topic() {
        }

        public Topic(JSONObject jsonObject) {
            if (jsonObject != null) {
                this.icon = jsonObject.optString("icon");
                this.text = jsonObject.optString("text");
                this.action = jsonObject.optString("action");
                this.color = jsonObject.optString("color");
                this.background = jsonObject.optString("background");
            }
        }
    }

图片处理

对于图片加载,自己来封装是不现实的,本着不重复制造轮子的想法,我们采用了Fresco加载框架,它的内部为我们做了大量的优化。本来以为就万事大吉了,但是发现某些低端机器在滑动的时候,一旦滑到中间的14个头像去,就会卡一下。通过调研,发现滑动过程中会出现大量的fresco的资源回收释放动作,而这些动作都是在UI线程中的,这就造成了卡顿。但是问什么会出现频繁回收呢,查阅了一下代码,14个头像是GridView来实现的,而每次划出来的时候,Adapter会重新调用onBindViewHolder方法,重新加载。既然找到了原因,我们就摒弃了GridView的写法,改用两个Linerlayout固定14个头像的方式来实现,问题得以解决

视频控制

视频播放我们要考虑两个方面:

  1. 首次出现到屏幕中直接播放
  2. 滑出屏幕或退到后台播放暂停

起初我们将视频播放组件放在Recyclerview中,可是在实际操作的时候,发现滑动会有不流畅的问题,另外如果要控制视频,需要对Recyclerview进行操作,影响性能 要解决这个问题,经过反复尝试,如果将video控件放在Recyclerview中,始终会有各种各样的问题。那么我们换一种思路,能不能不放在Recyclerview中呢,如果我们将video控件浮在Recyclerview中,就不会受到制约了

AbuCp8.png
AbuCp8.png

但是又有另一个问题,我们的视频需要随着列表的滑动而滑动,这就需要不断监听Recyclerview的滚动状态,来计算视频的位置

//跟随列表滚动而滚动
mDetailListRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
 
    int totalDy;
 
    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
        if (RecyclerView.SCROLL_STATE_IDLE == newState) {
            mOriginalHeight = mVideoView.getTranslationY();
            totalDy = 0;
        }
    }
 
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
        int firstItemPosition = layoutManager.findFirstVisibleItemPosition();
        currentPresent().onScrolled(firstItemPosition);
 
        if (mVideoItemPosition == -1) {
            return;
        }
        totalDy += dy;
        mMoveDeltaY = -totalDy;
        Log.e(TAG, "onScrolled scrollY:" + -totalDy);
        //跟随列表滚动而滚动
        startMoveFloatContainer(true);
        // 移除屏幕 停止播放
        if (mVideoItemPosition < firstItemPosition
                || mVideoItemPosition > layoutManager.findLastVisibleItemPosition()) {
            stopPlayback();
        }
    }
});
private void startMoveFloatContainer(boolean isScroll) {
    final float moveDelta;
    if (mVideoView.getVisibility() != View.VISIBLE) return;
 
    if (!isScroll) {
        ViewAnimator.putOn(mVideoView).translationY(0);
        int[] playAreaPos = new int[2];
        int[] floatContainerPos = new int[2];
        mCurrentPlayArea.getLocationOnScreen(playAreaPos);
        mVideoView.getLocationOnScreen(floatContainerPos);
        mOriginalHeight = moveDelta = playAreaPos[1] - floatContainerPos[1];
    } else {
        moveDelta = mMoveDeltaY;
    }
 
    float translationY = moveDelta + (isScroll ? mOriginalHeight : 0);
    //跟随列表平移视频位置
    ViewAnimator.putOn(mVideoView).translationY(translationY);
}

这样就解决了视频播放的问题,视频播放控件并没有放在列表中,而是巧妙的在recyclerview上面浮了一层,跟随recyclerview滑动而滑动,这样做有什么好处呢:

  • 减少播放操作对列表的影响,解耦
  • 极大的方便了对视频的控制,可定制性更强
  • 便于后期扩展(recyclerview嵌套多个视频、全屏)

输入框

开始的时候是用dialog实现的,遇到了一个问题:当输入框上去的时候,输入法并不是自动跟随上去的,而是有一个空白间隙,明显是两段过程,性能差的手机尤其明显。 然后我们就观察系统的EditText,发现弹起动画很自然,是android系统为我们自动封装的。按照这个思路,我们封装了一个输入框View,去掉了dialog的方式,让自定义view动态的显示与隐藏,可以看下效果

AbMUyD.gif
AbMUyD.gif

管理员操作

AgpyqS.png
AgpyqS.png

管理员操作实际上是独立于详情页的逻辑,涉及的操作比较多,比如添加热门,置顶回复,删除回复等等,而且管理员操作之后,详情页要随之变动,比如刷新,返回上一页等等,在设计的时候有两种实现思路:

  1. 管理dialog负责透传操作信息,具体的操作,都由详情页来实现
  2. 管理dialog将各种操作信息内部消化,处理完之后,告知详情页对应的变动

假设采用第一种思路,自己处理自己的变动,看起来比较合理,但是一旦操作增加之后,需要修改一些列表展示逻辑,比较分散,不容易维护。再来看第二种思路,如果内部消化这些操作单独抽离成一个模块,那么详情页就不需要关注有哪些操作变化,只负责更新即可,后续如果有操作变动的话,也更容易维护。最后我们采用了第二种思路,使用dialogfragment,接口调用都在dialogfragment内部实现操作结束之后,通过暴露接口的方式,处理当前页面的变动,使管理模块与展示模块职责更加清晰。

/**
     * 帖子管理回调
     */
    public interface ManagerListener {
        /**
         * 刷新详情页
         */
        void refreshDetail();

        /**
         * 返回上一级页面
         */
        void goBack();

        /**
         * 回复更新manager
         *
         * @param menuList
         */
        void updateManager(ArrayList<ManagerMenu> menuList);
    }

回复列表

AbYOoD.png
AbYOoD.png

回复列表分为全部回复、热门回复、无回复,与上面提到的输入框,管理员,都有交互,而且还有上拉加载更多,涉及到插入,删除,修改,区域更新等操作,交互细节非常之多,这就非常考验框架的设计了,由于我们用的是Recyclerview+delegate的方式,可以很方便的使用

  • adapter.notifyItemChange()
  • adapter.notifyItemInserted()
  • adapter.notifyItemRemoved()
  • adapter.notifyItemMoved()

对于上拉加载,区域更新可以使用

  • notifyItemRangeChanged()
  • notifyItemRangeInserted()
  • notifyItemRangeRemoved()

对于局部更新,我们支持partial-bind能力。 但是要想用这些方法的前提,我们要知道列表中更新的位置,或者是起始位置和终止位置。在一个复杂的列表中如何定位呢?有两种情况

  1. 不同type的Item
  2. 相同type的Item

解决方法

  1. 对于不同的type,我们可以通过遍历数据源,找到这个某type的Item在数据源中的索引,就是我们要定位的位置
  2. 如果相同type的Item的类型有很多个,就不能使用方法一了,我们需要一个唯一的id来进行标识,以回复列表来说,首先定位到回复区域,然后将replyId与之一一比对,如果匹配就是我们要定位的位置

最后要注意一点,详情页列表虽然没有下拉刷新,但是一旦发生数据更新,每一项的索引可能会发生变化,所以我们在每次更新操作时都要重新计算索引

动效设计

1.点赞动效

AgiCZT.gif
AgiCZT.gif

点赞动效是点一下,播放一次,而且播放完之后,需要改变状态,前面提到过,图片加载我们是采用Fresco实现的,而且Fresco也支持gif播放,但是我们播放完之后需要进行回调处理,fresco无法进行gif播放控制与回调,所以我们自定义Gifview,可支持循环播放与单次播放,并且暴露出播放事件,便于调用方处理。

private void stopPlay() {
    isPlaying = false;
    invalidateView();
    mMovieStart = 0;
    if (mPlayListener != null) {
        mPlayListener.onPlayFinish();
    }
}
  
public interface PlayListener {
    void onPlayFinish();
}

2.头bar滚动

AgP8K0.gif
AgP8K0.gif

头bar滚动需要避免两个问题:

  1. 用户信息的位置虽然看起来是固定的,但是不排除在列表中发生变化,所以不能简单的写死一个位置标识用户信息模块
  2. 如果用户信息模块显示或隐藏之后,不能频繁的改变头bar的内容

我们采用动态监听用户信息Item的显示隐藏,来控制头bar的内容,记录用户信息Item的位置,这样就保证了每次显示(隐藏)用户信息模块, 头bar的内容只改变一次。

@Override
public void onScrolled(int firstItemPosition) {
    if (mAdapter.getItemViewType(firstItemPosition) == TribeDetailAdapter.VIEW_TYPE_USER_INFO) {
        //记录用户信息位置
        mUserInfoPostion = firstItemPosition;
        //如果之前没显示,头bar作对应处理
        if (!isUserInfoShow) {
            isUserInfoShow = true;
            mView.onUserInfoItemMove(true, mDetailBaseBean.data.listDataBean.userInfoItemBean, mDetailBaseBean.data.rightBtn.type);
        }
     //如果之前显示,并且用户信息隐藏掉了
    } else if (isUserInfoShow && firstItemPosition > mUserInfoPostion) {
        isUserInfoShow = false;
        mView.onUserInfoItemMove(false, mDetailBaseBean.data.listDataBean.userInfoItemBean, mDetailBaseBean.data.rightBtn.type);
    }
}

有同学可能会问了,为什么不用CoordinatorLayout呢,你这不就是嵌套滑动吗?那么我们为什么不用呢:

  • 我们只是头bar内容的改变,比较小,用CoordinatorLayout有点大材小用的感觉
  • 如果使用CoordinatorLayout会增加布局嵌套

5.点赞详情

AgieQ1.gif
AgieQ1.gif

要实现点赞详情的动效有以下几个难点:

  1. 弹出页面的时候,并不是全屏的,上滑列表全屏
  2. 手势控制会与列表滚动事件冲突
  3. 下滑到列表顶部时当前页面才关闭掉,所以下滑的时候,是列表优先,然后才是关闭页面,上滑则相反。

首先想到的是用dialog实现,但是dialog从非全屏到全屏不好控制,然后改用activity来实现,这时候又遇到了一个问题,如何控制activity高度呢,如果改变activity内容区域的高度,会发现动画不连贯,最后采用修改window的高度,好,目前为止,动画已经实现了。

/**
 * 设置activity高度占屏幕高度比例
 * @param percent
 */
private void setContentHeight(float percent) {
    WindowManager.LayoutParams params = getWindow().getAttributes(); //获取对话框当前的参数值
    params.height = (int) (ScreenUtils.getRealHeight(this) * percent); //高度设置为屏幕的比例
    getWindow().setAttributes(params);
}

下一个问题又来了,如何控制滑动冲突的问题呢,因为都是滑动事件,直接监听Recyclerview的滑动事件不就行了,这样对Recyclerview的侵入性比较大,而且手势控制与滑动事件并不是完全对等,因为列表还有一个fling事件(惯性滚动),这就很难处理了,所以我们为Recyclerview单独加入了onTouch事件,来处理手势,这样与Recyclerview分离操作

case MotionEvent.ACTION_MOVE:
    float deltaY = event.getRawY() - mLastY;
    mLastY = event.getRawY();
    //下滑finish
    if (deltaY > 8) {   
      iView.onSlideDown();
    }
    //上滑全屏
    else if(deltaY < 0 && !isTop) {
        iView.onSlideUp();
        isTop = true;
    }
    break;
    
@Override
public void onSlideDown() {
  //-1 向下滑动  1 向上滑动
  if (!recyclerView.canScrollVertically(-1)) {
        finish();
    }
}

利用activity动画+监听手势操作+设置window高度实现

性能优化

  • GPU过度绘制:使用ConstraintLayout降低布局层次,很少有三次过度绘制,基本都控制在两次以内
  • 列表更新:采用Recyclerview的notifyItemXXXX()方法,同时支持局部更新的方式,让列表的刷新更加自然流畅
  • 点赞列表:固定14个icon数量,使用Linerlayout代替GridView,避免了滑动列表时Fresco大量重新渲染的问题

有同学可能会问,这么多复杂的更新方式,为什么不选择DiffUtil的AsyncListDiffer呢,其实一开始,我们也有考虑过这个问题,但是发现最后不太适合,主要有以下几点原因:

  • AsyncListDiffer适合下拉刷新的场景,对于部落详情页,并没有这样的场景
  • AsyncListDiffer本来是想引入解决partial-bind(局部更新)的问题,后来发现不使用AsyncListDiffer依然可以实现这种效果

总结

以上是此次部落详情页Native化改版的全部过程,下面是对此次改版的简单总结:

  1. 整体MVP模式解耦、分层代码更清晰。
  2. 使用Recyclerview对部落详情页改造,提升滑动性能、支持无限加载更多
  3. Recyclerview Item使用Delegate方式与Adapter进行解耦,多人开发时互不影响
  4. 全部详情页支持局部刷新,提升刷新性能
  5. 视频播放与Recyclerview 列表分离,滑动更流畅、扩展更便捷。
  6. 回复区域复杂页面与逻辑解耦,便于页面刷新与维护
  7. 点赞详情页动画弹出,下滑消失,用户体验更好

另欢迎大家常来58部落,58部落“记录真实生活,收获温暖回应”。

掀乱书页的风

2021/10/16  阅读:23  主题:兰青

作者介绍

掀乱书页的风