一种简单的Android骨架屏实现方案----0侵入0成本

对骨架屏的理解

什么是骨架屏

所谓骨架屏,就是在页面进行耗时加载时,先展示的等待 UI, 以告知用户程序目前正在运行,稍等即可。 等待的UI大部分是 loading 转圈的弹窗,有的是自己风格的小动画。其实大同小异。而骨架屏无非也是一个等待的UI。基本是由各种灰色块组成,夹杂着一些代表特殊样式的其他浅颜色的色块。骨架屏的不用之处就在于这些灰色块的排列组合和真正展示出来的页面样式基本一致。因此骨架屏的展示除了告知用户程序正在加载外,还能让用户大概知道稍后将要展示的内容是什么,给了用户一些期待,从心理上,让用户更愿意等待一会。

明明等2秒钟就会等够了返回离开,现在的一点期待刺激着可以等3秒钟了。解决不了页面加载耗时,就解决用户的等待意愿啊,哈哈。

就像减肥一样,坚持的困难性就在于你哼哧哼哧一顿,不知道到底减了多少,效果是什么。缺少刺激的机制。

 (就像我先把这两个对比图丢这,你有了一个心理预期,就更有意愿继续读下面这些枯燥的纯文字了,(*^▽^*))

对骨架屏功能的探究

目前的各种骨架屏框架,有的需要各种配置,有的需要在正常的代码逻辑之外,再编写展示这一堆灰色块的逻辑,比如上图左侧的正常页面,为了展示其骨架屏,需要对照着左侧页面的结构,手写一个各种灰色块的 xml 文件,然后在加载等待前后进行两个布局的切换。对于列表来说,还需要编写空的adapter来展示。此上种种少不了各种编码,既增加繁琐的工作量,又和正常的业务逻辑交织在一起,很不友好啊。程序员是拒绝的。

另外,通过调研发现,

1、大部分的骨架屏是不支持交互的,包括带列表的页面,只有美团的骨架屏可以正常交互操作。其实不支持交互也是情理之中,毕竟说到底就是个等待UI,停留时间就1-2秒,再长,那真的说明这个页面的加载该优化了。美团这个只能说是牛掰了。

2、对于列表的填充展示,骨架屏的列表样式和最终的item样式还是有较大区别的,有的可以说是差别很多。归根结底还是因为有工作量,映射个大概就差不多了。此外实际的业务逻辑会有各view的展示隐藏,所以无法一一对应。

理想的骨架屏框架

那骨架屏的使用可以有多简单呢?

1、希望没有一丝一毫的额外代码量。

2、对正常业务逻辑毫无侵入性,引入后,想用就用,不想用就不用,插拔式操作。

最终设计的骨架屏框架满足了以上0工作量0侵入的需求,但是同时也为此舍弃了一些细节。

骨架屏原理简介

其主要原理是:

解析正常页面的各 view 元素的布局位置,然后在已有页面的上面增加一层蒙层skeletonview,然后通过draw方法,将解析出的各个view 的 rect位置在skeletonview上画出来。

对于普通的view:

比如 MainActivity 加载的xml文件是 activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root_ll"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <TextView
        android:id="@+id/content_tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="20dp"
        android:layout_margin="15dp"
        android:text="骨架屏的实现原理"
        />
</LinearLayout>

在页面 layout 的时候就可以计算出 content_tv 的 x、y坐标,以及长h、宽w。即view 的rect

然后根布局 root_ll 的上面新增一个铺满的空白view,在此view draw的时候,调用

canvas.drawRoundRect(rectF, radius, radius, paint)

将rect画出来,至此就实现了骨架屏的效果

view 筛选

在真正的实现中,对灰色块的样式进行了处理,一是加了圆角,而是缩减了它的宽和高,也就是展示出来的灰色块要比view看起来短细一些。

主要原始是有些view是没有margin,紧挨着的,而内部有自己的padding,所以展示出来的灰色块连成了一片,另外一个原因就是将页面上的一些小view过滤掉,通过设置阈值,view 的 rect缩减后的大小小于这个阈值时,就直接丢弃了。

对于列表view(RecyclerView、ListView):

舍弃了item样式的准确性。采用模糊处理的方式来填充列表灰块。

即事先编写好几套item样式的灰色块组合,遇到列表,选择类似的item样式画出来即可。

主要原因:

1、复杂度的限制

如果去展示真正的item,那么必须要加载这个item 的xml布局,一旦要这么做,那目前的骨架屏框架结构就被推翻了,无法通过一个蒙层view来显示页面所有元素灰块的绘制。另外就必须要走列表加载Adapter的流程了,增加了工作量,做不到0侵入,0代码的目的了。。

2、样式的限制

对于一个app来说,因为业务的统一,所以app中的列表样式基本上可以归纳为几种,不会有太多的发散。加载一个真正的item,和使用一个预设好的灰块组合,差别不是很大。

所以在展示骨架屏时遇到列表,直接配置一个最相似的 item样式即可。

