“知你所需,予你所求” ,58 APP Android新首页改版历程!
在互联网的下半场中,用户增长乏力的形势日益凸显,充分发挥用户的流量价值变得尤为关键。58APP通过多年的沉淀,在用户业务需求的深挖上已经积累了相当多的经验,然而对用户单一业务维度的满足已经不足以支撑住用户日益增高的体验诉求,只有围绕用户诉求打造多场景、精细化、一致性的体验,才能打破用户对产品工具属性的定位,刷新对58多元化生活服务平台的认知,对平台形成更高程度的依赖。基于此,产品与设计部门发起了首页9.0版本的改版,希望达成“知你所需,予你所求”的战略目标。接到任务后我们4名同学对老版首页设计进行了分析,我们把首页分为了UI模块和数据模块。UI模块上没有什么新鲜的东西,还是使用AppBarLayout+ViewPager,当然在AppBarLayout内部的UI视图上要进行一个框架设计,满足动态模板的需求;数据模块在老版本数据模块的基础上进行了优化,并且增加了预加载功能。下面是详细的模块设计。1.1 UI模块
58app首页承载了各业务模块的功能入口、核心业务、个性化智能推荐的数据展示,是一个相对复杂的界面,为了能够从编程角度考虑将这个界面开发为易维护、低耦合目标,我们将这个界面逻辑的功能实现了组件化。按照产品设计图和原首页接口数据结构,将首页功能模块抽象组件化,以组件为单位分离组件边界和责任,便于独立升级和维护,同时在界面重构开发过程中可以并行开发,协同并进。
1.2 组件的设计
借鉴Android当中Fragment的优秀设计,将组件作为一个完整的片段,包含数据Bean、视图View和之间的绑定功能实现:将组件抽象定义一个容器,类似Fragment一样,包含数据和视图view,并没有将组件和数据、view设计成某种设计模式,比如MVC,因为那样对后期的开发扩展性差,不框定设计模式,便于大家根据自己的需要和业务需求自行设计数据和view的设计模式,这样每个组件又可用MVC、MVP、MVVM等软件架构,以便提高组件的易维护性。
组件化大大提高了整个首页的灵活性和高复用性,以首页数据驱动,动态更新功能模块,提高了业务的可控性,提升用户体验。
1.3 数据模块
首页数据主要涉及到四个接口:配置数据、内容数据、feed流数据、通知数据。首页数据模块重新设计后具备以下能力来全面保障首页的内容最大化:对每一次请求的接口数据统一进行本地缓存,提供指定缓存数据模块的白名单策略,让这些数据不浪费,同时提高了缓存的有效性,能够在下次请求前让用户有效消费,同时避免了白页出现,增强用户体验的同时提升了消费性。首页数据即便有缓存和内置策略,但是因为数据量大,解析耗时,如果等首页界面出现时再加载数据,那么依然无法完全避免白页的问题。这时候,预加载至关重要。预加载,在页面渲染之前就已经完成了数据的加载,包括解析过程到内存中,等页面显示时,直接读取内存中的数据进行显示,大大降低了白页时间。改版前,接口一旦出现字段数据非法问题时,页面都会莫名其妙的卡死,排查问题所在也很难。基于此背景,本版本在数据解析过程做了字段级的统一容错。一个字段出错,并不会影响整个页面内容的展示。每一个模块都有其自己的容错策略,如显示缓存降级策略,显示内置缓存策略等。比如,feed流中有很多帖子,每个帖子又对应很多模板,每个模板的字段都不一样,所以我们在字段解析时向业务上提供了统一的解析抛错能力,框架捕获到这样的错误,可以针对性的容错。以前四个接口是随意存放的,调用方式也是各自写自己的,比较凌乱并不好维护。本次优化后,提供了统一的接口暴露方式,一个接口提供一种接口调用方法,向外统一输出Observable结果,既满足数据接口的规范使用和编写,也满足了指定线程访问数据的能力。由于首页数据的访问频率高,因此我们在网络接口请求的上层实现数据的管理层,统一管理接口下发的数据,对上层调用者提供缓存能力,并且对首页四个接口进行一个请求优先级处理,使用不同的线程池请求不同优先级的接口,这样保证了首页数据的准确性与完整性。可以看到当列表向上滑到icon区域时,搜索的吸顶栏从上往下划出,反向的话则逐渐隐藏,要实现这样一个效果,我们要考虑以下几点:1)如何与列表的滑动保持联动
要与列表保持联动,需要一个联系的纽带,这里我们选择以滑动的偏移量来作为桥梁,吸顶的搜索栏本身并不属于列表中的一部分,那么如何来获取列表的偏移量呢,我们先看一下最外层的HomePageAppBarLayout,通过获取AppBarLayout的偏移量,然后传递到搜索栏内部。这样搜索栏就可以获取到整个列表的偏移量,类似一个大的齿轮一样,当大齿轮转动的时候,与大齿轮相关的小齿轮就可以随之转动。
那么如何来位移呢?在此之前,我们还是先了解Android坐标系的定义规则以及View的一些位置参数:
View的位置及大小是由四个参数决定,即left、top、right、bottom,并且这四个参数都是相对于其父View的
在Activity中布局完成后,我们可以通过View一些方法获取这些参数信息//left,top,right,bottom值的获取:
int left = getLeft();
int top = getTop();
int right = getRight();
int bottom = getBottom();另外,Android 3.0以后加入x,y,translationX,translationY等参数。(x,y)表示为View在ViewGroup中左上角的x,y的值,translationX,translationY在用于平移一个View。默认是都为0,在调用了View的setTranslationX()/setTranslationY()之后发生改变,我们就是在Y轴方向使用setTranslationY()触发位移。这个问题其实相对简单一些,因为列表中每个模块的高度是固定的,只要将icon区域上面的模块高度相加即可,不再展开赘述。3)刘海屏的适配
由于国产手机厂商的崛起,导致水滴屏,打孔屏,刘海屏的兴起,对于开发者来讲要让我们的app在所有手机上都要完美显示,对,所有手机,你没看错。刚开始吸顶搜索栏是按照UI设计稿固定高度的形式展示,在我的手机上是完美显示的,但是QA同学拿了一个刘海屏手机发现有一部分恰好在刘海中,导致部分内容看不到。原因是什么呢,国产手机的状态栏高度各家都有自己的标准,所以状态栏高度不同导致的。既然找到了原因,解决方案就是吸顶搜索栏的高度分为两部分= 状态栏高度+ 吸顶搜索栏内容高度,这样不管你状态栏高度如何变化,我都能完美适配。@Override
protected void onFinishInflate() {
super.onFinishInflate();
int statusBarHeight = StatusBarUtil.getStatusBarHeight(getContext());
setPadding(0, statusBarHeight, 0, 0);
stickSearchHeight = HolderUtils.getSearchHeight(getContext()) + statusBarHeight;
}
4)列表内的搜索栏与吸顶搜索栏信息同步问题
可以看到,在列表内的搜索栏与吸顶的搜索栏有相同的元素,搜索提示词,以及签到状态,那么当其中一处发生变化后,如何保持状态统一呢,最稳妥的方式是写两套相同监听代码,当然这是我们不能接受的,这个地方我们采用观察者模式的方式,也就是说当状态发生变化后,列表内的搜索框是能拿到所有信息的,吸顶搜索栏作为观察者,监听列表内搜索框的变化。
2.2部落入口动效
可以看到部落入口一共两个动效:第一个是文字的上下轮播,这种类似跑马灯的效果有很多方法实现,也比较简单,此处就不再赘述;第二个动效是右侧头像的气泡动效,其实这种效果在很多直播app中比较常见,常见的位置在点赞送礼物等位置。下面重点讲一下气泡动效的实现。
实现气泡动效在Android中其实至少也有两种实现方案,两种方案都离不开贝塞尔曲线这种数学曲线。第一种使用自定义的方式通过贝塞尔曲线实时计算每个气泡所在的位置,并且把气泡通过画板画出来;第二种方法就是使用Animator实现,实现起来稍微简单点,我们就使用了这种方案。下面具体介绍一下这种方案实现的气泡动效。
首先创建一个Evaluator,通过贝塞尔曲线公式计算出对应的位置,此处使用了二阶的贝塞尔曲线。代码如下:
private class BezierEvaluator implements TypeEvaluator<PointF> {
private PointF controlPoint;
public BezierEvaluator(PointF pointF) {
this.controlPoint = pointF;
}
@Override
public PointF evaluate(float fraction, PointF startValue,
PointF endValue) {
float oneMinusT = 1.0f - fraction;
PointF point = new PointF();
point.x = oneMinusT * oneMinusT * (startValue.x) + 2 * oneMinusT * fraction * (controlPoint.x) + fraction * fraction * (endValue.x);
point.y = oneMinusT * oneMinusT * (startValue.y) + 2 * oneMinusT * fraction * (controlPoint.y) + fraction * fraction * (endValue.y);
return point;
}
}
然后设置AnimatorUpdateListener,在AnimatorUpdateListener中获取到通过Evaluator计算出来的坐标信息设置给气泡View即可。代码如下: private class BezierListenr implements ValueAnimator.AnimatorUpdateListener {
private View target;
public void setTarget(View target) {
this.target = target;
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
if (target == null) {
return;
}
PointF pointF = (PointF) animation.getAnimatedValue();
target.setX(pointF.x);
target.setY(pointF.y);
}
}
当然除了这些还要注意图片的缓存,View的缓存复用等实现性能的最佳化,此处不再赘述。2.3 AppBarLayout与RecyclerView的Fling连接
这个问题的文字描述可能大家都不太理解,因此在说这个问题之前我们先看一下我们新老首页的对比,也就是这个问题解决之前和解决之后的效果对比。可以很明显的看到老版本在滚动到AppBarLayout底部时瞬间停住,给人一种很生硬的感觉,下面我们就来讲一讲如何进行优化。
为了搞清楚为什么会出现这样的问题,我们分析了一下AppBarLayout的源码。下面是一个大致的流程图:下面我们进行详细的源码分析:
首先AppBarLayout之所以可以折叠其实是依赖了CoordinatorLayout的能力,用户事件会被CoordinatorLayout感知然后传递给AppBarLayout的Behavior,AppBarLayout的Behavior继承自HeaderBehavior,我们阅读onTouchEvent方法,发现其处理fling的代码如下:case MotionEvent.ACTION_UP:
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1000);
float yvel = mVelocityTracker.getYVelocity(mActivePointerId);
fling(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
}
再看fling方法的实现,我们发现了其使用了OverScroller来实现fing效果的算法实现,具体的View滚动由FlingRunnable承担。代码如下: final boolean fling(CoordinatorLayout coordinatorLayout, V layout, int minOffset,
int maxOffset, float velocityY) {
mScroller.fling(
0, getTopAndBottomOffset(),
0, Math.round(velocityY),
0, 0,
minOffset, maxOffset);
}
通过以上代码可以发现,在使用OverScroller计算fling事件时,其设置了minOffset(Y轴向上滚动的边界),通过向上跟踪代码发现这个minOffset恰好就是AppBarLayout的高度取反。int getScrollRangeForDragFling(V view) {
return view.getHeight();
}
这就能解释了为什么滚动到顶部后停止的问题了。下面再看一下具体的fling实现:private class FlingRunnable implements Runnable {
@Override
public void run() {
if (mLayout != null && mScroller != null) {
if (mScroller.computeScrollOffset()) {
setHeaderTopBottomOffset(mParent, mLayout, mScroller.getCurrY());
ViewCompat.postOnAnimation(mLayout, this);
} else {
onFlingFinished(mParent, mLayout);
}
}
}
}
FlingRunnable是具体的滚动实现,run方法中并没有发现其将fling事件传递给父View CoordinatorLayout,因此这个fling事件由AppBarLayout消费,无法带动底部的RecyvlerView fling。上文中已经找到了具体的原因,但是我们无法修改AppBarLayout代码,因此这里我们要明确一点:如果想让AppBarLayout的Fling连接上RecyclerView就必须自定义Behavior或者修改HeaderBehavior。
由于自定义Behavior必须继承CoordinatorLayout.Behavior,然后把AppBarLayout.Behavior与其父类一直到ViewOffsetBehavior的代码全部复制出来,并且涉及相关类比较多,因此我们直接把AppBarLayout相关代码全部复制出来,效果如下:下面进行具体代码的修改:
上文中也提到在使用OverScroller计算fling事件时,其设置了minOffset这个minOffset恰好就是向上滚动到AppBarLayout底部的位置。因此第一步我们要把这个值设置的足够小,让OverScroller计算出更长的fling时间与距离。这里判断如果是向上fling时就把minOffset设置为Integer.MIN_VALUE,具体代码如下: int fixedMin = velocityY < 0 ? Integer.MIN_VALUE : minOffset;
mScroller.fling(
0, getTopAndBottomOffset(),
0, Math.round(velocityY),
0, 0,
fixedMin, maxOffset);
第二步就是要修改FlingRunnable了,让其在fling时带动AppBarLayout下面的View同时fling。我们知道CoordinatorLayout就是为了解决嵌套滚动而生,我们应该调用CoordinatorLayout的能力,把这个fling分发给下面的View就可以了。
CoordinatorLayout嵌套滚动的原理如下:CoordinatorLayout实现了NestedScrollingParent,当CoordinatorLayout内有一个支持NestedScroll的子View时,它的嵌套滑动事件通过NestedScrollingParent的回调分发到各直接子View的Behavior处理。RecyclerView就是实现了NestedScrollingChild2的子View(NestedScrollingChild2继承于NestedScrollingChild),而AppBarLayout却没有实现NestedScrollingChild接口。因此如果我们想通过调用CoordinatorLayout分发嵌套事件会存在以下两个问题:因此经过调研我们放弃了这种方案。下面说一下我们最终使用的方案:首先我们通过id或者tag的方式获取到需要需要被fling带动的目标View。第二步调用目标View的对应滚动/偏移方法。实际滚动时需要注意,RecyclerView并不支持直接滚动到某一个点,但是提供了scrollToPositionWithOffset方法,这个方法的意思是滚动到某一个Position并且偏移部分像素。我们可以基于此方法来实现滚动到某一个位置,调用这个方法时需要注意第一个参数position一定要传屏幕中显示的position,否则会导致已经不再屏幕中的item不回收,然后很容易引起OOM。为了满足产品的快速迭代很多页面已经由RN或hybird实现,仅有部分核心页面由纯native实现。之所有这些核心页面依然由native实现就是因为用户体验,纯native实现性能更强,体验更流畅。那么我们在这次的首页改版上性能上也不能落后!
其实性能是一个综合性的指标,跟性能有关的具体指标有很多,如启动速度、内存、cpu、fps……。如果要达到所有的指标都是最优的可能要花费很多的时间与精力,往往得不偿失。但是在很多情况下可以进行取舍,对用户当前感知最强的指标进行优化,牺牲一些用户感知不强的指标,就会事半功倍。因此我们重点做了对快速滑动时手机的fps优化,因为fps是用户在首页感知最强的一项指标,fps降低用户的直观感受就是卡顿。页面快速滚动时,在16毫秒时没有运算完时,就会导致新的一帧丢失。丢帧给用户的体验就是界面不流畅卡顿。卡顿的优化手段有很多,但是核心的思路就是降低cpu的计算量,比如优化算法减少计算量、在数据使用前提前计算好缓存下来也可以减少计算量。下面先看一下我们新老首页的对比效果。从上图的对比效果可以看出,低端手机老首页在滑动时最低降低到20帧以下,而新首页稳定在40帧以上,平均帧率提升了20%以上。下面介绍一下我们的具体优化手段。
在快速滚动时首页有大量图片进行加载,cpu的运算量与IO的读写压力都很大。于是我们思考能不能在滑动时不进行图片的加载,恰好我们的图片框架fresco恰好也提供了相关能力。经过优化,低端手机快速滚动的fps大幅提升。3. 数据缓存优化
我们发现在快速滚动时每滚动一屏就会有几十个埋点数据发出,这些埋点都会进行计算,可能埋点的计算量并不大,但是架不住数量多啊,快速滑动时手指用力上滑一次可能有会有几百条埋点发出了。于是我们对埋点进行了一次优化,将埋点需要的数据提前计算好,并进行缓存,每次滚动时的埋点仅读取内存数据进行埋点即可。经过优化,性能也有显著提升。整个首页改版开发下来,有很多的收获和感悟。当然技术上的收获是一方面,上面我们也进行了介绍。我个人认为最大的收获不应该是技术上的收获,而是为了实现某一功能效果我们进行的探索的过程与方法。技术是不断演进与变化的,唯有学习探索的方法是不变的。
首页改版经历了近一个月的开发与测试,涉及的范围之广由于篇幅的限制不能一一深入,其实任何一个点拿出来都可以进行深入挖掘写一篇深度文章。这篇文章主要是为首页的改版进行一次总结,总结其中的问题,后续我们遇到类似的问题时也就不用重蹈覆辙了。此次首页改版从运营效果上来看实现了产品设计部门的“知你所需,予你所求”期望,从效果上来看也帮助了我们的用户更快更便捷的找到所需要的信息与服务。这次的首页改版升级中的点点滴滴也践行了我们让生活简单美好的使命。孔校军、刘元亮、张志新、曾鹏,均为大前端技术部Android技术部研发工程师推荐阅读
如何利用App工厂支持创新App
58同城AI算法大赛开放报名,欢迎参赛
58App-Android端的动态化框架实践与思考
“暗黑模式”之58 同城 iOS App深色模式适配实践
开源|Zucker:Android APP模块化大小自动分析统计工具