本期技术加油站给大家带来百度一线的同学在日常工作中 Android 开发的小技巧:Android 有序管理功能引导;一行代码给 View 增加按下态;一行代码扩大 Andriod 点击区域,希望能为大家的技术提升助力!
01Android 有序管理功能引导
随着移动互联网的发展,APP 的迭代进入了深水区,产品迭代越来越精细化。很多新需求都会添加功能引导,提高用户对新功能的感知。但是,如果每个功能引导都不考虑其它的功能引导 View 冲突,就会出现多个引导同时出现的情况,非常影响用户体验,降低引导效果。因此,有序管理功能引导 View 就显得非常重要。
首先,我们需要根据自身的业务场景,梳理不同的引导类型。为了精准区分每一种引导,使用枚举定义。
enum class GuideType {
GuideTypeA,
...
GuideTypeN
}
复制代码
其次,将这些引导注册到引导管理器 GuideManager 中,注册方法需要传入引导的类型,显示引导回调,引导是否正在显示回调,引导是否已经显示回调等参数。注册引导实际上就是将引导的根据优先级保存在一个集合中,便于在需要显示引导时,判断此时是否能够显示该引导。
object GuideManager {
private val guideMap = mutableMapOf<Int, GuideModel>()
fun registerGuide(guideType: GuideType,
show: () -> Unit,
isShowing: () -> Boolean,
hasShown: () -> Boolean,
setHasShown: () -> Unit) {
guideMap[guideType.ordinal] = GuideModel(show, isShowing, hasShown, setHasShown)
}
...
}
复制代码
接下来,业务方调用 GuideManager.show(guideType)触发引导的显示。
object GuideManager {
...
fun show(guideType: GuideType) {
val guideModel = guideMap[guideType.ordinal] ?: return
if (guideModel.isShowing.invoke() || guideModel.hasShown.invoke()) {
return
}
guideMap.forEach {
if (entry.value.isShowing().invoke()) {
return
}
}
guideModel.run {
show().invoke()
setHasShown().invoke()
}
}
}
复制代码
最后,需要处理单例中已注册引导的释放逻辑,将 guideMap 集合清空。
object GuideManager {
...
fun release() {
guideMap.clear()
}
}
复制代码
以上实现是简易版的引导管理器,使用时还可以结合具体业务场景,添加更多的引导拦截策略,例如当前业务场景处于某个状态时,所有引导都不展示,则可以在 GuideManager.show(guideType)中添加个性化处理逻辑。
02 一行代码给 View 增加按下态
在 Android 开发中,经常会遇到 UE 要求添加按下态效果。常规的写法是使用 selector,分别设置按下态和默认态的资源,代码示例如下:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX_pressed" android:state_selected="true"/>
<item android:drawable="@drawable/XX_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/XX_normal"/>
</selector>
复制代码
UE 提供的按下态效果,有的时候仅需改变透明度。这种效果也可以用上述方法实现,但缺点也很明显,需要增加额外的按下态资源,影响包体积。这个时候我们可以使用 alpha 属性,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_selected="true"/>
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_pressed="true"/>
<item android:drawable="@drawable/XX"/>
</selector>
复制代码
这种写法,不需要额外增加按下态资源,但也有一些缺点:该属性 Android 6.0 以下不生效。
我们可以利用 Android 的事件分发机制,封装一个工具类,从而达到一行代码实现按下态。代码如下:
@JvmOverloads
fun View.addPressedState(pressedAlpha: Float = 0.2f) = run {
setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> v.alpha = 1.0f
}
// 注意这里要return false
false
}
}
复制代码
用户对屏幕的操作,可以简单划分为以下几个最基础的事件:
Android 的 View 是树形结构的,View 可能会重叠在一起,当点击的地方有多个 View 可以响应点击事件时,为了确定该让哪个 View 处理这次点击事件,就需要事件分发机制来帮忙。事件收集之后最先传递给 Activity,然后依次向下传递,大致如下:Activity -> PhoneWindow -> DecorView -> ViewGroup -> ... -> View。如果没有任何 View 消费掉事件,那么这个事件会按照反方向回传,最终传回给 Activity,如果最后 Activity 也没有处理,本次事件才会被抛弃。这是一个非常典型的责任链模式。整个过程,有三个非常重要的方法:
以上三个方法均有一个布尔类型的返回值,通过返回 true 和 false 来控制事件传递的流程。这三个方法的调用关系,可以用下面的伪代码描述:
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
复制代码
对于一个 View 来说,它可以注册很多事件监听器,例如单击事件、长按事件、触摸事件,并且 View 自身也有 onTouchEvent 方法,这些与事件相关的方法由 View 的 dispatchTouchEvent 方法管理,事件的调度顺序是 onTouchListener -> onTouchEvent -> onLongClickListener -> onClickListener。所以我们可以通过为 View 添加 onTouchListener 来处理 View 的按下、抬起效果。需要注意的是,如果 onTouchListener 中的 onTouch 返回 true,不会再继续执行 onTouchEvent,后面的事件都不会响应,所以我们需要在工具类中 return false。
03 一行代码扩大 Andriod 点击区域
在 Android 开发中,经常会遇到扩大某些按钮点击区域的场景,如某个页面关闭按钮比较小,为防止误触或点不到,需要扩大其点击区域。
常见的扩大点击区域的思路有三个:
1. 修改布局。如增加按钮的内 padding,或者外面嵌套一层 Layout,并在外层 Layout 设置监听。
2. 自定义事件处理。如在父布局中监听点击事件,并设置各组件的响应点击区域,在对应点击区域里时就转发到对应组件的点击。
3. 使用 Android 官方提供的 TouchDelegate 设置点击事件。
其中第一种方式弊端很明显,会增加业务复杂度,降低渲染性能;或者当布局位置不够时,增加 padding 或添加外层布局就行不通了。
第二种方式可以从根本上扩大点击区域,但是问题依旧明显:编码的复杂度太高,每次扩大点击区域都意味着需要根据实际需求去“重复造轮子”:写一堆获取位置、判定等代码。
第三种方式是 Android 官方提供的一个解决方案,能够比较优雅地解决这个问题,如下描述:
Helper class to handle situations where you want a view to have a larger touch area than its actual view bounds. The view whose touch area is changed is called the delegate view. This class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an instance that specifies the bounds that should be mapped to the delegate and the delegate view itself.
当然,如果使用 Android 的 TouchDelegate,很多时候还不能满足我们需求,比如我们想在一个父(祖先)View 中给多个子 View 扩大点击区域,如在一个互动 Bar 上有点赞、收藏、评论等按钮。这时可以在自定义 TouchDelegate 时维护一个 View Map,该 Map 中保存子 View 和对应需要扩大的区域,然后在点击转发逻辑里动态计算该点击事件属于哪个子 View 区域,并进行转发。关键代码如下:
// 已省略无关代码
public class MyTouchDelegate extends TouchDelegate {
/** 需要扩大点击区域的子 View 和其点击区域的集合 */
private Map<View, ExpandBounds> mDelegateViewExpandMap = new HashMap<>();
@Override
public boolean onTouchEvent(MotionEvent event) {
// ……
// 遍历拿到对应的view和扩大区域,其它逻辑跟原始逻辑类似
for (Map.Entry<View, ExpandBounds> entry : mDelegateViewExpandMap.entrySet()) {
View child = entry.getKey();
ExpandBounds childBounds = entry.getValue()
}
// ……
}
public void addExpandChild(View delegateView, int left, int top, int right, int bottom) {
MyTouchDelegate.ExpandBounds expandBounds = new MyouchDelegate.ExpandBounds(new Rect(), left, top, right, bottom);
this.mDelegateViewExpandMap.put(delegateView, expandBounds);
}
}
复制代码
更进一步的,可以写个工具类,或者 Kotlin 扩展方法,输入需要扩大点击区域的 View、祖先 View、以及对应的扩大大小,从而达到一行代码扩大一个 View 的点击区域的目的。
public static void expandTouchArea(View ancestor, View child, int left, int top, int right, int bottom) {
if (child != null && ancestor != null) {
MyTouchDelegate touchDelegate;
if (ancestor.getTouchDelegate() instanceof MyTouchDelegate) {
touchDelegate = (MyTouchDelegate)ancestor.getTouchDelegate();
touchDelegate.addExpandChild(child, left, top, right, bottom);
} else {
touchDelegate = new MyTouchDelegate(child, left, top, right, bottom);
ancestor.setTouchDelegate(touchDelegate);
}
}
}
复制代码
注意: TouchDelegate 在 Android8.0 及其以前有个 bug,如果需要兼容低版本需要留意下,在通过 delegate 触发子 View 点击事件之后,父 View 自己监听的点击事件就永远无法被触发了,原因在于 TouchDelegate 中对点击事件转发的处理中(onTouchEvent)对 MotionEvent.ACTION_DOWN)有问题,不在点击范围内时,未对 mDelegateTargeted 变量重置为 false,导致父 view 再也收不到点击事件,无法处理 click 等操作,相关 Android 源码如下:
// …… 已省略无关代码
public boolean onTouchEvent(MotionEvent event) {
// ……
boolean sendToDelegate = false;
boolean handled = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Rect bounds = mBounds;
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} // if的判断为false时未重置 mDelegateTargeted 的值为false
break;
// ……
if (sendToDelegate) {
// 转发代理view
handled = delegateView.dispatchTouchEvent(event);
}
return handled;
// ……
复制代码
如果需要兼容低版本,则可以继承自 TouchDelegate,覆写 onTouchEvent 方法,在事件不在代理范围内时,重置 mDelegateTargeted 和 sendToDelegate 值为 false,如下:
……
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} else {
mDelegateTargeted = false;
sendToDelegate = false;
}
// 或者如9.0之后源码的写法
mDelegateTargeted = mBounds.contains(x, y);
sendToDelegate = mDelegateTargeted;
……
复制代码
推荐阅读【技术加油站】系列:
人工智能超大规模预训练模型浅谈
揭秘百度智能测试在测试自动生成领域的探索
小程序自动化测试框架原理剖析
评论