比如

就是预设好的一组 rect

然后在 RecyclerView 的位置 Rect 内,不停的重复draw 几个这样的灰色即可。

对于骨架屏的展示,基本就是以上这两个方面。

下面是实际使用中的效果:


使用方式

页面级别

如何使用呢?

其实还是需要一点工作量,这里的0代码,夸张了一点

比如要对下面的布局使用骨架屏:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root_ll"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <TextView
        android:id="@+id/content_tv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="20dp"
        android:layout_margin="15dp"
        android:text="骨架屏的实现原理"
        />
</LinearLayout>

只要在布局文件中增加一层父view即可:

<?xml version="1.0" encoding="utf-8"?>
<com.haodf.skeleton.SkeletonLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/skeleton_sl"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:id="@+id/content_tv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="15dp"
            android:background="#dcdcdc"
            android:textSize="20dp" />
    </LinearLayout>
</com.haodf.skeleton.SkeletonLayout>

然后在代码里使用:

//requester请求网络数据
skeleton_sl.loading()  
requester
    .success { data ->
          skeleton_sl.normal()  
          initUI(data)
    }
    .failed{ error ->
          skeleton_sl.normal()
          toast(error)  
    }
    .reqeuset()

其实 skeleton_sl.loading() 出现的地方原本就是你发起网络请求时,展示loading弹窗的地方,只要替换一下即可。

说白了,代码上的修改量,就是把你现在的 LoadingDialog.show() 和LoadingDIalog.dismiss() 替换为 skeleton_sl.loading() 和 skeleton_sl.normal()

简单吧。

局部使用

到这里你会发现,骨架屏是通过对目标view进行包裹实现的。这也说明这个骨架屏不仅可以让整个页面实现骨架屏效果,还可以让任何一个view实现这个效果,无非就是包裹住那一层view即可。

比如:

原理简介

首先需要 SekeltonLayout 标签包裹住目标view 标签 target

<SkeletonLayout>
    <LinearLayout
        id:target
        ......>
        ......
    </LinearLayout>
</SkeletonLayout>

在 SkeletonLayout 内,在view 绘制流程的 OnLayout 阶段,解析 以 target 为 root 的 view 树,遍历找出所有的 View,和特殊的 ViewGroup,比如 RecyclerView 等,

然后通过

view.getGlobalVisibleRect(rect)

获取到这些view 在屏幕上的位置。

于是所有要绘制灰块的view变成了一个 rect 列表 rectList。

接下来 为 SkeletonLayout 添加一个子 view :skeletonView ,作为绘制灰块的蒙层。

addView(skeletonView, 1)
skeletonView?.layoutParams = LayoutParams(
    this.measuredWidth,
    this.measuredHeight
)

skeletonView 就是 SkeletonLayout 标签的宽高,既挡住了下面的正常UI,又作为一个画布,在其上画各个rect.

然后,在 skeletonView 的 onDraw方法中,遍历 rectList,针对每一个 rect 进行绘制即可。

canvas.drawRoundRect(rectF, radius, radius, paint)

rectF: RectF 就是rect,只不过转换一下,支持圆角绘制罢了。

骨架屏的 loading 和 normal 状态,就是 skeletonview 的 展示隐藏切换。

一些优化效果:

灰块颜色:

没有特殊处理,所有的灰块都是灰色的。而实际页面可能会有 一些其他颜色的圆角背景之类,还有一些特殊颜色的文字等等,有时候将这些 view 的灰块按照原来的颜色和形状展示出来,会更好一些。即让页面不那么呆板,又能给用户一些颜色上的激励,刺激用户的想象(这里是橘色圆角,肯定一会展示一个可以点的按钮吧,嗯~ 等等看)

所以,在解析view 的rect时,也对每个view 的颜色、样式、背景进行了解析。

对于背景,通过view.background来解析

is GradientDrawable, is StateListDrawable -> {
    val d = view.background.constantState?.newDrawable()
    d?.colorFilter = PorterDuffColorFilter(0x88ffffff.toInt(), PorterDuff.Mode.SRC_ATOP)
    d?.bounds = rect
    grayLands.add(GrayLand(this).apply {
        landType = GrayLand.LAND_TYPE_DRAWABLE
        drawable = d
    })
    return false
}

在背景的处理过程中,也需要注意不能原样绘制,那样颜色太鲜艳了,和整个骨架屏灰块色系不搭,所以对解析出的drawable 又进行了颜色的淡化处理。

最终,这些有背景的 view 的绘制,则通过

drawable.draw(canvas)

来绘制。

对于文字颜色:

无特殊处理,均处理成灰色。有些特殊颜色的 textView ,希望在灰块展示时就显示其特殊性,希望能以它本来的文字颜色作为灰块展示。

这个问题是通过 tag 标签来实现的。某个 textView 想以文字颜色来展示灰块,只需要在xml文件中声明自己的tag标签值即可。

<TextView
    android:id="@+id/title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textSize="19dp"
    android:textColor="#48aeff"
    android:text="这是一行蓝色的文字"
    android:layout_centerHorizontal="true"
    android:layout_marginTop="10dp"
    android:tag="sk_text_color"
    />

对比一下效果:

正常UI

没有特殊设置样式

设置了蓝色和绿色两个 textview 的 android:tag="sk_text_color"

动画效果:

动画主要有等待时的loading动画,和状态切换时的动画

loading动画:

是通过 draw方法和 ValueAnimator来实现的。

draw来绘制每一时刻的样子,ValueAnimator 来不断的改变动画的属性,比如从左到右的位置变化。

默认提供了两个loading动画。一个是从左到右一闪一闪而过的光条。这个其实就是自定义了一个颜色渐变的 drawable 文件,首先将它进行一点角度的旋转,然后通过 drawable.draw()方法绘制,而 ValueAnimator负责改变它从左到右的位置。

另一个是透明不断循环变化的动画,这个是通过 ValueAnimator来不断改变 paint 的透明度,进而影响整个骨架屏灰色块的透明度。

另外,将动画的逻辑抽取出来,提供了一个自定义loading 动画的接口 LoadingAnimator。

实现它即可快速自定义loading动画。

切换动画:

即页面从loading到normal时的,这个简单了 就是一个 骨架屏 skeletonView 透明度的渐变。

自定义配置:

skeletonLayout.config {
    listviewItemType = ItemRect.ITEM_TYPE_3
    loadingAnim = ILoadingAnimtor.TYPE_BAI_JV_GUO_XI
    skeletonEnable = true
    customLoadingAnim = ILoadingAnimtor的子类
}

listviewItemType :设置列表展示的 item样式,可以根据自己的项目,提前定义好几种item 样式,在不同页面设置不同的样式即可

样式1:

样式2:

样式3:

skeletonEnable :是否开启 骨架屏效果

对于已经在 xml文件中添加 SkeletonLayout 标签的页面,如果不想用骨架屏效果了,不需要再去修改xml文件,直接一行配置就可以开关骨架屏效果。

loadingAnim:选择默认提供的两种loading动画效果

customLoadingAnim :使用自定义的loading 动画效果,

这个骨架屏有什么遗憾的地方呢:

1、列表样式的妥协,放弃了准确性。

改为事先写好几种item 灰块样式,使用时选择类似的展示。

带来的好处就是无需关系任何页面的具体UI,无任何额外代码量。

补救措施

其实这个问题也有一个补救的措施,在实际使用的时候,可以扩展自定义的item样式。针对某个页面的列表,根据它的 item的样式,再写一组同样组合的灰块,展示时通过listviewItemType 配置一下,即可达到 item样式的准确性。

2、tools属性不友好

骨架屏的textview的灰块展示完全依赖于 xml 页面的默认样式,我们在 xml中写一个

<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:textSize="20dp" android:text="骨架屏的不同之处" />

就会展示它的灰块。

但是如果把 android:text="骨架屏的不同之处" 替换为 tools:text="骨架屏的不同之处" , 因为 tools 属性只是方便我们预览的,在页面加载的时候,这个 textView 的text还是“”,就不会展示出来,因此 view 的绘制流程就会计算出这个textview 的 rect 是 (0,0,0,0),因此它对应的骨架屏上就没有灰块。

这个问题的一个小解决方案是设置 layout_width="match_parent",

这样会计算出textview 的高度为字高,宽度为父view宽度,就能正常展示灰块。当然,并不是所有的 textview 都可以设置 match_parent 的。

这是一个很矛盾的问题,一方面使用 tools 很安全,既能预览实际展示时的页面UI效果,又不会因疏漏导致页面正式展示时展示了一些开发人员写的占位文案。

如果单纯为了骨架屏的展示,把 tools:text 换成android:text ,是不合理的,得不偿失。而使用tools:text,就会使预览看起来很丰满的一个页面,在展示骨架屏时,只有寥寥无几的几个灰块。

补救措施

在SkeletonLayout 的 onLayout之前,遍历到的这些 textview,如果其 text值是空的,则为他们赋一个默认的文案值,这样,在OnLayout 的时候就会计算出他们的有效 Rect。然后再把他们的text值恢复为空。

实测这个方案是可行的,但是是不安全的,数据加载过程中,骨架屏是在 loading和normal之前切换的,而这又同时伴随着各页面根据自己的实际业务逻辑,在数据返回后对UI进行正式的赋值渲染。

因为无法确保为 textview赋默认值的操作和实际的UI赋值操作是否冲突,万一先进行了实际赋值,又进行了骨架屏的赋值,所以把这一个方案暂时关掉了。

关于我实现的这个骨架屏框架基本介绍完了,=框架已投入项目使用了一段时间,效果还不错。主要还是提出一种思路,如何简单实效骨架屏效果。仅供大家参考。

如果想要框架完整代码使用的,可以点我。

猜你喜欢

转载自blog.csdn.net/xx23x/article/details/131002688