注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Android 时钟翻页效果

背景 今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。 原文链接:juejin.cn/post/724435… 具体实现分析请看上文原文链接,那我们开始吧! 容器 val space = 10f //上下半间隔 val...
继续阅读 »

背景


今天逛掘金,发现了一个有意思的web view效果,想着Android能不能实现一下捏。


image.png
原文链接:juejin.cn/post/724435…


具体实现分析请看上文原文链接,那我们开始吧!


容器


val space = 10f //上下半间隔
val bgBorderR = 10f //背景圆角
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
canvas.drawRoundRect(
0f,
0f,
width.toFloat(),
upperHalfBottom,
bgBorderR,
bgBorderR,
bgPaint
)
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2
canvas.drawRoundRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat(),
bgBorderR,
bgBorderR,
bgPaint
)

image.png


绘制数字


我们首先居中绘制数字4


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
//居中显示
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
canvas.drawText(number4, x, y, textPaint)

image.png


接下来我们将数字切分为上下两部分,分别绘制。


val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom
// 上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()

image.png


翻转卡片


如何实现让其旋转呢?
而且还得是3d的效果了。我们选择Camera来实现。
我们先让数字'4'旋转起来。


准备工作,通过属性动画来改变旋转的角度。


private var degree = 0f //翻转角度
private val camera = Camera()
private var flipping = false //是否处于翻转状态
...
//动画
val animator = ValueAnimator.ofFloat(0f, 360f)
animator.addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Float
setDegree(animatedValue)
}
animator.doOnStart {
flipping = true
}
animator.doOnEnd {
flipping = false
}
animator.duration = 1000
animator.interpolator = LinearInterpolator()
animator.start()
...

private fun setDegree(degree: Float) {
this.degree = degree
invalidate()
}

让数字'4'旋转起来:


  override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
canvas.drawText(number4, x, y, textPaint)
} else {
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
}
}

file.gif

我们再来看一边效果图:
我们希望将卡片旋转180度,并且0度-90度由上半部分完成,90度-180度由下半部分完成。


我们调整一下代码,先处理一下上半部分:


...
val animator = ValueAnimator.ofFloat(0f, 180f)
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//上半部分
val upperHalfBottom = height.toFloat() / 2 - space / 2
...
// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

效果如下:


upper.gif

接下来我们再来看一下下半部分:


override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val space = 10f //上下半间隔
//下半部分
val lowerHalfTop = height.toFloat() / 2 + space / 2

// 居中绘制数字4
val number4 = "4"
textPaint.getTextBounds(number4, 0, number4.length, textBounds)
val x = (width - textBounds.width()) / 2f - textBounds.left
val y = (height + textBounds.height()) / 2f - textBounds.bottom

if (!flipping) {
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
} else {
if (degree > 90) {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
}
}
}

lower.gif

那我们将上下部分结合起来,效果如下:


all.gif

数字变化


好!我们完成了翻转部分,现在需要在翻转的过程中将数字改变:

我们还是举例说明:数字由'4'变为'5'的情况。我们思考个问题,什么时候需要改变数字?

上半部分在翻转开始的时候,上半部分底部显示的数字就应该由'4'变为'5',但是旋转的部分还是应该为'4',
下半部分开始旋转的时候底部显示的数字还是应该为'4',而旋转的部分该为'5'。


canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
// 下半部分裁剪
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码显示的上下底部显示的内容,即上半部分地步显示5,下半部分显示4
if (degree < 90) {
//上半部分裁剪
canvas.save()
canvas.clipRect(
0f,
0f,
width.toFloat(),
upperHalfBottom
)
camera.save()
canvas.translate(width / 2f, height / 2f)
camera.rotateX(-degree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number4, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示上半部分旋转显示的内容,即数字4
} else {
canvas.save()
canvas.clipRect(
0f,
lowerHalfTop,
width.toFloat(),
height.toFloat()
)
camera.save()
canvas.translate(width / 2f, height / 2f)
val bottomDegree = 180 - degree
camera.rotateX(bottomDegree)
camera.applyToCanvas(canvas)
canvas.translate(-width / 2f, -height / 2f)
camera.restore()
canvas.drawText(number5, x, y, textPaint)
canvas.restore()
//=====⬆️=====上述的代码表示下半部分旋转显示的内容,即数字5
}

效果图如下:大伙可以在去理一下上面数字的变化的逻辑。


a.gif

最后我们加上背景再看一下效果:


a.gif

小结


上述代码仅仅提供个思路,仅为测试code,正式代码可不能这么写哦 >..<


作者:蹦蹦蹦
来源:juejin.cn/post/7271518821809438781
收起阅读 »

Android 15 可能最终修复了底部黑色导航栏问题

Android 15 可能最终修复了底部黑色导航栏问题 长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它. 这就是手势...
继续阅读 »

Preview image


Android 15 可能最终修复了底部黑色导航栏问题


长期以来, Android 系统一直存在一个问题: 手势栏/药丸/三键导航下面有一个可笑的黑条. 我曾找过相关的截图, 但要么截图太旧, 要么截图不清晰. 因为我一直在使用各种方法来隐藏它.


这就是手势栏, 它并不那么碍事. Android 系统也有三键导航功能, 如果你打开它, 黑条就会变得非常大.


这是因为在早期的 Android 系统中, 屏幕上的导航按钮是用来取代实体按键的. 但老实说, 由于屏幕与静态按键不同, 效果并不理想. 这感觉更像是一个噱头. 当他们最终推出允许用户在'沉浸模式'下隐藏按键的 API 时, 我真的很不喜欢, 因为这意味着你必须做这个愚蠢的轻扫手势才能按到按键.


总之, 随着屏幕边框越来越小, 我们需要更大的显示屏, 屏幕底部的黑条开始变得有些碍眼和不必要. 最初, 在定制的 Roms 中, 有一种叫做"派控制 器"(Pie Controll)的东西. 那是一段美好时光. 你只需在屏幕边缘轻扫, 就会跳出一堆按钮. 虽然我用得不多, 因为我的手机大多是电容按键.


Android pie controls


我确实改用了手势导航, 也正是从那时起, 我才真正开始讨厌黑条. 因为它看起来太多余了. iOS 系统没有黑条, 而 iPhone 却运行得非常好. 所以这些年来我一直在尝试禁用这个黑条. 最初, 我使用的是 Xposed 模块. 后来, 我用了带电容按键的手机, 这样我就再也不用看到这个栏了. 这样做的好处是, Android 系统不会在你轻扫之前隐藏按钮.


后来, 我发现了一款名为"流体导航手势"(Fluid Navigation Gestures)的应用, 它曾在一段时间内起过作用. 但我目前的解决方案是只使用 iOS, 因为 Android 手机现在太难root了.


但据 Android Authority 报道, 这个问题可能最终会消失.



有鉴于此, 当我翻阅 Android 14 QPR2 Beta 3 时, 我发现了一个名为 EDGE_TO_EDGE_BY_DEFAULT的新应用兼容性更改, 其描述如下: "如果目标 SDK 为 VANILLA_ICE_CREAM或更高版本, 则应用默认为Edge-to-Edge. Vanilla Ice Cream恰好是 Android 15 的内部甜点名称, 这意味着这一兼容性变更将适用于以今年即将发布的版本为目标的应用. 鉴于Google每年都会强制开发者更新他们的应用, 以适应更新的 API 级别, 因此 Play Store 上的大多数应用都会以 Android 15 为目标, 这只是时间问题. 除非Google再次修改政策, 否则新应用和应用更新将被迫以 Android 15 为目标的截止日期将是 2025 年 8 月 31 日.



我不确定Google是否强迫你每年更新应用. 我不认为他们会这样做, 因为 Google Play 上有一些非常老旧的应用, 我敢肯定它们已经不再被维护了. 现在, 如果你不按照新规定更新应用, 他们可以把你的应用踢出去, 但并没有那么多规定. 我想作者的意思是, 当你更新应用时, 你必须使用最新的 SDK, 也就是香草冰淇淋的 SDK, 很可能是 SDK 35 级.


总之, Edge-to-Edge模式是一种允许应用在整个屏幕上绘图的方式. 目前, 默认情况下 Android 应用无法在状态栏和导航栏上绘图. 除此之外, 状态栏和导航栏还会变成半透明状态, 这意味着你可以看到它们下方的内容.


现在, 无论出于何种原因, 有些人并不喜欢这一变化. 可能是因为这意味着内容被手势区域或按钮遮住了. 是的, 这种情况偶尔会发生. 我最近就遇到了这种情况.


正如你在上面的片段中看到的, 在过渡到放大视图时, 页面指示器的位置太低了. 在 iOS 上使用手势导航时问题不大, 但在使用三键导航时就会出现问题. 虽然在这个特定的例子中, 页面指示器不是可点击的, 所以问题不大.


但 iOS 这样做已经有很长一段时间了, 好像自己 iPhone X 就这样了. 他们有一个非常简单的方法来实现这一功能, 叫做安全区域 API. 我不太清楚它在本地是如何实现的, 但在 Flutter 中却很容易实现. 你只需使用 SafeArea() 对象或调用 MediaQuery.of(context).padding. 尽管 MediaQuery 有 3 个 padding 值. 这有点令人困惑.


总之, 我的所有应用都使用了专门的代码, 以确保 Android 应用从 Edge-to-Edge 显示. 这是因为我希望 iOS 和 Android 版本看起来一样. 此外, 我也更喜欢'Edge-to-Edge'的外观.


await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
statusBarColor: Colors.transparent,
),
)
;

这也使得状态栏和导航栏完全透明, 而不仅仅是半透明. 不过有一个小问题: 你必须确保状态栏图标的颜色正确, 否则它们将无法显示. 实际上, 这有点困难, 因为你必须根据你所处的屏幕来改变它们的颜色, 但这并不难.


要在 Flutter 中实现这一点, 你必须将Scaffold包裹在AnnotatedRegion<SystemUiOverlayStyle>中, 其值就是此函数的结果:


SystemUiOverlayStyle makeUiOverlayStyle(bool whiteIcons) {
return SystemUiOverlayStyle(
statusBarBrightness: whiteIcons ? Brightness.dark : Brightness.light,
statusBarIconBrightness: whiteIcons ? Brightness.light : Brightness.dark,
);
}

就是这样. 改变并不难, 我很高兴 Google 终于做到了. 俗话说, 迟到总比不到好.


虽然有些人认为这种改变还不够. 他们希望取消手势"提示". 这样, 你在使用手势时就不会看到屏幕底部的白条了. 要知道, 早在root很容易的时候, 我就在我的 Android 手机上这样做了. 以及最近的流畅导航手势(Fluid Navigation Gestures). 它是一种更简约的UI, 但我不认为它能给用户体验带来多少好处. 我认为强制应用采用 'Edge-to-Edge' 的设计已经足够好了.


作者:bytebeats
来源:juejin.cn/post/7356793698052866048
收起阅读 »

Android RecyclerView宫格拖拽效果实现

前言 在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLa...
继续阅读 »

前言


在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLayout等,实际上,由于RecyclerView的灵活性和可扩展性很高,这些View基本没必要去学了,为什么这样说呢?主要原因是基于RecyclerView可以实现很多布局效果,传统的很多Layout都可以通过RecyclerView去实现,比如ViewPager、SlideTabLayout、DrawerLayout、ListView等,甚至连九宫格解锁效果也可以实现。


当然,在很早之前,实现网格的拖拽效果主要是通过GridView去实现的,如果列数为1的话,那么GridView基本上就实现了ListView一样的上下拖拽。


话说回来,我们现在基本不用去学习这类实现了,因为RecyclerView足够强大,通过简单的数据组装,是完全可以替代GridView和ListView的。


效果


本篇我们会使用RecyclerView来实现网格拖拽,本篇将结合图片分片案例,实现拖拽效果。


fire_139.gif


如果要实现网格菜单的拖拽,也是可以使用这种方式的,只要你的想象丰富,理论上,借助RecyclerView其实可以做出很多效果。


fire_140.gif


拖拽效果原理


拖动其实需要处理3个核心的问题,事件、图像平移、数据交换。


事件处理


实际上无论传统的拖拽效果还是最新的拖拽效果,都离不开事件处理,不过,好处就是,google为RecyclerView提供了ItemTouchHelper来处理这个问题,相比传统的GridView实现方式,省去了很多事情,如动画、目标查找等。


不过,我们回顾下原理,其实他们很多方面都是相似的,不同之处就是ItemTouchHelper 设计的非常好用,而且接口暴露的非常彻底,甚至能控制那些可以拖动、那些不能拖动、以及什么方向可以拖动,如果我们上、下、左、右四个方向都选中的话,斜对角拖动完全没问题,


事件处理这里,GridView使用的方式相对传统,而ItemTouchHelper借助RecyclerView的一个接口(看样子是开的后门),通过View自身去拦截事件.


public interface OnItemTouchListener {
//是否让RecyclerView拦截事件
boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//拦截之后处理RecyclerView的事件
void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//监听禁止拦截事件的请求结果
void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}

这种其实相对GridView来说简单的多


图像平移


无论是RecyclerView和传统GridView拖动,都需要图像平移。我们知道,RecyclerView和GridView本身是通过子View的边界(left\top\right\bottom)来移动的,那么,在平移图像的时候必然不能选择这种方式,只能选择Matrix 变化,也就是transitionX和transitionY的等。不同点是GridView的子View本身并不移动,而是将图像绘制到一个GridView之外的View上,相当于灵魂附体到外面View上,实现上是比较复杂。


对于RecyclerView来说,ItemTouchHelper设计比较巧妙的一点是,通过RecyclerView#ItemDecoration来实现,在捕获可以滑动的View之后,在绘制时对View进行偏移。


class ItemTouchUIUtilImpl implements ItemTouchUIUtil {
static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl();

@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive)
{
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}

view.setTranslationX(dX);
view.setTranslationY(dY);
}
//省略一些有关或者无关的代码
}

不过,我们看到,Android 5.0的版本借助了setElevation 使得被拖拽View不被其他顺序的View遮住,那Android 5.0之前是怎么实现的呢?


其实,做过TV app的都比较清楚,子View绘制顺序可以通过下面方式调整,借助下面的方法,在TV上某个View获取焦点之后,就不会被后面的View盖住。


View#getChildDrawingOrder(...)

此方法实际上是改变了View的绘制顺序,原理是通过下面方式,将View的索引和绘制顺序进行了映射,比如原来的第一个View模式是第1个被绘制的子View,但可以变更成最后一个绘制的View。



原理:让第i个位置绘制第index的view,伪代码如下



void drawChildFunction(drawIndex,canvas){
children[mapChildIndex(drawIndex)].draw(canvas);
}

具体实现方法参考如下。


ArrayList<View> buildOrderedChildList() {
final int childrenCount = mChildrenCount;
if (childrenCount <= 1 || !hasChildWithZ()) return null;

if (mPreSortedChildren == null) {
mPreSortedChildren = new ArrayList<>(childrenCount);
} else {
// callers should clear, so clear shouldn't be necessary, but for safety...
mPreSortedChildren.clear();
mPreSortedChildren.ensureCapacity(childrenCount);
}

final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = 0; i < childrenCount; i++) {
// add next child (in child order) to end of list
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);

// 映射View
final View nextChild = mChildren[childIndex];
final float currentZ = nextChild.getZ();

// 如果Z值大的话往后移动,5.0之前的代码没有这段
int insertIndex = i;
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
mPreSortedChildren.add(insertIndex, nextChild);
}
return mPreSortedChildren;
}

ItemTouchHelper 同样借助了此方法,在我们测试后发现,其实Android 4.4之前的版本没有明显的效果差异,但是这里依然好奇,为什么不统一使用一种方式呢?


没有找到明确的答案,但是从代码效率来说,显然setElevation性能更好一些,同时也释放了对绘制顺序的功能的占用。


private void addChildDrawingOrderCallback() {
if (Build.VERSION.SDK_INT >= 21) {
return; // we use elevation on Lollipop
}
if (mChildDrawingOrderCallback == null) {
mChildDrawingOrderCallback = new RecyclerView.ChildDrawingOrderCallback() {
@Override
public int onGetChildDrawingOrder(int childCount, int i) {
if (mOverdrawChild == null) {
return i;
}
int childPosition = mOverdrawChildPosition;
if (childPosition == -1) {
childPosition = mRecyclerView.indexOfChild(mOverdrawChild);
mOverdrawChildPosition = childPosition;
}
if (i == childCount - 1) {
//将最后索引位置展示被拖拽的View
return childPosition;
}
//后面的View 绘制顺序往前移动
return i < childPosition ? i : i + 1;
}
};
}
mRecyclerView.setChildDrawingOrderCallback(mChildDrawingOrderCallback);
}

这里为什么要讲解之前的版本怎么做的呢?主要原因是,目前除了手机设备以外,有相当一部分设备是Android 4.4 的,而且事件传递过程中需要了解这方面的思想。


数据更新


数据更新这里其实ReyclerView的优势更加明显,我们知道RecyclerView可以做到无requestLayout的局部刷新,性能更好。


@Override
public boolean onItemMove(int fromPosition, int toPosition) {
Collections.swap(mDataList, fromPosition, toPosition);
notifyItemMoved(fromPosition, toPosition);
return true;
}

不过,数据交换后还有一点需要处理,对Matrix相关属性清理,防止无法落到指定区域。


@Override
public void clearView(View view) {
if (Build.VERSION.SDK_INT >= 21) {
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}

view.setTranslationX(0f);
view.setTranslationY(0f);
}

本篇实现


以上基本都是对ItemTouchHelper的原理梳理了,当然,如果你没时间看上面的话,就看实现部分吧。


图片分片


下面我们把多张图片分割成 [行数 x 列数]数量的图片。


Bitmap srcInputBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.image_4);
Bitmap source = Bitmap.createScaledBitmap(srcInputBitmap, width, height, true);
srcInputBitmap.recycle();

int colCount = spanCount;
int rowCount = 6;

int spanImageWidthSize = source.getWidth() / colCount;
int spanImageHeightSize = (source.getHeight() - rowCount * padding/2) / rowCount;

Bitmap[] bitmaps = new Bitmap[rowCount * colCount];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < colCount; j++) {
int y = i * spanImageHeightSize;
int x = j * spanImageWidthSize;
Bitmap bitmap = Bitmap.createBitmap(source, x, y, spanImageWidthSize, spanImageHeightSize);
bitmaps[i * colCount + j] = bitmap;
}
}

在这种过程我们一定要处理一个问题,如果我们对网格设置了边界线(ItemDecoration)且是纵向布局的话,那么RecyclerView天然都不会横向滑动,但是纵向就不一样了,纵向总高度要减去rowCount * bottomPadding,这里bottomPadding == padding/2,如下面代码。


为什么要这么做呢?因为RecyclerView计算高度的时候,需要考虑这个高度,如果不去处理,那么ReyclerView可能会滑动,虽然影响不大,但是如果实现全屏效果,拖动View时RecyclerView还能上下滑的话体验比较差。


public class SimpleItemDecoration extends RecyclerView.ItemDecoration {

public int delta;
public SimpleItemDecoration(int padding) {
delta = padding;
}

@Override
public void getItemOffsets(Rect outRect, View view,
RecyclerView parent, RecyclerView.State state)
{
int position = parent.getChildAdapterPosition(view);
RecyclerView.Adapter adapter = parent.getAdapter();
int viewType = adapter.getItemViewType(position);
if(viewType== Bean.TYPE_GR0UP){
return;
}
GridLayoutManager layoutManager = (GridLayoutManager) parent.getLayoutManager();
//列数量
int cols = layoutManager.getSpanCount();
//position转为在第几列
int current = layoutManager.getSpanSizeLookup().getSpanIndex(position,cols);
//可有可无
int currentCol = current % cols;


int bottomPadding = delta / 2;

if (currentCol == 0) { //第0列左侧贴边
outRect.left = 0;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
} else if (currentCol == cols - 1) {
outRect.left = delta / 4;
outRect.right = 0;
outRect.bottom = bottomPadding;
//最后一列右侧贴边
} else {
outRect.left = delta / 4;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
}
}
}

更新数据


这部分是常规操作,主要目的是设置LayoutManager、Decoration、Adapter以及ItemTouchHelper,当然,ItemTouchHelper比较特殊,因为其内部是ItemDecoration、OnItemTouchListener、Gesture的组合,因此封装为attachToRecyclerView 来调用。


mLinearLayoutManager = new GridLayoutManager(this, spanCount, LinearLayoutManager.VERTICAL, false);
mLinearLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){
@Override
public int getSpanSize(int position) {
if(mAdapter.getItemViewType(position) == Bean.TYPE_GR0UP){
return spanCount;
}
return 1;
}
});
mAdapter = new RecyclerViewAdapter();
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setLayoutManager(mLinearLayoutManager);
mRecyclerView.addItemDecoration(new SimpleItemDecoration(padding));
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new GridItemTouchCallback(mAdapter));
itemTouchHelper.attachToRecyclerView(mRecyclerView);

这里,我们主要还是关注ItemTouchHelper,在初始化的时候,我们给了一个GridItemTouchCallback,用于监听相关处理逻辑,最终通知Adapter调用notifyXXX更新View。


public class GridItemTouchCallback extends ItemTouchHelper.Callback {
private final ItemTouchCallback mItemTouchCallback;
public GridItemTouchCallback(ItemTouchCallback itemTouchCallback) {
mItemTouchCallback = itemTouchCallback;
}

@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
if(viewHolder.getItemViewType() == Bean.TYPE_GR0UP){
return 0; //设置此类型的View不可拖动
}
// 上下左右拖动
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
return makeMovementFlags(dragFlags, 0);
}

@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 通知Adapter移动View
return mItemTouchCallback.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// 通知Adapter删除View
mItemTouchCallback.onItemRemove(viewHolder.getAdapterPosition());
}

@Override
public void onChildDraw(@NonNull Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
Log.d("GridItemTouch","dx="+dX+", dy="+dY);
super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}

这里,主要是对Flag的关注需要处理,第一参数是拖拽方向,第二个是删除方向,我们本篇不删除,因此,第二个参数为0即可。


public static int makeMovementFlags(int dragFlags, int swipeFlags) {
return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags)
| makeFlag(ACTION_STATE_SWIPE, swipeFlags)
| makeFlag(ACTION_STATE_DRAG, dragFlags);
}

当然,删除和拖拽都不要的viewHolder,那么直接返回0.


总结


本篇到这里就结束了,我们利用RecyclerView实现了宫格图片的拖拽效果,主要是借助ItemTouchHelper实现,从ItemTouchHelper中我们能看到很多巧妙的的设计,里面有很多值得我们学习的技巧,特别是对事件的处理、绘制顺序调整的方式,如果做吸顶,未尝不是一种方案。


作者:时光少年
来源:juejin.cn/post/7348707728921853971
收起阅读 »

Android进阶之路 - 字体自适应

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获 很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种 默认执行多行显示 单行显示,不足部分显示....
继续阅读 »

开发中有很多场景需要进行自适应适配,但是关于这种字体自适应,我也是为数不多的几次使用,同时也简单分析了下源码,希望我们都有收获



很多时候控件的宽度是有限的,而要实现比较好看的UI效果,常见的处理方式应该有以下几种



  • 默认执行多行显示

  • 单行显示,不足部分显示...

  • 自适应字体


静态设置


宽度是有限的,内部文字会根据配置进行自适应


在这里插入图片描述


TextView 自身提供了自适应的相关配置,可直接在layout中进行设置


主要属性



  • maxLines="1"

  • autoSizeMaxTextSize

  • autoSizeMinTextSize

  • autoSizeTextType

  • autoSizeStepGranularity


    <TextView
android:id="@+id/tv_text3"
android:layout_width="50dp"
android:layout_height="40dp"
android:layout_marginTop="10dp"
android:autoSizeMaxTextSize="18sp"
android:autoSizeMinTextSize="10sp"
android:autoSizeStepGranularity="1sp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:maxLines="1"
android:text="自适应字体" />


源码:自定义属性


在这里插入图片描述




动态设置


 // 设置自适应文本默认配置(基础配置)
TextViewCompat.setAutoSizeTextTypeWithDefaults(textView, TextView.AUTO_SIZE_TEXT_TYPE_UNIFORM)
// 主动设置自适应字体相关配置
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(textView, 20, 48, 2, TypedValue.COMPLEX_UNIT_SP)



源码分析


如果你有时间,也有这方面的个人兴趣,可以一起分享学习一下


setAutoSizeTextTypeWithDefaults


根据源码来看的话,内部做了兼容处理,主要是设置自适应文本的默认配置


在这里插入图片描述


默认配置方法主要根据不同类型设置自适应相关配置,默认有AUTO_SIZE_TEXT_TYPE_NONE or AUTO_SIZE_TEXT_TYPE_UNIFORM ,如果没有设置的话就会报 IllegalArgumentException 异常



  • AUTO_SIZE_TEXT_TYPE_NONE 清除自适应配置

  • AUTO_SIZE_TEXT_TYPE_UNIFORM 添加一些默认的配置信息


在这里插入图片描述




setAutoSizeTextTypeUniformWithConfiguration


根据源码来看主传4个参数,内部也做了兼容处理,注明 Build.VERSION.SDK_INT>= 27 or 属于 AutoSizeableTextView 才能使用文字自定义适配



  • textView 需进行自适应的控件

  • autoSizeMinTextSize 自适应自小尺寸

  • autoSizeMaxTextSize 自适应自大尺寸

  • autoSizeStepGranularity 自适应配置

  • unit 单位,如 sp(字体常用)、px、dp


在这里插入图片描述


unit 有一些常见的到单位,例如 dp、px、sp等


在这里插入图片描述


作者:Shanghai_MrLiu
来源:juejin.cn/post/7247027677223485498
收起阅读 »

仿抖音评论,点击回复自动将该条评论上移至第一条

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容 评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recycl...
继续阅读 »

打开抖音的评论,回复评论时,自动将该条评论上滑至最上方的位置,目的也是为了让用户能够回复的时候,看到他要回复评论的内容


评论有一级评论和二级评论,一级评论是评论弹窗下的recyclerView,二级评论又是一级评论adpter中某一个item的一个recyclerView,回复一级评论时,上移相对简单一点,如果回复二级评论时,把二级评论上移到第一条稍微复杂一些。


有2种方法,目测抖音的评论就是用的一种方法


方法实现原理:先计算出该评论的在整个屏幕中的位置,主要是point Y这个点,然后计算移动到第一条需要向上移动的距离,通过属性动画完成。


具体步骤看代码注释



// 1. 拿到一级评论对应的viewHolder,有了这个viewHolder就可以拿到指定的子view
val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
// 如果回复一级评论,默认mSecondCommentPosition为-1,如果回复二级评论,mSecondCommentPosition肯定就不是-1了,因为position是从0开始的
if (mSecondCommentPosition != -1) {
// 拿到二级评论的viewHolder
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder4.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
} else {
// 我获取的时该评论头像相对于屏幕的的具体的点,用的是getLocationOnScreen,当然也可以参考使用# getLocationInWindow
viewHolder2.rrivAvatar.getLocationOnScreen(location)
// 获取需要上移的距离,然后上移
translateY = (location[1] - AppUtil.dp2px(264f)) * -1.0f
val ani: ObjectAnimator = ObjectAnimator.ofFloat(viewHolder!!.rvList, "translationY", 0f, translateY)
ani.duration = 300
ani.start()
}
}

方法二,通过scrollToPositionWithOffset实现


linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)

其中mFirstCommentPosition为一级评论的position,translateY为二级评论相对于一级评论的在竖直方向上的高度。


该方法有个小问题,不能实现缓慢的滑动效果,直接就上去了,有点突兀。


当然还有一个方法,调用recyclerView的smoothScrollToPosition方法,该方法只能实现评论滑动到屏幕可见,一般是在最下方,并不能实现滑动到顶。scrollToPositionWithOffset方法的第2个参数如果设置为0就可以实现滑动到顶。


还有一个小问题,就是如果回复的评论恰好是最后一条,则滑不上去了,因为下方没有数据了。


具体代码如下


val linearLayout = viewHolder!!.rvList.layoutManager as LinearLayoutManager

val viewHolder1 =
viewHolder!!.rvList.findViewHolderForAdapterPosition(mFirstCommentPosition)
if (viewHolder1 != null && viewHolder1 is VideoDetailCommentAdapter.VideoDetailCommentViewHolder) {
val viewHolder2 = viewHolder1 as VideoDetailCommentAdapter.VideoDetailCommentViewHolder
val location = IntArray(2)
viewHolder2.rrivAvatar.getLocationOnScreen(location)
if (mSecondCommentPosition != -1) {
val viewHolder3 = viewHolder2.rvList.findViewHolderForAdapterPosition(mSecondCommentPosition)
if (viewHolder3 != null && viewHolder3 is VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder) {
val viewHolder4 = viewHolder3 as VideoDetailSecondCommentAdapter.VideoDetailSecondCommentViewHolder
val location1 = IntArray(2)
viewHolder4.rrivAvatar.getLocationOnScreen(location1)
translateY = (location1[1] - location[1]) * -1
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, translateY)
}
} else {
linearLayout.scrollToPositionWithOffset(mFirstCommentPosition, 0)
}
}

作者:心在梦在
来源:juejin.cn/post/7356772896046415908
收起阅读 »

Android项目——LittlePainter

一、项目简介 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,Navigation,Lifecyle,DataBinding,LiveData,ViewModel等搭建的 MVVM 架构模式 通过组件化拆分,实现项目更好解耦和复用 自定义v...
继续阅读 »

一、项目简介



  • 项目采用 Kotlin 语言编写,结合 Jetpack 相关控件,NavigationLifecyleDataBindingLiveDataViewModel等搭建的 MVVM 架构模式

  • 通过组件化拆分,实现项目更好解耦和复用

  • 自定义view

  • recycleview的使用

  • 移动+淡入动画:补间动画

  • 播放Lottie资源

  • 项目截图
    445D49437D72307AB01FB87DD2E6D34C.jpg


D703A49583FAAFFD5449F21392BC0090.jpg


AF0F008861A540D2EB86B075C98372C7.jpg


16417F10BD034AEEE47BFA624D8590B2.jpg


439E66BE976571C244087AB01B38EE3F.jpg
github github.com/afbasfh/Lit…


二、项目详情


2.1 MVVM(Model-View-ViewModel)


是一种基于数据绑定的架构模式,用于设计和组织应用程序的代码结构。它将应用程序分为三个主要部分:Model(模型)、View(视图)和ViewModel(视图模型)。



  • Model(模型):负责处理数据和业务逻辑。它可以是从网络获取的数据、数据库中的数据或其他数据源。Model层通常是独立于界面的,可以在多个界面之间共享。

  • View(视图):负责展示数据和与用户进行交互。它可以是Activity、Fragment、View等。View层主要负责UI的展示和用户输入的响应。

  • ViewModel(视图模型):连接View和Model,作为View和Model之间的桥梁。它负责从Model中获取数据,并将数据转换为View层可以直接使用的形式。ViewModel还负责监听Model的数据变化,并通知View进行更新。ViewModel通常是与View一一对应的,每个View都有一个对应的ViewModel。


image.png


2.2 Jetpack组件


(1) Navtgation


Google 在2018年推出了 Android Jetpack,在Jetpack里有一种管理fragment的新架构模式,那就是navigation. 字面意思是导航,但是除了做APP引导页面以外.也可以使用在App主页分tab的情况.. 甚至可以一个功能模块就一个activity大部分页面UI都使用fragment来实现,而navigation就成了管理fragment至关重要的架构.


这里主要用于页面的切换


(2) ViewBinding&DataBinding



  • ViewBinding 的出现就是不再需要写 findViewById()

  • DataBinding 是一种工具,它解决了 View 和数据之间的双向绑定;减少代码模板,不再需要写findViewById()释放 Activity/Fragment,可以在 XML 中完成数据,事件绑定工作,让 Activity/Fragment 更加关心核心业务;数据绑定空安全,在 XML 中绑定数据它是空安全的,因为 DataBinding 在数据绑定上会自动装箱和空判断,所以大大减少了 NPE 问题。


(3) ViewModel


ViewModel 具备生命感知能力的数据存储组件。页面配置更改数据不会丢失,数据共享(单 Activity 多 Fragment 场景下的数据共享),以生命周期的方式管理界面相关的数据,通常和 DataBinding 配合使用,为实现 MVVM 架构提供了强有力的支持。


(4) LiveData


LiveData 是一个具有生命周期感知能力的数据订阅,分发组件。支持共享资源(一个数据支持被多个观察者接收的),支持粘性事件的分发,不再需要手动处理生命周期(和宿主生命周期自动关联),确保界面符合数据状态。在底层数据库更改时通知 View。


(5) Room


一个轻量级 orm 数据库,本质上是一个 SQLite 抽象层。使用更加简单(Builder 模式,类似 Retrofit),通过注解的形式实现相关功能,编译时自动生成实现类 IMPL


这里主要用于收藏点赞音乐,与 LiveData 和 Flow 结合处理可以避免不必要的 NPE,可以监听数据库表中的数据的变化,也可以和 RXJava 的 Observer 使用,一旦发生了 insert,update,delete等操作,Room 会自动读取表中最新的数据,发送给 UI 层,刷新页面。


2.3 RecycleView


1.1 什么是RecycleView


Recyclerview是可以展示大量数据 ,重视回收和复用的view的一种控件;
RecyclerView是一个强大的滑动组件,与经典的ListView相比,同样拥有item回收复用的功能,这一点从它的名字Recyclerview即回收view也可以看出。RecyclerView 支持 线性布局、网格布局、瀑布流布局 三种,而且同时还能够控制横向还是纵向滚动。


1.2RecycleView的用法


纵向排列 布局文件:

1 .创建主布局并在主布局中添加 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".home.HomeFragment">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_marginTop="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />



</androidx.constraintlayout.widget.ConstraintLayout>

2.创建子项布局文件addressbook_item.xml,并对其内部控件设置id 代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="wrap_content"
android:layout_height="match_parent">

<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView"
android:layout_width="250dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/f1" />

<View
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="@drawable/picture_border"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintEnd_toEndOf="@+id/imageView"
app:layout_constraintStart_toStartOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

3.创建适配器

直接继承RecyclerView.Adapter<AddressBookAdapter.ViewHolder> 然后一一实现


package com.example.littlepainter.home

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGr0up
import androidx.navigation.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.example.littlepainter.db.Picture
import com.example.littlepainter.databinding.LayoutPictureItemBinding

class PictureAdapter: RecyclerView.Adapter<PictureAdapter.MyViewHolder>() {
private var mPictures = emptyList<Picture>()

override fun getItemCount(): Int {
return mPictures.size
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): MyViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = LayoutPictureItemBinding.inflate(inflater,parent,false)
return MyViewHolder(binding)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.bind(mPictures[position])
}

@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<Picture>){
mPictures = newData
notifyDataSetChanged()
}

//定义ViewHolder
class MyViewHolder(private val binding:LayoutPictureItemBinding):RecyclerView.ViewHolder(binding.root){
fun bind(pictureModel: Picture){
binding.imageView.setImageBitmap(pictureModel.thumbnail)
binding.root.setOnClickListener {
//切换到绘制界面
val action = HomeFragmentDirections.actionHomeFragmentToDrawFragment(pictureModel)
binding.root.findNavController().navigate(action)
}
}
}
}

4.在活动中创建并设置适配器


binding.recyclerView.apply {
layoutManager = ScaleLayoutManager(requireContext())
adapter = mAdapter
PagerSnapHelper().attachToRecyclerView(this)
}

(2) ViewPager


2.1、什么是ViewPager


布局管理器允许左右翻转带数据的页面,你想要显示的视图可以通过实现PagerAdapter来显示。这个类其实是在早期设计和开发的,它的API在后面的更新之中可能会被改变,当它们在新版本之中编译的时候可能还会改变源码。
ViewPager经常用来连接Fragment,它很方便管理每个页面的生命周期,使用ViewPager管理Fragment是标准的适配器实现。最常用的实现一般有FragmentPagerAdapter和FragmentStatePagerAdapter。
ViewPager是android扩展包v4包中的类,这个类可以让我们左右切换当前的view。我们先来聊聊ViewPager的几个相关知识点:
1、ViewPager类直接继承了ViewGr0up类,因此它一个容器类,可以添加其他的view类


2、ViewPager类需要一个PagerAdapter适配器类给它提供数据(这点跟ListView一样需要数据适配器Adater)


3、ViewPager经常和Fragment一起使用,并且官方还提供了专门的FragmentPagerAdapterFragmentStatePagerAdapter类供Fragment中的ViewPager使用


2.4 移动+淡入动画:补间动画


<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="0.0"
android:toAlpha="1.0"
android:duration="500"/>

<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:duration="500"/>

2.5 自定义view


1 .创建一个类继承于View


class DrawView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
}

2 .重写构造方法


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}

2.6 播放Lottie资源


1.在Lottie寻找合适的资源放在资源项目下


VAC00JD74ROG)Q1CE0LMR.png


2. 在布局里使用


<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottieAnimationView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.304"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/anim4" />

作者:candy_
来源:juejin.cn/post/7305984583984234534
收起阅读 »

RecyclerView+多ItemType实现两级评论页面

多ItemType实现多级评论页面 前言 我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。 在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法...
继续阅读 »

多ItemType实现多级评论页面


前言


我的上一篇文章 Android简单的两级评论功能实现,得到了很多的评论(万万没想到),收获了jym们宝贵的建议和指导,在此特别感谢大家。


在上一篇文章中,对于‘两级评论功能’的实现,我采用的是Recycler嵌套的方法,这种方法的实现不难,但是非常的麻烦,有很多不必要的操作,扩展性很差,维护起来也是十分复杂。虽然最后实现效果还可以,但是有更好更方便的方法,何乐而不为呢。所以这篇文章的内容就是对多ItemType实现评论功能的过程阐述,还有两种实现方式的区别和性能差异。


:文章要参加更文活动,只会粘贴关键的代码。如需详细代码,请私信。


一、适配器


重复的部分就不说了,数据库和布局部分基本和上一篇是一致的,只是把item布局中的RecyclerView和对应的适配器及相关代码去掉了。


1、创建两个ViewHolder


分别是TestOneViewHolder和TestTwoViewHolder,这里不贴代码只展示布局了


一级评论的布局:


image.png


二级评论的布局:


image.png


2、设置两个ItemType


LEVEL_ONE_VIEW一级评论的ViewType,LEVEL_TWO_VIEW二级评论的ViewType


private val LEVEL_ONE_VIEW = 0 // 一级布局的的ViewType
private val LEVEL_TWO_VIEW = 1 // 二级布局的的ViewType

3、方法重写



  • getItemViewType方法中返回ViewType


override fun getItemViewType(position: Int): Int {
val commentInfo = list.toList()[position].first
return if (commentInfo.level == 1) {
LEVEL_ONE_VIEW
} else {
LEVEL_TWO_VIEW
}
}


这里list的类型是‘Map<CommentInfo, User>’,是因为还需要User的数据,所以映射来的。
获取到评论信息后对level进行判断,返回相应的ViewType。




  • onCreateViewHolder中根据ViewType进行判断,根据TYPE返回相应的ViewHolder


override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == LEVEL_ONE_VIEW) {
TestOneViewHolder(parent)
} else {
TestTwoViewHolder(parent)
}
}


  • onBindViewHolder通过getItemViewType(position)来获取当前的ViewType,再进行数据绑定。如图:


image.png

4、数据绑定


在ViewHolder中将传入的数据对布局进行赋值就好了,最后实现的效果如下图。为了能够更加直观的看出一级评论与二级评论之间的关联,图片中的评论内容用数字进行标识。


微信图片_2.jpg

可以看到在设置完多ItemType后,显示的布局符合我们的预期了,可是一级评论和二级评论之间毫无关联,各过各的,那如何将评论布局展示出绑定的效果呢?主要还是对数据进行处理啦,如何处理呢,请看下一节。



二、绑定


这个绑定指的是将与一级评论相关联的二级评论和该一级评论展示在一起,有一种类似的绑定效果。大致思路如下:



  1. 获取该文章的所有评论

  2. 分别获取到level为1、2的评论列表

  3. 将level为2的列表按照回复评论的Id进行分组

  4. 创建空列表

  5. 遍历level为1的列表,获取到相应的level2的列表并依次添加进空列表


实现代码如下:


// 获取该文章的所有评论
val comments = commentStoreRepository.getCommentsByNewId(newsId)
// 获取level为1、2的评论、按时间进行排序
val level1 = comments.filter { it.level == 1 }.sortedBy { it.time }
val level2 = comments.filter { it.level == 2 }.sortedBy { it.time }
// 将level为2的列表按照回复评论的Id进行分组
val level2Gr0up = level2.groupBy { it.replyId }
// 创建空列表
val list = mutableListOf<CommentInfo>()
// 遍历level1的列表 获取到对应的level2列表 依次添加进空列表中
level1.forEach { level1Info ->
val newLevel2Gr0up = level2Gr0up[level1Info.id]
list.add(level1Info)
if (newLevel2Gr0up != null) {
list.addAll(newLevel2Gr0up)
}
}


这个空列表,即list就是我们需要的能展示强绑定关系的列表啦



最终呈现的效果如下图


微信图片_1.jpg

这样,一个多ItemType的二级评论展示就实现啦!!!


三、两种实现方式比对



  1. 实现1 - 嵌套RecyclerView的实现

  2. 实现2 - 多ItemView的实现


1、复杂程度:


主观方面来说,



  • 实现1 -- 首先是在数据及布局的处理方面,会显得非常杂乱。我在非常了解其数据结构的情况下,很多时候也摸不着头脑,而且代码不方便管理。再就是扩展性,如果在这个实现的基础上进行扩展会非常的复杂,想着要是做个更多层级的评论那得多麻烦。优点就是能够对二级评论进行单独的管理。

  • 实现2 -- 单独对布局进行管理,很方便,复杂程度低,扩展性也更好,用来做个多级评论不成问题。缺点:我想实现一个评论下的二级评论最多展示两条,可以展开,还可以显示回复条数的功能不知道怎么做,实现一因为可以对二级数据统一管理就会比较好实现。这一点,如果有大佬知道如何解决,请在评论下激情发表你的言论。


060c26572c4bf3f54107bd8b1d0e713.jpg


2、性能方面:


分别插入100100010000条数据,记录消耗时间。如图所示,统计的次数较少,但也可以看出二者在性能方面的差异不大。


结论两种实现性能差异较小。


image.png

四、结语


以上,就是多个ItemType实现二级评论的过程和结果以及两种实现方式的主观对比。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7273685263841853496
收起阅读 »

Android 图片裁剪

前言   图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图 正文   从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自...
继续阅读 »

前言


  图片裁剪是对图片进行区域选定,然后裁剪选定的区域,形成一个图片,然后再对这个图片进行压缩,最终返回结果图片。运行效果图


在这里插入图片描述


正文


  从上面的描述来看貌似是挺简单的是吧,不过实际操作起来就没有那么简单了,下面先来看看简单的实现方式,就是Android自带的裁剪。


一、创建并配置项目


  我们依然从创建项目开始讲起,这虽然有一些繁琐,但无疑可以让每一个Android开发者看懂。创建一个名为PictureCroppingDemo的项目。


创建好之后,在app的build.gradle添加如下代码,有两处


	//JDK版本
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

	//  google权限管理框架
implementation 'pub.devrel:easypermissions:3.0.0'
//热门强大的图片加载器
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

添加位置如下图所示:


在这里插入图片描述


然后打开AndroidManifest.xml,在里面添加两个权限


	<!--读写外部存储-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

这两个权限在Android6.0及以上版本属于危险权限,需要动态申请,下面来写权限申请的代码吧。


二、权限申请


  首先在MainActivity中重写这个onRequestPermissionsResult方法。这个方法属于Android原生的权限请求返回,下面来看它的具体内容:


	/**
* 权限请求结果
* @param requestCode 请求码
* @param permissions 请求权限
* @param grantResults 授权结果
*/

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 将结果转发给 EasyPermissions
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
}

EasyPermissions就是刚才在build.gradle中添加的依赖库,然后写一个权限请求的方法。


	@AfterPermissionGranted(9527)
private void requestPermission(){
String[] param = {Manifest.permission.READ_EXTERNAL_STORAGE,Manifest.permission.WRITE_EXTERNAL_STORAGE};
if(EasyPermissions.hasPermissions(this,param)){
//已有权限
showMsg("已获得权限");
}else {
//无权限 则进行权限请求
EasyPermissions.requestPermissions(this,"请求权限",9527,param);
}
}

  这个requestPermission()方法上面有一个注解,这个注解是什么意思嗯呢,就是权限通过后再调用一次这个方法。然后看方法里面做了什么,定义了一个字符串数组,里面有两个权限,都是在AndroidManifest.xml中配置过的,实际上这两个权限在一个权限组里面,一个权限组只有有一个权限通过则表示整组权限通过,因此你只需要放置一个权限就好了,我这么写是为了让你更清楚一些。然后是一个判断,通过这框架去判断当前的权限是否以获取,是则进行后续操作,我这里是弹一个Toast,方法也很简单。


	/**
* Toast提示
* @param msg 内容
*/

private void showMsg(String msg){
Toast.makeText(this,msg,Toast.LENGTH_SHORT).show();
}

如果没有权限则通过下面这行代码去请求权限


EasyPermissions.requestPermissions(this,"请求权限",9527,param);

  这里的9527其实是一个请求码,它需要与注解中的对应,只有这样它在权限授予之后才会再次调用这个方法做检测。更规范的写法是定于一个全局变量,然后替换这个9527,比如这样


	/**
* 外部存储权限请求码
*/

public static final int REQUEST_EXTERNAL_STORAGE_CODE = 9527;

然后修改对应的地方即可,如下图所示:


在这里插入图片描述


最终记得在onCreate中调用这个requestPermission()方法。下面运行一下:


在这里插入图片描述


三、获取图片Uri


在上面我们已经获取到了权限,下面就来获取这个图片的Uri,然后通过图片Uri显示这个图片。


首先修改布局activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<ImageView
android:id="@+id/iv_picture"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="24dp"
android:onClick="openAlbum"
android:text="打开相册" />

</RelativeLayout>

  很简单的布局,这里唯一要说的就是这个onClick="openAlbum",如果你的按钮不需要进行设置的话,单个按钮的点击事件这样写更简洁一些,你会看到这个地方有一条红线,这需要到Activity中去写这个方法,你可以通过快捷键去生成这个方法。鼠标点击这个划红线的地方,然后Alt + Enter,下面会弹出一个窗口,第二项就是说在MainActivity中创建openAlbum方法。这种方式在Fragment中并不是适用,请注意。


在这里插入图片描述


然后你就会在MainActivity中看到这样的方法,请注意一点,这个方法名与你onClick中的值必须要一致。


	/**
* 打开相册
*/

public void openAlbum(View view) {

}

下面来写打开相册的方法。这里同样的需要一个请求码,去打开相册,然后通过返回的结果去读取图片的uri,定义一个请求码


	/**
* 打开相册请求码
*/

private static final int OPEN_ALBUM_CODE = 100;

然后在修改openAlbum方法,代码如下:


	/**
* 打开相册
*/

public void openAlbum(View view) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
intent.setType("image/*");
startActivityForResult(intent, OPEN_ALBUM_CODE);
}

注意这里使用了startActivityForResult,则需要获取返回值。重写onActivityResult方法。


	/**
* 返回Activity结果
*
* @param requestCode 请求码
* @param resultCode 结果码
* @param data 数据
*/

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);

}

这里先获取相册中的图片显示到Activity中,刚才在activity_main.xml中的ImageView控件就派上用场了。


	//图片
private ImageView ivPicture;

然后在onCreate中绑定xml的id。下面你再使用这个ivPicture就不会报空对象了。


	ivPicture = findViewById(R.id.iv_picture);

然后回到onActivityResult方法,修改代码如下:


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
final Uri imageUri = Objects.requireNonNull(data).getData();
//显示图片
Glide.with(this).load(imageUri).int0(ivPicture);
}
}

这里加了一个判断用于检测是否为打开相册之后的返回与返回是否成功。RESULT_OK是Activity中自带的。


  然后在获取数据时判空处理一下再赋值给一个Uri变量,然后通过Glide框架加载这个Url显示在刚才的ivPicture上。代码写好了,下面运行一下:


在这里插入图片描述


嗯,图片显示出来了,图片的url也拿到了,下面该做这个图片的剪裁了。


四、图片裁剪


既然是调用Android系统的图片裁剪,那么自然也和打开系统相册差不多,依然是先创建一个请求码:


	/**
* 图片剪裁请求码
*/

public static final int PICTURE_CROPPING_CODE = 200;

然后写一个裁剪的方法。


	/**
* 图片剪裁
*
* @param uri 图片uri
*/

private void pictureCropping(Uri uri) {
// 调用系统中自带的图片剪裁
Intent intent = new Intent("com.android.camera.action.CROP");
intent.setDataAndType(uri, "image/*");
// 下面这个crop=true是设置在开启的Intent中设置显示的VIEW可裁剪
intent.putExtra("crop", "true");
// aspectX aspectY 是宽高的比例
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
// outputX outputY 是裁剪图片宽高
intent.putExtra("outputX", 150);
intent.putExtra("outputY", 150);
// 返回裁剪后的数据
intent.putExtra("return-data", true);
startActivityForResult(intent, PICTURE_CROPPING_CODE);
}

图片裁剪需要用到uri,再上面打开相册返回时就已经拿到了uri,那么下面修改onActivityResult方法。


	@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OPEN_ALBUM_CODE && resultCode == RESULT_OK) {
//打开相册返回
final Uri imageUri = Objects.requireNonNull(data).getData();
//图片剪裁
pictureCropping(imageUri);
} else if (requestCode == PICTURE_CROPPING_CODE && resultCode == RESULT_OK) {
//图片剪裁返回
Bundle bundle = data.getExtras();
if (bundle != null) {
//在这里获得了剪裁后的Bitmap对象,可以用于上传
Bitmap image = bundle.getParcelable("data");
//设置到ImageView上
ivPicture.setImageBitmap(image);
}
}
}

  在打开相册返回之后调用pictureCropping方法,传入图片url,然后会启动系统剪裁,剪裁后通过返回数据数据设置到ImageVIew控件上。注意剪裁后就不再是uri了,而是Bitmap。运行一下:


在这里插入图片描述


  可以看到系统的剪裁并不是很彻底,gif中虽然演示的剪裁时是一个圆形,但实际上剪裁的是一个正方形的,这其实和Android系统版本及设置的参数有关系。我在荣耀8和荣耀20i上运行都是这样的,对应的版本是8.0和10.0,效果基本一致。那么下面修改一下参数试试看,如下图我修改了宽高比例和剪裁后的宽高。


在这里插入图片描述


再运行一下:


在这里插入图片描述


可以看到通过该参数真的就不一样了不是吗?


  但是有一些朋友想要圆形的剪裁,那么这里有一个问题你要弄清楚,你要真的还是假的,真的圆形,那么肯定是需要剪裁后重新生成的,而假的圆形就很好办了,首先我们改回刚才的参数,那么在我的是手机上就还是这样的圆形剪裁框,而我只要让他显示出来是一个圆形,你就会以为你是剪裁成功了,当然这都是忽悠用户的好办法,下面来实践一下。这个可以通过外力来解决,圆形图片很多方式能做到,比如第三方框架、自定义View等。


还记得刚才用过的Glide吗?创建requestOptions对象


	/**
* Glide请求图片选项配置
*/

private RequestOptions requestOptions = RequestOptions
.circleCropTransform()//圆形剪裁
.diskCacheStrategy(DiskCacheStrategy.NONE)//不做磁盘缓存
.skipMemoryCache(true);//不做内存缓存

然后在剪裁图片的返回中设置图片


	Glide.with(this).load(image).apply(requestOptions).int0(ivPicture);

在这里插入图片描述


运行一下:


在这里插入图片描述


五、源码


源码地址:PictureCroppingDemo


尾声


  OK,就到这里了。我是初学者-Study,山高水长,后会有期。
此项目并不一定适配所有机型和Android版本,要根据实际情况就改动才行。


作者:初学者_Study
来源:juejin.cn/post/7226894630880460859
收起阅读 »

RecyclerView 实现Item倒计时效果

前言 平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等...
继续阅读 »

前言


平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等问题。


效果


这里可以简单先写个Demo看看效果


bd42682d-57b0-44e4-b569-1c0a1a62142f.gif


功能实现


1. 倒计时功能实现

核心就是开启一个计时器,每秒都更新时间到页面上,这个计时器的实现就很多了,比如直接handler,或者kotlin能用flow去做,或者TimerTask这些也能实现。打个广告,要做精准的倒计时可以看这篇文章juejin.cn/post/714065…


我这里是做Demo演示,为了代码整洁和方便,我就用TimerTask来做。


2. 设计思路

试着想想,你用RecyclerView做倒计时,每个ViewHolder的倒计时时间都不同,难道要在每个ViewHolder中开一个TimerTask来做吗?然后对viewholder的缓存再单独做处理?


我的想法是可以所有Item共用一个倒计时


这个系统有3个重要部分组成:


(1)倒计时的实现,只用一个TimerTask来做一个心跳的效果。每次心跳去更新正在显示的Item页面的倒计时 ,比如你有100个Item,但是显示在屏幕上的只有5个,那我只需要关心这5个Item的时间变动,其他95个没必要做处理。


(2)观察者队列。我的每次心跳都要通知正在显示的Item更新页面,那是不是很明显要通过观察者模式去做。


(3)倒计时时间列表。倒计时也需要用一个列表管理起来,recyclerview的页面显示是根据数据去显示,虽然比如说100个数组只需要5、6个viewholder来复用,但是你的差异数据还是100个数据。


3. 倒计时列表


倒计时列表的实现,我这里是用一个HashMap来实现,因为方便直接获取某个实际Item的当前倒计时的时间。


private val cdMap: HashMap<Long, Long> = HashMap()

我的key假设用一个id来做处理,因为我们的数据结构中基本都会存在id并且id是基础数据类型。


data class RcdItemData(  
var id : Long, // id
var cd : Long // 总倒计时时间
)

添加倒计时


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

这个isCover是一个策略,adapter数据刷新时是否更新倒计时,这里可以先不用管,可以简单看成


fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
if (!cdMap.containsKey(id)) {
cdMap[id] = totalCd
}
}

清除倒计时(比如页面退出时就需要做释放操作)


fun clearCountDown() {  
cdMap.clear()
}

获取某个Item当前倒计时的时间


fun getCountDownById(id: Long): Long? {  
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

更新时间(随心跳更新所有数据)


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}

......
}

这些代码都不难理解,就不过多解释了


4. 观察者数组实现


先创建一个观察者数组


private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()


然后就是最基础的添加观察者和移除观察者操作


fun addHolderObservable(onItemSchedule: OnItemSchedule?) {  
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

通知观察者(通知Item倒计时1秒了,可以刷新页面了)


private fun notifyCdFinish() {  
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

5. 倒计时心跳实现


前面说了,我们让所有的Item共用一个倒计时,也是通过一个心跳去更新各自倒计时时间


private var task: TimerTask? = null  
private var timer: Timer? = null

开始倒计时


fun startHeartBeat() {  
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

每一秒都会调用updateCdByMap()方法去刷新时间。


private fun updateCdByMap() {  
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}

// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

TimerTask会在子线程中进行,所以最后通知观察者的操作需要切到主线程


最后关闭倒计时(页面关闭这些时机调用)


fun closeHeartBeat() {  
task?.cancel()
task = null
timer = null
}

6. 整体功能


因为上面是拆开来解释说明,这里再把整个工具的代码合起来可能会比较好管理。


object RecyclerCountDownManager {  

private var task: TimerTask? = null
private var timer: Timer? = null

// viewHolder观察者
private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()

// 倒计时对象数组
private val cdMap: HashMap<Long, Long> = HashMap()

/**
* 添加viewHolder观察
*/

fun addHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.add(onItemSchedule)
}

fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {
viewHolderObservables.remove(onItemSchedule)
}

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

/**
* 添加倒计时对象
* @param totalCd 总倒计时时间
* @param isCover 是否覆盖
*/

fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {
if (cdMap.containsKey(id)) {
if (isCover) {
cdMap[id] = totalCd
}
} else {
cdMap[id] = totalCd
}
}

/**
* 清除倒计时
*/

fun clearCountDown() {
cdMap.clear()
}

/**
* 根据id获取倒计时
*/

fun getCountDownById(id: Long): Long? {
if (cdMap.containsKey(id)) {
return cdMap[id]
}

return null
}

/**
* 开始心跳
*/

fun startHeartBeat() {
if (task == null) {
timer = Timer()
task = object : TimerTask() {
override fun run() {
updateCdByMap()
}
}
timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次
}
}

/**
* 更新所有倒计时对象
*/

private fun updateCdByMap() {
cdMap.forEach { (t, u) ->
if (cdMap[t]!! > 0) {
cdMap[t] = u - 1
}
}
// 更改完数据之后通知观察者
Handler(Looper.getMainLooper()).post {
notifyCdFinish()
}
}

private fun notifyCdFinish() {
viewHolderObservables.forEach {
it?.onCdSchedule()
}
}

/**
* 关闭心跳
*/

fun closeHeartBeat() {
task?.cancel()
task = null
timer = null
}

/**
* 调度通知,一般由ViewHolder实现该接口
*/

interface OnItemSchedule {

fun onCdSchedule()

}


}

可以看到代码都整体比较简单,就不用过多说明,就是需要注意一下这个是用一个单例去实现的工具,在页面关闭之后需要手动调用closeHeartBeat()、clearCountDown()、releaseHolderObservable()去释放资源。


调用的地方,Demo的Adapter


class RcdAdapter(var context: Context, var list: List<RcdItemData>) :  
RecyclerView.Adapter<RcdAdapter.RcdViewHolder>() {

init {
// 因为模式默认选择不覆盖,需要每次添加前先清除
RecyclerCountDownManager.clearCountDown()
list.forEach {
RecyclerCountDownManager.addCountDown(it.id, it.cd)
}
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): RcdViewHolder {
val text: TextView = TextView(context)
text.layoutParams = ViewGr0up.LayoutParams(ViewGr0up.LayoutParams.MATCH_PARENT, 64)
text.gravity = Gravity.CENTER
val holder = RcdViewHolder(text)
RecyclerCountDownManager.addHolderObservable(holder)
return holder
}

override fun getItemCount(): Int {
return list.size
}

override fun onBindViewHolder(holder: RcdViewHolder, position: Int) {
holder.setData(list[position])
}

class RcdViewHolder(var view: TextView) : RecyclerView.ViewHolder(view),
RecyclerCountDownManager.OnItemSchedule {

private var mData: RcdItemData? = null

fun setData(data: RcdItemData) {
mData = data
}

override fun onCdSchedule() {
val cd = mData?.id?.let { RecyclerCountDownManager.getCountDownById(it) }
if (cd != null) {
// 测试展示分秒
view.text = "${String.format("d", cd / 60)}:${String.format("d", cd % 60)}"
}
}

}

}

其他都比较基础的adapter的写法,就是viewholder要实现RecyclerCountDownManager.OnItemSchedule来充当观察者,然后拿到列表数据后调用RecyclerCountDownManager.addCountDown(it.id, it.cd)去创建倒计时列表。在onCreateViewHolder中调用RecyclerCountDownManager.addHolderObservable(holder)去添加观察者。最后在onCdSchedule()回调中做倒计时的更新


image.png


在页面销毁的时候主动释放内存


image.png


作者:流浪汉kylin
来源:juejin.cn/post/7355687352457560116
收起阅读 »

用Kotlin通杀“一切”单位换算

用Kotlin通杀“一切”单位换算之存储容量 前言 在之前的文章《用Kotlin Duration来优化时间单位换算》 中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,...
继续阅读 »

用Kotlin通杀“一切”单位换算之存储容量


前言


在之前的文章《用Kotlin Duration来优化时间单位换算》
中,用Duration可以很方便的进行时间的单位换算和运算。我忽然想到平时的工作中经常用到的换算和运算。(长度单位m,cm;质量单位 kg,g,lb;存储容量单位 mb,gb,tb 等等)


//进率为1024
val tenMegabytes = 10 * 1024 * 1024 //10mb
val tenGigabytes = 10 * 1024 * 1024 * 1024 //10gb

加入这样的业务代码后阅读性就变差了,能否有像Duration一样的api实现下面这样的代码呢?


fun main() {
1.kg = 2.20462262.lb; 1.m = 100.cm

val fiftyMegabytes = 50.mb
val divValue = fiftyMegabytes - 30.mb
// 20mb
val timesValue = fiftyMegabytes * 2.4
// 120mb

// 1G文件 再增加2个50mb的数据空间
val fileSpace = fiftyMegabytes * 2 + 1.gb
RandomAccessFile("fileName","rw").use {
it.setLength(fileSpace.inWholeBytes)
it.write(...)
}
}

下面我们通过分析Duration源码了解原理,并且实现存储容量单位DataSize的换算和运算。


简单拆解Duration


kotlin没有提供,要做到上面的api那么我不会啊,但是我看到Duration可以做到,那我们来看看它的原理,进行仿写就行了。



  1. 枚举DurationUnit是用来定义时间不同单位,方便换算和转换的(详情看源码或上篇文)。

  2. Duration是如何做到不同单位的数据换算的,先看看Duration的创建函数和构造函数。toDuration把当前的值通过convertDurationUnit把时间换算成nanos或millis的值,再通过shl运算用来记录单位。
    //Long创建 Duration
    public fun Long.toDuration(unit: DurationUnit): Duration {
    //最大支持的 nanos值
    val maxNsInUnit = convertDurationUnitOverflow(MAX_NANOS, DurationUnit.NANOSECONDS, unit)
    //当前值如果在最大和最小值中间 表示不会溢出
    if (this in -maxNsInUnit..maxNsInUnit) {
    //创建 rawValue 是Nanos的 Duration
    return durationOfNanos(convertDurationUnitOverflow(this, unit, DurationUnit.NANOSECONDS))
    } else {
    //创建 rawValue 是millis的 Duration
    val millis = convertDurationUnit(this, unit, DurationUnit.MILLISECONDS)
    return durationOfMillis(millis.coerceIn(-MAX_MILLIS, MAX_MILLIS))
    }
    }
    // 用 nanos
    private fun durationOfNanos(normalNanos: Long) = Duration(normalNanos shl 1)
    // 用 millis
    private fun durationOfMillis(normalMillis: Long) = Duration((normalMillis shl 1) + 1)
    //不同os平台实现,肯定是 1小时60分 1分60秒那套算法
    internal expect fun convertDurationUnit(value: Long, sourceUnit: DurationUnit, targetUnit: DurationUnit): Long


  3. Duration是一个value class用来提升性能的,通过rawValue还原当前时间换算后的nanos或millis的数据value。为何不全部都用Nanos省去了这些计算呢,根据代码看应该是考虑了Nanos的计算会溢出。用一个long值可以还原构造对象前的所有参数,这代码设计真牛逼。
    @JvmInline
    public value class Duration internal constructor(private val rawValue: Long) : Comparable<Duration> {
    //原始最小单位数据
    private val value: Long get() = rawValue shr 1
    //单位鉴别器
    private inline val unitDiscriminator: Int get() = rawValue.toInt() and 1
    private fun isInNanos() = unitDiscriminator == 0
    private fun isInMillis() = unitDiscriminator == 1
    //还原的最小单位 DurationUnit对象
    private val storageUnit get() = if (isInNanos()) DurationUnit.NANOSECONDS else DurationUnit.MILLISECONDS


  4. Duration是如何做到算术运算的,是通过操作符重载实现的。不同单位Duration,持有的数据是同一个单位的那么是可以互相运算的,我们后面会着重介绍和仿写。

  5. Duration是如何做到逻辑运算的(>,<,>=,<=),构造函数实现了接口Comparable<Duration>重写了operator fun compareTo(other: Duration): Int,返回1,-1,0



Duration主要依靠对象内部持有的rawValue: Long,由于value的单位是“相同”的,就可以实现不同单位的换算和运算。



存储容量单位换算设计



  1. 存储容量的单位一般有比特(b),字节(B),千字节(KB),兆字节(MB),千兆字节(GB),太字节(TB),拍字节(PB),艾字节(EB),泽字节(ZB),尧字节(YB)
    ,考虑到实际应用和Long的取值范围我们最大支持PB即可。


    enum class DataUnit(val shortName: String) {
    BYTES("B"),
    KILOBYTES("KB"),
    MEGABYTES("MB"),
    GIGABYTES("GB"),
    TERABYTES("TB"),
    PETABYTES("PB")
    }


  2. 对于存储容量来说最小单位我们就定为Bytes,最大支持到PB,然后可以省去对数据过大的溢出的"单位鉴别器"设计。(注意使用pb时候,>= 8192.pb就会溢出)


    @JvmInline
    value class DataSize internal constructor(private val rawBytes: Long)


  3. 参照Duration在创建和最后单位换算时候都用到了convertDurationUnit函数,接受原始单位和目标单位。另外考虑到可能出现换算溢出使用Math.multiplyExact来抛出异常,防止数据计算异常无法追溯的问题。


    /** Bytes per Kilobyte.*/
    private const val BYTES_PER_KB: Long = 1024
    /** Bytes per Megabyte.*/
    private const val BYTES_PER_MB = BYTES_PER_KB * 1024
    /** Bytes per Gigabyte.*/
    private const val BYTES_PER_GB = BYTES_PER_MB * 1024
    /** Bytes per Terabyte.*/
    private const val BYTES_PER_TB = BYTES_PER_GB * 1024
    /** Bytes per PetaByte.*/
    private const val BYTES_PER_PB = BYTES_PER_TB * 1024

    internal fun convertDataUnit(value: Long, sourceUnit: DataUnit, targetUnit: DataUnit): Long {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> Math.multiplyExact(value, BYTES_PER_KB)
    DataUnit.MEGABYTES -> Math.multiplyExact(value, BYTES_PER_MB)
    DataUnit.GIGABYTES -> Math.multiplyExact(value, BYTES_PER_GB)
    DataUnit.TERABYTES -> Math.multiplyExact(value, BYTES_PER_TB)
    DataUnit.PETABYTES -> Math.multiplyExact(value, BYTES_PER_PB)
    }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }

    internal fun convertDataUnit(value: Double, sourceUnit: DataUnit, targetUnit: DataUnit): Double {
    val valueInBytes = when (sourceUnit) {
    DataUnit.BYTES -> value
    DataUnit.KILOBYTES -> value * BYTES_PER_KB
    DataUnit.MEGABYTES -> value * BYTES_PER_MB
    DataUnit.GIGABYTES -> value * BYTES_PER_GB
    DataUnit.TERABYTES -> value * BYTES_PER_TB
    DataUnit.PETABYTES -> value * BYTES_PER_PB
    }
    require(!valueInBytes.isNaN()) { "DataUnit value cannot be NaN." }
    return when (targetUnit) {
    DataUnit.BYTES -> valueInBytes
    DataUnit.KILOBYTES -> valueInBytes / BYTES_PER_KB
    DataUnit.MEGABYTES -> valueInBytes / BYTES_PER_MB
    DataUnit.GIGABYTES -> valueInBytes / BYTES_PER_GB
    DataUnit.TERABYTES -> valueInBytes / BYTES_PER_TB
    DataUnit.PETABYTES -> valueInBytes / BYTES_PER_PB
    }
    }


  4. 扩展属性和构造DataSize,rawBytes是Bytes因此所有的目标单位设置为DataUnit.BYTES,而原始单位就通过调用者告诉convertDataUnit


    fun Long.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES))
    }
    fun Double.toDataSize(unit: DataUnit): DataSize {
    return DataSize(convertDataUnit(this, unit, DataUnit.BYTES).roundToLong())
    }
    inline val Long.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Long.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Long.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Long.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Long.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Long.pb get() = this.toDataSize(DataUnit.PETABYTES)

    inline val Int.bytes get() = this.toLong().toDataSize(DataUnit.BYTES)
    inline val Int.kb get() = this.toLong().toDataSize(DataUnit.KILOBYTES)
    inline val Int.mb get() = this.toLong().toDataSize(DataUnit.MEGABYTES)
    inline val Int.gb get() = this.toLong().toDataSize(DataUnit.GIGABYTES)
    inline val Int.tb get() = this.toLong().toDataSize(DataUnit.TERABYTES)
    inline val Int.pb get() = this.toLong().toDataSize(DataUnit.PETABYTES)

    inline val Double.bytes get() = this.toDataSize(DataUnit.BYTES)
    inline val Double.kb get() = this.toDataSize(DataUnit.KILOBYTES)
    inline val Double.mb get() = this.toDataSize(DataUnit.MEGABYTES)
    inline val Double.gb get() = this.toDataSize(DataUnit.GIGABYTES)
    inline val Double.tb get() = this.toDataSize(DataUnit.TERABYTES)
    inline val Double.pb get() = this.toDataSize(DataUnit.PETABYTES)


  5. 换算函数设计
    Duration用toLong(DurationUnit)或者toDouble(DurationUnit)来输出指定单位的数据,inWhole系列函数是对toLong(DurationUnit) 的封装。toLong和toDouble实现就比较简单了,把convertDataUnit传入输出单位,而原始单位就是rawValue的单位DataUnit.BYTES
    toDouble需要输出更加精细的数据,例如: 512mb = 0.5gb。


    val inWholeBytes: Long
    get() = toLong(DataUnit.BYTES)
    val inWholeKilobytes: Long
    get() = toLong(DataUnit.KILOBYTES)
    val inWholeMegabytes: Long
    get() = toLong(DataUnit.MEGABYTES)
    val inWholeGigabytes: Long
    get() = toLong(DataUnit.GIGABYTES)
    val inWholeTerabytes: Long
    get() = toLong(DataUnit.TERABYTES)
    val inWholePetabytes: Long
    get() = toLong(DataUnit.PETABYTES)

    fun toDouble(unit: DataUnit): Double = convertDataUnit(bytes.toDouble(), DataUnit.BYTES, unit)
    fun toLong(unit: DataUnit): Long = convertDataUnit(bytes, DataUnit.BYTES, unit)



操作符设计


在Kotlin 中可以为类型提供预定义的一组操作符的自定义实现,被称为操作符重载。这些操作符具有预定义的符号表示(如 + 或
*)与优先级。为了实现这样的操作符,需要为相应的类型提供一个指定名称的成员函数或扩展函数。这个类型会成为二元操作符左侧的类型及一元操作符的参数类型。


如果函数不存在或不明确,则导致编译错误(编译器会提示报错)。下面为常见操作符对照表:


操作符函数名说明
+aa.unaryPlus()一元操作 取正
-aa.unaryMinus()一元操作 取负
!aa.not()一元操作 取反
a + ba.plus(b)二元操作 加
a - ba.minus(b)二元操作 减
a * ba.times(b)二元操作 乘
a / ba.div(b)二元操作 除

算术运算支持



  1. 这里用算术运算符+实现来举例:假如DataSize对象需要重载操作符+
    val a = DataSize(); val c: DataSize = a + b


  2. 需要定义扩展函数1或者添加成员函数2
    1. operator fun DataSize.plus(other: T): DataSize {...}
    2. class DataSize { operator fun plus(other: T): DataSize {...} }


  3. 函数中的参数other: T表示b的对象类型,例如
    // val a: DataSize; val b: DataSize; a + DataSize()
    operator fun DataSize.plus(other: DataSize): DataSize {...}
    // val a: DataSize; val b: Int; a + 1
    operator fun DataSize.plus(other: Int): DataSize {...}


  4. 为了阅读性,Duration不会和同类型的对象乘除法运算,而使用了Int或Double,因此重载运算符用了operator fun times(scale: Int): Duration

  5. 那么在DataSize中我们也重载(+,-,*,/),并且(*,/)重载的参数只支持Int和Double即可
    operator fun unaryMinus(): DataSize {
    return DataSize(-this.bytes)
    }
    operator fun plus(other: DataSize): DataSize {
    return DataSize(Math.addExact(this.bytes, other.bytes))
    }

    operator fun minus(other: DataSize): DataSize {
    return this + (-other) // a - b = a + (-b)
    }

    operator fun times(scale: Int): DataSize {
    return DataSize(Math.multiplyExact(this.bytes, scale.toLong()))
    }

    operator fun div(scale: Int): DataSize {
    return DataSize(this.bytes / scale)
    }

    operator fun times(scale: Double): DataSize {
    return DataSize((this.bytes * scale).roundToLong())
    }

    operator fun div(scale: Double): DataSize {
    return DataSize((this.bytes / scale).roundToLong())
    }

    上面的操作符重载中minus(),我们使用了 plus()unaryMinus()重载组合a-b = a+(-b),这样我们可以多一个-DataSize的操作符


逻辑运算支持



  • (>,<,>=,<=)让DataSize构造函数实现了接口Comparable<DataSize>重写了operator fun compareTo(other: DataSize): Int,返回rawBytes对比值即可。

  • (==,!=)通过equals(other)函数实现的,value class默认为rawBytes的对比,可以通过java字节码看到。kotlin 1.9之前不支持重写value classs的equals和hashCode


    value class DataSize internal constructor(private val bytes: Long) : Comparable<DataSize> {
    override fun compareTo(other: DataSize): Int {
    return this.bytes.compareTo(other.bytes)
    }
    //示例
    600.mb > 0.5.gb //true
    512.mb == 0.5.gb




操作符重载的目的是为了提升阅读性,并不是所有对象为了炫酷都可以用操作符重载,滥用反而会增加代码的阅读难度。例如给DataSize添加*操作符,5mb * 2mb 就让人头大。



获取字符串形式


为了方便打印和UI展示,一般我们需要重写toSting。Duration的toSting不需要指定输出单位,可以详细的输出当前对象的字符串格式(1h 0m 45.677s)算法比较复杂。我不太会,就简单实现指定输出单位的toString(DataUnit)


 override fun toString(): String = String.format("%dB", rawBytes)

fun toString(unit: DataUnit, decimals: Int = 2): String {
require(decimals >= 0) { "decimals must be not negative, but was $decimals" }
val number = toDouble(unit)
if (number.isInfinite()) return number.toString()
val newDecimals = decimals.coerceAtMost(12)
return DecimalFormat("0").run {
if (newDecimals > 0) minimumFractionDigits = newDecimals
roundingMode = RoundingMode.HALF_UP
format(number) + unit.shortName
}
}

单元测试


功能都写好了需要验证期望的结果和实现的功能是否一直,那么这个时候就用单元测试最好来个100%覆盖。


class ExampleUnitTest {
@Test
fun data_size() {
val dataSize = 512.mb

println("format bytes:$dataSize")
// format bytes:536870912B
println("format kb:${dataSize.toString(DataUnit.KILOBYTES)}")
// format kb:524288.00KB
println("format gb:${dataSize.toString(DataUnit.GIGABYTES)}")
// format gb:0.50GB
// 单位换算
assertEquals(536870912, dataSize.inWholeBytes)
assertEquals(524288, dataSize.inWholeKilobytes)
assertEquals(512, dataSize.inWholeMegabytes)
assertEquals(0, dataSize.inWholeGigabytes)
assertEquals(0, dataSize.inWholeTerabytes)
assertEquals(0, dataSize.inWholePetabytes)
}

@Test
fun data_size_operator() {
val dataSize1 = 512.mb
val dataSize2 = 3.gb

val unaryMinusValue = -dataSize1 //取负数
println("unaryMinusValue :${unaryMinusValue.toString(DataUnit.MEGABYTES)}")
// unaryMinusValue :-512.00MB

val plusValue = dataSize1 + dataSize2 //+
println("plus :${plusValue.toString(DataUnit.GIGABYTES)}")
// plus :3.50GB

val minusValue = dataSize1 - dataSize2 // -
println("minus :${minusValue.toString(DataUnit.GIGABYTES)}")
// minus :-2.50GB

val timesValue = dataSize1 * 2 //乘法
println("times :${timesValue.toString(DataUnit.GIGABYTES)}")
// times :1.00GB

val divValue = dataSize2 / 2 //除法
println("div :${divValue.toString(DataUnit.GIGABYTES)}")
// div :1.50GB
}

@Test(expected = ArithmeticException::class)
fun data_size_overflow() {
8191.pb
8192.pb //溢出了不支持,如果要支持参考"单位鉴别器"设计
}

@Test
fun data_size_compare() {
assertTrue(600.mb > 0.5.gb)
assertTrue(512.mb == 0.5.gb)
}
}

总结


通过学习Kotlin Duration的源码,举一反三应用到储存容量单位转换和运算中。Duration中的拆解计算api,还有toSting算法实现就留给大家学习吧。当然了你也可以实现和Duration一样更加精细的"单位鉴别器"设计,支持ZB、YB等大单位。


另外类似的进率场景也可以实现,用Kotlin通杀“一切进率换算”。比如Degrees角度计算 -90.0.degrees == 270.0.degrees;质量计算kg和磅,两等等1.kg == 2.20462262.lb;甚至人民币汇率 (动态实现算法)8.dollar == 1.rmb 🐶


github 代码: github.com/forJrking/K…


操作符重载文档: book.kotlincn.net/text/operat…


作者:forJrking
来源:juejin.cn/post/7301145359852765218
收起阅读 »

Android自定义定时通知实现

Android自定义通知实现 前言 自定义通知就是使用自定义的布局,实现自定义的功能。 Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。 常规的布局固然方便,可当需求变多就只能使用自定义布局了。 我想要实现的通知布局除了...
继续阅读 »

Android自定义通知实现


前言


自定义通知就是使用自定义的布局,实现自定义的功能。


Notification的常规布局中可以设置标题、内容、大小图标,也可以实现点击跳转等。


常规的布局固然方便,可当需求变多就只能使用自定义布局了。


我想要实现的通知布局除了时间、标题、图标、跳转外,还有“5分钟后提醒”、“已知悉”这两个功能需要实现。如下图所示:


nnotify.gif

正文


一、待办数据库


1、待办实体类


image.png

假设现在时间是12点,添加了一个14点的待办会议,并设置提前20分钟进行提醒


其中year、month、day、time构成会议的时间,也就是今天的14点content是待办的内容。remind是该待办提前提醒的时间,也就是20分钟type是待办类型,包括未完成,已完成和忽略


2、数据访问Dao


添加或更新一条待办事项


image.png



@Insert(onConflict = OnConflictStrategy.REPLACE) 注解: 如果指定 id 的对象没有保存在数据库中, 就会新增一条数据到数据库。如果指定 id 的对象数据已经保存到数据库中, 就会删除掉原来的数据, 然后新增一条数据。



3、数据库封装


在仓库层的TodoStoreRepository类中对待办数据库的操作进行封装。不赘述了。


二、添加定时器


每条待办都对应着一个定时任务,在用户添加一条待办数据的同时需要添加一条对应的定时任务。在这里使用映射的方式,将待办的Id和定时任务一一绑定。


1、思路:



  1. 首先要构造一个Map<Long, TimerTask>>类型的参数和一个定时器Timer。

  2. 在添加定时任务前,先对待办数据进行过滤。

  3. 计算出延迟的时间。

  4. 定时器调度,当触发时,消息弹窗提醒。


2、实现


image.png


说明



  • isOver() -- 判断该待办有没有完成,通过待办的type进行判断。



代码:fun isOver() = type == 1




  • delayTime -- 延迟时间。获取到当前时间的时间戳、将待办提醒的时间转换为时间戳,最后相减,得到一个Long类型的时间戳即延迟时间。

  • 当delayTime大于0时,进行定时器调度,将待办Id与定时任务绑定,当延迟时间到达时会触发定时器任务,定时器任务中包含了消息弹窗提醒。


3、封装


image.png



在 TodoScheduleUseCase 类中将待办数据插入和待办定时器创建封装在一起了,插入数据后获取到数据的Id,将id赋值传给待办定时器任务。



三、注册广播


1. 首先创建一个TodoNotifyReceiver广播


image.png



在TodoNotifyReceiver中,首先获取到待办数据,根据Action判断广播的类型并执行相应的回调。



2. 自定义Action


分别是“5分钟后提醒”、“已知悉”的Action


image.png


3. 广播注册方法


image.png

4.广播注册及回调实现


“5分钟后提醒”实现是调用delayTodoTask5min方法,原理就是将remind即提醒时间减5达到五分钟后提醒的效果。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


“已知悉”实现是调用markTodoTaskDone方法,原理就是将type属性标记成1,代表已完成。并取消该通知。再将修改过属性的待办重新添加到待办列表中。


image.png


/**
* 延迟5分钟
*/

fun delayTodoTask5min(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.remind -= 5
insertOrUpdateTodo(todoInfo)
}
}

/**
* 标记已完成
*/

fun markTodoTaskDone(todoInfo: TodoInfo) {
useScope.launch {
todoInfo.type = 1
insertOrUpdateTodo(todoInfo)
}
}



四、自定义通知构建


fun showNotify(todoInfo: TodoInfo) {
binding = LayoutTodoNotifyItemBinding.inflate(context.layoutInflater())

// 自定义通知布局
val notificationLayout =
RemoteViews(context.packageName, R.layout.layout_todo_notify_item)
// 设置自定义的Action
val notifyAfterI = Intent().setAction(TodoNotifyReceiver.TODO_CHANGE_ACTION)
val alreadyKnowI = Intent().setAction(TodoNotifyReceiver.TODO_ALREADY_KNOW_ACTION)
// 传入TodoInfo
notifyAfterI.putExtra("todoInfo", todoInfo)
alreadyKnowI.putExtra("todoInfo", todoInfo)
// 设置点击时跳转的界面
val intent = Intent(context, MarketActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, todoInfo.id.toInt(), intent, PendingIntent.FLAG_CANCEL_CURRENT)
val notifyAfterPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), notifyAfterI, PendingIntent.FLAG_CANCEL_CURRENT)
val alreadyKnowPI = PendingIntent.getBroadcast(context, todoInfo.id.toInt(), alreadyKnowI, PendingIntent.FLAG_CANCEL_CURRENT)

//给通知布局中的组件设置点击事件
notificationLayout.setOnClickPendingIntent(R.id.notify_after, notifyAfterPI)
notificationLayout.setOnClickPendingIntent(R.id.already_know, alreadyKnowPI)

// 构建自定义通知布局
notificationLayout.setTextViewText(R.id.notify_content, todoInfo.content)
notificationLayout.setTextViewText(R.id.notify_date, "${todoInfo.year}-${todoInfo.month + 1}-${todoInfo.day} ${todoInfo.time}")

var notifyBuild: NotificationCompat.Builder? = null
// 构建NotificationChannel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(context.packageName, "todoNotify", NotificationManager.IMPORTANCE_HIGH)
notificationChannel.lockscreenVisibility = Notification.VISIBILITY_SECRET
notificationChannel.enableLights(true) // 是否在桌面icon右上角展示小红点
notificationChannel.lightColor = Color.RED// 小红点颜色
notificationChannel.setShowBadge(true) // 是否在久按桌面图标时显示此渠道的通知
notificationManager.createNotificationChannel(notificationChannel)
notifyBuild = NotificationCompat.Builder(context, todoInfo.id.toString())
notifyBuild.setChannelId(context.packageName);
} else {
notifyBuild = NotificationCompat.Builder(context)
}
notifyBuild.setSmallIcon(R.mipmap.icon_todo_item_normal)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout) //设置自定义通知布局
.setPriority(NotificationCompat.PRIORITY_MAX) //设置优先级
.setAutoCancel(true) //设置点击后取消Notification
.setContentIntent(pendingIntent) //设置跳转
.build()
notificationManager.notify(todoInfo.id.toInt(), notifyBuild.build())

// 取消指定id的通知
fun cancelNotifyById(id: Int) {
notificationManager.cancel(id)
}
}


步骤:



  1. 构建自定义通知布局

  2. 设置自定义的Action

  3. 传入待办数据

  4. 设置点击时跳转的界面

  5. 设置了两个BroadcastReceiver类型的点击回调

  6. 给通知布局中的组件设置点击事件

  7. 构建自定义通知布局

  8. 构建NotificationChannel

  9. 添加取消指定id的通知方法



总结


以上,Android自定义定时通知实现的过程和结果。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7278566669282787383
收起阅读 »

Android 使用TextView实现验证码输入框

前言 网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下 1、数字 / 字符键盘切换后键盘状态无法保存 2、焦点切换无法判断 3、光标位置无法修正 4、切换过程需要做很多同步工作 5、需要处理聚焦选中区域问题 6、性能差 EditText...
继续阅读 »

前言


网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下


1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作

5、需要处理聚焦选中区域问题

6、性能差


EditText越多,造成的不确定性问题将越多,因此,在开发中,如果我们自行实现一个纯View的输入框有没有可能呢?比较遗憾的是,Android 层面android.widget.Editor是非公开的类,因此很难去实现一个想要的View。


另一种方案,我们继承TextView,改写TextView的绘制逻辑也是可以。


为什么TextView是可以的呢?



  • 第一:TextView 本身可以输入任何文本

  • 第二:TextView 绘制方法中使用android.widget.Editor可以辅助keycode->文本转换

  • 第三:TextView 提供了光标等各种组件


核心步骤


为了解决上述问题,使用 TextView 实现输入框,这里需要解决的问题是


1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现,不实用原有的绘制逻辑。

3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写

4、重写长按菜单逻辑,防止弹出剪切、复制、选中等PopWindow弹窗。
5、限制文本长度


fire_89.gif


代码实现


首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作


变量定义


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键状态


禁止复制、粘贴、选中


mrb62ges5a.jpeg


super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑


我们重写onDraw方法,自行绘制View


TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();
//获取默认风格
Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

InsertionHandleView问题


image.png


我们上文处理了各种可能出现的选中区域弹窗,然而一个很难处理的弹窗双击后会展示,评论区有同学也贴出来了。主要原因是Editor为了方便EditText选中,在内部使用了InsertionHandleView去展示一个弹窗,但这个弹窗并不是直接addView的,而是通过PopWindow展示的,具体可以参考下面源码。


实际上,掘金Android 客户端也有类似的问题,不过掘金app的实现方式是使用多个EditText实现的,点击的时候就会明显看到这个小雨点,其次还有光标卡顿的问题。



android.widget.Editor.InsertionHandleView



解决方法其实有3种:


第一种是Hack Context,返回一个自定义的WindowManager给PopWindow,不过我们知道InputManagerService 作为 WindowManagerService中的子服务,如果处理不当,可能产生输入法无法输入的问题,另外要Hack WindowManager,显然工作量很大。


第二种是替换:修改InsertionHandleView的背景元素,具体可参考:blog.csdn.net/shi_xin/art… 一文


<item name="textSelectHandleLeft">@drawable/text_select_handle_left_material</item>
<item name="textSelectHandleRight">@drawable/text_select_handle_right_material</item>
<item name="textSelectHandle">@drawable/text_select_handle_middle_material</item>

这种方式增加了View的可扩展性,自定义View要尽可能避免和xml配置耦合,除非是自定义属性。


第三种是拦截hide方法,在popWindow展示之后,会立即设置一个定时消失的逻辑,这种相对简单,而且View的通用性不受影响,但是也有些不规范,不过目前这个调用还是相当稳定的。


综上,我们选择第三种方案,我这里直接拦截其内部调用postDelay的方法,如果是InsertionHandleView的内部类,且时间为4000秒,直接执行runnable


private void hideAfterDelay() {
if (mHider == null) {
mHider = new Runnable() {
public void run() {
hide();
}
};
} else {
removeHiderCallback();
}
mTextView.postDelayed(mHider, DELAY_BEFORE_HANDLE_FADES_OUT);
}

下面是解法:


@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
Log.d("TAG","delayMillis = " + delayMillis);
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

总结


上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。


这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。


本篇全部代码


按照惯例,这里依然提供全部代码,仅供参考,当然,也可以直接使用到项目中,本篇代码在线上已经使用过。


public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//光标闪烁控制
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;
// box radius
private float boxRadius = dp2px(0);

InputFilter[] inputFilters = new InputFilter[]{
new InputFilter.LengthFilter(inputBoxNum)
};


public EditableTextView(Context context) {
this(context, null);
}

public EditableTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

Drawable cursorDrawable = getTextCursorDrawable();
if(cursorDrawable == null){
cursorDrawable = new PaintDrawable(Color.MAGENTA);
setTextCursorDrawable(cursorDrawable);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
super.setPointerIcon(null);
}
super.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return true; //抑制长按出现弹窗的问题
}
});

//禁用ActonMode弹窗
super.setCustomSelectionActionModeCallback(null);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
}
mBoxSpace = (int) dp2px(10f);

}

@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
return null;
}

@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
return null;
}

@Override
public boolean hasSelection() {
return false;
}

@Override
public boolean showContextMenu() {
return false;
}

@Override
public boolean showContextMenu(float x, float y) {
return false;
}

public void setBoxSpace(int mBoxSpace) {
this.mBoxSpace = mBoxSpace;
postInvalidate();
}

public void setInputBoxNum(int inputBoxNum) {
if (inputBoxNum <= 0) return;
this.inputBoxNum = inputBoxNum;
this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
super.setFilters(inputFilters);
}

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

}
@Override
protected boolean getDefaultEditable() {
return true;
}


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);
}


private Runnable invalidateCursor = new Runnable() {
@Override
public void run() {
invalidate();
}
};


//避免paint.getFontMetrics内部频繁创建对象
Paint.FontMetrics fm = new Paint.FontMetrics();

/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/


public float getTextPaintBaseline(Paint p) {
p.getFontMetrics(fm);
Paint.FontMetrics fontMetrics = fm;
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

/**
* 控制是否保存完整文本
*
* @return
*/

@Override
public boolean getFreezesText() {
return true;
}

@Override
public Editable getText() {
return (Editable) super.getText();
}

@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}

/**
* 控制光标展示
*
* @return
*/

@Override
protected MovementMethod getDefaultMovementMethod() {
return ArrowKeyMovementMethod.getInstance();
}

@Override
public boolean isCursorVisible() {
return isCursorVisible;
}

@Override
public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
// super.setTextCursorDrawable(null);
this.textCursorDrawable = textCursorDrawable;
postInvalidate();
}

@Nullable
@Override
public Drawable getTextCursorDrawable() {
return textCursorDrawable; //支持android Q 之前的版本
}

@Override
public void setCursorVisible(boolean cursorVisible) {
isCursorVisible = cursorVisible;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

public void setBoxRadius(float boxRadius) {
this.boxRadius = boxRadius;
postInvalidate();
}

public void setBoxColor(int boxColor) {
this.boxColor = boxColor;
postInvalidate();
}

public void setCursorHeight(float cursorHeight) {
this.cursorHeight = cursorHeight;
postInvalidate();
}

public void setCursorWidth(float cursorWidth) {
this.cursorWidth = cursorWidth;
postInvalidate();
}

@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

}


作者:时光少年
来源:juejin.cn/post/7313242064196452361
收起阅读 »

Android:监听滑动控件实现状态栏颜色切换

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。 今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图: 看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动...
继续阅读 »

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap情有独钟。



今天给大家分享一个平时在滑动页面经常遇到的效果:滑动过程动态修改状态栏颜色。咱们废话不多说,有图有真相,直接上效果图:


1.gif


看到效果是不是感觉很熟悉,相对而言如果页面顶部有背景色,而滑动到底部的时候背景色变为白色或者其他颜色的时候,状态栏颜色不跟随切换颜色有可能会显得难看至极。因此有了上图的效果,其实就是简单的实现了状态栏颜色切换的功能,效果看起来不至于那么尴尬。


首先,我们需要分析,其中需要用到哪些东西呢?



  • 沉浸式状态栏

  • 滑动组件监听


关于沉浸式状态栏,这里推荐使用immersionbar,一款非常不错的轮子。我们只需要将mannifests中主体配置为NoActionBar类型,再根据文档配置好状态栏颜色等属性即可快速得到沉浸式效果:


<style name="Theme.MyApplication" parent="Theme.AppCompat.Light.NoActionBar">

//基础设置
ImmersionBar.with(this)
.navigationBarColor(R.color.color_bg)
.statusBarDarkFont(true, 0.2f)
.autoStatusBarDarkModeEnable(true, 0.2f)//启用自动根据StatusBar颜色调整深色模式与亮式
.autoNavigationBarDarkModeEnable(true, 0.2f)//启用自动根据NavigationBar颜色调整深色式
.init()

//状态栏view
status_bar_view?.let {
ImmersionBar.setStatusBarView(this, it)
}

//xml中状态栏
<View
android:id="@+id/status_bar_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#b8bfff" />

关于滑动监听,我们都知道滑动控件有个监听滑动的方法OnScrollChangeListener,其中返回了Y轴滑动距离的参数。那么,我们可以根据这个参数进行对应的条件判断以达到动态修改状态栏的颜色。


scroll?.setOnScrollChangeListener { _, _, scrollY, _, _ ->
if (scrollY > linTop!!.height) {
if (!isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#ffffff")
)
isChange = true
}
} else {
if (isChange) {
status_bar_view?.setBackgroundColor(
Color.parseColor("#b8bfff")
)
isChange = false
}
}
}

这里判断滑动距离达到紫色视图末端时修改状态栏颜色。因为是在回调方法中,所以这里一旦滑动就在不停触发,所以给了一个私有属性进行不必要的操作,仅当状态改变时且滑动条件满足时才能修改状态栏。当然在这个方法内大家可以发挥自己的想象力做出更多的新花样来。


注意:



  • 滑动监听的这个方法只能在设备6.0及以上才能使用。

  • 需要初始化滑动控件的默认位置,xml中将焦点设置到其父容器中,防止滑动控件不再初始位置。


//初始化位置
scroll?.smoothScrollTo(0, 0)

//xml中设置父view焦点
android:focusable="true"
android:focusableInTouchMode="true"

好了,以上便是滑动控件中实现状态栏切换的简单实现,希望对大家有所帮助。


作者:似曾相识2022
来源:juejin.cn/post/7272229204870561850
收起阅读 »

Android 切换主题时如何恢复 Dialog?

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。 如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个...
继续阅读 »

我们都知道,Android 在横竖屏切换、主题切换、语言等操作时,系统会 finish Activity ,然后重建,这样便可以重新加载配置变更后的资源。


如果你只有 Activity 的内容需要展示,那这样处理是没有问题的,但是如果界面在点击操作之后打开一个 Dialog,那在配置改变后这个 Dialog 还会在么?答案是不一定,我们来看看展示 Dialog 有几种方式。


Dilog#show()


这可能是大家比较常用的方法,创建一个 Dialog ,然后调用其 show 方法,就像这样。


class MainActivity : AppCompatActivity() {  
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

findViewById<View>(R.id.tvDialog).setOnClickListener {
AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.show()
}
}
}

每次点击按钮会创建一个新的 Dialog 对象,然后调用 show 方法展示。我们来看看配置改变后,Dialog 的表现是怎样的。


video2.gif


通过视频我们可以看到,在切换横竖屏或主题时,Dialog 都没有恢复。这是因为Dialog#show这种方式是开发者自己管理 Dialog,所以在恢复 Activity 时,Activity 是不知道需要恢复 Dialog 的。那怎么让 Activity 知道当前展示了 Dialog 呢?那就需要用到下面的方式。


Activity#showDialog()


先来看看此方法的注释



Show a dialog managed by this activity. A call to onCreateDialog(int, Bundle) will be made with the same id the first time this is called for a given id. From thereafter, the dialog will be automatically saved and restored. If you are targeting Build.VERSION_CODES.HONEYCOMB or later, consider instead using a DialogFragment instead.
Each time a dialog is shown, onPrepareDialog(int, Dialog, Bundle) will be made to provide an opportunity to do any timely preparation.



简单来说这个方法会让 Activity 来管理需要展示的 Dialog,会跟 onCreateDialog(int, Bundle)成对出现,并且会保存这个 Dialog,在重复调用Activity#showDialog()时不会重复创建 Dialog 对象。Activity 自己管理 Dialog?那就能恢复了吗?我们来试试。


override fun onCreate(savedInstanceState: Bundle?) {  
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

findViewById<View>(R.id.tvDialog).setOnClickListener {
showDialog(100) //自定义 id
}
}

override fun onCreateDialog(id: Int): Dialog? {
if(id == 100){ // id 与 showDialog 匹配
return AlertDialog.Builder(this)
.setView(R.layout.test_dialog)
.create()
}
return super.onCreateDialog(id)
}

代码很简单,调用 Activity#showDialog(int id)方法,然后重写 Activity#onCreateDialog(id:Int),匹配两边的 id 就可以了。我们来看看效果。


video3.gif


我们可以看到,确实切换主题后 Dialog 是恢复了的,不过还有个问题,就是这个 ScrollView 的状态没有恢复,滑动的位置被还原了,难道我们需要手动记住滑动的 position 然后再恢复?是的,不过这个操作 Android 已经替我们做了,我们需要做的就是给需要恢复的组件指定一个 id 就行。


<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"  
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="200dp">


<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="300dp"
android:scrollbars="vertical"
android:scrollbarSize="10dp"
android:background="@color/primary_background">


<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">


<TextView
android:id="@+id/tvContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:text="@string/test_content"
android:textAlignment="center"
android:textSize="30sp"
android:textColor="@color/primary_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</FrameLayout>

刚刚 ScrollView 标签是没有 id 的,现在我们加了一个 id 再看看效果。


video4.gif


是不是很方便?这是什么原理呢?主要是两个方法,如下:


public void saveHierarchyState(SparseArray<Parcelable> container) {  
dispatchSaveInstanceState(container);
}

protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID && (mViewFlags & SAVE_DISABLED_MASK) == 0) {
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
Parcelable state = onSaveInstanceState();
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onSaveInstanceState()");
}
if (state != null) {
// Log.i("View", "Freezing #" + Integer.toHexString(mID)
// + ": " + state);
container.put(mID, state);
}
}
}

public void restoreHierarchyState(SparseArray<Parcelable> container) {
dispatchRestoreInstanceState(container);
}

protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
if (mID != NO_ID) {
Parcelable state = container.get(mID);
if (state != null) {
// Log.i("View", "Restoreing #" + Integer.toHexString(mID)
// + ": " + state);
mPrivateFlags &= ~PFLAG_SAVE_STATE_CALLED;
onRestoreInstanceState(state);
if ((mPrivateFlags & PFLAG_SAVE_STATE_CALLED) == 0) {
throw new IllegalStateException(
"Derived class did not call super.onRestoreInstanceState()");
}
}
}
}


在 Actvity 执行 onSaveInstance 时,会保存 View 的层级状态,View 的 id 为 key,状态为 value,这样的一个SparseArray,View 的状态是在 View 的 onSaveInstance 方法生成的,所以,如果 View 没有重写 onSaveInstance时,就算指定了 id 也不会被恢复。我们来看看 ScrollView#onSaveInstance做了什么工作。


protected Parcelable onSaveInstanceState() {  
if (mContext.getApplicationInfo().targetSdkVersion <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
// Some old apps reused IDs in ways they shouldn't have.
// Don't break them, but they don't get scroll state restoration.
return super.onSaveInstanceState();
}
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.scrollPosition = mScrollY;
return ss;
}

ss.scrollPosition = mScrollY关键代码就是这一句,保存了 scrollPosition,恢复的逻辑就是在onRestoreInstance大家可以自己看看,逻辑比较简单,我这边就不列了。


Activity 如何恢复 Dialog?


配置变化后的恢复都会依赖onSaveInstanceonRestoreInstance,Dialog 也不例外,不过 Dialog 这两个流程都依赖 Activity,我们来完整过一遍 onSaveInstance 的流程,saveInstanceActivity#performSaveInstanceState开始.


Activity.java


/**  
* The hook for {@link ActivityThread} to save the state of this activity.
*
* Calls {@link #onSaveInstanceState(android.os.Bundle)}
* and {@link #saveManagedDialogs(android.os.Bundle)}.
*
* @param outState The bundle to save the state to.
*/

final void performSaveInstanceState(@NonNull Bundle outState) {
dispatchActivityPreSaveInstanceState(outState);
onSaveInstanceState(outState);
saveManagedDialogs(outState);
mActivityTransitionState.saveState(outState);
storeHasCurrentPermissionRequest(outState);
if (DEBUG_LIFECYCLE) Slog.v(TAG, "onSaveInstanceState " + this + ": " + outState);
dispatchActivityPostSaveInstanceState(outState);
}

/**
* Save the state of any managed dialogs.
*
* @param outState place to store the saved state.
*/

@UnsupportedAppUsage
private void saveManagedDialogs(Bundle outState) {
if (mManagedDialogs == null) {
return;
}
final int numDialogs = mManagedDialogs.size();
if (numDialogs == 0) {
return;
}
Bundle dialogState = new Bundle();
int[] ids = new int[mManagedDialogs.size()];
// save each dialog's bundle, gather the ids
for (int i = 0; i < numDialogs; i++) {
final int key = mManagedDialogs.keyAt(i);
ids[i] = key;
final ManagedDialog md = mManagedDialogs.valueAt(i);
dialogState.putBundle(savedDialogKeyFor(key), md.mDialog.onSaveInstanceState());
if (md.mArgs != null) {
dialogState.putBundle(savedDialogArgsKeyFor(key), md.mArgs);
}
}
dialogState.putIntArray(SAVED_DIALOG_IDS_KEY, ids);
outState.putBundle(SAVED_DIALOGS_TAG, dialogState);
}

saveManagedDialogs这个方法就是处理 Dialog 的流程,我们可以看到它会调用 md.mDialog.onSaveInstanceState(),来保存 Dialog 的状态,而这个md.mDialog就是在showDialog时保存的


public final boolean showDialog(int id, Bundle args) {  
if (mManagedDialogs == null) {
mManagedDialogs = new SparseArray<ManagedDialog>();
}
ManagedDialog md = mManagedDialogs.get(id);
if (md == null) {
md = new ManagedDialog();
md.mDialog = createDialog(id, null, args);
if (md.mDialog == null) {
return false;
}
mManagedDialogs.put(id, md);
}
md.mArgs = args;
onPrepareDialog(id, md.mDialog, args);
md.mDialog.show();
return true;
}

这样流程就能串起来了吧,用Activity#showDialog关联 Activity 与 Dialog,在 Activity onSaveInstance 时会调用 Dialog#onSaveInstance保存状态,而不管在 Activity 或 Dialog 的 onSaveInstance 里都会执行View#saveHierarchyState来保存视图层级状态,这样不管是 Activity 还是 Dialog 亦或是 View 便都可以恢复啦。


不过以上描述的恢复,恢复的都是 Android 原生数据,如果你需要恢复业务数据,那就需要自己保存啦,不过 Google 也为我们提供了解决方案,就是 Jetpack ViewModel,对吧?


这样通过 ViewModel 和 SaveInstance 就可以恢复所有业务和视图状态了!


总结


到这边,关于如何恢复 Dialog 的主要内容就分享完了,需要多说一句的是,Activity#showDialog方法已被标记为废弃。



Use the new DialogFragment class with FragmentManager instead; this is also available on older platforms through the Android compatibility package.



原理都是一样,大家可以根据自己的需要选择。


作者:PuddingSama
来源:juejin.cn/post/7246293244636004409
收起阅读 »

RecyclerView刷新后定位问题

问题描述做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳...
继续阅读 »

问题描述

做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。

原因分析

先简单描述下RecyclerView在notify后的过程:

  1. 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法

onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout

  1. 进入dispatchLayout流程 这一步分为三个步骤:
  • dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
  • dispatchLayoutStep2:真正执行childView的layout操作
  • dispatchLayoutStep3:触发动画、保存状态、清理信息

需要注意的是,在onMeasure的过程中,如果传入的measureMode不是exactly,会去调用dispatchLayoutStep1和dispatchLayoutStep2从而取得真正需要的宽高。 所以在dispatchLayout会先判断是否需要重新执行dispatchLayoutStep1和dispatchLayoutStep2

重点分析dispatchLayoutStep2这一步: 核心操作在 mLayout.onLayoutChildren(mRecycler, mState)这一行。以LinearLayoutManager为例继续往下挖:

public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final View focused = getFocusedChild();
if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
|| mPendingSavedState != null) {
mAnchorInfo.reset();
mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
// 关键步骤1,寻找锚点View位置
updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
mAnchorInfo.mValid = true;
} else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
>= mOrientationHelper.getEndAfterPadding()
|| mOrientationHelper.getDecoratedEnd(focused)
<= mOrientationHelper.getStartAfterPadding())) {
mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
}
...
// fill towards end
updateLayoutStateToFillEnd(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForEnd;
//关键步骤2,从锚点View位置往后填充
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
final int lastElement = mLayoutState.mCurrentPosition;
if (mLayoutState.mAvailable > 0) {
//如果锚点位置后面数据不足,无法填满剩余的空间,那把剩余空间加到顶部
extraForStart += mLayoutState.mAvailable;
}
// fill towards start
updateLayoutStateToFillStart(mAnchorInfo);
mLayoutState.mExtraFillSpace = extraForStart;
mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
//关键步骤3,从锚点View位置向前填充
fill(recycler, mLayoutState, state, false);
startOffset = mLayoutState.mOffset;

if (mLayoutState.mAvailable > 0) {
//如果锚点View位置前面数据不足,那把剩余空间加到尾部再做一次尝试
extraForEnd = mLayoutState.mAvailable;
// start could not consume all it should. add more items towards end
updateLayoutStateToFillEnd(lastElement, endOffset);
mLayoutState.mExtraFillSpace = extraForEnd;
fill(recycler, mLayoutState, state, false);
endOffset = mLayoutState.mOffset;
}
}

先解释一下锚点View,锚点View在一次layout过程中的位置不会发生变化,即之前在哪里显示,这次layout完还在哪,从视觉上看没有位移。

总结一下,mLayout.onLayoutChildren主要做了以下几件事:

  1. 调用updateAnchorInfoForLayout方法确定锚点view位置
  2. 从锚点view后面的位置开始填充,直到后面空间被填满或者已经遍历到最后一个itemView
  3. 从锚点view前面的位置开始填充,直到空间被填满或者遍历到indexe为0的itemView
  4. 经过第三步后仍有剩余空间,则把剩余空间加到尾部再做一次尝试

所以回到一开始的问题,RecyclerView在notify之后位置跳跃的关键在于锚点View的确定,也就是updateAnchorInfoForLayout方法,所以下面重点看下这个方法:

private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
AnchorInfo anchorInfo)
{
if (updateAnchorFromPendingData(state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from pending information");
}
return;
}

if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
if (DEBUG) {
Log.d(TAG, "updated anchor info from existing children");
}
return;
}
if (DEBUG) {
Log.d(TAG, "deciding anchor info for fresh state");
}
anchorInfo.assignCoordinateFromPadding();
anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
}

这个方法比较短,所以代码全贴出来了。如果是调用了scrollToPosition后的刷新,会通过updateAnchorFromPendingData方法确定锚点View位置,否则调用updateAnchorFromChildren来计算:

private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
RecyclerView.State state, AnchorInfo anchorInfo) {
if (getChildCount() == 0) {
return false;
}
final View focused = getFocusedChild();
if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
return true;
}
if (mLastStackFromEnd != mStackFromEnd) {
return false;
}
View referenceChild =
findReferenceChild(
recycler,
state,
anchorInfo.mLayoutFromEnd,
mStackFromEnd);
if (referenceChild != null) {
anchorInfo.assignFromView(referenceChild, getPosition(referenceChild));
...
return true;
}
return false;
}

代码比较简单,如果有焦点View,并且焦点View没被remove,则使用焦点View作为锚点。否则调用findReferenceChild来查找:

View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state,
boolean layoutFromEnd, boolean traverseChildrenInReverseOrder)
{
ensureLayoutState();

// Determine which direction through the view children we are going iterate.
int start = 0;
int end = getChildCount();
int diff = 1;
if (traverseChildrenInReverseOrder) {
start = getChildCount() - 1;
end = -1;
diff = -1;
}

int itemCount = state.getItemCount();

final int boundsStart = mOrientationHelper.getStartAfterPadding();
final int boundsEnd = mOrientationHelper.getEndAfterPadding();

View invalidMatch = null;
View bestFirstFind = null;
View bestSecondFind = null;

for (int i = start; i != end; i += diff) {
final View view = getChildAt(i);
final int position = getPosition(view);
final int childStart = mOrientationHelper.getDecoratedStart(view);
final int childEnd = mOrientationHelper.getDecoratedEnd(view);
if (position >= 0 && position < itemCount) {
if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) {
if (invalidMatch == null) {
invalidMatch = view; // removed item, least preferred
}
} else {
// b/148869110: usually if childStart >= boundsEnd the child is out of
// bounds, except if the child is 0 pixels!
boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart;
boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd;
if (outOfBoundsBefore || outOfBoundsAfter) {
// The item is out of bounds.
// We want to find the items closest to the in bounds items and because we
// are always going through the items linearly, the 2 items we want are the
// last out of bounds item on the side we start searching on, and the first
// out of bounds item on the side we are ending on. The side that we are
// ending on ultimately takes priority because we want items later in the
// layout to move forward if no in bounds anchors are found.
if (layoutFromEnd) {
if (outOfBoundsAfter) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
} else {
if (outOfBoundsBefore) {
bestFirstFind = view;
} else if (bestSecondFind == null) {
bestSecondFind = view;
}
}
} else {
// We found an in bounds item, greedily return it.
return view;
}
}
}
}
// We didn't find an in bounds item so we will settle for an item in this order:
// 1. bestSecondFind
// 2. bestFirstFind
// 3. invalidMatch
return bestSecondFind != null ? bestSecondFind :
(bestFirstFind != null ? bestFirstFind : invalidMatch);
}

解释一下,查找过程会遍历RecyclerView当前可见的所有childView,找到第一个没被notifyRemove的childView就停止查找,否则会把遍历过程中找到的第一个被notifyRemove的childView作为锚点View返回。

这里需要注意final int position = getPosition(view);这一行代码,getPosition返回的是经过校正的最终position,如果ViewHolder被notifyRemove了,这里的position会是0,所以如果可见的childView都被remove了,那最终定位的锚点View是第一个childView,锚点的position是0,偏移量offset是这个被删除的childView的top值,这就会导致后面fill操作时从位置0开始填充,先把position=0的view填充到偏移量offset的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。


作者:Ernest912
来源:juejin.cn/post/7259358063517515834

收起阅读 »

Android应用内版本更新:使用BasicUI库的简单实现

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK...
继续阅读 »

在移动应用开发中,应用内版本更新是一项重要的功能。它允许开发者轻松地向用户提供新的应用版本,以修复错误、改进性能,或者引入新功能。这篇文章将介绍如何使用 BasicUI 库,一个Android库,来实现应用内版本更新的功能。我们将演示如何从远程服务器下载APK文件并进行安装。


BasicUI库简介


BasicUI 是一个功能强大且易于使用的Android库,用于实现各种常见UI和网络操作,其中包括文件下载和更新功能。这个库提供了一些便捷的方法来简化Android应用开发中的一些常见任务,包括版本更新。


要开始使用BasicUI库,你需要在你的项目中添加相应的依赖,可以在官方GitHub仓库中找到详细的文档和示例。


GitHub库链接: BasicUI


应用内部升级弹窗的流程图


image.png


代码实现应用内版本更新


下面是一个简单的代码示例,演示了如何使用BasicUI库来实现应用内版本更新。这段代码将从远程服务器下载APK文件,并在下载完成后进行安装。请确保你已经添加了BasicUI库的依赖。


val file = File(cacheDir, "update.apk")
if (file.exists()) {
file.delete()
}
mDialog.apply {
setOnCancelListener {
HttpUtils.cancel()
}
}.show()
with(this@OkHttpActivity)
.url("http://example.com/your_update.apk") // 替换成实际的APK下载链接
.downloadSingle()
.file(file)
.exectureDownload(object : DownloadCallback {
override fun onFailure(e: Exception?) {
LogUtils.e(e!!.message)
mDialog.dismiss()
}

override fun onSucceed(file: File?) {
ToastUtils.showShort("文件下载完成")
LogUtils.e("文件保存的位置:" + file!!.absolutePath)
mProgressBar!!.visibility = View.GONE
mProgressBar!!.progress = 0
installApk(file)
mDialog.dismiss()
}

override fun onProgress(progress: Int) {
LogUtils.e("单线程下载APK的进度:$progress")
mProgressBar!!.progress = progress
mProgressBar!!.visibility = View.VISIBLE
}
})

上述代码的主要步骤包括:



  1. 创建一个用于保存下载APK文件的本地文件,要使用cacheDir目录,原因是可以不需要读写权限。

  2. 如果之前存在同名文件,先进行删除。

  3. 创建一个对话框,其中包括一个取消监听器,用于在用户取消下载时取消网络请求。

  4. 使用BasicUI库的网络操作类(HttpUtils)创建一个下载请求,指定下载地址、下载完成后保存的文件,以及下载回调接口。

  5. 在下载回调接口中处理下载成功、失败和进度更新的情况。


请注意,你需要将示例代码中的下载链接替换为实际的APK下载链接。这段代码提供了一个简单而有效的方式来执行应用内版本更新,但你还可以根据你的需求进行进一步的定制化。


结语


在本文中,我们演示了如何使用BasicUI库来实现Android应用内版本更新的功能。这是一个快速、方便的解决方案,可以帮助你轻松地向用户提供最新版本的应用程序。请记住,版本更新是确保用户始终使用最新、最稳定版本的应用的关键步骤。


为了更好地满足你的需求,你可以根据实际情况进一步定制版本更新流程,例如添加灰度发布、自动检测新版本等功能。希望这篇文章对你有所帮助,使你能够更好地满足用户的需求和提供卓越的应用体验。




这篇文章演示了如何使用 BasicUI 库来实现应用内版本更新的功能。你可以根据自己的需求进一步定制这个流程,以满足特定的应用程序要求。希望这篇文章对你有所帮助!


作者:peakmain9
来源:juejin.cn/post/7293401255053819941
收起阅读 »

Androidmanifest文件加固和对抗

前言 恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。...
继续阅读 »

前言


恶意软件为了不让我们很容易反编译一个apk,会对androidmanifest文件进行魔改加固,本文探索androidmanifest加固的常见手法以及对抗方法。这里提供一个恶意样本的androidmanifest.xml文件,我们学完之后可以动手实践。


1、Androidmanifest文件组成


这里贴一张经典图,主要描述了androidmanifest的组成


image


androidmanifest文件头部仅仅占了8个字节,紧跟其后的是StringPoolType字符串常量池


(为了方便我们观察分析,可以先安装一下010editor的模板,详细见2、010editor模板)


Magic Number


这个值作为头部,是经常会被魔改的,需要重点关注


image


StylesStart


该值一般为0,也是经常会发现魔改


image


StringPool


image


寻找一个字符串,如何计算?


1、获得字符串存放开放位置:0xac(172),此时的0xac是不带开头的8个字节


所以需要我们加上8,最终字符串在文件中的开始位置是:0xb4


2、获取第一个字符串的偏移,可以看到,偏移为0


image


3、计算字符串最终存储的地方: 0xb4 = 0xb4 + 0


读取字符串,以字节00结束


image


读取到的字符为:theme


帮助网安学习,全套资料S信领取:


① 网安学习成长路径思维导图

② 60+网安经典常用工具包

③ 100+SRC漏洞分析报告

④ 150+网安攻防实战技术电子书

⑤ 最权威CISSP 认证考试指南+题库

⑥ 超1800页CTF实战技巧手册

⑦ 最新网安大厂面试题合集(含答案)

⑧ APP客户端安全检测指南(安卓+IOS)


总结:


stringpool是紧跟在文件头后面的一块区域,用于存储文件所有用到的字符串


这个地方呢,也是经常发生魔改加固的,比如:将StringCount修改为0xFFFFFF无穷大


在经过我们的手动计算和分析后,我们对该区域有了更深的了解。


2、010editor模板


使用010editor工具打开,安装模板库


image


搜索:androidmanifest.bt


image


安装完成且运行之后:


image


会发现完整的结构,帮助我们分析


3、使用AXMLPrinter2进行的排错和修复


用法十分简单:


java -jar AXMLPrinter2.jar AndroidManifest_origin.xml

会有一系列的报错,但是不要慌张,根据这些报错来对原androidmanifest.xml进行修复


image​​


意思是:出乎意料的0x80003(正常读取的数据),此时却读取到:0x80000


按照小端序,正常的数据应该是: 03 00 08


使用 010editor 打开


image


将其修复


image


保存,再次尝试运行AXMLPrinter2


image


好家伙还有错误,这个-71304363,不方便我们分析,将其转换为python的hex数据


NegativeArraySizeException 表示在创建数组的时候,数组的大小出现了负数。


androidmanifest加固后文件与正常的androidmanifest文件对比之后就可以发现魔改的地方。


image


将其修改回去


image


运行仍然报错,是个新错误:


image


再次去分析:


image


stringoffsets如此离谱,并且数组的大小变为了0xff


image


image


根据报错的信息,尝试把FF修改为24


image


image


再次运行


image


成功拿到反编译后的androidmanifest.xml文件


总结:


这个例子有三个魔改点经常出现在androidmanifest.xml加固


恶意软件通过修改这些魔改点来对抗反编译


作者:合天网安实验室
来源:juejin.cn/post/7324011299272310811
收起阅读 »

使用RecyclerView实现三种阅读器翻页样式

一、整体逻辑 为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下: Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附) Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以...
继续阅读 »

一、整体逻辑


image.png


为何直接对RecyclerView进行扩展而不使用ViewPager/ViewPager2?原因如下:



  1. Scroll Model(垂直滑动)需要自定义自动滑动(对指定页进行吸附)

  2. Flip Mode(仿真翻页)需要获取各种情况下的方向信息,以实现更好的控制

  3. RecyclerView方便拓展,同时三种模式同时使用RecyclerView实现,便于复用


实现逻辑:三种滑动模式都在RecyclerView地基础上更改其滑动行为,横向滑动需要修改子View层级,仿真翻页需要再覆盖一层仿真动画


二、横向覆盖滑动(Slide Mode)


横向.gif


Slide Mode 最适合直接使用 ViewPager,不过我们还是以 RecyclerView 为基础来实现,让三种模式统一实现方式。实现思路:先实现跨页吸附,再实现覆盖翻页效果


1、跨页吸附


实现跨页吸附,需要在手指离开屏幕时对 RecyclerView 进行复位吸附操作,有两种情况:


(1)Scroll Idle


拖拽发生后,RecyclerView 滑动状态变为 SCROLL_STATE_IDLE 时,需要进行复位吸附操作


// OrientationHelper为系统提供的辅助类,LayoutManager的包装类
// 可以让我们方便的计算出RecyclerView相关的各种宽高,计算结果和LayoutManager方向相关
open fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
val lm = mRecyclerView.layoutManager ?: return null
val childCount = lm.childCount // 可见数量
if (childCount < 1) return null

var closestChild: View? = null
var absClosest = Int.MAX_VALUE
var scrollDistance = 0
// RecyclerView中心点,LayoutManager为竖向则是Y轴坐标,为横向则是X轴坐标
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

// 从可见Item中找到距RecyclerView离中心最近的View
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return null // consumeSnap 默认返回false,竖直滑动模式才使用
val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val absDistance = abs(childCenter - containerCenter)
if (absDistance < absClosest) {
absClosest = absDistance
closestChild = child
scrollDistance = childCenter - containerCenter
}
}
closestChild ?: return null

// 滑动
when (orientation) {
VERTICAL -> mRecyclerView.smoothScrollBy(0, scrollDistance)
HORIZONTAL -> mRecyclerView.smoothScrollBy(scrollDistance, 0)
}
return Pair(scrollDistance, lm.getPosition(closestChild))
}


(2)Fling


可以通过 RecyclerView 提供的OnFlingListener消费掉Fling,将其转化为 SmoothScroll ,滑动到指定位置


①、找到吸附目标的位置(adapter position)


open fun findTargetSnapPosition(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Int {
val itemCount: Int = lm.itemCount
if (itemCount == 0) return RecyclerView.NO_POSITION

// 中心点以前距离最近的View
var closestChildBeforeCenter: View? = null
var distanceBefore = Int.MIN_VALUE // 中心点以前,距离为负数
// 中心点以后距离最近的View
var closestChildAfterCenter: View? = null
var distanceAfter = Int.MAX_VALUE // 中心点以后,距离为正数
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2

val childCount: Int = lm.childCount
for (i in 0 until childCount) {
val child = lm.getChildAt(i) ?: continue
if (consumeSnap(i, child)) return RecyclerView.NO_POSITION // consumeSnap 默认返回false,竖直滑动模式才使用

val childCenter = (helper.getDecoratedStart(child)
+ helper.getDecoratedMeasurement(child) / 2)
val distance = childCenter - containerCenter

// Fling需要考虑方向,先获取两个方向最近的View
if (distance in (distanceBefore + 1)..0) {
distanceBefore = distance
closestChildBeforeCenter = child
}
if (distance in 0 until distanceAfter) {
distanceAfter = distance
closestChildAfterCenter = child
}
}

// 根据方向选择Fling到哪个View
val forwardDirection = velocity > 0
if (forwardDirection && closestChildAfterCenter != null) {
return lm.getPosition(closestChildAfterCenter)
} else if (!forwardDirection && closestChildBeforeCenter != null) {
return lm.getPosition(closestChildBeforeCenter)
}

// 边界情况处理
val visibleView =
(if (forwardDirection) closestChildBeforeCenter else closestChildAfterCenter)
?: return RecyclerView.NO_POSITION
val visiblePosition: Int = lm.getPosition(visibleView)
val snapToPosition = (visiblePosition - 1)

return if (snapToPosition < 0 || snapToPosition >= itemCount) {
RecyclerView.NO_POSITION
} else snapToPosition
}

②、使用RecyclerView的「LinearSmoothScroller」完成吸附动画


private fun createScroller(
oh: OrientationHelper
)
: LinearSmoothScroller {
return object : LinearSmoothScroller(mRecyclerView.context) {
override fun onTargetFound(
targetView: View,
state: RecyclerView.State,
action: Action
)
{
val d = distanceToCenter(targetView, oh)
val time = calculateTimeForDeceleration(abs(d))
if (time > 0) {
when (orientation) {
VERTICAL -> action.update(0, d, time, mDecelerateInterpolator)
HORIZONTAL -> action.update(d, 0, time, mDecelerateInterpolator)
}
}
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) =
100f / displayMetrics.densityDpi

override fun calculateTimeForScrolling(dx: Int) =
100.coerceAtMost(super.calculateTimeForScrolling(dx))
}
}

protected fun distanceToCenter(targetView: View, helper: OrientationHelper): Int {
val childCenter = (helper.getDecoratedStart(targetView)
+ helper.getDecoratedMeasurement(targetView) / 2)
val containerCenter = helper.startAfterPadding + helper.totalSpace / 2
return childCenter - containerCenter
}

完整操作:


protected fun snapFromFling(
lm: RecyclerView.LayoutManager,
velocity: Int,
helper: OrientationHelper
)
: Pair<Boolean, Int> {
val targetPosition = findTargetSnapPosition(lm, velocity, helper)
if (targetPosition == RecyclerView.NO_POSITION) return Pair(false, 0)
val smoothScroller = createScroller(helper)
smoothScroller.targetPosition = targetPosition
lm.startSmoothScroll(smoothScroller)
return Pair(true, targetPosition) // 消费fling
}

2、覆盖效果实现


(1)如果使用PageTransform实现


如果使用ViewPagerPageTransform,是可以实现覆盖动画的,实现思路:使可见View的第二个View跟随屏幕滑动


image.png


假设上图蓝色透明矩形为屏幕,其他为ItemView,图片上半部分正常滑动的状态,下半部分为 translate view 之后的状态。可以看到,在横向滑动过程中,最多可见2个View(蓝色透明方框最多覆盖2个View),此时将第二个View跟随屏幕,其他View保持跟随画布滑动,即可达到效果。在OnPageScroll回调中实现这个逻辑:


for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
if (i == 1) {
// view.left是个负数,offsetPx(=-view.left)是个正数
view.translationX = offsetPx.toFloat() - view.width // 需要translate的距离(向前移需要负数)
} else {
// 恢复其余位置的translate
view.translationX = 0f
}
}
}

(2)扩展RecyclerView实现覆盖翻页


知道如何通过 PageTransfrom 实现后,我们来看看直接使用 RecyclerView 如何实现。观看ViewPager2源码可知PageTransfrom的实现方式


image.png


故我们直接copy代码,在OnScrollListener中自行实现onPageScrolled回调即可实现覆盖翻页效果。


但是此时还有一个问题,就是子View的层级问题,你会发现上面的滑动示意图中,绿色View会在黄色View之上,如何解决这个问题呢?我们需要控制View的绘制顺序,前面的View后绘制,保证前面地View在后面的View的绘制层级之上。


观看源码会发现,RecyclerView其实提供了一个回调ChildDrawingOrderCallback,可以很方便地实现这个效果:


override fun attach() {
super.attach()
mRecyclerView.setChildDrawingOrderCallback(this)
}

override fun onGetChildDrawingOrder(childCount: Int, i: Int) = childCount - i - 1 // 反向绘制

三、竖直滑动(Scroll Mode)


垂直.gif


竖直滑动需要滑动到跨章的位置时才吸附(自动回滚到指定位置),需要实现两个效果:跨章吸附、跨章Fling阻断。我们可以在横向覆盖滑动(Slide Mode)的基础上做一个减法,首先将LayoutManager改为竖向的,然后实现上述两个效果。


1、跨章吸附


实现跨章吸附,我们先在 RecyclerViewAdapter 中对每个View进行一个标记:


companion object {
const val TYPE_NONE = 100 // 其他
const val TYPE_FIRST_PAGE = 101 // 首页
const val TYPE_LAST_PAGE = 102 // 末页
}


fun bind() { // onBindViewHolder 时调用
itemView.tag = when {
textPage.isLastPage -> TYPE_LAST_PAGE
textPage.isFirstPage -> TYPE_FIRST_PAGE
else -> TYPE_NONE
}
......
}

其次我们实现横向覆盖滑动(Slide Mode)中的一段代码(做一个减法):


// 如果不是章节的最后一页,则消费Snap(不进行吸附操作)
override fun consumeSnap(index: Int, child: View) =
index == 0 && child.tag != ReadBookAdapter.TYPE_LAST_PAGE

这样就可以实现不是跨越章节的翻页不进行吸附,而跨越章节的滑动会自动吸附。


2、跨章Fling阻断


在滑动过程中,基于可见View只有两个的情况:



  • 如果向上滑动,判断第一个可见View是否「末页」,如果是,smoothScroll到第二个可见View

  • 如果向下滑动,判断第二个可见View是否「首页」,如果是,smoothScroll到第一个可见View


private var inFling = false     // 正在fling,在OnFlingListener中设置为true
private var inBlocking = false // 阻断fling


override val mScrollListener = object : RecyclerView.OnScrollListener() {
var mScrolled = false

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
inFling = false // 重置inFling
}
RecyclerView.SCROLL_STATE_IDLE -> {
inFling = false // 重置inFling
if (inBlocking) {
inBlocking = false // 忽略阻断造成的IDLE
} else if (mScrolled) {
mScrolled = false
snapToTargetExistingView(orientationHelper.value)
}
}
}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) {
if (!mScrolled) {
this@VSnapHelper.mCallback.onScrollBegin()
mScrolled = true
}
val lm = mRecyclerView.layoutManager ?: return
// fling阻断
if (inFling && !inBlocking) {
val child: View?
val type: Int
if (dy > 0) { // 向上滑动
child = lm.getChildAt(0)
type = ReadBookAdapter.TYPE_LAST_PAGE
} else {
child = lm.getChildAt(lm.childCount - 1)
type = ReadBookAdapter.TYPE_FIRST_PAGE
}
child?.let {
if (it.tag == type) {
inBlocking = true
val d = distanceToCenter(it, orientationHelper.value)
mRecyclerView.smoothScrollBy(0, d)
}
}
}
}
}
}

四、仿真页(Flip Mode)


仿真.gif


仿真页在横向覆盖滑动(Slide Mode)基础之上实现,我们还需要实现:



  1. 确认手指滑动方向

  2. 所有可见View都跟随屏幕

  3. 绘制次序根据拖拽方向改变,保证目标页在当前页之上

  4. 绘制仿真页

  5. 手指抬起后的翻页动画(确认Fling、Scroll Idle产生的两种Snap的方向,因为手指会来回滑动导致方向判断错误)


1、确认手指滑动方向


滑动方向不能直接在 onTouchdispatchTouchEvent 这些方法中直接判断,
因为极微小的滑动都会决定方向,这样会造成轻微触碰就判定了方向,导致页面内容闪动、抖动等问题。
我们需要在滑动了一定距离后确定方向,最好的选择就是在 onPageScroll 中进行判断,系统为我们保证了ScrollState已变为DRAGGING,此时用户100%已经在滑动。可以看下源码真正触发「onPageScroll」的条件有哪些


image.png


我们实现的判断方向的代码:


// 在onScrolled中调用
// mCurrentItem:onPageSelected中赋值,代表当前Item
// position:第一个可见View的位置
// offsetPx:第一个可见View的left取负
// mForward:方向,true为画布向左滑动(向尾部滑动),false画布向右滑动(向头部滑动)
private fun dispatchScrolled(position: Int, offsetPx: Int) {
if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING) {
mForward = mCurrentItem == position
}
mCallback.onPageScrolled(position, mCurrentItem, offsetPx, mForward)
}

image.png


不过这个规则在超快速滑动时会判断错误,即settling直接变dragging的时候,所以会对滑动做一点限制


override fun dispatchTouchEvent(e: MotionEvent): Boolean {
if (snapHelper.mScrollState == RecyclerView.SCROLL_STATE_SETTLING) {
return true // sellting过程中禁止滑动
}
delegate.onTouch(e)
return super.dispatchTouchEvent(e)
}

2、遮盖效果


所有可见View都跟随屏幕,横向覆盖滑动(Slide Mode)的增强版,因为给 RecyclerView设置了 offScreenLimit=1 的效果,所以 LayoutManagerchild 数量最多会有4个
(参照 ViewPager2 # LinearLayoutManagerImpl 实现,这里设置是为了滑动时可以第一时间生成目标页的截图)


// onPageScrolled中调用
private fun transform(offsetPx: Int, firstVisible: Int) {
val count = layoutManager.childCount
if (count == 2 || (count == 3 && offsetPx == 0)) {
// 可见View只有一个的时候,全部复位
for (i in 0 until count) {
layoutManager.getChildAt(i)?.also { view ->
view.translationX = 0f
}
}
} else {
var target = 1
if (count == 3 && firstVisible == 0) target-- // 首位适配,currentItem=0且存在滑动的时候
for (i in 0 until layoutManager.childCount) {
layoutManager.getChildAt(i)?.also { view ->
when (i) {
target -> view.translationX = offsetPx.toFloat()
target + 1 -> view.translationX = offsetPx.toFloat() - view.width
else -> view.translationX = 0f
}
}
}
}
}

3、绘制次序根据拖拽方向改变


保证目标页在当前页之上,防止绘制的仿真页消失时出现闪屏(瞬间显示了不正确的页)


// 画布左移则反向绘制,右移则正想绘制
override fun getDrawingOrder(childCount: Int, i: Int) =
if (snapHelper.mForward) childCount - i - 1 else i

4、绘制仿真页


我们在 RecyclerView 的父View上直接覆盖绘制一层仿真页Bitmap


(1)生成截图


如上面所说,实现了 offScreenLimit=1 的效果,我们在首次获取到方向时生成截图:


// 生成截图方法
fun View.screenshot(): Bitmap? {
return runCatching {
val screenshot = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
val c = Canvas(screenshot)
c.translate(-scrollX.toFloat(), -scrollY.toFloat())
draw(c)
screenshot
}.getOrNull()
}
private var isBeginDrag = false

override fun onPageStateChange(state: Int) {
when (state) {
RecyclerView.SCROLL_STATE_DRAGGING -> {
isBeginDrag = true
}
}
}

override fun onPageScrolled(firstVisible: Int, current: Int, offsetPx: Int, forward: Boolean) {
if (isBeginDrag) {
isBeginDrag = false
delegate.apply {
if (forward) {
nextBitmap?.recycle()
nextBitmap = layoutManager.findViewByPosition(current + 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
} else {
prevBitmap?.recycle()
prevBitmap = layoutManager.findViewByPosition(current - 1)?.screenshot()
curBitmap?.recycle()
curBitmap = layoutManager.findViewByPosition(current)?.screenshot()
}
setDirection(if (forward) AnimDirection.NEXT else AnimDirection.PREV)
}
invalidate()
}
}

(2)绘制仿真页


绘制仿真页参考 gedoor/legadoSimulationPageDelegate



  • 基础知识:三角函数、Android的矩阵、贝塞尔曲线、canvas.clipPath的 XOR & INTERSECT 模式

  • 绘制方法:Android仿真翻页:cnblogs.com

  • 计算方法:使用手指触摸点和触摸点对应的角位置(比如触摸点靠近右下角,角位置就是右下角),这两个点可以算出所有参数


确认方向后,我们只用通过修改手指触碰点的参数即可控制整个动画(根据点击位置实时计算即可)


5、动画控制


手指抬起后的翻页动画通过 Scroller+invalidate实现


override fun computeScroll() {
if (scroller.computeScrollOffset()) {
setTouchPoint(scroller.currX.toFloat(), scroller.currY.toFloat())
} else if (isStarted) {
stopScroll()
}
}

对于FlingScroll Idle产生的吸附效果,我们需要各自回调方向:


// 选中时开始动画,此时position改变
override fun onPageSelected(position: Int) {
val page = adapter.data[position]
ReadBook.onPageChange(page)
if (canDraw) {
delegate.onAnimStart(300, false)
}
}

// position未改变的情况
override fun onSnap(isFling: Boolean, forward: Boolean, changePosition: Boolean) {
if (!changePosition) {
delegate.onAnimStart(
300,
true,
// 未改变方向,向前则播放向后动画
if (forward) AnimDirection.PREV else AnimDirection.NEXT
)
}
}

Scroll Idle通过 SmoothScroll 所需要滑动的距离正负判断方向:


// Scroll
override fun snapToTargetExistingView(helper: OrientationHelper): Pair<Int, Int>? {
mSnapping = true
super.snapToTargetExistingView(helper)?.also {
// first为滑动距离,second为目标Item的position
mCallback.onSnap(false, it.first > 0, mCurrentItem != it.second)
return it
}
return null
}

// Fling
override val mFlingListener = object : RecyclerView.OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
val lm = mRecyclerView.layoutManager ?: return false
mRecyclerView.adapter ?: return false
val minFlingVelocity = mRecyclerView.minFlingVelocity
val result = snapFromFling(
lm,
velocityX,
orientationHelper.value
)
val consume = abs(velocityX) > minFlingVelocity && result.first
if (consume) {
mSnapping = true
// second为目标Item的position,这里直接通过速度正负来判断方向
mCallback.onSnap(true, velocityX > 0, result.second != mCurrentItem)
}
return consume
}
}

(以上为所有关键点,只截取了部分代码,提供一个思路)


作者:尉迟涛
来源:juejin.cn/post/7244819106343829564
收起阅读 »

Android - 你可能需要这样一个日志库

前言 目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少, 所以作者想自己造一个轮子。 这种api风格有什么不好呢? 首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满...
继续阅读 »

前言


目前大多数库api设计都是Log.d("tag", "msg")这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。


这种api风格有什么不好呢?


首先,它的tag是一个字符串,需要开发人员严格管理tag,要不然可能各种硬编码的tag满天飞。


另外,它也可能导致性能陷阱,假设有这么一段代码:


// 打印一个List
Log.d("tag", list.joinToString())

此处使用Debug打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()这一行代码仍然会被执行,有可能导致性能问题。


下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin,库中好用的api也是基于kotlin特性来实现的。


作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI { "1" }
flogI { "2" }
flogW { "3" }
flogI { "user debug" }
thread {
flogI { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志


常用方法


// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),

//(可选参数)是否异步发布日志,默认值false
async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)

打印日志


interface AppLogger : FLogger

flogV { "Verbose" }
flogD { "Debug" }
flogI { "Info" }
flogW { "Warning" }
flogE { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

Kotlin中 for in 是有序的吗?forEach呢?

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。 数组的 for in // 调用: val arr = arrayOf(1, 2, 3)...
继续阅读 »

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。


数组的 for in


// 调用:
val arr = arrayOf(1, 2, 3)
for (ele in arr) {
println(ele)
}

反编译成Java是个什么东西呢?


Integer[] arr = new Integer[]{1, 2, 3};
Integer[] var4 = arr;
int var5 = arr.length;

for(int var3 = 0; var3 < var5; ++var3) {
int ele = var4[var3];
System.out.println(ele);
}

总结:从Java代码可以看出,实际就是一个普通的for循环,是从下标0开始遍历到结束的,所以是有序的。


列表的 for in


// 调用:
val list = listOf(1, 2, 3)
for (ele in list) {
println(ele)
}

反编译成Java:


List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});
Iterator var3 = list.iterator();

while(var3.hasNext()) {
int ele = ((Number)var3.next()).intValue();
System.out.println(ele);
}

可以看出列表的for in是通过iterator实现的,和数组不一样,那这个iterator遍历是否是有序的呢?首先我们要知道这个iterator怎么来的:


// iterator 是通过调用 list.iterator() 得到的,那么这个list又是什么呢?
Iterator var3 = list.iterator();

// list
List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});

// list是通过数组elements.asList()得到的
public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()

// 这里有个expect,找到对应的actual
public expect fun <T> Array<out T>.asList(): List<T>

// 对应的actual
public actual fun <T> Array<out T>.asList(): List<T> {
return ArraysUtilJVM.asList(this)
}

// 最终调用了Arrays.asList(array)
class ArraysUtilJVM {
static <T> List<T> asList(T[] array) {
return Arrays.asList(array);
}
}

public class Arrays {

// 从这里看到最终拿到的list是 Arrays 类中的 ArrayList
// 然后我们找到里面的 iterator() 方法
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private final E[] a;

@Override
public Iterator<E> iterator() {
// 最终得到的iterator是ArrayItr
// 这里的a是一个数组,也就是我们一开始传进来的1,2,3
return new ArrayItr<>(a);
}
}

private static class ArrayItr<E> implements Iterator<E> {
private int cursor;
private final E[] a;

ArrayItr(E[] a) {
this.a = a;
}

@Override
public boolean hasNext() {
return cursor < a.length;
}

@Override
public E next() {
int i = cursor;
if (i >= a.length) {
throw new NoSuchElementException();
}
cursor = i + 1;
return a[i];
}
}
}

总结:列表的for in是通过iterator实现的,这个iterator是ArrayItr,从里面的next()方法可以看出,这也是有序的,从cursor开始,cursor默认是0,也就是从下标0开始遍历。
注:这里只是分析了Arrays.ArrayList的iterator,具体的集合类需要具体分析,比如ArrayList、LinkedList等,不过从正常思维来看,iterator是一个迭代器,就应该有序的把数据一个一个丢出来。


数组的 forEach


// 调用:
val arr = arrayOf(1, 2, 3)
arr.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Array<out T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

列表的 forEach


// 调用:
val list = listOf(1, 2, 3)
list.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

作者:linq
来源:juejin.cn/post/7304562756429611046
收起阅读 »

Android文件存储

前言在Android中,对于持久化有如下4种:本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。正文先来看看内部存储空间。内部存储空间由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/...
继续阅读 »

前言

在Android中,对于持久化有如下4种:

持久化.jpg

本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。

正文

先来看看内部存储空间。

内部存储空间

由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/,对应的目录如下:

内部存储空间.jpg

内部存储空间有如下特点:

  • 每个应用独占一个以包名命名的私有文件夹。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。
  • 适用于私密数据。

对于内部存储空间,里面有一些默认的文件夹,而对于不同文件的访问,有着不同的API,如下:

  1. 对于data/data/<包名>/目录:
方法描述
Context#getDir(String name,int mode): File获取内部存储根目录下的文件夹,不存在则创建
  1. 对于data/data/<包名>/files/目录:
方法描述
Context#getFilesDir():File!返回files文件夹
Context#fileList(): Array!列举files目录下所有文件和文件夹,String类型为文件或者文件夹的名字
Context#openFileInput(String name):FileInputStream打开files文件下的某个文件的输入流,不存在则抛出异常:FileNotFoundException
Context#openFileOut(String name,int mode):FileOutputStream打开files文件下的某个文件的输入流,文件不存在则新建
Context#deleteFile(String name): Boolean删除文件或文件夹
  1. 对于data/data/<包名>/cache/目录:
方法描述
Context#getCacheDir():File返回cache文件夹
  1. 对于data/data/<包名>/code_cache目录:
方法描述
Context#getCodeCacheDir():File返回优化过的代码目录,如JIT优化

上述方法测试代码如下:

        val testDir = getDir("rootDir", MODE_PRIVATE)
//打印为:/data/user/0/com.wayeal.ocr/app_rootDir    
Logger.t("testFile").d("testDir = ${testDir.absolutePath}")
//打印为:/data/user/0/com.wayeal.ocr/files  
Logger.t("testFile").d("filesDir = ${filesDir.absolutePath}")
//在files目录下新建文件
val fileOutputStream = openFileOutput("filesTest", MODE_PRIVATE)
//打印为:[datastore, bugly_last_us_up_tm, local_crash_lock, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")
File(filesDir,"haha").createNewFile()
//打印为:[datastore, bugly_last_us_up_tm, haha, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")

外部存储空间

对于外部存储空间在使用前一般要判断是否挂载,因为早期的的Android手机是有SD卡的,是可以进行卸载SD卡的。

对于外部存储空间,也有严格的划分,如下:

外部存储空间划分.jpg

这里可以发现外部存储空间分为了公共目录和私有目录,对于公共目录特点:

  • 外部存储中除了私有目录外的其他空间。
  • 所有应用共享。
  • 在应用卸载时不会被卸载。
  • 对MediaScanner可见。
  • 适用于非私密数据,不需要随应用卸载删除。

对于私有目录,有如下特点:

  • 目录名为Android。
  • 在media和data等目录中,以包名区分各个应用。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。(对多媒体文件夹例外,要求API 21)
  • 适用于非私密数据,需要在应用卸载时删除。

这里对于公共目录storage/emulated/0/来说,其API主要是Environment类来完成,如下:

方法描述
Environment.getExternalStorageDirectory(): File获取外部存储目录
Environment.getExternalStoragePublicDirectory(name: String): File外部存储根目录下的某个文件夹
Environment.getExternalStorageState(): String外部存储的状态

对于外部空间的私有目录storage/emulated/0/Android/data/<包名>/来说,其API还是由Context,主要是方法名都携带external字样,如下:

方法描述
Context.getExternalCacheDir(): File获取cache文件夹
Context.getExternalCacheDirs(): Array多部分cache文件夹(API 18),因为外部存储空间可能有多个
Context.getExternalFilesDir(type: String): File获取files文件夹
Context.getExternalFilesDirs(type: String): Array获取多部分的files文件夹
Context.getExternalMediaDirs(): Array获取多部分多媒体文件(API 21)

上述方法测试代码和log如下:

        Logger.t("testFile")
          .d("外部公共存储根目录 = ${Environment.getExternalStorageDirectory().absolutePath}")
//外部公共存储根目录 = /storage/emulated/0
       Logger.t("testFile")
          .d("外部公共存储Pictures目录 = ${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).absolutePath}")
//外部公共存储Pictures目录 = /storage/emulated/0/Pictures
       Logger.t("testFile")
          .d("外部公共存储状态 = ${Environment.getExternalStorageState()}")
//外部公共存储状态 = mounted
       Logger.t("testFile")
          .d("外部存储私有缓存目录 = ${externalCacheDir?.absolutePath}")
//外部存储私有缓存目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/cache
       Logger.t("testFile")
          .d("外部存储私有多部分缓存目录 = ${externalCacheDirs?.toMutableList()}")
//外部存储私有多部分缓存目录 = [/storage/emulated/0/Android/data/com.wayeal.ocr/cache]
       Logger.t("testFile")
          .d("外部存储私有files的Pictures目录 = ${getExternalFilesDir(Environment.DIRECTORY_PICTURES)}")
//外部存储私有files的Pictures目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/files/Pictures
       Logger.t("testFile")
          .d("外部存储私有媒体多部分目录 = ${externalMediaDirs.toMutableList()}")
//外部存储私有媒体目录 = [/storage/emulated/0/Android/media/com.wayeal.ocr]

总结

对于不同的存储空间的特点以及API要了解,在需要保存文件时选择适当的存储空间。


作者:yuanhao
来源:juejin.cn/post/7158365077488271367

收起阅读 »

借某次写需求谈Android文件存储

前言 某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。 Round 1 哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就...
继续阅读 »

前言


某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。


Round 1


哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就是Android的内部存储空间。


然后哥们很顺利的啊,把这个功能做出来了。


第二天开站会,测试提出了致命疑问:我们测试要怎么看到报错信息呢?


众所周知啊,这个路径手机不root是无法查看的。所以我看向我导:“手机root一下不就行了”


image.png


我导:改!


Round 2


哥们吸取教训啊,咱不存在内部,咱存外面还不行吗。这次我用context.getExternalFilesDir()获取存储目录,也就是外部存储的应用私有目录,路径是storage/emulated/0/Android/data/包名/files。


改个路径的事情,瞬间写好了。


我们这个日志搜集,一个是搜集Native层的报错,一个是搜集Jvm层的报错。然后经过测试,发现Jvm层的报错信息有权限取出来,而Native层的报错信息却没权限取出来


我们当时就震惊了:啊?同一个目录下存东西居然会出现两套权限?


image.png


然后另外一个Android开发的前辈就想通过adb强行把这个报错信息拿出来,但是问题是没有root过没法用su命令啊,所以这件事又绕回去了。


然后我导就让我改到根目录下。


行,哥们改!


Round 3


既然内部存储不行,存到外部存储的私有目录也不行,就只能存在公共目录了。也就是我们使用手机文件管理应用看,Music和Movie的那一层。


获取存储路径用Environment.getExternalStorageDirectory(),得到的路径是storage/emulated/0。


改完后我又发现,Native层的权限正常了,Jvm的报错信息写不进去了。


报错信息是:


java.io.FileNotFoundException:...(Opration not permitted)


我心想:啊?这个目录难道没有写权限?那Natvie的报错信息怎么写进去的?


当时复制粘贴进百度,看到了一名CSDN老哥的回答:


img_v3_027e_6922fad6-53b9-4b94-b35f-c5445a90a4eg.jpg


其实我当时就对这个回答存疑的,因为明显我能mkdir,但是.txt文本信息却写不进去。


终于,我在Stack Overflow看到了正解:


image.png


没错,真相只有一个,是文件名有问题。我将.txt改成了.log就能成功存储了。


至此,终于可以下班。


image.png


总结


Android的文件存储和权限管理是真的*蛋。


实习的每一天做需求,都像在拍走进科学,哎。


顺便复习一下Android文件存储吧:Android文件存储


作者:leiteorz
来源:juejin.cn/post/7327920541989781504
收起阅读 »

动态代理在Android中的运用

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原...
继续阅读 »

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原理、用途和实际示例。


什么是动态代理?


动态代理是一种通过创建代理对象来代替原始对象的技术,以便在方法调用前后执行额外的操作。代理对象通常实现与原始对象相同的接口,但可以添加自定义行为。动态代理是在运行时生成的,因此它不需要在编译时知道原始对象的类型。


动态代理的原理


动态代理的原理涉及两个关键部分:



  1. InvocationHandler(调用处理器):这是一个接口,通常由开发人员实现。它包含一个方法 invoke,在代理对象上的方法被调用时会被调用。在 invoke 方法内,你可以定义在方法调用前后执行的逻辑。

  2. Proxy(代理类):这是Java提供的类,用于创建代理对象。你需要传递一个 ClassLoader、一组接口以及一个 InvocationHandlerProxy.newProxyInstance 方法,然后它会生成代理对象。


下面是一个示例代码,演示了如何创建一个简单的动态代理:


import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

// 接口
interface MyInterface {
fun doSomething()
}

// 实现类
class MyImplementation : MyInterface {
override fun doSomething() {
println("Original method is called.")
}
}

// 调用处理器
class MyInvocationHandler(private val realObject: MyInterface) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
println("Before method is called.")
val result = method.invoke(realObject, *(args ?: emptyArray()))
println("After method is called.")
return result
}
}

fun main() {
val realObject = MyImplementation()
val proxyObject = Proxy.newProxyInstance(
MyInterface::class.java.classLoader,
arrayOf
(MyInterface::class.java),
MyInvocationHandler
(realObject)
) as MyInterface

proxyObject.doSomething()
}

运行上述代码会输出:


Before method is called.
Original method is called.
After method is called.

这里,MyInvocationHandler 拦截了 doSomething 方法的调用,在方法前后添加了额外的逻辑。


Android中的动态代理


在Android中,动态代理通常使用Java的java.lang.reflect.Proxy类来实现。该类允许你创建一个代理对象,该对象实现了指定接口,并且可以拦截接口方法的调用以执行额外的逻辑。在Android开发中,常见的用途包括性能监控、权限检查、日志记录和事件处理。


动态代理的用途


性能监控


你可以使用动态代理来监控方法的执行时间,以便分析应用程序的性能。例如,你可以创建一个性能监控代理,在每次方法调用前记录当前时间,然后在方法调用后计算执行时间。


import android.util.Log

class PerformanceMonitorProxy(private val target: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target, *(args ?: emptyArray()))
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
Log.d("Performance", "${method.name} took $duration ms to execute.")
return result
}
}

AOP(面向切面编程)


动态代理也是AOP的核心概念之一。AOP允许你将横切关注点(如日志记录、事务管理和安全性检查)从业务逻辑中分离出来,以便更好地维护和扩展代码。通过创建适当的代理,你可以将这些关注点应用到多个类和方法中。


事件处理


Android中常常需要处理用户界面上的各种事件,例如点击事件、滑动事件等。你可以使用动态代理来简化事件处理代码,将事件处理逻辑从Activity或Fragment中分离出来,使代码更加模块化和可维护。


实际示例


下面是一个简单的示例,演示了如何在Android中使用动态代理来处理点击事件:


import android.util.Log
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import android.view.View

class ClickHandlerProxy(private val target: View.OnClickListener) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
if (method.name == "onClick") {
Log.d("ClickHandler", "Click event intercepted.")
// 在事件处理前可以执行自定义逻辑
}
return method.invoke(target, *args.orEmpty())
}
}

// 使用示例
val originalClickListener = View.OnClickListener {
// 原始的点击事件处理逻辑
}

val proxyClickListener = Proxy.newProxyInstance(
originalClickListener::class.java.classLoader,
originalClickListener::
class.java.interfaces,
ClickHandlerProxy
(originalClickListener)
) as View.OnClickListener

button.setOnClickListener(proxyClickListener)

通过这种方式,你可以在原始的点击事件处理逻辑前后执行自定义逻辑,而无需修改原始的OnClickListener实现。


结论


动态代理是Android开发中强大的工具之一,它允许你在不修改原始对象的情况下添加额外的行为。在性能监控、AOP和事件处理等方面,动态代理都有广泛的应用。通过深入理解动态代理的原理和用途,你可以更好地设计和维护Android应用程序。




作者:午后一小憩
来源:juejin.cn/post/7275185537815183360
收起阅读 »

错过Android主线程空闲期,你可能损失的不仅仅是性能

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于An...
继续阅读 »

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于Android主线程的空闲状态,使得开发者能够巧妙地利用这些空闲时间执行一些耗时的操作,而不影响用户界面的流畅性。


在深入研究IdleHandler之前,让我们先了解一下它的基本原理,以及为何它成为Android性能优化的重要组成部分。


IdleHandler的基本原理


Android应用的主线程通过一个消息循环(Message Loop)来处理各种事件和任务。当主线程没有新的消息需要处理时,它就处于空闲状态。这就是IdleHandler发挥作用的时机。


通过注册IdleHandler来告诉系统在主线程空闲时执行特定的任务。当主线程进入空闲状态时,系统会依次调用注册的IdleHandler,执行相应的任务。


IdleHandler与Handler和MessageQueue密切相关。它通过MessageQueue的空闲时间来执行任务。每当主线程处理完一个消息后,系统会检查是否有注册的IdleHandler需要执行。


空闲状态的定义


了解什么时候主线程被认为是空闲的至关重要。一般情况下,Android系统认为主线程在处理完所有消息后即处于空闲状态。IdleHandler通过这个定义,能够在保证不影响用户体验的前提下执行一些耗时的操作。


	// 没有消息,判断是否有IdleHandler
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked
= true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);

....

// 执行IdleHandler
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

如何使用IdleHandler


使用IdleHandler可以执行一些轻量级的任务,例如加载数据、更新UI等。以下是使用IdleHandler的几个使用技巧:



  1. 注册IdleHandler:


Looper.myQueue().addIdleHandler(MyIdleHandler())

class MyIdleHandler : MessageQueue.IdleHandler {
override fun queueIdle(): Boolean {
// 在主线程空闲时执行的任务逻辑
performIdleTask()
// 返回 false,表示任务处理完毕,不再执行
return false
}

private fun performIdleTask() {
// 具体的任务逻辑
// ...
}
}


  1. 取消注册


当不需要继续执行任务时,可以通过removeIdleHandler方法取消注册


Looper.myQueue().removeIdleHandler(idleHandler);

IdleHandler的适用场景



  • 轻量级任务:IdleHandler主要用于执行轻量级的任务。由于它是在主线程空闲时执行,所以不适合执行耗时的任务。

  • 主线程空闲时执行:IdleHandler通过在主线程空闲时被调用,避免了主线程的阻塞。因此,适用于需要在主线程执行的任务,并且这些任务对于用户体验的影响较小。

  • 优先级较低的任务:如果有多个任务注册了IdleHandler,系统会按照注册的顺序调用它们的queueIdle方法。因此,适用于需要在较低优先级下执行的任务。


总的来说IdleHandler适用于需要在主线程空闲时执行的轻量级任务,以提升应用的性能和用户体验。


高级应用



  1. 性能监控与优化
    利用 IdleHandler 可以实现性能监控和优化,例如统计每次空闲时的内存占用情况,或者执行一些内存释放操作。

  2. 预加载数据
    在用户操作前,通过 IdleHandler 提前加载一些可能会用到的数据,提高用户体验。

  3. 动态资源加载
    利用空闲时间预加载和解析资源,减轻在用户操作时的资源加载压力。


性能优化技巧


虽然IdleHandler提供了一个方便的机制来在主线程空闲时执行任务,但在使用过程中仍需注意一些性能方面的问题。



  1. 任务的轻量级处理: 确保注册的IdleHandler中的任务是轻量级的,不要在空闲时执行过于复杂或耗时的操作,以免影响主线程的响应性能。

  2. **避免频繁注册和取消IdleHandler: **频繁注册和取消IdleHandler可能会引起性能问题,因此建议在应用的生命周期内尽量减少注册和取消的操作。可以在应用启动时注册IdleHandler,在应用退出时取消注册。

  3. **合理设置任务执行频率: **根据任务的性质和执行需求,合理设置任务的执行频率。不同的任务可能需要在不同的时间间隔内执行,这样可以更好地平衡性能和功能需求。


结语


通过深度解析 IdleHandler 的原理和高级应用,让我们更好地利用这一工具进行性能优化。在实际项目中,灵活运用 IdleHandler 可以有效提升应用的响应速度和用户体验。希望本文能够激发大家对于Android性能优化的更多思考和实践。




作者:午后一小憩
来源:juejin.cn/post/7307471896693522471
收起阅读 »

Android:优雅的处理首页弹框逻辑:责任链模式

背景 随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。 并且弹框显示还有要求,比如: 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗...
继续阅读 »

背景


随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。
并且弹框显示还有要求,比如:



  • 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框

  • 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗高,所以希望优先级高的优先显示

  • 广告弹框只展示一次

  • 等等


如何优雅的处理这个逻辑呢?请出我们的主角:责任链模式。


责任链模式


举个栗子🌰


一位男性在结婚之前有事要和父母请示,结婚之后要请示妻子,老了之后就要和孩子们商量。作为决策者的父母、妻子或孩子,只有两种选择:要不承担起责任来,允许或不允许相应的请求; 要不就让他请示下一个人,下面来看如何通过程序来实现整个流程。


先看一下类图:
未命名文件.png
类图非常简单,IHandler上三个决策对象的接口。


//决策对象的接口
public interface IHandler {
//处理请求
void HandleMessage(IMan man);
}

//决策对象:父母
public class Parent implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("孩子向父母的请求是:" + man.getRequest());
System.out.println("父母的回答是:同意");
}
}

//决策对象:妻子
public class Wife implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("丈夫向妻子的请求是:" + man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

//决策对象:孩子
public class Children implements IHandler{
@Override
public void HandleMessage(IMan man) {
System.out.println("父亲向孩子的请求是:" + man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

IMan上男性的接口:


public interface IMan {
int getType(); //获取个人状况
String getRequest(); //获取个人请示(这里就简单的用String)
}

//具体男性对象
public class Man implements IMan {
/**
* 通过一个int类型去描述男性的个人状况
* 0--幼年
* 1--成年
* 2--年迈
*/

private int mType = 0;
//请求
private String mRequest = "";

public Man(int type, String request) {
this.mType = type;
this.mRequest = request;
}

@Override
public int getType() {
return mType;
}

@Override
public String getRequest() {
return mRequest;
}
}

最后我们看下一下场景类:


public class Client {
public static void main(String[] args) {
//随机生成几个man
Random random = new Random();
ArrayList<IMan> manList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
manList.add(new Man(random.nextInt(3), "5块零花钱"));
}
//定义三个请示对象
IHandler parent = new Parent();
IHandler wife = new Wife();
IHandler children = new Children();
//处理请求
for (IMan man: manList) {
switch (man.getType()) {
case 0:
System.out.println("--------孩子向父母发起请求-------");
parent.HandleMessage(man);
break;
case 1:
System.out.println("--------丈夫向妻子发起请求-------");
wife.HandleMessage(man);
break;
case 2:
System.out.println("--------父亲向孩子发起请求-------");
children.HandleMessage(man);
break;
default:
break;
}
}
}
}

首先是通过随机方法产生了5个男性的对象,然后看他们是如何就要5块零花钱这件事去请示的,运行结果如下所示:


--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------父亲向孩子发起请求-------
父亲向孩子的请求是:5块零花钱
孩子的回答是:同意
--------孩子向父母发起请求-------
孩子向父母的请求是:5块零花钱
父母的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意


发没发现上述的代码是不是有点不舒服,有点别扭,有点想重构它的感觉?那就对了!这段代码有以下几个问题:



  • 职责界定不清晰



对孩子提出的请示,应该在父母类中做出决定,父母有责任、有义务处理孩子的请示,



因此Parent类应该是知道孩子的请求自己处理,而不是在Client类中进行组装出来,
也就是说 原本应该是父亲这个类做的事情抛给了其他类进行处理,不应该是这样的。



  • 代码臃肿



我们在Client类中写了if...else的判断条件,而且能随着能处理该类型的请示人员越多,
if...else的判断就越多,想想看,臃肿的条件判断还怎么有可读性?!




  • 耦合过重



这是什么意思呢,我们要根据Man的type来决定使用IHandler的那个实现类来处理请



求。有一个问题是:如果IHandler的实现类继续扩展怎么办?修改Client类?
与开闭原则违背了!【开闭原则:软件实体如类,模块和函数应该对扩展开放,对修改关闭】
http://www.jianshu.com/p/05196fac1…



  • 异常情况欠考虑



丈夫只能向妻子请示吗?丈夫向自己的父母请示了,父母应该做何处理?
我们的程序上可没有体现出来,逻辑失败了!


既然有这么多的问题,那我们要想办法来解决这些问题,我们先来分析一下需求,男性提出一个请示,必然要获得一个答复,甭管是同意还是不同意,总之是要一个答复的,而且这个答复是唯一的,不能说是父母作出一个决断,而妻子也作出了一个决断,也即是请示传递出去,必然有一个唯一的处理人给出唯一的答复,OK,分析完毕,收工,重新设计,我们可以抽象成这样一个结构,男性的请求先发送到父亲,父母一看是自己要处理的,就作出回应处理,如果男性已经结婚了,那就要把这个请求转发到妻子来处理,如果男性已经年迈,那就由孩子来处理这个请求,类似于如图所示的顺序处理图。
未命名文件 (1).png
父母、妻子、孩子每个节点有两个选择:要么承担责任,做出回应;要么把请求转发到后序环节。结构分析得已经很清楚了,那我们看怎么来实现这个功能,类图重新修正,如图 :
未命名文件 (2).png
从类图上看,三个实现类Parent、Wife、Children只要实现构造函数和父类中的抽象方法 response就可以了,具体由谁处理男性提出的请求,都已经转移到了Handler抽象类中,我们 来看Handler怎么实现,


public abstract class Handler {
//处理级别
public static final int PARENT_LEVEL_REQUEST = 0; //父母级别
public static final int WIFE_LEVEL_REQUEST = 1; //妻子级别
public static final int CHILDREN_LEVEL_REQUEST = 2;//孩子级别

private Handler mNextHandler;//下一个责任人

protected abstract int getHandleLevel();//具体责任人的处理级别

protected abstract void response(IMan man);//具体责任人给出的回应

public final void HandleMessage(IMan man) {
if (man.getType() == getHandleLevel()) {
response(man);//当前责任人可以处理
} else {
//当前责任人不能处理,如果有后续处理人,将请求往后传递
if (mNextHandler != null) {
mNextHandler.HandleMessage(man);
} else {
System.out.println("-----没有人可以请示了,不同意该请求-----");
}
}
}

public void setNext(Handler next) {
this.mNextHandler = next;
}
}

再看一下具体责任人的实现:Parent、Wife、Children


public class Parent extends Handler{

@Override
protected int getHandleLevel() {
return Handler.PARENT_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------孩子向父母提出请示----------");
System.out.println(man.getRequest());
System.out.println("父母的回答是:同意");
}
}

public class Wife extends Handler{
@Override
protected int getHandleLevel() {
return Handler.WIFE_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------丈夫向妻子提出请示----------");
System.out.println(man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

public class Children extends Handler{
@Override
protected int getHandleLevel() {
return Handler.CHILDREN_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------父亲向孩子提出请示----------");
System.out.println(man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

那么再看一下场景复现:
在Client中设置请求的传递顺序,先向父母请示,不是父母应该解决的问题,则由父母传递到妻子类解决,若不是妻子类解决的问题则传递到孩子类解决,最终的结果必然有一个返回,其运行结果如下所示。


----------孩子向父母提出请示----------
15块零花钱
父母的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意

结果也正确,业务调用类Client也不用去做判断到底是需要谁去处理,而且Handler抽象类的子类可以继续增加下去,只需要扩展传递链而已,调用类可以不用了解变化过程,甚至是谁在处理这个请求都不用知道。在这种模式就是责任链模式


定义


Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request.Chain the receiving objects and pass the request along the chain until an object handles it.
(使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。) 责任链模式的重点是在“链”上,由一条链去处理相似的请求在链中决定谁来处理这个请 求,并返回相应的结果,其通用类图如图所示
未命名文件 (3).png
最后总结一下,责任链的模版:
包含四个对象,Handler,Request,Level,Response:


public class Request {
//请求的等级
public Level getRequestLevel(){
return null;
}
}

public class Level {
//请求级别
}


public class Response {
//处理者返回的数据
}

//抽象处理者
public abstract class Handler {
private Handler mNextHandler;

//每个处理者都必须对请求做出处理
public final Response handleMessage(Request request) {
Response response = null;
if (getHandlerLevel().equals(request.getRequestLevel())) {
//是自己处理的级别,自己处理
response = echo(request);
} else {
//不是自己处理的级别,交给下一个处理者
if (mNextHandler != null) {
response = mNextHandler.echo(request);
} else {
//没有处理者能处理,业务自行处理
}
}
return response;
}

public void setNext(Handler next) {
this.mNextHandler = next;
}

@NotNull
protected abstract Level getHandlerLevel();

protected abstract Response echo(Request request);
}

实际应用


我们回到开篇的问题:如何设计弹框的责任链?


//抽象处理者
abstract class AbsDialog(private val context: Context) {
private var nextDialog: AbsDialog? = null

//优先级
abstract fun getPriority(): Int

//是否需要展示
abstract fun needShownDialog(): Boolean

fun setNextDialog(dialog: AbsDialog?) {
nextDialog = dialog
}

open fun showDialog() {
//这里的逻辑,我们就简单点,具体逻辑根据业务而定
if (needShownDialog()) {
show()
} else {
nextDialog?.showDialog()
}
}

protected abstract fun show()

// Sp存储, 记录是否已经展示过
open fun needShow(key: String): Boolean {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(key, true)
}

open fun setShown(key: String, show: Boolean) {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(key, !show).apply()
}

companion object {
const val LOG_TAG = "Dialog"
const val SP_NAME = "dialog"
const val POLICY_DIALOG_KEY = "policy_dialog"
const val AD_DIALOG_KEY = "ad_dialog"
const val PRAISE_DIALOG_KEY = "praise_dialog"
}
}

/**
* 模拟 隐私政策弹窗
* */

class PolicyDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 0

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如接口控制等等
// 这里通过Sp存储来模拟
return needShow(POLICY_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示隐私政策弹窗")
setShown(POLICY_DIALOG_KEY, true) //记录已经显示过
}
}

/**
* 模拟 广告弹窗
* */

class AdDialog(private val context: Context) : AbsDialog(context) {
private val ad = DialogData(1, "XX广告弹窗") // 模拟广告数据

override fun getPriority(): Int = 1

override fun needShownDialog(): Boolean {
// 广告数据通过接口获取,广告id应该是唯一的,所以根据id保持sp
return needShow(AD_DIALOG_KEY + ad.id)
}

override fun show() {
Log.d(LOG_TAG, "显示广告弹窗:${ad.name}")
setShown(AD_DIALOG_KEY + ad.id, true)
}
}

/**
* 模拟 好评弹窗
* */

class PraiseDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 2

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如用户使用7天等
// 这里通过Sp存储来模拟
return needShow(PRAISE_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示好评弹窗")
setShown(PRAISE_DIALOG_KEY, true)
}
}

//模拟打开app
val dialogs = mutableListOf<AbsDialog>()
dialogs.add(PolicyDialog(this))
dialogs.add(PraiseDialog(this))
dialogs.add(AdDialog(this))
//根据优先级排序
dialogs.sortBy { it.getPriority() }
//创建链条
for (i in 0 until dialogs.size - 1) {
dialogs[i].setNextDialog(dialogs[i + 1])
}
dialogs[0].showDialog()

第一次打开
image.png


第二次打开
image.png


第三次打开
image.png


总结:



  • 优点


责任链模式非常显著的优点是将请求和处理分开。请求者可以不用知道是谁处理的,处理者可以不用知道请求的全貌,两者解耦,提高系统的灵活性。



  • 缺点


责任链有两个非常显著的缺点:一是性能问题,每个请求都是从链头遍历到链尾,特别是在链比较长的时候,性能是一个非常大的问题。二是调试不很方便,特别是链条比较长, 环节比较多的时候,由于采用了类似递归的方式,调试的时候逻辑可能比较复杂。



  • 注意事项


链中节点数量需要控制,避免出现超长链的情况,一般的做法是在Handler中设置一个最大节点数量,在setNext方法中判断是否已经是超过其阈值,超过则不允许该链建立,避免无意识地破坏系统性能。


作者:蹦蹦蹦
来源:juejin.cn/post/7278239421706633252
收起阅读 »

记一次安卓广播引起的ANR死锁问题

年初遇到个bug,设备安装应用宝之后打开使用一段时间(浏览和安装应用),大概率会卡死,然后整个系统重启(表现为卡死后过一段时间开机动画出现,重新进入系统)。非常容易复现。 看到这种软重启首先考虑ANR,分析抓到的log   到日志中查找watchdog关键词 ...
继续阅读 »

年初遇到个bug,设备安装应用宝之后打开使用一段时间(浏览和安装应用),大概率会卡死,然后整个系统重启(表现为卡死后过一段时间开机动画出现,重新进入系统)。非常容易复现。


看到这种软重启首先考虑ANR,分析抓到的log


  到日志中查找watchdog关键词
01-25 11:02:42.032 22774 22796 W Watchdog: *** WATCHDOG KILLING SYSTEM PROCESS: Blocked in handler on foreground thread (android.fg), Blocked in handler on main thread (main), Blocked in handler on ActivityManager (ActivityManager), Blocked in handler on PowerManagerService (PowerManagerService)
01-25 11:02:42.033 22774 22796 W Watchdog: android.fg annotated stack trace:
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.am.ActivityManagerService.bindServiceInstance(ActivityManagerService.java:12853)
01-25 11:02:42.037 22774 22796 W Watchdog: - waiting to lock <0x02af09e1> (a com.android.server.am.ActivityManagerService)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.am.ActivityManagerService.bindServiceInstance(ActivityManagerService.java:12810)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.app.ContextImpl.bindServiceCommon(ContextImpl.java:2035)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.app.ContextImpl.bindService(ContextImpl.java:1958)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.bindService(ServiceConnector.java:343)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.enqueueJobThread(ServiceConnector.java:462)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.lambda$enqueue$1$com-android-internal-infra-ServiceConnector$Impl(ServiceConnector.java:445)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl$$ExternalSyntheticLambda2.run(Unknown Source:4)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Handler.handleCallback(Handler.java:942)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:99)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.038 22774 22796 W Watchdog: main annotated stack trace:
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.alarm.AlarmManagerService$AlarmHandler.handleMessage(AlarmManagerService.java:4993)
01-25 11:02:42.038 22774 22796 W Watchdog: - waiting to lock <0x0edf48f2> (a java.lang.Object)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:106)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.SystemServer.run(SystemServer.java:968)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.SystemServer.main(SystemServer.java:653)
01-25 11:02:42.038 22774 22796 W Watchdog: at java.lang.reflect.Method.invoke(Native Method)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920)
01-25 11:02:42.038 22774 22796 W Watchdog: ActivityManager annotated stack trace:
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue.processNextBroadcast(BroadcastQueue.java:1154)
01-25 11:02:42.038 22774 22796 W Watchdog: - waiting to lock <0x02af09e1> (a com.android.server.am.ActivityManagerService)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue.-$$Nest$mprocessNextBroadcast(Unknown Source:0)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue$BroadcastHandler.handleMessage(BroadcastQueue.java:224)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:106)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.039 22774 22796 W Watchdog: PowerManagerService annotated stack trace:
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService.handleSandman(PowerManagerService.java:3257)
01-25 11:02:42.039 22774 22796 W Watchdog: - waiting to lock <0x0f550be5> (a java.lang.Object)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService.-$$Nest$mhandleSandman(Unknown Source:0)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService$PowerManagerHandlerCallback.handleMessage(PowerManagerService.java:5103)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:102)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.039 22774 22796 W Watchdog: *** GOODBYE!

可以很清楚的看到watchdog提示waiting to lock </xxxxx/> 关键词已经确定大概率是死锁问题了。
导出Anr日志继续进行分析,一般先从group="main"分析起


"main" prio=5 tid=1 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x71c515d0 self=0xb4000078c0a0abe0
| sysTid=2257 nice=-2 cgrp=foreground sched=0/0 handle=0x7a80db94f8
| state=S schedstat=( 67235248988 24733272110 200587 ) utm=3966 stm=2756 core=7 HZ=100
| stack=0x7fca926000-0x7fca928000 stackSize=8188KB
| held mutexes=
at com.android.server.am.ActivityManagerService.broadcastIntentWithFeature(ActivityManagerService.java:14615)
- waiting to lock <0x0185c3b1> (a com.android.server.am.ActivityManagerService) held by thread 16
at android.app.ActivityManager.broadcastStickyIntent(ActivityManager.java:4620)
at android.app.ActivityManager.broadcastStickyIntent(ActivityManager.java:4610)
at com.android.server.BatteryService.lambda$sendBatteryChangedIntentLocked$0(BatteryService.java:780)
at com.android.server.BatteryService.$r8$lambda$r64V5AVg_Okl7PnB1VjeN4oyo1I(unavailable:0)
at com.android.server.BatteryService$$ExternalSyntheticLambda5.run(unavailable:2)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at com.android.server.SystemServer.run(SystemServer.java:968)
at com.android.server.SystemServer.main(SystemServer.java:653)
at java.lang.reflect.Method.invoke(Native method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920)

main它在等待thread 16的lock <0x0185c3b1>,所以我们去tid=16再看看


"android.display" prio=5 tid=16 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x14340db8 self=0xb4000078c0a40a10
| sysTid=2349 nice=-3 cgrp=top-app sched=0/0 handle=0x76c8933cb0
| state=S schedstat=( 1805684706 1655206467 5034 ) utm=99 stm=81 core=7 HZ=100
| stack=0x76c8830000-0x76c8832000 stackSize=1039KB
| held mutexes=
at com.android.server.wm.ActivityTaskManagerService$LocalService.onProcessAdded(ActivityTaskManagerService.java:5740)
- waiting to lock <0x033a7977> (a com.android.server.wm.WindowManagerGlobalLock) held by thread 198
at com.android.server.am.ProcessList$MyProcessMap.put(ProcessList.java:699)
at com.android.server.am.ProcessList.addProcessNameLocked(ProcessList.java:2946)
- locked <0x09f229e4> (a com.android.server.am.ActivityManagerProcLock)
at com.android.server.am.ProcessList.newProcessRecordLocked(ProcessList.java:3039)
at com.android.server.am.ProcessList.startProcessLocked(ProcessList.java:2487)
at com.android.server.am.ActivityManagerService.startProcessLocked(ActivityManagerService.java:2854)
at com.android.server.am.ActivityManagerService$LocalService.startProcess(ActivityManagerService.java:17450)
- locked <0x0185c3b1> (a com.android.server.am.ActivityManagerService)
at com.android.server.wm.ActivityTaskManagerService$$ExternalSyntheticLambda11.accept(unavailable:27)
at com.android.internal.util.function.pooled.PooledLambdaImpl.doInvoke(PooledLambdaImpl.java:363)
at com.android.internal.util.function.pooled.PooledLambdaImpl.invoke(PooledLambdaImpl.java:204)
at com.android.internal.util.function.pooled.OmniFunction.run(OmniFunction.java:97)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.os.HandlerThread.run(HandlerThread.java:67)
at com.android.server.ServiceThread.run(ServiceThread.java:44)

可以看到waiting to lock <0x033a7977>,它在等待threa198的锁释放。因此再去tid=198看看


"binder:2257_1C" prio=5 tid=198 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x143443a0 self=0xb4000078c0bb49f0
| sysTid=6550 nice=-10 cgrp=foreground sched=0/0 handle=0x7638d60cb0
| state=S schedstat=( 12455767235 6173702511 27824 ) utm=695 stm=550 core=4 HZ=100
| stack=0x7638c69000-0x7638c6b000 stackSize=991KB
| held mutexes=
at com.android.server.display.DisplayManagerService.setDisplayPropertiesInternal(DisplayManagerService.java:2019)
- waiting to lock <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot) held by thread 130
at com.android.server.display.DisplayManagerService.-$$Nest$msetDisplayPropertiesInternal(unavailable:0)
at com.android.server.display.DisplayManagerService$LocalService.setDisplayProperties(DisplayManagerService.java:3811)
at com.android.server.wm.DisplayContent.applySurfaceChangesTransaction(DisplayContent.java:4878)
at com.android.server.wm.RootWindowContainer.applySurfaceChangesTransaction(RootWindowContainer.java:1022)
at com.android.server.wm.RootWindowContainer.performSurfacePlacementNoTrace(RootWindowContainer.java:824)
at com.android.server.wm.RootWindowContainer.performSurfacePlacement(RootWindowContainer.java:785)
at com.android.server.wm.WindowSurfacePlacer.performSurfacePlacementLoop(WindowSurfacePlacer.java:177)
at com.android.server.wm.WindowSurfacePlacer.performSurfacePlacement(WindowSurfacePlacer.java:126)
at com.android.server.wm.WindowManagerService.relayoutWindow(WindowManagerService.java:2501)
- locked <0x033a7977> (a com.android.server.wm.WindowManagerGlobalLock)
at com.android.server.wm.Session.relayout(Session.java:253)
at com.android.server.wm.Session.relayoutAsync(Session.java:267)
at android.view.IWindowSession$Stub.onTransact(IWindowSession.java:757)
at com.android.server.wm.Session.onTransact(Session.java:178)
at android.os.Binder.execTransactInternal(Binder.java:1285)
at android.os.Binder.execTransact(Binder.java:1244)

198在等待130来释放锁,waiting to lock <0x0faa6d02>,不要嫌麻烦再跟去130


"binder:2257_4" prio=5 tid=130 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x15288e48 self=0xb4000078c0b32400
| sysTid=2541 nice=0 cgrp=foreground sched=0/0 handle=0x76489a8cb0
| state=S schedstat=( 8323462229 6484203506 25835 ) utm=482 stm=349 core=7 HZ=100
| stack=0x76488b1000-0x76488b3000 stackSize=991KB
| held mutexes=
at com.android.server.am.ActivityManagerService.registerReceiverWithFeature(ActivityManagerService.java:13285)
- waiting to lock <0x0185c3b1> (a com.android.server.am.ActivityManagerService) held by thread 16
at android.app.ContextImpl.registerReceiverInternal(ContextImpl.java:1816)
at android.app.ContextImpl.registerReceiver(ContextImpl.java:1750)
at android.app.ContextImpl.registerReceiver(ContextImpl.java:1738)
at com.android.server.display.DisplayPowerController.<init>(DisplayPowerController.java:674)
at com.android.server.display.DisplayManagerService.addDisplayPowerControllerLocked(DisplayManagerService.java:2710)
at com.android.server.display.DisplayManagerService.handleLogicalDisplayAddedLocked(DisplayManagerService.java:1561)
at com.android.server.display.DisplayManagerService.-$$Nest$mhandleLogicalDisplayAddedLocked(unavailable:0)
at com.android.server.display.DisplayManagerService$LogicalDisplayListener.onLogicalDisplayEventLocked(DisplayManagerService.java:2811)
at com.android.server.display.LogicalDisplayMapper.sendUpdatesForDisplaysLocked(LogicalDisplayMapper.java:759)
at com.android.server.display.LogicalDisplayMapper.updateLogicalDisplaysLocked(LogicalDisplayMapper.java:733)
at com.android.server.display.LogicalDisplayMapper.handleDisplayDeviceAddedLocked(LogicalDisplayMapper.java:580)
at com.android.server.display.LogicalDisplayMapper.onDisplayDeviceEventLocked(LogicalDisplayMapper.java:201)
at com.android.server.display.DisplayDeviceRepository.sendEventLocked(DisplayDeviceRepository.java:214)
at com.android.server.display.DisplayDeviceRepository.handleDisplayDeviceAdded(DisplayDeviceRepository.java:158)
- locked <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot)
at com.android.server.display.DisplayDeviceRepository.onDisplayDeviceEvent(DisplayDeviceRepository.java:87)
at com.android.server.display.DisplayManagerService.createVirtualDisplayLocked(DisplayManagerService.java:1418)
at com.android.server.display.DisplayManagerService.createVirtualDisplayInternal(DisplayManagerService.java:1381)
- locked <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot)
at com.android.server.display.DisplayManagerService.-$$Nest$mcreateVirtualDisplayInternal(unavailable:0)
at com.android.server.display.DisplayManagerService$BinderService.createVirtualDisplay(DisplayManagerService.java:3180)
at android.hardware.display.IDisplayManager$Stub.onTransact(IDisplayManager.java:659)
at android.os.Binder.execTransactInternal(Binder.java:1280)
at android.os.Binder.execTransact(Binder.java:1244)

130在等待16释放锁,这下子就明朗了,画个它们之间的关系图,这样就很明朗了他们三个之间死锁引起main主线程等待超时。


graph TD
binder2257_4 --> binder2257_1c --> android.display --> binder2257_4 & main主线程

前面这里只是分析出问题原因。下面来说问题为什么会发生。从main主线程开始追到binder2257_4然后开始循环。合理怀疑问题是先从binder2257_4出现的。都是binder是因为他们之间是用binder通信的。
看到binder2257_4这个anr日志中出现


at com.android.server.display.DisplayPowerController.<init>(DisplayPowerController.java:674)

竟然是在displaypowercontroller init中出现的,去到这个文件的674行,发现这里在注册广播接收器。


image.png


尝试退回这条提交之后果然问题没有复现了,分析这条提交,它是想在displaypowercontroller中注册一个广播接收器。用于接收挂电话的时候发出的广播。当收到这条广播之后就会通知displaypowercontroller亮屏。其实就是实现了一个通话时对方挂断电话之后自动亮屏。它是在创建阶段进行的注册。要分析为什么这个会导致死锁。


作者:用户8081391597591
来源:juejin.cn/post/7353158088730165259
收起阅读 »

Android使用Hilt依赖注入,让人看不懂你代码

前言 之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt...
继续阅读 »

前言


之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt不了解或是想了解得更多,那么接下来的内容将助力你玩转Hilt。


通过本篇文章,你将了解到:




  1. 什么是依赖注入?

  2. Hilt 的引入与基本使用

  3. Hilt 的进阶使用

  4. Hilt 原理简单分析

  5. Android到底该不该使用DI框架?



1. 什么是依赖注入?


什么是依赖?


以手机为例,要组装一台手机,我们需要哪些部件呢?

从宏观上分类:软件+硬件。

由此我们可以说:手机依赖了软件和硬件。

而反映到代码的世界:


class FishPhone(){
val software = Software()
val hardware = Hardware()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
//软件
class Software() {
fun handle(){}
}
//硬件
class Hardware() {
fun handle(){}
}

FishPhone 依赖了两个对象:分别是Software和Hardware。

Software和Hardware是FishPhone的依赖(项)。


什么是注入?


上面的Demo,FishPhone内部自主构造了依赖项的实例,考虑到依赖的变化挺大的,每次依赖项的改变都要改动到FishPhone,容易出错,也不是那么灵活,因此考虑从外部将依赖传进来,这种方式称之为:依赖注入(Dependency Injection 简称DI)

有几种方式:




  1. 构造函数传入

  2. SetXX函数传入

  3. 从其它对象间接获取



构造函数依赖注入:


class FishPhone(val software: Software, val hardware: Hardware){
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone的功能比较纯粹就是打电话功能,而依赖项都是外部传入提升了灵活性。


为什么需要依赖注入框架?


手机制造出来后交给客户使用。


class Customer() {
fun usePhone() {
val software = Software()
val hardware = Hardware()
FishPhone(software, hardware).call()
}
}

用户想使用手机打电话,还得自己创建软件和硬件,这个手机还能卖出去吗?

而不想创建软件和硬件那得让FishPhone自己负责去创建,那不是又回到上面的场景了吗?


你可能会说:FishPhone内部就依赖了两个对象而已,自己负责创建又怎么了?


解耦


再看看如下Demo:


interface ISoftware {
fun handle()
}

//硬件
interface IHardware {
fun handle()
}

//软件
class SoftwareImpl() : ISoftware {
override fun handle() {}
}

//硬件
class HardwareImpl : IHardware {
override fun handle() {}
}

class FishPhone() {
val software: ISoftware = SoftwareImpl()
val hardware: IHardware = HardwareImpl()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone 只关注软件和硬件的接口,至于具体怎么实现它不关心,这就达到了解耦的目的。
既然要解耦,那么SoftwareImpl()、HardwareImpl()就不能出现在FishPhone里。

应该改为如下形式:


class FishPhone(val software: ISoftware, val hardware: IHardware) {
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

消除模板代码


即使我们不考虑解耦,假若HardwareImpl里又依赖了cpu、gpu、disk等模块:


//硬件
class HardwareImpl : IHardware {
val cpu = CPU(Regisgter(), Cal(), Bus())
val gpu = GPU(Image(), Video())
val disk = Disk(Block(), Flash())
//...其它模块
override fun handle() {}
}

现在仅仅只是三个模块,若是依赖更多的模块或者模块的本身也需要依赖其它子模块,比如CPU需要依赖寄存器、运算单元等等,那么我们就需要写更多的模板代码,要是我们只需要声明一下想要使用的对象而不用管它的创建就好了。


class HardwareImpl(val cpu: CPU, val gpu: GPU, val disk: Disk) : IHardware {
override fun handle() {}
}

可以看出,下面的代码比上面的简洁多了。




  1. 从解耦和消除模板代码的角度看,我们迫切需要一个能够自动创建依赖对象并且将依赖注入到目标代码的框架,这就是依赖注入框架

  2. 依赖注入框架能够管理依赖对象的创建,依赖对象的注入,依赖对象的生命周期

  3. 使用者仅仅只需要表明自己需要什么类型的对象,剩下的无需关心,都由框架自动完成



先想想若是我们想要实现这样的框架需要怎么做呢?

相信很多小伙伴最朴素的想法就是:使用工厂模式,你传参告诉我想要什么对象我给你构造出来。

这个想法是半自动注入,因为我们还要调用工厂方法去获取,而全自动的注入通常来说是使用注解标注实现的。


2. Hilt 的引入与基本使用


Hilt的引入


从Dagger到Dagger2再到Hilt(Android专用),配置越来越简单也比较容易上手。

前面说了依赖注入框架的必要性,我们就想迫不及待的上手,但难度可想而知,还好大神们早就造好了轮子。

以AGP 7.0 以上为例,来看看Hilt框架是如何引入的。


一:project级别的build.gradle 引入如下代码:


plugins {
//指定插件地址和版本
id 'com.google.dagger.hilt.android' version '2.48.1' apply false
}

二:module级别的build.gradle引入如下代码:


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
//使用插件
id 'com.google.dagger.hilt.android'
//kapt生成代码
id 'kotlin-kapt'
}
//引入库
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'

实时更新最新版本以及AGP7.0以下的引用请参考:Hilt最新版本配置


Hilt的简单使用


前置步骤整好了接下来看看如何使用。


一:表明该App可以使用Hilt来进行依赖注入,添加如下代码:


@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}

@HiltAndroidApp 添加到App的入口,即表示依赖注入的环境已经搭建好。


二:注入一个对象到MyApp里:

有个类定义如下:


class Software {
val name = "fish"
}

我们不想显示的构造它,想借助Hilt注入它,那得先告诉Hilt这个类你帮我注入一下,改为如下代码:


class Software @Inject constructor() {
val name = "fish"
}

在构造函数前添加了@Inject注解,表示该类可以被注入。

而在MyApp里使用Software对象:


@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: Software

override fun onCreate() {
super.onCreate()
println("inject result:${software.name}")
}
}

对引用的对象使用@Inject注解,表示期望Hilt帮我将这个对象new出来。

最后查看打印输出正确,说明Software对象被创建了。


这是最简单的Hilt应用,可以看出:




  1. 我们并没有显式地创建Software对象,而Hilt在适当的时候就帮我们创建好了

  2. @HiltAndroidApp 只用于修饰Application



如何注入接口?


一:错误示范
上面提到过,使用DI的好处之一就是解耦,而我们上面注入的是类,现在我们将Software抽象为接口,很容易就会想到如下写法:


interface ISoftware {
fun printName()
}

class SoftwareImpl @Inject constructor(): ISoftware{
override fun printName() {
println("name is fish")
}
}

@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: ISoftware

override fun onCreate() {
super.onCreate()
println("inject result:${software.printName()}")
}
}

不幸的是上述代码编译失败,Hilt提示说不能对接口使用注解,因为我们并没有告诉Hilt是谁实现了ISoftware,而接口本身不能直接实例化,因此我们需要为它指定具体的实现类。


二:正确示范

再定义一个类如下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftware(impl: SoftwareImpl):ISoftware
}



  1. @Module 表示该类是一个Hilt的Module,固定写法

  2. @InstallIn 表示模块在哪个组件生命周期内生效,SingletonComponent::class指的是全局

  3. 一个抽象类,类名随意

  4. 抽象方法,方法名随意,返回值是需要被注入的对象类型(接口),而参数是该接口的实现类,使用@Binds注解标记,



如此一来我们就告诉了Hilt,SoftwareImpl是ISoftware的实现类,于是Hilt注入ISoftware对象的时候就知道使用SoftwareImpl进行实例化。
其它不变运行一下:
image.png


可以看出,实际注入的是SoftwareImpl。



@Binds 适用在我们能够修改类的构造函数的场景



如何注入第三方类


上面的SoftwareImpl是我们可以修改的,因为使用了@Inject修饰其构造函数,所以可以在其它地方注入它。

在一些时候我们不想使用@Inject修饰或者说这个类我们不能修改,那该如何注入它们呢?


一:定义Provides模块


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():Hardware {
return Hardware()
}
}



  1. @Module和@InstallIn 注解是必须的

  2. 定义object类

  3. 定义函数,方法名随意,返回类型为我们需要注入的类型

  4. 函数体里通过构造或是其它方式创建具体实例

  5. 使用@Provides注解函数



二:依赖使用

而Hardware定义如下:


class Hardware {
fun printName() {
println("I'm fish")
}
}

在MyApp里引用Hardware:

在这里插入图片描述


虽然Hardware构造函数没有使用@Inject注解,但是我们依然能够使用依赖注入。


当然我们也可以注入接口:


interface IHardware {
fun printName()
}

class HardwareImpl : IHardware {
override fun printName() {
println("name is fish")
}
}

想要注入IHardware接口,需要定义provides模块:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}


@Provides适用于无法修改类的构造函数的场景,多用于注入第三方的对象



3. Hilt 的进阶使用


限定符


上述 ISoftware的实现类只有一个,假设现在有两个实现类呢?

比如说这些软件可以是美国提供,也可以是中国提供的,依据上面的经验我们很容易写出如下代码:


class SoftwareChina @Inject constructor() : ISoftware {
override fun printName() {
println("from china")
}
}

class SoftwareUS @Inject constructor() : ISoftware {
override fun printName() {
println("from US")
}
}

@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

//依赖注入:
@Inject
lateinit var software: ISoftware

兴高采烈的进行编译,然而却报错:
image.png


也就是说Hilt想要注入ISoftware,但不知道选择哪个实现类,SoftwareChina还是SoftwareUS?没人告诉它,所以它迷茫了,索性都绑定了。


这个时候我们需要借助注解:@Qualifier 限定符注解来对实现类进行限制。

改造一下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
@China
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
@US
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class US

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class China

定义新的注解类,使用@Qualifier修饰。

而后在Module里,分别使用注解类修饰返回的函数,如bindSoftwareCh函数指定返回SoftwareChina来实现ISoftware接口。


最后在引用依赖注入的地方分别使用@China @US修饰。


    @Inject
@US
lateinit var software1: ISoftware

@Inject
@China
lateinit var software2: ISoftware

此时,虽然software1、software2都是ISoftware类型,但是由于我们指定了限定符@US、@China,因此最后真正的实现类分别是SoftwareChina、SoftwareUS。



@Qualifier 主要用在接口有多个实现类(抽象类有多个子类)的注入场景



预定义限定符


上面提及的限定符我们还可以扩展其使用方式。

你可能发现了,上述提及的可注入的类构造函数都是无参的,很多时候我们的构造函数是需要有参数的,比如:


class Software @Inject constructor(val context: Context) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
//注入
@Inject
lateinit var software: Software

这个时候编译会报错:

image.png
意思是Software依赖的Context没有进行注入,因此我们需要给它注入一个Context。


由上面的分析可知,Context类不是我们可以修改的,只能通过@Provides方式提供其注入实例,并且Context有很多子类,我们需要使用@Qualifier指定具体实现类,因此很容易我们就想到如下对策。

先定义Module:


@Module
@InstallIn(SingletonComponent::class)
object MyContextModule {
@Provides
@GlobalContext
fun provideContext(): Context? {
return MyApp.myapp
}
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GlobalContext

再注入Context:


class Software @Inject constructor(@GlobalContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

可以看出,借助@Provides和@Qualifier,可以实现全局的Context。

当然了,实际上我们无需如此麻烦,因为这部分工作Hilt已经预先帮我们弄了。

与我们提供的限定符注解GlobalContext类似,Hilt预先提供了:


@Qualifier
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ApplicationContext {}

因此我们只需要在需要的地方引用它即可:


class Software @Inject constructor(@ApplicationContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

如此一来我们无需重新定义Module。




  1. 除了提供Application级别的上下文:@ApplicationContext,Hilt还提供了Activity级别的上下文:@ActivityContext,因为是Hilt内置的限定符,因此称为预定义限定符。

  2. 如果想自己提供限定符,可以参照GlobalContext的做法。



组件作用域和生命周期


Hilt支持的注入点(类)


以上的demo都是在MyApp里进行依赖,MyApp里使用了注解:@HiltAndroidApp 修饰,表示当前App支持Hilt依赖,Application就是它支持的一个注入点,现在想要在Activity里使用Hilt呢?


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {

除了Application和Activity,Hilt内置支持的注入点如下:
image.png


除了Application和ViewModel,其它注入点都是通过使用@AndroidEntryPoint修饰。



注入点其实就是依赖注入开始的点,比如Activity里需要注入A依赖,A里又需要注入B依赖,B里又需要注入C依赖,从Activity开始我们就能构建所有的依赖



Hilt组件的生命周期


什么是组件?在Dagger时代我们需要自己写组件,而在Hilt里组件都是自动生成的,无需我们干预。
依赖注入的本质实际上就是在某个地方悄咪咪地创建对象,这个地方的就是组件,Hilt专为Android打造,因此势必适配了Android的特性,比如生命周期这个Android里的重中之重。

因此Hilt的组件有两个主要功能:




  1. 创建、注入依赖的对象

  2. 管理对象的生命周期



Hilt组件如下:
image.png


可以看出,这些组件的创建和销毁深度绑定了Android常见的生命周期。

你可能会说:上面貌似没用到组件相关的东西,看了这么久也没看懂啊。

继续看个例子:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@InstallIn(SingletonComponent::class) 表示把模块安装到SingletonComponent组件里, SingletonComponent组件顾名思义是全局的,对应的是Application级别。因此安装的这个模块可在整个App里使用。


问题来了:SingletonComponent是不是表示@Provides修饰的函数返回的实例是同一个?

答案是否定的。


这就涉及到组件的作用域。


组件的作用域


想要上一小结的代码提供全局唯一实例,则可用组件作用域注解修饰函数:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@Singleton
fun provideHardware():IHardware {
return HardwareImpl()
}
}

当我们在任何地方注入IHardware时,获取到的都是同一个实例。

除了@Singleton表示组件的作用域,还有其它对应组件的作用域:

image.png


简单解释作用域:

@Singleton 被它修饰的构造函数或是函数,返回的始终是同一个实例

@ActivityRetainedScoped 被它修饰的构造函数或是函数,在Activity的重建前后返回同一实例

@ActivityScoped 被它修饰的构造函数或是函数,在同一个Activity对象里,返回的都是同一实例

@ViewModelScoped 被它修饰的构造函数或是函数,与ViewModel规则一致




  1. Hilt默认不绑定任何作用域,由此带来的结果是每一次注入都是全新的对象

  2. 组件的作用域要么不指定,要指定那必须和组件的生命周期一致



以下几种写法都不符合第二种限制:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityComponent::class)
object HardwareModule {
@Provides
@Singleton//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityRetainedComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

除了修饰Module,作用域还可以用于修饰构造函数:


@ActivityScoped
class Hardware @Inject constructor(){
fun printName() {
println("I'm fish")
}
}

@ActivityScoped表示不管注入几个Hardware,在同一个Activity里注入的实例都是一致的。


构造函数里无法注入的字段


一个类的构造函数如果被@Inject注入,那么构造函数的其它参数都需要支持注入。


class Hardware @Inject constructor(val context: Context) {
fun printName() {
println("I'm fish")
}
}

以上代码是无法编译通过的,因为Context不支持注入,而通过上面的分析可知,我们可以使用限定符:


class Hardware @Inject constructor(@ApplicationContext val context: Context) {
fun printName() {
println("I'm fish")
}
}

这就可以成功注入了。


再看看此种场景:


class Hardware @Inject constructor(
@ApplicationContext val context: Context,
val version: String,
) {
fun printName() {
println("I'm fish")
}
}

很显然String不支持注入,当然我们可以向@ApplicationContext 一样也给String提供一个@Provides和@Qualifier注解,但可想而知很麻烦,关键是String是动态变化的,我们确实需要Hardware构造的时候传入合适的String。


由此引入新的写法:辅助注入


class Hardware @AssistedInject constructor(
@ApplicationContext val context: Context,
@Assisted
val version: String,
) {

//辅助工厂类
@AssistedFactory
interface Factory{
//不支持注入的参数都可以放这,返回值为待注入的类型
fun create(version: String):Hardware
}

fun printName() {
println("I'm fish")
}
}

在引用注入的地方不能直接使用Hardware,而是需要通过辅助工厂进行创建:


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {
private lateinit var binding: ActivitySecondBinding
@Inject
lateinit var hardwareFactory : Hardware.Factory

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySecondBinding.inflate(layoutInflater)
setContentView(binding.root)

val hardware = hardwareFactory.create("3.3.2")
println("${hardware.printName()}")
}
}

如此一来,通过辅助注入,我们还是可以使用Hilt,值得一提的是辅助注入不是Hilt独有,而是从Dagger继承来的功能。


自定义注入点


Hilt仅仅内置了常用的注入点:Application、Activity、Fragment、ViewModel等。

思考一种场景:小明同学写的模块都是需要注入:


class Hardware @Inject constructor(
val gpu: GPU,
val cpu: CPU,
) {
fun printName() {
println("I'm fish")
}
}

class GPU @Inject constructor(val videoStorage: VideoStorage){}

//显存
class VideoStorage @Inject constructor() {}

class CPU @Inject constructor(val register: Register) {}

//寄存器
class Register @Inject() constructor() {}

此时小刚需要引用Hardware,他有两种选择:




  1. 使用注入方式很容易就引用了Hardware,可惜的是他没有注入点,仅仅只是工具类。

  2. 不选注入方式,则需要构造Hardware实例,而Hardware依赖GPU和CPU,它们又分别依赖VideoStorage和Register,想要成功构造Hardware实例需要将其它的依赖实例都手动构造出来,可想而知很麻烦。



这个时候适合小刚的方案是:



自定义注入点



方案实施步骤:

一:定义入口点


@InstallIn(SingletonComponent::class)
interface HardwarePoint {
//该注入点负责返回Hardware实例
fun getHardware(): Hardware
}

二:通过入口点获取实例


class XiaoGangPhone {
fun getHardware(context: Context):Hardware {
val entryPoint = EntryPointAccessors.fromApplication(context, HardwarePoint::class.java)
return entryPoint.getHardware()
}
}

三:使用Hardware


        val hardware = XiaoGangPhone().getHardware(this)
println("${hardware.printName()}")

注入object类


定义了object类,但在注入的时候也需要,可以做如下处理:


object MySystem {
fun getSelf():MySystem {
return this
}
fun printName() {
println("I'm fish")
}
}

@Module
@InstallIn(SingletonComponent::class)
object MiddleModule {
@Provides
@Singleton
fun provideSystem():MySystem {
return MySystem.getSelf()
}
}
//使用注入
class Middleware @Inject constructor(
val mySystem:MySystem
) {
}

4. Hilt 原理简单分析


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {}

Hilt通过apt在编译时期生成代码:


public abstract class Hilt_SecondActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {

private boolean injected = false;

Hilt_SecondActivity() {
super();
//初始化注入监听
_initHiltInternal();
}

Hilt_SecondActivity(int contentLayoutId) {
super(contentLayoutId);
_initHiltInternal();
}

private void _initHiltInternal() {
addOnContextAvailableListener(new OnContextAvailableListener() {
@Override
public void onContextAvailable(Context context) {
//真正注入
inject();
}
});
}

protected void inject() {
if (!injected) {
injected = true;
//通过manager获取组件,再通过组件注入
((SecondActivity_GeneratedInjector) this.generatedComponent()).injectSecondActivity(UnsafeCasts.<SecondActivity>unsafeCast(this));
}
}
}

在编译期,SecondActivity的父类由AppCompatActivity变为Hilt_SecondActivity,因此当SecondActivity构造时就会调用父类的构造器监听create()的回调,回调调用时进行注入。



由此可见,Activity.onCreate()执行后,Hilt依赖注入的字段才会有值



真正注入的过程涉及到不少的类,都是自动生成的类,有兴趣可以对着源码查找流程,此处就不展开说了。


5. Android到底该不该使用DI框架?


有人说DI比较复杂,还不如我直接构造呢?

又有人说那是你项目不复杂,用不到,在后端流行的Spring全家桶,依赖注入大行其道,Android复杂的项目也需要DI来解耦。


从个人的实践经验看,Android MVVM/MVI 模式还是比较适合引入Hilt的。
image.png


摘抄官网的:现代Android 应用架构

通常来说我们这么设计UI层到数据层的架构:


class MyViewModel @Inject constructor(
val repository: LoginRepository
) :ViewModel() {}

class LoginRepository @Inject constructor(
val rds : RemoteDataSource,
val lds : LocalDataSource
) {}

//远程来源
class RemoteDataSource @Inject constructor(
val myRetrofit: MyRetrofit
) {}

class MyRetrofit @Inject constructor(
) {}

//本地来源
class LocalDataSource @Inject constructor(
val myDataStore: MyDataStore
) {}

class MyDataStore @Inject constructor() {}

可以看出,层次比较深,使用了Hilt简洁了许多。


本文基于 Hilt 2.48.1

参考文档:

dagger.dev/hilt/gradle…

developer.android.com/topic/archi…

repo.maven.apache.org/maven2/com/…


作者:小鱼人爱编程
来源:juejin.cn/post/7294965012749320218
收起阅读 »

Android应用保活全攻略:30个实用技巧助你突破后台限制

在Android系统中,保活(保持应用进程一直存活)通常是为了让应用在后台持续运行,以实现某些特定的功能,如实时消息推送、定位服务等。然而,由于Android系统为了节省资源和保护用户隐私,通常会限制后台应用的运行。因此,开发者需要采取一些策略来实现保活。以下...
继续阅读 »

在Android系统中,保活(保持应用进程一直存活)通常是为了让应用在后台持续运行,以实现某些特定的功能,如实时消息推送、定位服务等。然而,由于Android系统为了节省资源和保护用户隐私,通常会限制后台应用的运行。因此,开发者需要采取一些策略来实现保活。以下是30个常见的Android保活手段,帮助你突破后台限制:


1. 前台服务(Foreground Service)


将应用的Service设置为前台服务,这样系统会认为这个服务是用户关心的,不容易被杀死。前台服务需要显示一个通知,告知用户当前服务正在运行。通过调用startForeground(int id, Notification notification)方法将服务设置为前台服务。


2. 双进程守护


创建两个Service,分别运行在不同的进程中。当一个进程被杀死时,另一个进程可以通过监听onServiceDisconnected(ComponentName name)方法来感知,并重新启动被杀死的进程。这样可以相互守护,提高应用的存活率。


3. 使用系统广播拉活


使用系统广播拉活。监听系统广播,如开机广播、网络变化广播、应用安装卸载广播等。当收到广播时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。


4. JobScheduler


使用JobScheduler定时启动应用。JobScheduler是Android 5.0引入的一种任务调度机制,可以在满足特定条件下执行任务。通过创建一个Job,设置触发条件,然后将Job提交给JobScheduler。当触发条件满足时,JobScheduler会启动应用。


5. 白名单


引导用户将应用加入系统的白名单,如省电白名单、自启动白名单等。加入白名单的应用不会受到系统的限制,可以在后台持续运行。


6. 第三方推送服务


使用第三方推送服务,如极光推送、小米推送等。这些推送服务通常使用保活技巧,可以保证消息的实时推送。


7. 静态广播监听


在AndroidManifest.xml中注册静态广播,监听系统广播,如电池状态改变、屏幕解锁等。当收到广播时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。需要注意的是,从Android 8.0开始,静态广播的使用受到了限制,部分隐式广播无法通过静态注册来接收。


8. 合理利用Activity


在必要时,将应用的Activity设置为singleTask或singleInstance模式,确保应用在后台时只有一个实例。这可以减少系统对应用的限制,提高应用在后台的存活率。


9. 使用AlarmManager定时唤醒


使用AlarmManager定时唤醒应用。通过设置一个定时任务,当到达指定时间时,使用PendingIntent启动应用。需要注意的是,从Android 6.0开始,AlarmManager的行为受到了限制,当设备处于低电量模式时,定时任务可能会被延迟。


10. 合理设置进程优先级


Android系统会根据进程的优先级来决定是否回收进程。通过合理设置进程优先级,可以降低系统回收进程的概率。例如,可以将Service设置为前台服务,或者将进程与用户正在交互的Activity绑定。


11. 使用sticky广播


使用sticky广播在一定程度上可以提高广播接收器的优先级。当发送一个sticky广播时,系统会将该广播存储在内存中,这样即使应用被杀死,也可以在重新启动时收到广播。但需要注意的是,从Android 5.0开始,sticky广播的使用受到了限制,部分广播无法使用sticky模式发送。


12. 使用WorkManager


WorkManager是Android Architecture Components的一部分,它为后台任务提供了一种统一的解决方案。WorkManager可以自动选择最佳的执行方式,即使应用退出或设备重启,它仍然可以确保任务完成。WorkManager在保活方面的效果可能不如其他方法,但它是一种更符合Android系统规范的解决方案,可以避免系统限制和用户体验问题。


13. 合理使用WakeLock


在某些特定场景下,可以使用WakeLock(电源锁)来防止CPU进入休眠状态,从而确保应用能够在后台持续运行。但请注意,WakeLock可能会导致设备电量消耗增加,因此应谨慎使用,并在不需要时尽快释放锁。


14. 合理使用SyncAdapter


SyncAdapter是Android提供的一种同步框架,用于处理数据同步操作。SyncAdapter可以根据设备的网络状态、电池状态等条件来自动调度同步任务。虽然SyncAdapter并非专门用于保活,但它可以在一定程度上提高应用在后台的存活率。


15. 使用AccountManager


通过在应用中添加一个账户,并将其与SyncAdapter关联,可以在一定程度上提高应用的存活率。当系统触发同步操作时,会启动与账户关联的应用进程。但请注意,这种方法可能会对用户造成困扰,因此应谨慎使用。


16. 适配Doze模式和App Standby


从Android 6.0(API级别23)开始,系统引入了Doze模式和App Standby,以优化设备的电池使用。在这些模式下,系统会限制后台应用的网络访问和CPU使用。为了保证应用在这些模式下正常运行,您需要适配这些特性,如使用高优先级的Firebase Cloud Messaging(FCM)消息来唤醒应用。


17. 使用Firebase Cloud Messaging(FCM)


对于需要实时消息推送的应用,可以使用Firebase Cloud Messaging(FCM)服务。FCM是一种跨平台的消息推送服务,可以实现高效且可靠的消息传递。通过使用FCM,您可以确保应用在后台时接收到实时消息,而无需采取过多的保活手段。


18. 遵循Android系统的最佳实践


在开发过程中,遵循Android系统的最佳实践和推荐方案,可以提高应用的兼容性和稳定性。例如,合理使用后台任务、避免长时间运行的服务、优化内存使用等。这样可以降低系统对应用的限制,从而提高应用在后台的存活率。


19. 及时适配新系统版本


随着Android系统版本的更新,系统对后台应用的限制可能会发生变化。为了确保应用在新系统版本上能够正常运行,您需要及时适配新系统版本,并根据需要调整保活策略。


20. 与用户建立信任


在实际开发中,应尽量遵循系统的规范和限制,避免过度使用保活手段。与用户建立信任,告知用户应用在后台运行的原因和目的。在用户授权的情况下,采取适当的保活策略,以实现所需功能。


21. 使用Binder机制


Binder是Android中的一种跨进程通信(IPC)机制。通过在Service中创建一个Binder对象,并在其他进程中获取这个Binder对象,可以使得两个进程建立连接,从而提高Service的存活率。


22. 使用native进程


通过JNI技术,创建一个native进程来守护应用进程。当应用进程被杀死时,native进程可以感知到这个事件,并重新启动应用进程。这种方法需要C/C++知识,并且可能会增加应用的复杂性和维护成本。


23. 使用反射调用隐藏API


Android系统中有一些隐藏的API和系统服务,可以用于提高应用的存活率。例如,通过反射调用ActivityManager的addPersistentProcess方法,可以将应用设置为系统进程,从而提高应用的优先级。然而,这种方法存在很大的风险,可能会导致应用在某些设备或系统版本上无法正常运行。


24 监听系统UI


监听系统UI的变化,如状态栏、导航栏等。当系统UI变化时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。


25. 使用多进程


在AndroidManifest.xml中为Service或Activity设置android:process属性,使其运行在单独的进程中。这样,即使主进程被杀死,其他进程仍然可以存活。


26. 使用Provider


在AndroidManifest.xml中注册一个Provider,并在其他应用中通过ContentResolver访问这个Provider。这样,即使应用在后台,只要有其他应用访问Provider,应用就可以保持存活。


27. 关注Android开发者文档和官方博客


Android开发者文档和官方博客是获取保活策略和系统更新信息的重要途径。关注这些资源,以便了解最新的系统特性、开发者指南和最佳实践。


28. 性能优化


优化应用的性能,降低内存、CPU和电池的消耗。这样,系统在资源紧张时可能会优先回收其他消耗较高的应用,从而提高您的应用在后台的存活率。


29. 用户反馈


关注用户的反馈,了解他们在使用应用过程中遇到的问题。根据用户的反馈,调整保活策略,以实现最佳的用户体验。


30. 使用NotificationListenerService


通过实现一个NotificationListenerService并在AndroidManifest.xml中注册,可以监听系统通知栏的变化。当收到新的通知时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。这种方法可以利用系统通知的变化来触发应用的启动,从而提高应用在后台的存活率。需要注意的是,为了使用NotificationListenerService,用户需要在设置中授权应用访问通知权限。


请注意,保活策略可能会导致系统资源消耗增加、用户体验下降,甚至引发系统限制或用户卸载应用。因此,在实际开发中,应根据功能需求和用户体验来权衡保活策略,尽量遵循系统的规范和限制。在可能的情况下,优先考虑使用系统推荐的解决方案,如前台服务、JobScheduler等。


作者:程序员陆业聪
来源:juejin.cn/post/7352079364611276819
收起阅读 »

APP端上通用安全体系建设

背景:APP端上安全在谈什么 APP的每个业务场景都有其既定的运行模式,若被人为破坏就可认为是不安全的。举个栗子,比如秒杀场景:大量用户在特定时间点,通过点击抢购来秒杀优惠商品,从而营造一种紧迫而有噱头的营销场景,但如果能通过非法手段自动抢购、甚至提前开始刷接...
继续阅读 »

背景:APP端上安全在谈什么


APP的每个业务场景都有其既定的运行模式,若被人为破坏就可认为是不安全的。举个栗子,比如秒杀场景:大量用户在特定时间点,通过点击抢购来秒杀优惠商品,从而营造一种紧迫而有噱头的营销场景,但如果能通过非法手段自动抢购、甚至提前开始刷接口抢购,那就彻底破坏了业务的玩法,这就是一种不安全的运行模式。再比如常用的用户拉新场景:新客获取成本高达200左右,所有产品的拉新投入都蛮高,如何获得真正的新用户而不是羊毛党也是拉新必须处理的事,一般而言,新设备+新账户是新用户的基本条件,但新账户的成本其实不高,大部分是要靠新设备来识别的,但如果能通过非法手段不断模拟新设备,那拉新投入获取的可能大部分都是无效的羊毛党,这也可看做是一种不安全的运行场景,甚至还有二次篡改,构建马甲APP等各种场景。而APP端上安全要做的就是甄别并防范这种异常场景的发生,简而言之它就是:一种确保官方APP既定业务模型中运行的能力。


APP端上安全体系应该具备哪些能力


一个安全体系要具备哪些能力呢,简单说可分两块:甄别与防御。即一:甄别运行环境是否安全的能力,二:针对不同的场景作出不同的防御的能力,场景千变万化,所以防御手段也没有一剑破万法的能力,基本都要根据具体的风险场景,产出不同的应对方案,但是整体涉及的流程基本一致,如下:


image.png


要做的事情就是围绕四个阶段构建不同的能力。先看第一阶段:风险场景的假设,预判有哪些风险场景,从AppStore或者官方应用市场下载,安装、正常使用,这是理想中的运行模式,但不安分的用户千千万,异常场景也多变,不存在一剑破万法的说法,这里人为做了一些场景归类:


image.png


从实践来看,安全模型必须覆盖的场景包含上述四类,即



一: 运行的APP非官方应用



这种情况一般是非法用户为了谋求特定的权益,对原装APP进行魔改二次打包后发布,这对于一些广告类、离线付费类APP是一种毁灭性的打击,最常见的就是一些APP会在闪屏页面投放广告,从而获取收益,但一旦这个业务逻辑被篡改绕过,那么广告收益直接归零;还有些情况APP被碰瓷,魔改一些功能,跳转到非官方设定的网站,比如在比较火的APP添加劫持逻辑,跳转到一些黄赌网站,这给官方APP带来的负面影响是难易估量的,如果不能自证清白,甚至还会面临法律上的追责。



二:业务的运转模型被篡改



简单的讲就是没有按照产品的既定玩法进行,就像文章开头的秒杀场景【电商平台的茅台抢购】,如果能通过API直接组单,那APP里点击的那批用户无论如何也抢不过插件;再比如有些签到的场景是为了促活,如果通过自动签到工具自动打卡,那促活的目的也无法达到,这也是要重点防控的一个场景。



三:运行的设备非目标设备



这种场景主要影响一些拉新、优惠权益等,甚至直接影响整体运营策略,简单举个例子,拉新场景中会投放大量的新人免单、直减券,基本等于免费领取权益,比如我们都经历过的打车软件、外卖软件、新电商产品上线等。全国有无数的专业羊毛党专注于这一场景,他们掌握大量手机号,可以注册大量新账号,同时能通过虚拟机、应用多开、复刻设备等手段无限冒充新设备,如果不加反制,优惠策略一上线,各种券基本全被这批人掠夺,被这种手段折磨致死的产品数不胜数,也几乎是每个电商平台的噩梦。



四:一些核心逻辑的泄漏



这不是端上特有的风险,比如代码泄漏这种,各方都有这种风险,只不过端上风险更高,因为APP是要上架发出去的,虽然经历过各种混淆与保护,但是用户最终还是能拿到可执行的APP包的,里面各种的核心的逻辑、秘钥都有潜在泄漏的风险,一旦泄漏,也是一种毁灭性的打击,比如某些音视频软件特有滤镜、转场、特效,这些算法是一个产品的核心,一旦破解,产品优势不再,如果防止逻辑代码的泄漏与破解也是安全必须关注的点。


覆盖上述场景的意思是要提供甄别的能力,针对不同的场景,抽象特征值并搜集上报,建立特定的模型,推导C端操作环境是否安全,之后上线不同的应对策略:比如直接退出应用、标记为风险用户等。


建设方案


甄别与防御是体系的核心,建设方案主要是围绕这两个主题展开,虽说名称是“端上安全体系”,但只依靠端自己是无法解决所有问题的,也无法将价值发挥到最大,仍需多端系统配合来完成整个体系的搭建,分工的基本原则是:端上侧重特征信息的搜集,云端负责整体策略的执行,根据上述场景,搭建示意如下:


image.png


按层次跟功能大致分四块,端、网关、业务后台、数据风控中心:



  • 端是信息的来源,负责信息采集上报,是安全体系建设的基石,所以端上采集信息的真实性、为完整性至关重要,同时端上也可以执行部分风险低,但收益高的拦截略,比如对于一些已经侦测到的马甲包,在上报用户信息之后,可以选择Crash,对于一些机器点击类的签到、秒杀场景,可以主动拦截请求,降低带宽压力。

  • 网关是第二层,一般处理一些具体规则类的拦截与信息采集,比如有些简单的规则检验,Header里是否携带必备的校验字段,如多开标识、模拟器标志等,如果携带则可以在这一层直接拦截,并沉淀到数据中心,既保证了信息的采集,又能减轻业务后台的压力,尤其对于一些秒杀类的场景非常有效。

  • 业务后台跟数据平台可以一起看做第三层,负责更复杂的模型建设跟业务落地,比如在什么样的节点,才有什么样的策略,比如在组单的时候,业务后台可根据风控侧的判断决定用户的优惠力度等


针对各个具体的场景会有具体的建设方案,



如何应对非官方APP:APP包识别



非法人员总是出于自己的权益来破解官方APP,定制一些逻辑再二次打包发布,比如对一些付费类APP,含广告类工具APP等,通过破解代码,并二次打包后就可以官方造成冲击,甚至是毁灭性的打击,对于这种场景如何甄别,又如何处理呢?拿Android为例,检测手段有签名校验、文件校验、包完整性校验等,一旦检测到风险就可以做出响应处理,在处理方式上也需要根据不同产品不同场景随机变动,比如工具类APP就Crash阻断,而对于一些有用户体系类的APP则可以先回传用户信息,用作用户画像,再做响应的处理,而处理的手段也可以根据风险等级的不同再做定制,甄别的技术手段可能是死的,但获取的收益一定要灵活。



如何应对非理想设备:设备识别与设备指纹



非理想设备最经典的就是文章开头的场景,拉新拉来一堆老羊毛党,说到底其实就是对于用户设备的定位追踪能力不足,对于这种场景的如何应对?这里单独说说设备指纹,设备指纹主要解决的如何定位一台设备的问题,在理想情况下,一台设备只有一个身份信息,它不因APP卸载、升级、HOOK伪装所改变,这在现在的互联产品生态中是非常困难的,困难主要来源于两个方面:一技术上的、一是法规上的,从技术上来讲,以Android端为例,它是一个开源的系统,每一行代码都不可信,任何通过官方API拿到的信息都可以被HOOK篡改,而指纹很大程度依赖API获得的设备特征信息,如果这些信息都不可信,那指纹的可信程度也会降低。另一方面,从法规上来讲,现在注重保护个人隐私,不可以随意获取用户信息,这一点有利于用户【包括羊毛党】,但是对于运营方却是不利的,信息越少越难定位到,因此,在隐私合规的前提下,仍需要多维度的获取更多的用户信息,从更多的维度定位到该用户。


具体如何执行?以Android为例,定位一台设备的信息有MAC、IMEI、IMSI、序列号、AndroidID、IP+UA、OAID、各种设备型号等,虽然信息很多,但单独任何一条的可信度都不高,比如之前的某盾、某盟都曾用MAC地址作为指纹,甚至有些产品直接用IMEI作为指纹,但网上利用XPOSED来篡改的插件比比皆是,通过官方API获取的分分钟被破解,但是可以对多种信息进行整合生成一个唯一可信的ID,这种方式获取的ID的稳定性要比单一的稳定性要高,原理示意如下:


image.png


简单来说:只有篡改了全部的设备特征信息,才会导致设备指纹更新,这会大幅提高设备逃逸的难度。设备指纹的另一个难题是如何识别虚拟设备,这里特指模拟器,每个新开的模拟器都可以看做是新设备,如果不能识别,同样无法解决设备跟踪的问题,尤其对于国内的Android生态来讲,问题更加严重,各种游戏厂商都有对应的手游模拟器,不仅支持多开,还原生支持篡改各种设备特征信息,可以算得上助纣为虐,在模拟器甄别与防控的层面能做的有如下几种:



  • 通过特征信息甄别【容易绕过】

  • 通过CPU架构甄别【ARM与SimpleX86】

  • 限定APP的运行平台


这里简单介绍下通过CPU架构甄别方式,就目前的硬件市场,几乎99.9%以上的手机设备都是基于ARM处理,而模拟器大部分是面向x86平台设计的,采用的是simplex86架构,两者采用的不一样缓存机制,ARM采用的哈弗架构将指令存储跟数据存储分开,分为I-Cache(指令缓存)与D-Cahce(数据缓存),CPU无法直接修改I-Cache【同步延迟导致不一致】,但Simpled X86架构的模拟器只有一块缓存,这一点导致两者在运行Self-Modifying Code【自修改代码】时会有不同的表现,可以借助这个特性进行甄别,示意如下


image.png


至此,设备的定位与跟踪能力基本已经具备,在用户在领券的节点,就可以从更多维度判断他当前的设备是否有资格享受这个权益,保障业务按既定模型运转。


image.png


当然还有更多的场景,比如应用多开、应用分身等,都要具体问题具体分析,但思路一致:特征搜集、甄别、防控,因为所有的不轨行为一定有迹可循,



如何应对非设定业务场景:场景识别与校验



每种业务都有其既定的运行模式,只有照章办事,运营才能获取最大的收益,这里特指一些可以通过自己的参与获得收益的场景,比如秒杀、签到、预约摇号等。一般而言,在这类场景下,破坏者可钻的空子有两个方向,一个是便利:通过插件自动预约,免得用户自己操作,适合摇号、签到类【签到领积分】;一个是速度,通过插件直接API请求,抢跑下单,获得收益,适合限时秒杀类的场景【各大平台强茅台】。以秒杀为例,通过营造紧迫而又刺激的氛围可以让活动更有意思,但如果能直接刷接口/或者通过插件抢跑,那就会破坏其公平性,影响用户的参与感,造成资产及口碑受损,这类场景如何应对?其实要做的事情分两块:一 识别请求是从APP发出来 , 二 识别是真实用户操作的,这两快一般会整体考虑,非APP端的请求往往伴随着非用户触发,多归结于脚本,所以识别“人”与识别“场景”殊途同归,具体有哪些手段可以用呢?



  • 扩展核心API接口的能力,承载更多逻辑

  • 通过埋点、用户操作轨迹分析识别用户

  • 启用端上特有能力校验,如短信验证码、行程码分析


如何拓展API接口的能力?比如预约接口其基础能力就是预约,如不特殊处理,PC上就完全可以复制APP端发出的请求,进而通过脚本预约,如要加以限制就必须拓展端上API能力,让其携带更多端上独有特征,同时服务端可以完成校验,形成一个闭环,比较容易理解的就是让APP端与服务端协商一套加解密通信协议,并假定协议无法破解,避免接口直刷,从而确保请求是从APP发出的,即使不是从APP发出的,也能被甄别出来,进而提高APP与服务端通信阶段的安全性。当然,无法破解只是理论上,实际上只要舍得投入成本,暴力破解并不是问题,这种就需要通过更多元的手段,不断更新迭代,持续做攻防,例如,为了保证加密算法的保密性,可以将其用c实现,并通混淆、加固、防探测等手段保证这个策略的正确执行;暴力堆积加密的类型、节点,提升秘钥的更新频率也是一种应对手段,而且,惩罚手段上也可以多元,同直接拦截相比,隐秘的搜捕,诱捕也是一种灵活收益的手段。


其次,基于埋点、用户操作行为的大数据分析是另一种更高级的防御手段,对于识别用户操作场景更加科学,正常的用户轨迹与插件类的访问轨迹会有很大的差异,直刷的目标明确,主攻几个关键接口,但正常用户访问会有一系列的曝光、点击等行为,并且每次的点击也会有各种零零散散的活体特征可以采集,比如点击的点位置、数量、力度、频率等,这些维度为用户识别提供了更广的操作空间。基于以上几点的模型示意如下:


image.png


最后一点,启用端上特有的校验能力,这个已经是最终的防御手段,在实在没有办法的情况才会采用的,因为这种手段很影响用户体验,由于采用的是端上特有的能力,比如短信验证码,必须真机才能收到,这就从根本上避免了插件类的直刷,所以可靠性确实所有手段中最高的,但体验差,成本高,所以算是最后一道防线。



如何应对核心逻辑的泄漏



这一块主要关注的是APP端的一些核心逻辑的破解或泄密,可以分两个方向,对外与对内,对外主要是APP包的逆向与破解,不法人员从发布上架的APP包中获取核心业务实现或其他敏感信息;而对内主要指工程安全,核心源码或秘钥的泄漏、误改等。


相应的防范策略也是分两块,对外的线上防破解可以从以下几点入手:



  • 利用代码混淆防APP逆向,一般而言官方会提供相应的能力,也可借助三方加固来提高混淆的力度

  • 核心源码、秘钥下沉,采用更难破解的方式实现,同时增加防外部调用的防范策略,比如Android采用C+混淆来处理

  • 为线上APP添加防调试与HOOK的能力,防止动态调试探测,

  • 添加防止代理与中间人劫持的能力,例如SSLPING等技术,避免被抓包探测

  • 从二次打包入手,添加签名、完整性检测的能力,防止被探测、篡改


而对内主要从工程安全角度推进,主要是做好代码的权责管理



  • 采用组件化开发模式,不同等级的基础能力、业务、核心逻辑做好隔离

  • 仓库单独部署,同时做好权责划分,代码、文档做好权限隔离

  • 加强秘钥、KEY的管控,开发与生产环境严格隔离


上述手段基本涵盖大部可预见的风险场景,即使未覆盖,也大概有类似的手段作为参考,无非就是抽象、搜集、判断、处理。


线上执行方案


最后一步是上线执行,上述的手段多种多样,但相互之间并非孤立运行,彼此可以相互穿插,灵活配合,不存在特定的章法,全看使用方的意图,如何探测,探测之后如何处理,是全杀还是放一部分,都看操刀者自己的运作,以应用多开场景为例,除了利用多开基础的多开检测手段,还可以配合设备指纹做更多的事情,有时虽然没有检测到多开,但是基于设备指纹的补刀,也能定位到问题设备,而在最后一步惩治处理中,不同处理手段也会获得不一样的收益:


类型处理方式最终收益优缺点
被动拦截端上部署检测规则,检测到风险,100%在端上拦截处理【如Crash】效果明显,但易被发现,徒增防御成本
被动捕获检测到风险,在端上不处理,只上报,后端隐形标记或拦截不易被发现,但长期运行收益比较局限
主动诱捕人为制造有迹可循的漏洞,捕获后在端上不拦截或部分拦截,并上报,后端隐形标记不易被发现,虚虚实实,操作空间更大,收益更大

理论上讲,APP技术层面不存在100%有效的安防策略,虚虚实实才是王道,敬畏,才是最有效的防御手段


总结与展望


目前国内APP的生态环境并不健康,甚至可以说野蛮,随着隐私策略收紧,APP所能获取的信息越来越少,安防也越来越难做,反之,刷子却越活越滋润,技术所面临的的挑战也更加棘手,安防注定是一个长期攻防的领域。最后,技术不能解决所有问题,最终还是要依赖法律的健全与全民意识提升。


作者:看书的小蜗牛
来源:juejin.cn/post/7350354672861052980
收起阅读 »

深入研究Kotlin运行时的泛型

深入研究Kotlin运行时的泛型 通过前面的学习,对Kotlin的泛型已经有了比较全面的了解了,泛型的目的是让通用的代码更加的类型安全。现在我们离写出类型安全的泛型代码还差最后一块拼图,那就是泛型的类型擦除,今天就来深入地学习一下运行时的泛型,彻底的弄懂类型...
继续阅读 »

深入研究Kotlin运行时的泛型


通过前面的学习,对Kotlin的泛型已经有了比较全面的了解了,泛型的目的是让通用的代码更加的类型安全。现在我们离写出类型安全的泛型代码还差最后一块拼图,那就是泛型的类型擦除,今天就来深入地学习一下运行时的泛型,彻底的弄懂类型擦除的前因后果,并学会如何在运行时做类型检查和类型转换,以期完成拼图掌握泛型,写出类型安全的通用代码。





关于泛型话题的一系列文章:



泛型类型擦除(Type erasure)


泛型的类型安全性(包括类型检查type check,和类型转换type casting)都是由编译器在编译时做的,为了保持在JVM上的兼容性,编译器在保障完类型安全性后会对泛型类型进行擦除(Type erasure)。在运行时泛型类型的实例并不包含其类型信息,也就是说它不知道具体的类型参数,比如Foo和Foo都被擦除成了Foo<*>,在虚拟机(JVM)来看,它们的类型是一样的。


因为泛型Foo的类型参数T会被擦除(erased),所以与类型参数相关的类型操作(类型检查is T和类型转换as T)都是不允许的。


可行的类型检查和转换


虽然类型参数会被擦除,但并不是说对泛型完全不能进行类型操作。


星号类型操作


因为所有泛型会被擦除成为星号无界通配Foo<*>,它相当于Foo,是所有Foo泛型的基类,类型参数Any?是根基类,所以可以进行类型检查和类型转换:


if (something is List<*>) {
 something.forEach { println(it) } // 元素被视为Any?类型
}

针对星号通配做类型操作,类型参数会被视为Any?。但其实这种类型操作没有任何意义,毕竟Any是根基类,任何类当成Any都是没有问题的。


完全已知具体的类型参数时


另外一种情况就是,整个方法的上下文中已经完全知道了具体的类型参数时,不涉及泛型类型时,也是可以进行类型操作的,说的比较绕,我们来看一个🌰:


fun handleStrings(list: MutableList<String) {
 if (list is ArrayList) {
  // list is smart-cast to ArrayList
 }
}

这个方法并不涉及泛型类型,已经知道了具体的类型参数是String,所以类型操作也是可行的,因为编译器知道具体的类型,能对类型进行检查 保证是类型安全的。并且因为具体类型参数String可以推断出来,所以是可以省略的。


未检查的转换


当编译器能推断出具体的类型时,进行类型转换就是安全的,这就是被检查的转型(checked cast),如上面的🌰。


如果无法推断出类型时,比如涉及泛型类型T时,因为类型会被擦除,编译器不知道具体的类型,这时as T或者as List都是不安全的,编译器会报错,这就是未检查转型(unchecked cast)。


但如果能确信是类型转换是安全的,可以用注解@Suppress("UNCHECKED_CAST")来忽略。


用关键reified修饰inline泛型函数


要想能够对泛型类型参数T做类型操作,只能是在用关键字reified修饰了的inline泛型函数,在这种函数体内可以对泛型类型参数T做类型操作,🌰如:


inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

val somePair: Pair = "items" to listOf(123)


val stringToSomething = somePair.asPairOf()
val stringToInt = somePair.asPairOfInt>()

需要注意的是关键字reified能够让针对类型参数T的操作得到编译器的检查,保证安全,是允许的。但是对于泛型仍是不允许的,🌰如:


inline fun <reified T> List<*>.asListOfType(): List? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List else
        null

这个inline泛型函数用关键字reified修饰了,因此针对类型参数T是允许类型检查类型转换,如第2行是允许的。但泛型仍是不合法,如第4行,这时可以用上一小节提到的注解@Suppress("UNCHECKED_CAST")来忽略未检查类型转换。


inline和reified的原理


对于一些泛型工厂方法,就非常适合使用inline和reified,以保证转换为类型参数(因为工厂方法最终肯定要as T)是允许的且是安全的:


inline fun <reified T> logger(): Logger = LoggerFactory.getLogger(T::class.java)

class User {
    private val log = logger()
    // ...
}

关键字reified其实也没有什么神秘的,因为这是inline函数,这种函数是会把函数体嵌入到任何调用它的地方(call site),而每个调用泛型函数的地方必然会有明确的具体类型参数,那么编译器就知道了具体的类型能保证类型安全(checked cast)。上面的工厂方法在调用时就会大概变成酱紫:


class User {
 private val log = LoggerFactory.getLogger(User.class.java)
}

这时其实在函数体内已经知道了具体的类型参数User,编译器能够进行类型检查,所以是安全的。


总结


本文深入的讨论一下运行时泛型的一些特性,泛型类型在运行时会被擦除,无法做泛型相关的类型操作,因为编译器无法保证其类型安全。例外就是在用reified修饰的inline函数中可以对类型参数T做类型操作,但泛型类型(带尖括号的)仍是会被擦除,可以用注解@Suppress("UNCHECKED_CAST")来忽略unchecked cast。


参考资料



作者:稀有猿诉
来源:toughcoder.net/blog/2024/03/16/deep-dive-int0-kotlin-generics-runtime
收起阅读 »

Android:优雅的处理首页弹框逻辑:责任链模式

背景 随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。 并且弹框显示还有要求,比如: 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗...
继续阅读 »

背景


随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。
并且弹框显示还有要求,比如:



  • 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框

  • 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗高,所以希望优先级高的优先显示

  • 广告弹框只展示一次

  • 等等


如何优雅的处理这个逻辑呢?请出我们的主角:责任链模式。


责任链模式


举个栗子🌰


一位男性在结婚之前有事要和父母请示,结婚之后要请示妻子,老了之后就要和孩子们商量。作为决策者的父母、妻子或孩子,只有两种选择:要不承担起责任来,允许或不允许相应的请求; 要不就让他请示下一个人,下面来看如何通过程序来实现整个流程。


先看一下类图:
未命名文件.png
类图非常简单,IHandler上三个决策对象的接口。


//决策对象的接口
public interface IHandler {
//处理请求
void HandleMessage(IMan man);
}

//决策对象:父母
public class Parent implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("孩子向父母的请求是:" + man.getRequest());
System.out.println("父母的回答是:同意");
}
}

//决策对象:妻子
public class Wife implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("丈夫向妻子的请求是:" + man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

//决策对象:孩子
public class Children implements IHandler{
@Override
public void HandleMessage(IMan man) {
System.out.println("父亲向孩子的请求是:" + man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

IMan上男性的接口:


public interface IMan {
int getType(); //获取个人状况
String getRequest(); //获取个人请示(这里就简单的用String)
}

//具体男性对象
public class Man implements IMan {
/**
* 通过一个int类型去描述男性的个人状况
* 0--幼年
* 1--成年
* 2--年迈
*/

private int mType = 0;
//请求
private String mRequest = "";

public Man(int type, String request) {
this.mType = type;
this.mRequest = request;
}

@Override
public int getType() {
return mType;
}

@Override
public String getRequest() {
return mRequest;
}
}

最后我们看下一下场景类:


public class Client {
public static void main(String[] args) {
//随机生成几个man
Random random = new Random();
ArrayList<IMan> manList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
manList.add(new Man(random.nextInt(3), "5块零花钱"));
}
//定义三个请示对象
IHandler parent = new Parent();
IHandler wife = new Wife();
IHandler children = new Children();
//处理请求
for (IMan man: manList) {
switch (man.getType()) {
case 0:
System.out.println("--------孩子向父母发起请求-------");
parent.HandleMessage(man);
break;
case 1:
System.out.println("--------丈夫向妻子发起请求-------");
wife.HandleMessage(man);
break;
case 2:
System.out.println("--------父亲向孩子发起请求-------");
children.HandleMessage(man);
break;
default:
break;
}
}
}
}

首先是通过随机方法产生了5个男性的对象,然后看他们是如何就要5块零花钱这件事去请示的,运行结果如下所示:


--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------父亲向孩子发起请求-------
父亲向孩子的请求是:5块零花钱
孩子的回答是:同意
--------孩子向父母发起请求-------
孩子向父母的请求是:5块零花钱
父母的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意


发没发现上述的代码是不是有点不舒服,有点别扭,有点想重构它的感觉?那就对了!这段代码有以下几个问题:



  • 职责界定不清晰



对孩子提出的请示,应该在父母类中做出决定,父母有责任、有义务处理孩子的请示,



因此Parent类应该是知道孩子的请求自己处理,而不是在Client类中进行组装出来,
也就是说 原本应该是父亲这个类做的事情抛给了其他类进行处理,不应该是这样的。



  • 代码臃肿



我们在Client类中写了if...else的判断条件,而且能随着能处理该类型的请示人员越多,
if...else的判断就越多,想想看,臃肿的条件判断还怎么有可读性?!




  • 耦合过重



这是什么意思呢,我们要根据Man的type来决定使用IHandler的那个实现类来处理请



求。有一个问题是:如果IHandler的实现类继续扩展怎么办?修改Client类?
与开闭原则违背了!【开闭原则:软件实体如类,模块和函数应该对扩展开放,对修改关闭】
http://www.jianshu.com/p/05196fac1…



  • 异常情况欠考虑



丈夫只能向妻子请示吗?丈夫向自己的父母请示了,父母应该做何处理?
我们的程序上可没有体现出来,逻辑失败了!


既然有这么多的问题,那我们要想办法来解决这些问题,我们先来分析一下需求,男性提出一个请示,必然要获得一个答复,甭管是同意还是不同意,总之是要一个答复的,而且这个答复是唯一的,不能说是父母作出一个决断,而妻子也作出了一个决断,也即是请示传递出去,必然有一个唯一的处理人给出唯一的答复,OK,分析完毕,收工,重新设计,我们可以抽象成这样一个结构,男性的请求先发送到父亲,父母一看是自己要处理的,就作出回应处理,如果男性已经结婚了,那就要把这个请求转发到妻子来处理,如果男性已经年迈,那就由孩子来处理这个请求,类似于如图所示的顺序处理图。
未命名文件 (1).png
父母、妻子、孩子每个节点有两个选择:要么承担责任,做出回应;要么把请求转发到后序环节。结构分析得已经很清楚了,那我们看怎么来实现这个功能,类图重新修正,如图 :
未命名文件 (2).png
从类图上看,三个实现类Parent、Wife、Children只要实现构造函数和父类中的抽象方法 response就可以了,具体由谁处理男性提出的请求,都已经转移到了Handler抽象类中,我们 来看Handler怎么实现,


public abstract class Handler {
//处理级别
public static final int PARENT_LEVEL_REQUEST = 0; //父母级别
public static final int WIFE_LEVEL_REQUEST = 1; //妻子级别
public static final int CHILDREN_LEVEL_REQUEST = 2;//孩子级别

private Handler mNextHandler;//下一个责任人

protected abstract int getHandleLevel();//具体责任人的处理级别

protected abstract void response(IMan man);//具体责任人给出的回应

public final void HandleMessage(IMan man) {
if (man.getType() == getHandleLevel()) {
response(man);//当前责任人可以处理
} else {
//当前责任人不能处理,如果有后续处理人,将请求往后传递
if (mNextHandler != null) {
mNextHandler.HandleMessage(man);
} else {
System.out.println("-----没有人可以请示了,不同意该请求-----");
}
}
}

public void setNext(Handler next) {
this.mNextHandler = next;
}
}

再看一下具体责任人的实现:Parent、Wife、Children


public class Parent extends Handler{

@Override
protected int getHandleLevel() {
return Handler.PARENT_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------孩子向父母提出请示----------");
System.out.println(man.getRequest());
System.out.println("父母的回答是:同意");
}
}

public class Wife extends Handler{
@Override
protected int getHandleLevel() {
return Handler.WIFE_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------丈夫向妻子提出请示----------");
System.out.println(man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

public class Children extends Handler{
@Override
protected int getHandleLevel() {
return Handler.CHILDREN_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------父亲向孩子提出请示----------");
System.out.println(man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

那么再看一下场景复现:
在Client中设置请求的传递顺序,先向父母请示,不是父母应该解决的问题,则由父母传递到妻子类解决,若不是妻子类解决的问题则传递到孩子类解决,最终的结果必然有一个返回,其运行结果如下所示。


----------孩子向父母提出请示----------
15块零花钱
父母的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意

结果也正确,业务调用类Client也不用去做判断到底是需要谁去处理,而且Handler抽象类的子类可以继续增加下去,只需要扩展传递链而已,调用类可以不用了解变化过程,甚至是谁在处理这个请求都不用知道。在这种模式就是责任链模式


定义


Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request.Chain the receiving objects and pass the request along the chain until an object handles it.
(使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。) 责任链模式的重点是在“链”上,由一条链去处理相似的请求在链中决定谁来处理这个请 求,并返回相应的结果,其通用类图如图所示
未命名文件 (3).png
最后总结一下,责任链的模版:
包含四个对象,Handler,Request,Level,Response:


public class Request {
//请求的等级
public Level getRequestLevel(){
return null;
}
}

public class Level {
//请求级别
}


public class Response {
//处理者返回的数据
}

//抽象处理者
public abstract class Handler {
private Handler mNextHandler;

//每个处理者都必须对请求做出处理
public final Response handleMessage(Request request) {
Response response = null;
if (getHandlerLevel().equals(request.getRequestLevel())) {
//是自己处理的级别,自己处理
response = echo(request);
} else {
//不是自己处理的级别,交给下一个处理者
if (mNextHandler != null) {
response = mNextHandler.echo(request);
} else {
//没有处理者能处理,业务自行处理
}
}
return response;
}

public void setNext(Handler next) {
this.mNextHandler = next;
}

@NotNull
protected abstract Level getHandlerLevel();

protected abstract Response echo(Request request);
}

实际应用


我们回到开篇的问题:如何设计弹框的责任链?


//抽象处理者
abstract class AbsDialog(private val context: Context) {
private var nextDialog: AbsDialog? = null

//优先级
abstract fun getPriority(): Int

//是否需要展示
abstract fun needShownDialog(): Boolean

fun setNextDialog(dialog: AbsDialog?) {
nextDialog = dialog
}

open fun showDialog() {
//这里的逻辑,我们就简单点,具体逻辑根据业务而定
if (needShownDialog()) {
show()
} else {
nextDialog?.showDialog()
}
}

protected abstract fun show()

// Sp存储, 记录是否已经展示过
open fun needShow(key: String): Boolean {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(key, true)
}

open fun setShown(key: String, show: Boolean) {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(key, !show).apply()
}

companion object {
const val LOG_TAG = "Dialog"
const val SP_NAME = "dialog"
const val POLICY_DIALOG_KEY = "policy_dialog"
const val AD_DIALOG_KEY = "ad_dialog"
const val PRAISE_DIALOG_KEY = "praise_dialog"
}
}

/**
* 模拟 隐私政策弹窗
* */

class PolicyDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 0

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如接口控制等等
// 这里通过Sp存储来模拟
return needShow(POLICY_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示隐私政策弹窗")
setShown(POLICY_DIALOG_KEY, true) //记录已经显示过
}
}

/**
* 模拟 广告弹窗
* */

class AdDialog(private val context: Context) : AbsDialog(context) {
private val ad = DialogData(1, "XX广告弹窗") // 模拟广告数据

override fun getPriority(): Int = 1

override fun needShownDialog(): Boolean {
// 广告数据通过接口获取,广告id应该是唯一的,所以根据id保持sp
return needShow(AD_DIALOG_KEY + ad.id)
}

override fun show() {
Log.d(LOG_TAG, "显示广告弹窗:${ad.name}")
setShown(AD_DIALOG_KEY + ad.id, true)
}
}

/**
* 模拟 好评弹窗
* */

class PraiseDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 2

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如用户使用7天等
// 这里通过Sp存储来模拟
return needShow(PRAISE_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示好评弹窗")
setShown(PRAISE_DIALOG_KEY, true)
}
}

//模拟打开app
val dialogs = mutableListOf<AbsDialog>()
dialogs.add(PolicyDialog(this))
dialogs.add(PraiseDialog(this))
dialogs.add(AdDialog(this))
//根据优先级排序
dialogs.sortBy { it.getPriority() }
//创建链条
for (i in 0 until dialogs.size - 1) {
dialogs[i].setNextDialog(dialogs[i + 1])
}
dialogs[0].showDialog()

第一次打开
image.png


第二次打开
image.png


第三次打开
image.png


总结:



  • 优点


责任链模式非常显著的优点是将请求和处理分开。请求者可以不用知道是谁处理的,处理者可以不用知道请求的全貌,两者解耦,提高系统的灵活性。



  • 缺点


责任链有两个非常显著的缺点:一是性能问题,每个请求都是从链头遍历到链尾,特别是在链比较长的时候,性能是一个非常大的问题。二是调试不很方便,特别是链条比较长, 环节比较多的时候,由于采用了类似递归的方式,调试的时候逻辑可能比较复杂。



  • 注意事项


链中节点数量需要控制,避免出现超长链的情况,一般的做法是在Handler中设置一个最大节点数量,在setNext方法中判断是否已经是超过其阈值,超过则不允许该链建立,避免无意识地破坏系统性能。


作者:蹦蹦蹦
来源:juejin.cn/post/7278239421706633252
收起阅读 »

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


"1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.comdomain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPinpin>

<pin digest="SHA-256">ReplaceWithYourPinpin>
pin-set>
domain-config>
network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



作者:bytebeats
来源:juejin.cn/post/7342861726000791603
收起阅读 »

【手把手教Android聊天室uikit集成-kotlin 第一期】

前言:环信提供一个开源的 ChatroomUIKit 示例项目,演示了如何使用该 UIKit 快速搭建聊天室页面,实现完整业务。本文展示如何编译并运行 Android 平台的聊天室 UIKit 示例项目。#一、详细步骤导入uikit二、遇到集成报错解决&nbs...
继续阅读 »

前言:环信提供一个开源的 ChatroomUIKit 示例项目,演示了如何使用该 UIKit 快速搭建聊天室页面,实现完整业务。

本文展示如何编译并运行 Android 平台的聊天室 UIKit 示例项目。

一、详细步骤导入uikit
二、遇到集成报错解决 
1. 从github下载的附件我们打开以后 会有两个 一个是ChatRoomService ,另外一个是ChatroomUIKit

2.先倒入UIkit的本地库(引导的内容可以参考标题1. 的绿色箭头第二个文件夹)

3.然后在导入ChatRoomservice 选择文件后也点击Finish 注: 一共两个文件 都需要导入
4.填写settings.gradle
include(":ChatroomUIKit")
include(":ChatroomService")

5.添加:build.gradle(app)
implementation(project(mapOf("path" to ":ChatroomUIKit")))

6.如果遇到该报错如下:
遇到报错如下:
Dependency 'androidx.activity:activity:1.8.0' requires libraries and applications that depend on it to compile against version 34 or later of the Android APIs.
:app is currently compiled against android-33.
Also, the maximum recommended compile SDK version for Android Gradle
plugin 7.4.2 is 33.
Recommended action: Update this project's version of the Android Gradle
plugin to one that supports 34, then update this project to use
compileSdkVerion of at least 34.
Note that updating a library or application's compileSdkVersion (which
allows newer APIs to be used) can be done separately from updating
targetSdkVersion (which opts the app in to new runtime behavior) and
minSdkVersion (which determines which devices the app can be installed
解决方案: 注意一下自己app的 targetSDK版本号以及compilesdk 都给到 34 大概在报错信息也能提示到是 需要强制到34
7.初始化UIkit

(1)appkey管理后台位置

8.客户端登录调用
ChatroomUIKitClient.getInstance().login("4","YWMtFTJV-OXGEe6LxEWLvu_JdPqlsNlfrUUAh3km7oObq2HVh7Pgj9ER7JuEZ0XLQ13UAwMAAAGOVbV_AAWP1AB9sFv_7oIlDyK7Jay0Coha-HnF5o0PnXttL7r4gxryCA", onSuccess = {
val intent = Intent(this@MainActivity, As::class.java)
startActivity(intent)

}, onError = {
code, error ->


})


(1)参数管理后台具体位置 ,每次点击查看token的token内容都是不同的,这个不必担心。


(2)跳转到Asactivity 后遇到了一个问题!
继承ComponentActivity() 无法拿到setContent
解决办法:将这个依赖升级到 1.8.0 刚才用了1.7.0版本 无法拿到这个setContent
implementation("androidx.activity:activity-compose:1.8.0")
9.展示进入聊天室逻辑
class As : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent{
ComposeChatroom(roomId = "242681589596161",roomOwner = UserInfoProtocol)
}
(1)参数roomId 在管理后台可以查看

(2)roomOwner 为 UserInfoProtocol 类型 ,可以自己定义编辑属性将参数存入方法内


收起阅读 »

UNIAPP开发电视app教程

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。 开发难点 如何方便的开发调试 如何使需要被聚焦的元素获取聚焦状态 如何使被聚焦的元素滚动到视图中心位置 如何在切换路由时,缓存聚焦的状态 如...
继续阅读 »

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。


开发难点



  1. 如何方便的开发调试

  2. 如何使需要被聚焦的元素获取聚焦状态

  3. 如何使被聚焦的元素滚动到视图中心位置

  4. 如何在切换路由时,缓存聚焦的状态

  5. 如何启用wgt和apk两种方式的升级


一、如何方便的开发调试


之前我在论坛看到人家说,没办法呀,电脑搬到电视,然后调试。


其实大可不必,安装android studio里边创建一个模拟器就可以了。


注意:最好安装和电视系统相同的版本号,我这里是长虹电视,安卓9所以使用安卓9的sdk


二、如何使需要被聚焦的元素获取聚焦状态


uniapp的本质上是webview, 因此我们可以在它的元素上添加tabIndex, 就可以获取焦点了。


  <view class="card" tabindex="0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>
</view>


.card {
border-radius: 1.25vw;
overflow: hidden;
}
.card:focus {
box-shadow: 0 0 0 0.3vw #fff, 0 0 1vw 0.3vw #333;
outline: none;
transform: scale(1.03);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}


三、如何使被聚焦的元素滚动到视图中心位置


使用renderjs进行实现如下


<script  module="homePage" lang="renderjs">
export default {
mounted() {
let isScrolling = false; // 添加一个标志位,表示是否正在滚动
document.body.addEventListener('focusin', e => {
if (!isScrolling) {
// 检查是否正在滚动
isScrolling = true; // 设置滚动标志为true
requestAnimationFrame(() => {
// @ts-ignore
e.target.scrollIntoView({
behavior: 'smooth', // @ts-ignore
block: e.target.dataset.index ? 'end' : 'center'
});
isScrolling = false; // 在滚动完成后设置滚动标志为false
});
}
});
}
};
</script>

就可以使被聚焦元素滚动到视图中心,requestAnimationFrame的作用是缓存


四、如何在切换路由时,缓存聚焦的状态


通过设置tabindex属性为0和1,会有不同的效果:



  1. tabindex="0":将元素设为可聚焦,并按照其在文档中的位置来确定焦点顺序。当使用Tab键进行键盘导航时,tabindex="0"的元素会按照它们在源代码中的顺序获取焦点。这可以用于将某些非交互性元素(如
    等)设为可聚焦元素,使其能够被键盘导航。

  2. tabindex="1":将元素设为可聚焦,并将其置于默认的焦点顺序之前。当使用Tab键进行键盘导航时,tabindex="1"的元素会在默认的焦点顺序之前获取焦点。这通常用于重置焦点顺序,或者将某些特定的元素(如重要的输入字段或操作按钮)置于首位。


需要注意的是,如果给多个元素都设置了tabindex属性,那么它们的焦点顺序将取决于它们的tabindex值,数值越小的元素将优先获取焦点。如果多个元素具有相同的tabindex值,则它们将按照它们在文档中的位置来确定焦点顺序。同时,负数的tabindex值也是有效的,它们将优先于零和正数值获取焦点。


我们要安装缓存插件,如pinia或vuex,需要缓存的页面单独配置


import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({ home_active_tag: 'active0', hot_active_tag: 'hot0', dish_active_tag: 'dish0' })
});


更新一下业务代码


组件区域
<view class="card" :tabindex="home_active_tag === 'packagecard' + props.id ? 1 : 0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>

</view>

const { home_active_tag } = storeToRefs(useGlobalStore());

页面区域

<view class="content">
<FoodCard
v-for="_package in list.dishes"
@click="goShopByFood(_package)"
:id="_package.id"
:name="_package.name"
:image="_package.image"
:tags="_package.tags"
:price="_package.price"
:shop_name="_package.shop_name"
:shop_id="_package.shop_id"
:key="_package.id"
></FoodCard>
<image
class="card"
@click="goMore"
:tabindex="home_active_tag === 'more' ? 1 : 0"
style="width: 29.375vw; height: 25.9375vw"
src="/static/home/more.png"
mode="aspectFill"
/>
</view>

const goShopByFood = async (row: Record<string, any>) => {
useGlobalStore().home_active_tag = 'foodcard' + row.id;
uni.navigateTo({
url: `/pages/shop/index?shop_id=${row.shop_id}`,
animationDuration: 500,
animationType: 'zoom-fade-out'
});
};


如果,要设置启动默认焦点 id和index可默认设置,推荐启动第一个焦点组用index,它可以确定


  <view class="active">
<image
v-for="(active, i) in list.active"
:key="active.id"
@click="goActive(active, i)"
:tabindex="home_active_tag === 'active' + i ? 1 : 0"
:src="`${VITE_URL}${active.image}`"
data-index="0"
fade-show
lazy-load
mode="aspectFill"
class="card"
></image>
</view>

import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
home_active_tag: 'active0', //默认选择
hot_active_tag: 'hot0',
dish_active_tag: 'dish0'
})
});


对于多层级的,要注意销毁,在前往之前设置默认焦点


const goHot = (index: number) => {
useGlobalStore().home_active_tag = 'hotcard' + index;
useGlobalStore().hot_active_tag = 'hot0';
uni.navigateTo({ url: `/pages/hot/index?index=${index}`, animationDuration: 500, animationType: 'zoom-fade-out' });
};


五、如何启用wgt和apk两种方式的升级


pages.json


{
"path": "components/update/index",
"style": {
"disableScroll": true,
"backgroundColor": "#0068d0",
"app-plus": {
"backgroundColorTop": "transparent",
"background": "transparent",
"titleNView": false,
"scrollIndicator": false,
"popGesture": "none",
"animationType": "fade-in",
"animationDuration": 200
}
}
}


组件


<template>
<view class="update">
<view class="content">
<view class="content-top">
<text class="content-top-text">发现版本</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="@/static/bg_top.png"> </image>
</view>
<text class="message"> {{ message }} </text>
<view class="progress-box">
<progress
class="progress"
border-radius="35"
:percent="progress.progress"
activeColor="#3DA7FF"
show-info
stroke-width="10"
/>

<view class="progress-text">
<text>安装包正在下载,请稍后,系统会自动重启</text>
<text>{{ progress.totalBytesWritten }}MB/{{ progress.totalBytesExpectedToWrite }}MB</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import { reactive, ref } from 'vue';
const message = ref('');
const progress = reactive({ progress: 0, totalBytesExpectedToWrite: '0', totalBytesWritten: '0' });
onLoad((query: any) => {
message.value = query.content;
const downloadTask = uni.downloadFile({
url: `${import.meta.env.VITE_URL}/${query.url}`,
success(downloadResult) {
plus.runtime.install(
downloadResult.tempFilePath,
{ force: false },
() => {
plus.runtime.restart();
},
e => {}
);
}
});
downloadTask.onProgressUpdate(res => {
progress.progress = res.progress;
progress.totalBytesExpectedToWrite = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
progress.totalBytesWritten = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
});
});
</script>
<style lang="less">
page {
background: transparent;
.update {
/* #ifndef APP-NVUE */
display: flex; /* #endif */
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
.content {
position: relative;
top: 0;
width: 50vw;
height: 50vh;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
border-radius: 2vw;
.content-top {
position: absolute;
top: -5vw;
left: 0;
image {
width: 50vw;
height: 30vh;
}
.content-top-text {
width: 50vw;
top: 6.6vw;
left: 3vw;
font-size: 3.8vw;
font-weight: bold;
color: #f8f8fa;
position: absolute;
z-index: 1;
}
}
}
.message {
position: absolute;
top: 15vw;
font-size: 2.5vw;
}
.progress-box {
position: absolute;
width: 45vw;
top: 20vw;
.progress {
width: 90%;
border-radius: 35px;
}
.progress-text {
margin-top: 1vw;
font-size: 1.5vw;
}
}
}
}
</style>


App.vue


import { onLaunch } from '@dcloudio/uni-app';
import { useRequest } from './hooks/useRequest';
import dayjs from 'dayjs'; onLaunch(() => {
// #ifdef APP-PLUS
plus.runtime.getProperty('', async app => { const res: any = await useRequest('GET', '/api/tv/app'); if (res.code === 2000 && res.row.version > (app.version as string)) { uni.navigateTo({ url: `/components/update/index?url=${res.row.url}&type=${res.row.type}&content=${res.row.content}`, fail: err => { console.error('更新弹框跳转失败', err); } }); } });
// #endif
});

如果要获取启动参数


plus.android.importClass('android.content.Intent');
const MainActivity = plus.android.runtimeMainActivity();
const Intent = MainActivity.getIntent();
const roomCode = Intent.getStringExtra('roomCode');
if (roomCode) {
uni.setStorageSync('roomCode', roomCode);
} else if (!uni.getStorageSync('roomCode') && !roomCode) {
uni.setStorageSync('roomCode', '8888');
}

作者:Rjl_CLI
来源:juejin.cn/post/7272348543625445437
收起阅读 »

如何移植 JsBridge 到鸿蒙

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。 关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL 和 对象注...
继续阅读 »

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。


关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL对象注入 两种,我们分别看一下如何在鸿蒙上实现。


拦截 URL


在安卓上,拦截 URL 这个技术方案的代表作一定是 github.com/lzyzsd/JsBr… ,相信有不少小伙伴都使用了这个开源库。


我这里就以该开源库为例,介绍一下如何在鸿蒙上无缝迁移。


首先,在页面加载完成后注入通信需要的 JS 代码。在 Android 中,是 WebViewClient.onPageFinished(),在鸿蒙中对应 Web组件的 onPageEnd()方法。


Web({ src: this.url, controller: this.controller })
.onPageEnd(() => {
this.onPageEnd()
BridgeUtil.webViewLoadLocalJs(getContext(), this.controller, BridgeUtil.toLoadJs)
})

鸿蒙中本地资源文件放在 resouce/rawfile 目录下,通过以下代码读取:


rawFile2Str(context: Context, file: string): string {
try {
let data = context.resourceManager.getRawFileContentSync(file)
let decoder = util.TextDecoder.create("utf-8")
let str = decoder.decodeWithStream(data, { stream: false })
return str
} catch (e) {
return ""
}
}

读取到的 JS 代码,通过系统能力动态执行。在 Android 中,通过 WebView.loadUrl() 或者 WebView.evaluateJavaScript() 来实现。在鸿蒙中,对应的是 WebviewController.runJavaScriptExt()


webViewLoadLocalJs(context: Context, controller: WebviewController, path: string) {
let jsContent = BridgeUtil.rawFile2Str(context, path)
controller.runJavaScriptExt(BridgeUtil.JAVASCRIPT_STR + jsContent, (err, result) => {
...
})
}

JS 代码注入完成后,就是核心的拦截 URL 了。在 Android 中,通过 WebViewClient.shouldOverrideUrlLoading() 实现,看一下具体的代码:


    @Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
try {
url = URLDecoder.decode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}

if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
webView.handlerReturnData(url);
return true;
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) { //
webView.flushMessageQueue();
return true;
} else {
return super.shouldOverrideUrlLoading(view, url);
}
}

拦截到所有的 URL,判断是否是 H5 通过 iFrame.src 发送的指定特征的 URL,来完成通信流程。


在鸿蒙中,对应的是 Web 组件的 onInterceptRequest()方法。


Web({ src: this.url, controller: this.controller })
.onInterceptRequest((event) => {
if (event) {
let url = event.request.getRequestUrl()
if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
this.ytoJsBridge.handlerReturnData(url)
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) {
this.ytoJsBridge.flushMessageQueue()
} else {
return null
}
}
return null
})

核心逻辑就这样,剩下的工作量就是苦逼的翻译代码。好在代码量并不大,大概五六个文件。


移植过程中,也踩了一些坑,印象最深的是 ArkTs 中关于接口的写法。


export interface CallBackFunction {
onCallBack(data: string): void
}

这是在 Java/Kotlin 中很常见的一种写法,顺手在 ArkTs 也这么写,但是在使用过程中尝试去写实现的时候就犯了难。如果直接按照传统的前端写法:


let responseFunction: CallBackFunction
if (callBackId != undefined)
{
responseFunction = {
onCallBack: (data: string): void => {
...
}
}
}

你会得到一个 lint 错误 Object literal must correspond to some explicitly declared class or interface (arkts-no-untyped-obj-literals) 。


你可以使用箭头函数来解决这个问题。


export interface CallBackFunction {
onCallBack: (data: string) => void
// onCallBack(data: string): void
}

这也是 ArkTs 目前比较割裂的地方,基于 TS,但是禁用了很多特性。


设想一下如果可以继续兼容 Java/Kotlin,那么这篇文章都不会存在了,压根不存在迁移成本,海量移动端类库无缝衔接......


对象注入


对象注入在 Android WebView 中的实现是 WebView.addJavascriptInterface(Object object, String name) 方法 。


addJavascriptInterface(JsBridge(this@MainActivity, webView), "JsBridge")

class JsBridge(private val activity: Activity, private val webView: WebView) {

@JavascriptInterface
fun webCallNative(message: String) {
Log.e("JsBridge", "webCallNative: ${Thread.currentThread().name}")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
}

在鸿蒙中,可以通过 Web 组件的 javaScriptProxy() 方法,或者 WebviewController.registerJavaScriptProxy() 方法。


      Web({ src: this.url, controller: this.controller })
.javaScriptAccess(true)
.javaScriptProxy({
object: this.testObj,
name: "objName",
methodList: ["test", "toString"],
controller: this.controller,
})

这种方式只支持注入一个对象,如果需要注入多个对象,要用 WebviewController.registerJavaScriptProxy()


this.controller.registerJavaScriptProxy(this.testObjtest, "objName", ["test", "toString", "testNumber", "testBool"]);
this.controller.registerJavaScriptProxy(this.webTestObj, "objTestName", ["webTest", "webString"]);

这个方法的调用时机需要注意,必须发生在 controller 和 Web 组件绑定之后,建议放在 Web.onPageEnd()。注册之后需要调用 WebviewController.refresh() 才会生效。


总结


一入鸿蒙深似海,波涛汹涌无尽头。

云涛翻滚遮日月,雾霭弥漫掩星楼。

仙禽异兽齐飞舞,灵草神木共清幽。

鸿蒙奥秘难穷尽,探寻真道意未休。


Write by 文心一言,如有雷同,请...


作者:MobileDeveloper
来源:juejin.cn/post/7345071687309180962
收起阅读 »

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

Android-桌面小组件RemoteViews播放动画

一、前言 前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器! 我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~) 咳咳,扯远了,说回正题 我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能...
继续阅读 »

一、前言


前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器!


我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~)


咳咳,扯远了,说回正题


我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能。好嘛,用户的话就是圣旨,那必须要安排上,正好我也练练手。


老规矩,先来看下我实现的效果



这个功能看着很简单对吧,却也花了我一天半的时间。主要用来实现敲击动画了!!


二、代码实现


1、新建小组件



 2、修改界面样式


主要会生成3个关键文件(文件名根据你设置的来)

①、APPWidget  类,继承于 AppWidgetProvider,本质是一个 BroadCastReceiver


②、layout/widget.xml ,小组件布局文件


③、xml/widget_info.xml ,小组件信息说明文件


同时会在 AndroidManifest中注册好


类似如下代码:


     <receiver
android:name=".receiver.MuyuAppWidgetBig"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.fyspring.bluetooth.receiver.action_appwidget_muyu_knock" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_info_big" />
</receiver>

3、添加敲木鱼逻辑代码


通过 APPWidget 的模板代码我们知道,内部通过 RemoteViews 来进行更新View,而我们都知道 RemoteViews 是无法通过 findViewById 来转成对应的 view,更无法对其添加 Animator。那么我们该怎么办来给桌面木鱼组件添加一个 缩放动画呢?


给你三秒时间考虑下,这里我可花了一天时间来研究....


通过 layoutAnimation !!!


layoutAnimation 是在 ViewGr0up 创建之后,显示时作用的,作用时间是:ViewGr0up 的首次创建显示,之后再有改变就不行了。


虽然 RemoteViews 不能执行 findViewById,但它提供了两个关键方法: remoteViews.removeAllViews  和  remoteViews.addView 。如果我们在点击时,向组件布局中添加一个带有 layoutAnimation 的布局,不是就可以间接播放动画了么?


关键代码:


private fun doAnimation(context: Context?, remoteViews: RemoteViews?) {
remoteViews?.removeAllViews(R.id.muyu_rl)
val remoteViews2 = RemoteViews(context?.packageName, R.layout.anim_layout)
remoteViews2.setImageViewResource(R.id.widget_muyu_iv, R.mipmap.ic_muyu)
remoteViews?.addView(R.id.muyu_rl, remoteViews2)
}

小组件布局:


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.BlueToothDemo.AppWidget.Container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_round_bg"
android:theme="@style/Theme.BlueToothDemo.AppWidgetContainer">

<LinearLayout
android:layout_width="140dp"
android:layout_height="140dp"
android:gravity="center_horizontal"
android:orientation="vertical">

<TextView
android:id="@+id/appwidget_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:contentDescription="测试桌面木鱼"
android:text="已敲0次"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />

<RelativeLayout
android:id="@+id/muyu_rl"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_margin="15dp"
android:src="@mipmap/ic_muyu" />

</RelativeLayout>
</LinearLayout>
</RelativeLayout>

添加替换的动画布局(anim_layout.xml),注意两边的木鱼ImgView 的 ID保持一致,因为要统一设置点击事件!!


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layoutAnimation="@anim/muyu_anim">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_muyu2" />
</RelativeLayout>

动画文件:(muyu_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/scale_anim"/>


动画文件:(scale_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="100"
android:fromXScale="0.9"
android:fromYScale="0.9"
android:interpolator="@android:anim/accelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1"
android:toYScale="1" />

关键动画代码就是以上这些,如果有问题欢迎私信。希望大家在新的一年里,木鱼一敲,烦恼全消~


欢迎体验下我做的木鱼,记得搜  我要敲木鱼  哦~~


作者:今夜太冷不宜私奔丶
来源:juejin.cn/post/7323025855154962459
收起阅读 »

Android开发中“真正”的仓库模式

原文标题:The “Real” Repository Pattern in Android 原文地址:proandroiddev.com/the-real-re… 原文发表日期:2019.9.5 作者:Denis Brandi 翻译:tommwq 翻译日期:2...
继续阅读 »

  • 原文标题:The “Real” Repository Pattern in Android

  • 原文地址:proandroiddev.com/the-real-re…

  • 原文发表日期:2019.9.5

  • 作者:Denis Brandi

  • 翻译:tommwq

  • 翻译日期:2024.1.3



Figure 1: 仓库模式


多年来我见过很多仓库模式的实现,我想其中大部分是错误而无益的。


下面是我所见最多的5个错误(一些甚至出现在Android官方文档中):



  1. 仓库返回DTO而非领域模型。

  2. 数据源(如ApiService、Dao等)使用同一个DTO。

  3. 每个端点集合使用一个仓库,而非每个实体(或DDD聚合根)使用一个仓库。

  4. 仓库缓存全部模型,即使是频繁更新的域。

  5. 数据源被多个仓库共享使用。


那么要如何把仓库模式做对呢?


1. 你需要领域模型


这是仓库模式的关键点,我想开发者难以正确实现仓库模式的原因在于他们不理解领域是什么。


引用Martin Fowler的话,领域模型是:



领域中同时包含行为和数据的对象模型。



领域模型基本上表示企业范围的业务规则。


对于不熟悉领域驱动设计构建块或分层架构(六边形架构,洋葱架构,干净架构等)的人来说,有三种领域模型:



  1. 实体:实体是具有标识(ID)的简单对象,通常是可变的。

  2. 值对象:没有标识的不可变对象。

  3. 聚合根(仅限DDD):与其他实体绑定在一起的实体(通常是一组关联对象的聚合)。


对于简单领域,这些模型看起来与数据库和网络模型(DTO)很像,不过它们也有很多差异:



  • 领域模型包含数据和过程,其结构最适于应用程序。

  • DTO是表示JSON/XML格式请求/应答或数据库表的对象模型,其结构最适于远程通信。


Listing 1: 领域模型示例


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

Listing 2: 网络DTO示例


// Network DTO
data class NetworkProduct(
@SerializedName("id")
val id: String?,
@SerializedName("name")
val name: String?,
@SerializedName("nowPrice")
val nowPrice: Double?,
@SerializedName("wasPrice")
val wasPrice: Double?
)

Listing 3: 数据库DTO示例


// Database DTO
@Entity(tableName = "Product")
data class DBProduct(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "nowPrice")
val nowPrice: Double,
@ColumnInfo(name = "wasPrice")
val wasPrice: Double
)

如你所见,领域模型不依赖框架,对象字段提倡使用多值属性(正如你看到的Price逻辑分组),并使用空对象模式(域不可为空)。而DTO则与框架(Gson、Room)耦合。


幸好有这样的隔离:



  • 应用程序的开发变得更容易,因为不需要检查空值,多值属性也减少了字段数量。

  • 数据源变更不会影响高层策略。

  • 避免了“上帝模型”,带来更多的关注点分离。

  • 糟糕的后端接口不会影响高层策略(想象一下,如果你需要执行两个网络请求,因为后端无法在一个接口中提供所有信息。你会让这个问题影响你的整个代码库吗?)


2. 你需要数据转换器


这是将DTO转换成领域模型,以及进行反向转换的地方。


多数开发者认为这种转换是无趣又无效的,他们喜欢将整个代码库,从数据源到界面,与DTO耦合。


这也许能让第一个版本更快交付,但不在表示层中隐藏业务规则和用例,而是省略领域层并将界面与数据源耦合会产生一些只会在生产环境遇到的故障(比如后端没有发送空字符串,而是发送null,并因此引发NPE)。


以我所见,转换器写起来快,测起来也简单。即使实现过程缺乏趣味,它也能保护我们不会因数据源行为的改变而受到意外影响。


如果你没有时间(或者干脆懒得)进行数据转换,你可以使用对象转换框架,比如如modelmapper.org/ ,来加速开发过程。


我不喜欢在代码中使用框架,为减少样板代码,我建立了一个泛型转换接口,以免为每个转换器建立独立接口:


interface Mapper<I, O> {
fun map(input: I): O
}

以及一组泛型列表转换器,以免实现特定的“列表到列表”转换:


// Non-nullable to Non-nullable
interface ListMapper<I, O>: Mapper<List<I>, List<O>>

class ListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : ListMapper<I, O> {
override fun map(input: List<I>): List<O> {
return input.map { mapper.map(it) }
}
}


// Nullable to Non-nullable
interface NullableInputListMapper<I, O>: Mapper<List<I>?, List<O>>

class NullableInputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableInputListMapper<I, O> {
override fun map(input: List<I>?): List<O> {
return input?.map { mapper.map(it) }.orEmpty()
}
}


// Non-nullable to Nullable
interface NullableOutputListMapper<I, O>: Mapper<List<I>, List<O>?>

class NullableOutputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableOutputListMapper<I, O> {
override fun map(input: List<I>): List<O>? {
return if (input.isEmpty()) null else input.map { mapper.map(it) }
}
}

注:在这篇文章中我展示了如何使用简单的函数式编程,以更少的样板代码实现相同的功能。


3. 你需要为每个数据源建立独立模型


假设在网络和数据库中使用同一个模型:


@Entity(tableName = "Product")
data class ProductDTO(
@PrimaryKey
@ColumnInfo(name = "id")
@SerializedName("id")
val id: String?,
@ColumnInfo(name = "name")
@SerializedName("name")
val name: String?,
@ColumnInfo(name = "nowPrice")
@SerializedName("nowPrice")
val nowPrice: Double?,
@ColumnInfo(name = "wasPrice")
@SerializedName("wasPrice")
val wasPrice: Double?
)

刚开始你可能会认为这比使用两个模型开发起来要快得多,但是你注意到它的风险了吗?


如果没有,我可以为你列出一些:



  • 你可能会缓存不必要的内容。

  • 在响应中添加新字段将需要变更数据库(除非添加@Ignore注解)。

  • 所有不应当在请求中发送的字段都需要添加@Transient注解。

  • 除非使用新字段,否则必须要求网络和数据库中的同名字段使用相同的数据类型(例如你无法解析网络响应中的字符串nowPrice并缓存双精度浮点数nowPrice)。


如你所见,这种方法最终将比独立模型需要更多的维护工作。


4. 你应该只缓存所需内容


如果要显示存储在远程目录中的产品列表,并且对本地保存的愿望清单中的每个产品显示经典的心形图标。


对于这个需求,需要:



  • 获取产品列表。

  • 检查本地存储,确认产品是否在愿望清单中。


这个领域模型很像前面例子,添加了一个字段表示产品是否在愿望清单中:


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

网络模型也和前面的示例类似,数据库模型则不再需要。


对于本地的愿望清单,可以将产品id保存在SharedPreferences中。不要使用数据库把简单的事情复杂化。


最后是仓库代码:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getProducts(): Single<Result<List<Product>>> {
return productApiService.getProducts().map {
when(it) {
is Result.Success -> Result.Success(mapProducts(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapProducts(networkProductList: List<NetworkProduct>): List<Product> {
return networkProductList.map {
productDataMapper.map(DataProduct(it, productPreferences.isFavourite(it.id)))
}
}
}

其中依赖的类定义如下:


// A wrapper for handling failing requests
sealed class Result<T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure<T>(val throwable: Throwable) : Result<T>()
}

// A DataSource for the SharedPreferences
interface ProductPreferences {
fun isFavourite(id: String?): Boolean
}

// A DataSource for the Remote DB
interface ProductApiService {
fun getProducts(): Single<Result<List<NetworkProduct>>>
fun getWishlist(productIds: List<String>): Single<Result<List<NetworkProduct>>>
}

// A cluster of DTOs to be mapped int0 a Product
data class DataProduct(
val networkProduct: NetworkProduct,
val isFavourite: Boolean
)

现在,如果只想获取愿望清单中的产品要怎么做呢?实现方式是类似的:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getWishlist(): Single<Result<List<Product>>> {
return productApiService.getWishlist(productPreferences.getFavourites()).map {
when (it) {
is Result.Success -> Result.Success(mapWishlist(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapWishlist(wishlist: List<NetworkProduct>): List<Product> {
return wishlist.map {
productDataMapper.map(DataProduct(it, true))
}
}
}

5. 后记


我多次熟练使用这种模式,我想它是一个时间节约神器,尤其在大型项目中。


然而我多次看到开发者使用这种模式仅仅是因为“不得不”,而非他们了解它的真正优势。


希望你觉得这篇文章有趣也有用。


作者:tommwq
来源:juejin.cn/post/7319698586421542953
收起阅读 »

安卓拍照、裁切、选取图片实践

安卓拍照、裁切、选取图片实践 前言 最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。 更新 最近花了点时间把拍照...
继续阅读 »

安卓拍照、裁切、选取图片实践


前言


最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。


更新


最近花了点时间把拍照、裁切的功能整理了下,并解决了下Android11上裁切闪退的问题、相册裁切闪退问题,就不多写一篇文章了,可以看我github的demo:


TakePhotoFragment.kt


BitmapFileUtil.kt


拍照


本来拍照是没什么难度的,不就是调用intent去系统相机拍照么,但是由于文件权限问题,Uri这东西就能把人很头疼。下面是代码(onActivityResult见后文):


    private fun openCamera() {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// 应用外部私有目录:files-Pictures
val picFile = createFile("Camera")
val photoUri = getUriForFile(picFile)
// 保存路径,不要uri,读取bitmap时麻烦
picturePath = picFile.absolutePath
// 给目标应用一个临时授权
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
//android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
startActivityForResult(intent, REQUEST_CAMERA_CODE)
}

private fun createFile(type: String): File {
// 在相册创建一个临时文件
val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
"${type}_${System.currentTimeMillis()}.jpg")
try {
if (picFile.exists()) {
picFile.delete()
}
picFile.createNewFile()
} catch (e: IOException) {
e.printStackTrace()
}

// 临时文件,后面会加long型随机数
// return File.createTempFile(
// type,
// ".jpg",
// requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
// )

return picFile
}

private fun getUriForFile(file: File): Uri {
// 转换为uri
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
FileProvider.getUriForFile(
requireActivity(),
"com.xxx.xxx.fileProvider", file
)
} else {
Uri.fromFile(file)
}
}

简单说明


这里的file是使用getExternalFilesDir和Environment.DIRECTORY_PICTURES生成的,它的文件保存在应用外部私有目录:files-Pictures里面。这里要注意不能存放在内部的私有目录里面,不然是无法访问的,外部私有目录虽然也是私有的,但是外面是可以访问的,这里拿官网上的说明:



在搭载 Android 9(API 级别 28)或更低版本的设备上,只要您的应用具有适当的存储权限,就可以访问属于其他应用的应用专用文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。



Uri的获取


再一个比较麻烦的就是Uri的获取了,网上有一大堆资料,不过我这也贴一下,网上的可能有问题。



manifest.xml



        <provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.xxx.xxx.fileProvider"
android:exported="false"
android:grantUriPermissions="true">

<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>

</provider>


res -> xml -> file_paths.xml



<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!--1、对应内部内存卡根目录:Context.getFileDir()-->
<files-path
name="int_root"
path="/" />

<!--2、对应应用默认缓存根目录:Context.getCacheDir()-->
<cache-path
name="app_cache"
path="/" />

<!--3、对应外部内存卡根目录:Environment.getExternalStorageDirectory()-->
<external-path
name="ext_root"
path="/" />

<!--4、对应外部内存卡根目录下的APP公共目录:Context.getExternalFileDir(String)-->
<external-files-path
name="ext_pub"
path="/" />

<!--5、对应外部内存卡根目录下的APP缓存目录:Context.getExternalCacheDir()-->
<external-cache-path
name="ext_cache"
path="/" />

</paths>

ps. 注意authorities这个最好填自己的包名,不然有两个应用用了同样的authorities,后面的应用会安装不上。


path里面填 “/” 和 “*” 是有区别的,前者包含了子目录,后面只包含当前目录,最好就是用 “/”,不然创建个子文件夹,到时候访问搞出了线上问题,那就凉凉喽(还好我遇到的时候测试测出来了)。


打开相册


这里打开相册用的是SAF框架,使用intent去选取(onActivityResult见后文)。


    private fun openAlbum() {
val intent = Intent()
intent.type = "image/*"
intent.action = "android.intent.action.GET_CONTENT"
intent.addCategory("android.intent.category.OPENABLE")
startActivityForResult(intent, REQUEST_ALBUM_CODE)
}

裁切


裁切这里比较麻烦,参数比较多,而且Uri那里有坑,不能使用provider,再一个就是图片传递那因为安卓版本变更,不会传略缩图了,很坑。


    private fun cropImage(path: String) {
cropImage(getUriForFile(File(path)))
}

private fun cropImage(uri: Uri) {
val intent = Intent("com.android.camera.action.CROP")
// Android 7.0需要临时添加读取Url的权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.setDataAndType(uri, "image/*")
// 使图片处于可裁剪状态
intent.putExtra("crop", "true")
// 裁剪框的比例(根据需要显示的图片比例进行设置)
// if (Build.MANUFACTURER.contains("HUAWEI")) {
// //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
// intent.putExtra("aspectX", 9999)
// intent.putExtra("aspectY", 9998)
// } else {
// //其他手机一般默认为方形
// intent.putExtra("aspectX", 1)
// intent.putExtra("aspectY", 1)
// }

// 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
// intent.putExtra("circleCrop", true);
// 让裁剪框支持缩放
intent.putExtra("scale", true)
// 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
// intent.putExtra("outputX", 400)
// intent.putExtra("outputY", 400)

// 生成临时文件
val cropFile = createFile("Crop")
// 裁切图片时不能使用provider的uri,否则无法保存
// val cropUri = getUriForFile(cropFile)
val cropUri = Uri.fromFile(cropFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
// 记录临时位置
cropPicPath = cropFile.absolutePath

// 设置图片的输出格式
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

// return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
intent.putExtra("return-data", false)

startActivityForResult(intent, REQUEST_CROP_CODE)
}

回调处理


下面是对上面三个操作的回调处理,一开始我觉得uri没什么用,还制造麻烦,后面发现可以通过流打开uri,再去获取bitmap,好像又不是那么麻烦了。


    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
when(requestCode) {
REQUEST_CAMERA_CODE -> {
// 通知系统文件更新
// requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
// Uri.fromFile(File(picturePath))))
if (!enableCrop) {
val bitmap = getBitmap(picturePath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(picturePath)
}
}
REQUEST_ALBUM_CODE -> {
data?.data?.let { uri ->
if (!enableCrop) {
val bitmap = getBitmap("", uri)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(uri)
}
}
}
REQUEST_CROP_CODE -> {
val bitmap = getBitmap(cropPicPath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}
}
}
}

private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
var bitmap: Bitmap?
val options = BitmapFactory.Options()
// 先不读取,仅获取信息
options.inJustDecodeBounds = true
if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}

// 预获取信息,大图压缩后加载
val width = options.outWidth
val height = options.outHeight
Log.d("TAG", "before compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 尺寸压缩
var size = 1
while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
size *= 2
}
options.inSampleSize = size
options.inJustDecodeBounds = false
bitmap = if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}
Log.d("TAG", "after compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 质量压缩
val baos = ByteArrayOutputStream()
bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val bais = ByteArrayInputStream(baos.toByteArray())
options.inSampleSize = 1
bitmap = BitmapFactory.decodeStream(bais, null, options)

return bitmap
}

这里还做了一个图片的质量压缩和采样压缩,需要注意的是采样压缩的采样率只能是2的倍数,如果需要按任意比例采样,需要用到Matrix,不是很难,读者可以研究下。


权限问题


如果你发现你没有申请权限,那你的去申请一下相机权限;如果你发现你还申请了储存权限,那你可以试一下去掉储存权限,实际还是可以使用的,因为这里并没有用到外部储存,都是应用的私有储存内,具体关于储存的适配,可以看我转载的这几篇文章,我觉得写的非常好:


Android 存储基础


Android 10、11 存储完全适配(上)


Android 10、11 存储完全适配(下)


结语


以上代码都经过我这里实践了,确认了可用,可能写法不是最优,可以避免使用绝对路径,只使用Uri。至于请求码、布局什么的,读者自己改一下加一个就行,核心部分已经在这了。如果需要完整代码,可以看下篇文章末尾!


Android 不申请权限储存、删除相册图片


作者:方大可
来源:juejin.cn/post/7222874734186037285
收起阅读 »

如何写一个无侵入式的动态权限申请Android框架?

1、核心逻辑 在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功,申请失败,多次拒绝。以上就是使用者需要做的。 简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。 public class MainActivi...
继续阅读 »

1、核心逻辑


在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功申请失败多次拒绝。以上就是使用者需要做的。


简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。


public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void permissionRequestTest(View view) {
testRequest();
}
// 申请权限 函数名可以随意些
@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200)
public void testRequest() {
Toast.makeText(this, "权限申请成功...", Toast.LENGTH_SHORT).show();
}
// 权限被取消 函数名可以随意些
@PermissionCancel
public void testCancel() {
Toast.makeText(this, "权限被拒绝", Toast.LENGTH_SHORT).show();
}

// 多次拒绝,还勾选了“不再提示”
@PermissionDenied
public void testDenied() {
Toast.makeText(this, "权限被拒绝(用户勾选了 不再提示),注意:你必须要去设置中打开此权限,否则功能无法使用", Toast.LENGTH_SHORT).show();
}


2、实现


需要用到的技术有Aspect、注解、反射


2.1、Aspect


它的作用就是劫持被注释的方法的执行。比如上方testRequest()是用来请求权限的,但是我在ASPECT中配置拦截@permission注释的方法。先做判断。


如果没有听过Aspect的话,AOP面向切面编程,大家应该听说过,它可以用来配置事务、做日志、权限验证、在用户请求时做一些处理等等。而用@Aspect做一个切面,就可以直接实现。


2.2、PermissionAspect

我们会创建一个PermissionAspect类,整一个切点,让@Permission被劫持。


// AOP 思维 切面的思维
// 切点 --- 是注解 @
// * * 任何函数 都可以使用 此注解
//(..) 我要带参数 带的参数就是后面那个 @annotation(permission)意思就是 参数里是permission。这样我就拿到了Permission注解它里面的参数
//这样通过切点就拿到了下面这个注解
//@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200) 这就是 Permission permission
@Pointcut
("execution(@com.derry.premissionstudy.permission.annotation.Permission * *(..)) && @annotation(permission)")
//那么这个
// @Permission == permission
public void pointActionMethod(Permission permission) {

} // 切点函数

//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this


这样@Permission就被切点劫持了,然后方法就会跑到切面aProceedingJoinPoint。然后获取上下文Context,把权限请求交给一个透明的Activity来做。做完之后判断结果,用户是同意了还是拒绝了还是曲线了。同意了直接执行point.proceed(),其他方式则通过Activity或者fragment获取带注解的方法,反射执行即可。


//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this

Context context = null;
// MainActivity this == thisObject
final Object thisobject = point.getThis();

// context初始化
if(thisobject instanceof Context){
context = (Context) thisobject;
} else if(thisobject instanceof Fragment){
context = ((Fragment) thisobject).getActivity();
}

// 判断是否为null
if (null == context || permission == null) {
throw new IllegalAccessException("null == context || permission == null is null");
}


//trestRequest 次函数被控制了 不会执
//
//动态申请 危险权限 透明的空白的Ativity
//这里一定要得知接口三个状态 已经授权 取消授权 拒绝授权
//调用 空白的Actiivty 开始授权

MyPermissionActivity.requestPermissionAction(context, permission.value(), permission.requestCode(), new IPermission() {
@Override
public void ganted() {
try {
point.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
@Override
public void cancel() {
PermissionUtils.invokeAnnotion(thisobject, PermissionCancel.class);
}

@Override
public void denied() {
PermissionUtils.invokeAnnotion(thisobject, PermissionDenied.class);
}
});
}

2.3、空白执行权限的Activity


执行请求权限的Activity的的相应方法会流到PermissionAspect,然后到空白Activity请求。请求完之后的结果,再通过回调传回去就好了。



public class MyPermissionActivity extends AppCompatActivity {



// 定义权限处理的标记, ---- 接收用户传递进来的
private final static String PARAM_PERMSSION = "param_permission";
private final static String PARAM_PERMSSION_CODE = "param_permission_code";
public final static int PARAM_PERMSSION_CODE_DEFAULT = -1;


private String[] permissions;
private int requestCode;


// 方便回调的监听 告诉外交 已授权,被拒绝,被取消
private static IPermission iPermissionListener;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my_permission);

permissions = getIntent().getStringArrayExtra(PARAM_PERMSSION);
requestCode = getIntent().getIntExtra(PARAM_PERMSSION_CODE, PARAM_PERMSSION_CODE_DEFAULT);


if(permissions == null){
this.finish();
return;
}

// 能够走到这里,就开始去检查,是否已经授权了
boolean permissionRequest = PermissionUtils.hasPermissionRequest(this,permissions);
if (permissionRequest) {
// 通过监听接口,告诉外交,已经授权了
iPermissionListener.ganted();

this.finish();
return;
}
ActivityCompat.requestPermissions(this,permissions,requestCode);
}


@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

if(PermissionUtils.requestPermissionSuccess(grantResults)){
iPermissionListener.ganted();//已经授权成功了 告知AspectJ
this.finish();
}

// 没有成功,可能是用户 不听话
// 如果用户点击了,拒绝(勾选了”不再提醒“) 等操作
if(!PermissionUtils.shouldShowRequestPermissionRationable(this,permissions)){
iPermissionListener.denied();

this.finish();
return;
}
// 取消
iPermissionListener.cancel(); // 接口告知 AspectJ
this.finish();
return;
}


// 让此Activity不要有任何动画
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}


public static void requestPermissionAction(Context context, String[] permissions, int requestCode, IPermission iPermissionLIstener){

MyPermissionActivity.iPermissionListener = iPermissionLIstener;
Intent intent = new Intent(context,MyPermissionActivity.class);
//效果
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Bundle bundle = new Bundle();

Log.d("TAG", "requestPermissionAction: "+requestCode);
bundle.putInt(PARAM_PERMSSION_CODE,requestCode);
bundle.putStringArray(PARAM_PERMSSION,permissions);
intent.putExtras(bundle);
context.startActivity(intent);
}
}

2.4、其它


app gradle中


 
apply plugin: 'com.android.application'


buildscript {
repositories {
mavenCentral()
}

dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "com.netease.premissionstudy"
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

dependencies {

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

implementation 'org.aspectj:aspectjrt:1.8.13'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->

if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile

javaCompile.doLast {

String[] args = ["-showWeaveInfo",

"-1.8",

"-inpath", javaCompile.destinationDir.toString(),

"-aspectpath", javaCompile.classpath.asPath,

"-d", javaCompile.destinationDir.toString(),

"-classpath", javaCompile.classpath.asPath,

"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]

log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);

new Main().run(args, handler);

for (IMessage message : handler.getMessages(null, true)) {

switch (message.getKind()) {

case IMessage.ABORT:

case IMessage.ERROR:

case IMessage.FAIL:

log.error message.message, message.thrown

break;

case IMessage.WARNING:

log.warn message.message, message.thrown

break;

case IMessage.INFO:

log.info message.message, message.thrown

break;

case IMessage.DEBUG:

log.debug message.message, message.thrown

break;
}
}
}
}

项目 gradle中



buildscript {
repositories {
google()
jcenter()

}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.1'

classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()

}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

3、总结


其实核心就是,用aspect去劫持注解,然后让一个公共的Activity来处理这个事情,然后回调,再反射其它方法执行。
等有时间了给他打成一个包。


作者:KentWang
来源:juejin.cn/post/7245836790039445562
收起阅读 »

什么情况下Activity会被杀掉呢?

首先一个报错来作为开篇: Caused by androidx.fragment.app.Fragment$InstantiationException Unable to instantiate fragment xxx: could not find Fr...
继续阅读 »

首先一个报错来作为开篇:


Caused by androidx.fragment.app.Fragment$InstantiationException
Unable to instantiate fragment xxx: could not find Fragment constructor

这个报错原因就是Fragment如果重载了有参的构造方法,没有实现默认无参构造方法。Activity被回收又回来尝试重新恢复Fragment的时候报错的。


那如何模拟Activity被回收呢?

可能有人知道,一个方便快捷的方法就是:打开 开发者选项 - 不保留活动,这样每次Activity回到后台都会被回收,也就可以很方便的测试这种case。


但抛开这种方式我怎么来复现这种情况呢?

这里我提出一种方式:我是不是可以打开我的App,按Home回到后台,然后疯狂的打开手机里其他的大型应用或者游戏这类的能占用大量手机内存的App,等手机内存占用大的时候是不是可以复现这种情况呢?


结论是不可以,不要混淆两个概念,系统内存不足App内存不足,两者能引起的后果也是不同的



  • 系统内存不足 -> 杀掉应用进程

  • App内存不足 -> 杀掉后台Activity


首先明确一点,Android框架对进程创建与管理进行了封装,对于APP开发者只需知道Android四大组件的使用。当Activity, Service, ContentProvider, BroadcastReceiver任一组件启动时,当其所承载的进程存在则直接使用,不存在则由框架代码自动调用startProcessLocked创建进程。所以说对APP来说进程几乎是透明的,但了解进程对于深刻理解Android系统是至关关键的。


1. 系统内存不够 -> 杀掉应用进程


1.1. LKM简介

Android底层还是基于Linux,在Linux中低内存是会有oom killer去杀掉一些进程去释放内存,而Android中的lowmemorykiller就是在此基础上做了一些调整来的。因为手机上的内存毕竟比较有限,而Android中APP在不使用之后并不是马上被杀掉,虽然上层ActivityManagerService中也有很多关于进程的调度以及杀进程的手段,但是毕竟还需要考虑手机剩余内存的实际情况,lowmemorykiller的作用就是当内存比较紧张的时候去及时杀掉一些ActivityManagerService还没来得及杀掉但是对用户来说不那么重要的进程,回收一些内存,保证手机的正常运行。


lowmemkiller中会涉及到几个重要的概念:

/sys/module/lowmemorykiller/parameters/minfree:里面是以”,”分割的一组数,每个数字代表一个内存级别

/sys/module/lowmemorykiller/parameters/adj: 对应上面的一组数,每个数组代表一个进程优先级级别


比如:

/sys/module/lowmemorykiller/parameters/minfree:18432, 23040, 27648, 32256, 55296, 80640

/sys/module/lowmemorykiller/parameters/adj: 0, 100, 200, 300, 900, 906


代表的意思是两组数一一对应:



  • 当手机内存低于80640时,就去杀掉优先级906以及以上级别的进程

  • 当内存低于55296时,就去杀掉优先级900以及以上的进程


可能每个手机的配置是不一样的,可以查看一下手头的手机,需要root。


1.2. 如何查看ADJ

如何查看进程的ADJ呢?比如我们想看QQ的adj


-> adb shell ps | grep "qq" 
UID PID PPID C STIME TTY TIME CMD
u0_a140 9456 959 2 10:03:07 ? 00:00:22 com.tencent.mobileqq
u0_a140 9987 959 1 10:03:13 ? 00:00:07 com.tencent.mobileqq:mini3
u0_a140 16347 959 0 01:32:48 ? 00:01:12 com.tencent.mobileqq:MSF
u0_a140 21475 959 0 19:47:33 ? 00:01:25 com.tencent.mobileqq:qzone

# 看到QQ的PID为 9456,这个时候打开QQ,让QQ来到前台
-> adb shell cat /proc/9456/oom_score_adj
0

# 随便打开一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
700

# 再随便打开另外一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
900

我们可以看到adj是在根据用户的行为不断变化的,前台的时候是0,到后台是700,回到后台后再打开其他App后是900

常见ADJ级别如下:


ADJ级别取值含义
NATIVE_ADJ-1000native进程
SYSTEM_ADJ-900仅指system_server进程
PERSISTENT_PROC_ADJ-800系统persistent进程
PERSISTENT_SERVICE_ADJ-700关联着系统或persistent进程
FOREGROUND_APP_ADJ0前台进程
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_APP_ADJ200可感知进程,比如后台音乐播放
BACKUP_APP_ADJ300备份进程
HEAVY_WEIGHT_APP_ADJ400重量级进程
SERVICE_ADJ500服务进程
HOME_APP_ADJ600Home进程
PREVIOUS_APP_ADJ700上一个进程
SERVICE_B_ADJ800B List中的Service
CACHED_APP_MIN_ADJ900不可见进程的adj最小值
CACHED_APP_MAX_ADJ906不可见进程的adj最大值

So,当系统内存不足的时候会kill掉整个进程,皮之不存毛将焉附,Activity也就不在了,当然也不是开头说的那个case。


2. App内存不足 -> 杀掉后台Activity


上面分析了是直接kill掉进程的情况,一旦出现进程被kill掉,说明内存情况已经到了万劫不复的情况了,抛开内存泄漏的情况下,framework也需要一些策略来避免无内存可用的情况。下面我们来找一找fw里面回收Activity的逻辑(代码Base Android-30)。



Android Studio查看源码无法查看com.android.internal包名下的代码,双击Shift,勾选右上角Include non-prject Items.



入口定位到ActivityThreadattach方法,ActivityThread是App的入口程序,main方法中创建并调用atttach


// ActivityThread.java
private void attach(boolean system, long startSeq) {
...
// Watch for getting close to heap limit.
BinderInternal.addGcWatcher(new Runnable() {
@Override public void run() {
// mSomeActivitiesChanged在生命周期变化的时候会修改为true
if (!mSomeActivitiesChanged) {
return;
}
Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) {
mSomeActivitiesChanged = false;
try {
ActivityTaskManager.getService().releaseSomeActivities(mAppThread);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
});
...
}

这里关注BinderInternal.addGcWatcher, 下面有几个点需要理清:



  1. addGcWatcher是干嘛的,这个Runnable什么时候会被执行。

  2. 这里的maxMemory() / totalMemory() / freeMemory()都怎么理解,值有什么意义

  3. releaseSomeActivities()做了什么事情,回收Activity的逻辑是什么。


还有一个小的点是这里还用了mSomeActivitiesChanged这个标记位来标记让检测工作不会过于频繁的执行,检测到需要releaseSomeActivities后会有一个mSomeActivitiesChanged = false;赋值。而所有的mSomeActivitiesChanged = true操作都在handleStartActivity/handleResumeActivity...等等这些操作Activity声明周期的地方。控制了只有Activity声明周期变化了之后才会继续去检测是否需要回收。


2.1. GcWatcher

BinderInternal.addGcWatcher是个静态方法,相关代码如下:


public class BinderInternal {
private static final String TAG = "BinderInternal";
static WeakReference<GcWatcher> sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
static Runnable[] sTmpWatchers = new Runnable[1];

static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
handleGc();
sLastGcTime = SystemClock.uptimeMillis();
synchronized (sGcWatchers) {
sTmpWatchers = sGcWatchers.toArray(sTmpWatchers);
}
for (int i=0; i<sTmpWatchers.length; i++) {
if (sTmpWatchers[i] != null) {
sTmpWatchers[i].run();
}
}
sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
}
}

public static void addGcWatcher(Runnable watcher) {
synchronized (sGcWatchers) {
sGcWatchers.add(watcher);
}
}
...
}

两个重要的角色:sGcWatcherssGcWatcher



  • sGcWatchers保存了调用BinderInternal.addGcWatcher后需要执行的Runnable(也就是检测是否需要kill Activity的Runnable)。

  • sGcWatcher是个装了new GcWatcher()的弱引用。


弱引用的规则是如果一个对象只有一个弱引用来引用它,那GC的时候就会回收这个对象。那很明显new出来的这个GcWatcher()只会有sGcWatcher这一个弱引用来引用它,所以每次GC都会回收这个GcWatcher对象,而回收的时候会调用这个对象的finalize()方法,finalize()方法中会将之前注册的Runnable来执行掉。
注意哈,这里并没有移除sGcWatcher中的Runnable,也就是一开始通过addGcWatcher(Runnable watcher)进来的runnable一直都在,不管执行多少次run的都是它。


为什么整个系统中addGcWatcher只有一个调用的地方,但是sGcWatchers确实一个List呢?我在自己写了这么一段代码并且想着怎么能反射搞到系统当前的BinderInternal一探究竟的时候明白了一点点,我觉着他们就是怕有人主动调用了addGcWatcher给弄了好多个GcWatcher导致系统的失效了才搞了个List吧。。


2.2. App可用的内存

上面的Runnable是如何检测当前的系统内存不足的呢?通过以下的代码


        Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) { ... }

看变量名字就知道,在使用的内存到达总内存的3/4的时候去做一些事情,这几个方法的注释如下:


    /**
* Returns the amount of free memory in the Java Virtual Machine.
* Calling the gc method may result in increasing the value returned by freeMemory.
* @return an approximation to the total amount of memory currently available for future allocated objects, measured in bytes.
*/

public native long freeMemory();

/**
* Returns the total amount of memory in the Java virtual machine.
* The value returned by this method may vary over time, depending on the host environment.
* @return the total amount of memory currently available for current and future objects, measured in bytes.
*/

public native long totalMemory();

/**
* Returns the maximum amount of memory that the Java virtual machine will attempt to use.
* If there is no inherent limit then the value java.lang.Long#MAX_VALUE will be returned.
* @return the maximum amount of memory that the virtual machine will attempt to use, measured in bytes
*/

public native long maxMemory();

首先确认每个App到底有多少内存可以用,这些Runtime的值都是谁来控制的呢?


可以使用adb shell getprop | grep "dalvik.vm.heap"命令来查看手机给每个虚拟机进程所分配的堆配置信息:


yocn@yocn ~ % adb shell getprop | grep "dalvik.vm.heap"
[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]

这些值分别是什么意思呢?



  • [dalvik.vm.heapgrowthlimit]和[dalvik.vm.heapsize]都是当前应用进程可分配内存的最大限制,一般heapgrowthlimit < heapsize,如果在Manifest中的application标签中声明android:largeHeap=“true”,APP直到heapsize才OOM,否则达到heapgrowthlimit就OOM

  • [dalvik.vm.heapstartsize] Java堆的起始大小,指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小,后面再根据需要逐渐向系统申请更多的物理内存,直到达到MAX

  • [dalvik.vm.heapminfree] 堆最小空闲值,GC后

  • [dalvik.vm.heapmaxfree] 堆最大空闲值

  • [dalvik.vm.heaptargetutilization] 堆目标利用率


比较难理解的就是heapminfree、heapmaxfree和heaptargetutilization了,按照上面的方法来说:
在满足 heapminfree < freeMemory() < heapmaxfree的情况下使得(totalMemory() - freeMemory()) / totalMemory()接近heaptargetutilization


所以一开始的代码就是当前使用的内存到达分配的内存的3/4的时候会调用releaseSomeActivities去kill掉某些Activity.


2.3. releaseSomeActivities

releaseSomeActivities在API 29前后差别很大,我们来分别看一下。


2.3.1. 基于API 28的版本的releaseSomeActivities实现如下:

// step①:ActivityManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized(this) {
final long origId = Binder.clearCallingIdentity();
try {
ProcessRecord app = getRecordForAppLocked(appInt);
mStackSupervisor.releaseSomeActivitiesLocked(app, "low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// step②:ActivityStackSupervisor.java
void releaseSomeActivitiesLocked(ProcessRecord app, String reason) {
TaskRecord firstTask = null;
ArraySet<TaskRecord> tasks = null;
for (int i = 0; i < app.activities.size(); i++) {
ActivityRecord r = app.activities.get(i);
// 如果当前有正在销毁状态的Activity,Do Nothing
if (r.finishing || r.state == DESTROYING || r.state == DESTROYED) {
return;
}
// 只有Activity在可以销毁状态的时候才继续往下走
if (r.visible || !r.stopped || !r.haveState || r.state == RESUMED || r.state == PAUSING
|| r.state == PAUSED || r.state == STOPPING) {
continue;
}
if (r.task != null) {
if (firstTask == null) {
firstTask = r.task;
} else if (firstTask != r.task) {
// 2.1 只有存在两个以上的Task的时候才会到这里
if (tasks == null) {
tasks = new ArraySet<>();
tasks.add(firstTask);
}
tasks.add(r.task);
}
}
}
// 2.2 只有存在两个以上的Task的时候才不为空
if (tasks == null) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Didn't find two or more tasks to release");
return;
}
// If we have activities in multiple tasks that are in a position to be destroyed,
// let's iterate through the tasks and release the oldest one.
// 2.3 遍历找到ActivityStack释放最旧的那个
final int numDisplays = mActivityDisplays.size();
for (int displayNdx = 0; displayNdx < numDisplays; ++displayNdx) {
final ArrayList<ActivityStack> stacks = mActivityDisplays.valueAt(displayNdx).mStacks;
// Step through all stacks starting from behind, to hit the oldest things first.
// 从后面开始遍历,从最旧的开始匹配
for (int stackNdx = 0; stackNdx < stacks.size(); stackNdx++) {
final ActivityStack stack = stacks.get(stackNdx);
// Try to release activities in this stack; if we manage to, we are done.
// 尝试在这个stack里面销毁这些Activities,如果成功就返回。
if (stack.releaseSomeActivitiesLocked(app, tasks, reason) > 0) {
return;
}
}
}
}

上面代码都加了注释,我们来理一理重点需要关注的点。整个流程可以观察tasks的走向



  • 2.1 & 2.2: 第一次循环会给firstTask赋值,当firstTask != r.task的时候才会给tasks赋值,后续会继续对tasks操作。所以单栈的应用不会回收,如果tasks为null,就直接return了,什么都不做

  • 2.3: 这一大段的双重for循环其实都没有第一步遍历出来的tasks参与,真正释放Activity的操作在ActivityStack中,所以尝试找到这些tasks对应的ActivityStack,让ActivityStack去销毁tasks,直到成功销毁。


继续查看releaseSomeActivitiesLocked:


// step③ ActivityStack.java
final int releaseSomeActivitiesLocked(ProcessRecord app, ArraySet<TaskRecord> tasks, String reason) {
// Iterate over tasks starting at the back (oldest) first.
int maxTasks = tasks.size() / 4;
if (maxTasks < 1) {
maxTasks = 1;
}
// 3.1 maxTasks至少为1,至少清理一个
int numReleased = 0;
for (int taskNdx = 0; taskNdx < mTaskHistory.size() && maxTasks > 0; taskNdx++) {
final TaskRecord task = mTaskHistory.get(taskNdx);
if (!tasks.contains(task)) {
continue;
}
int curNum = 0;
final ArrayList<ActivityRecord> activities = task.mActivities;
for (int actNdx = 0; actNdx < activities.size(); actNdx++) {
final ActivityRecord activity = activities.get(actNdx);
if (activity.app == app && activity.isDestroyable()) {
destroyActivityLocked(activity, true, reason);
if (activities.get(actNdx) != activity) {
// Was removed from list, back up so we don't miss the next one.
// 3.2 destroyActivityLocked后续会调用TaskRecord.removeActivity(),所以这里需要将index--
actNdx--;
}
curNum++;
}
}
if (curNum > 0) {
numReleased += curNum;
// 移除一个,继续循环需要判断 maxTasks > 0
maxTasks--;
if (mTaskHistory.get(taskNdx) != task) {
// The entire task got removed, back up so we don't miss the next one.
// 3.3 如果整个task都被移除了,这里同样需要将获取Task的index--。移除操作在上面3.1的destroyActivityLocked,移除Activity过程中,如果task为空了,会将task移除
taskNdx--;
}
}
}
return numReleased;
}


  • 3.1: ActivityStack利用maxTasks 保证,最多清理tasks.size() / 4,最少清理1个TaskRecord,同时,至少要保证保留一个前台可见TaskRecord,比如如果有两个TaskRecord,则清理先前的一个,保留前台显示的这个,如果三个,则还要看看最老的是否被有效清理,也就是是否有Activity被清理,如果有则只清理一个,保留两个,如果没有,则继续清理次老的,保留一个前台展示的,如果有四个,类似,如果有5个,则至少两个清理。一般APP中,很少有超过两个TaskRecord的。

  • 3.2: 这里清理的逻辑很清楚,for循环,如果定位到了期望的activity就清理掉,但这里这个actNdx--是为什么呢?注释说activity从list中移除了,为了能继续往下走,需要index--,但在这个方法中并没有将activity从lsit中移除的操作,那肯定是在destroyActivityLocked方法中。继续追进去可以一直追到TaskRecord.java#removeActivity(),从当前的TaskRecord的mActivities中移除了,所以需要index--。

  • 3.3: 我们弄懂了上面的actNdx--之后也就知道这里为什么要index--了,在ActivityStack.java#removeActivityFromHistoryLocked()中有


	if (lastActivity) {
removeTask(task, reason, REMOVE_TASK_MODE_DESTROYING);
}

如果task中没有activity了,需要将这个task移除掉。


以上就是基于API 28的releaseSomeActivities分析。


2.3.2. 基于29+的版本的releaseSomeActivities实现如下:

// ActivityTaskManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized (mGlobalLock) {
final long origId = Binder.clearCallingIdentity();
try {
final WindowProcessController app = getProcessController(appInt);
app.releaseSomeActivities("low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// WindowProcessController.java
void releaseSomeActivities(String reason) {
// Examine all activities currently running in the process. Candidate activities that can be destroyed.
// 检查进程里所有的activity,看哪些可以被关掉
ArrayList<ActivityRecord> candidates = null;
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Trying to release some activities in " + this);
for (int i = 0; i < mActivities.size(); i++) {
final ActivityRecord r = mActivities.get(i);
// First, if we find an activity that is in the process of being destroyed,
// then we just aren't going to do anything for now; we want things to settle
// down before we try to prune more activities.
// 首先,如果我们发现一个activity正在执行关闭中,在关掉这个activity之前什么都不做
if (r.finishing || r.isState(DESTROYING, DESTROYED)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Abort release; already destroying: " + r);
return;
}
// Don't consider any activities that are currently not in a state where they can be destroyed.
// 如果当前activity不在可关闭的state的时候,不做处理
if (r.mVisibleRequested || !r.stopped || !r.hasSavedState() || !r.isDestroyable()
|| r.isState(STARTED, RESUMED, PAUSING, PAUSED, STOPPING)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Not releasing in-use activity: " + r);
continue;
}

if (r.getParent() != null) {
if (candidates == null) {
candidates = new ArrayList<>();
}
candidates.add(r);
}
}

if (candidates != null) {
// Sort based on z-order in hierarchy.
candidates.sort(WindowContainer::compareTo);
// Release some older activities
int maxRelease = Math.max(candidates.size(), 1);
do {
final ActivityRecord r = candidates.remove(0);
r.destroyImmediately(true /*removeFromApp*/, reason);
--maxRelease;
} while (maxRelease > 0);
}
}

新版本的releaseSomeActivities放到了ActivityTaskManagerService.java这个类中,这个类是API 29新添加的,承载部分AMS的工作。
相比API 28基于Task栈的回收Activity策略,新版本策略简单清晰, 也激进了很多。


遍历所有Activity,刨掉那些不在可销毁状态的Activity,按照Activity堆叠的顺序,也就是Z轴的顺序,从老到新销毁activity。


有兴趣的读者可以自行编写测试代码,分别在API 28和API 28+的手机上测试看一下回收策略是否跟上面分析的一致。

也可以参考我写的TestKillActivity,单栈和多栈的情况下在高于API 28和低于API 28的手机上的表现。


总结:



  1. 系统内存不足时LMK会根据内存配置项来kill掉进程释放内存

  2. kill时会按照进程的ADJ规则来kill

  3. App内存不足时由GcWatcher来决定回收Activity的时机

  4. 可以使用getprop命令来查看当前手机的JVM内存分配和OOM配置

  5. releaseSomeActivities在API 28和API 28+的差别很大,低版本会根据Task数量来决定清理哪个task的。高版本简单粗暴,遍历activity,按照z order排序,优先release掉更老的activity。


参考资料:
Android lowmemorykiller分析
解读Android进程优先级ADJ算法
http://www.jianshu.com/p/3233c33f6…
juejin.cn/post/706306…
Android可见APP的不可见任务栈(TaskRecord)销毁分析


作者:Yocn
来源:juejin.cn/post/7231742100844871736
收起阅读 »

Android架构设计 搞懂应用架构设计原则,不要再生搬硬套的使用MVVM、MVI

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。 对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVV...
继续阅读 »

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。


对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVVM 和 MVI。



首先看看现在最新的官方应用架构指南,是如何建议我们搭建应用架构的。


一,官方应用架构指南


1,架构的原则


应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。谷歌建议按照以下原则设计应用架构。


1,分离关注点


2,通过数据模型驱动界面


3,单一数据源


4,单向数据流


2,谷歌推荐的应用架构


每个应用应至少有两个层:



  • 界面层 - 在屏幕上显示应用数据。

  • 数据层 - 包含应用的业务逻辑并公开应用数据。


可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。


总结下来,我们的应用架构应该有三层:界面层、网域层、数据层。


其中网域层可选,即无论你的应用中有没有网域层,与你的应用架构是 MVVM 还是 MVI 无关。


image.png


个人的理解是:界面层、网域层、数据层应该是应用级别的,而不是页面级别的。


如果我认为分层是页面级别的,那么我在接到一个业务需求 A 时(一般一个业务会新建一个页面activity 或 fragment 来承接),我的实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 新建数据层 A_Repository


这样的话,数据层 A_Repository 和 界面层:界面元素 A_Activity + 状态容器 A_ViewModel 强相关,数据层无复用性可言。


如果我认为分层是应用级别的,那么在接到业务A 时,实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 首先从应用的数据层寻找业务 A 需要使用的 业务数据 是否有对应的存储仓库 Respository,如果有,则复用(A_ViewModel 中依赖 Repository),拿到业务数据后,转成 UI 数据;如果无,则创建 这种业务数据 的存储仓库 Repository,后续其他界面层如果页使用到这种业务数据,可以直接复用这种业务数据对应的 Repository。


2.1,界面层架构设计指导


界面层在架构中的作用


界面的作用是在屏幕上显示应用数据,并充当主要的用户互动点。


从数据层获取是业务数据,有时候需要界面层将业务数据转换成 UI 数据供界面元素显示。


界面层的组成


界面层由以下两部分组成:



  • 界面元素:在屏幕上呈现数据的界面元素可以使用 View 或 Jetpack Compose 函数实现。

  • 状态容器:用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。


image.png


界面层的架构设计遵循的原则


这里以一个常见的列表页面为案例进行讲解,这个列表页面有以下交互:



  • 打开页面时,网络数据回来之前展示一个加载中 view。

  • 首次打卡页面,如果没有数据或者网络请求发生错误,展示一个错误 view。

  • 具备下拉刷新能力,刷新后,如果有数据,则替换列表数据;如果无返回数据,则弹出一个 Toast。


接着我们用这个业务,按照以下原则进行分析:


1, 定义界面状态


界面元素 加上 界面状态 才是用户看到的界面。


image.png


上面说的列表页面,根据它的业务需求,需要有以下界面状态



  • 展示加载中 view 的界面状态

  • 展示加载错误 view 的界面状态

  • 列表数据 view 界面状态

  • Toast view 界面状态

  • 刷新完成 view 界面状态


无论采用 MVVM 还是 MVI,都需要这些界面状态,只是他们的实现细节不同,具体可以看下面的讲解。


2,定义状态容器


状态容器:就是存放我们定义的界面状态,并且包含执行相应任务所必需的逻辑的类。


ViewModel 类型是推荐的状态容器,用于管理屏幕级界面状态,具有数据层访问权限。但并不是只能用 ViewModel作为状态容器。


无论采用 MVVM 还是 MVI,都需要定义状态容器,来存放界面状态。


3,使用单向数据流管理状态


看看官方在界面层的架构指导图:


image.png


界面状态数据流动是单向的,只能从 状态容器 到 界面元素。


界面发生的事件 events(如刷新、加载更多等事件)流动是单向的,只能从 界面元素 到 状态容器。


无论采用 MVVM 还是 MVI,都需要使用单向数据流管理状态。


4,唯一数据源


唯一数据源针对的是:定义的界面状态 和 界面发生的事件。


界面状态唯一数据源指的是将定义的多个界面状态,封装在一个类中,如上面的列表业务,不采用唯一数据源,界面状态的声明为:


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

采用唯一数据源声明界面状态时,代码如下:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)


界面发生的事件的唯一数据源指的是将界面发生的事件封装在一个类中,然后统一处理。比如上面描述的列表业务,它的界面事件有 初始化列表事件(首屏请求网络数据)、刷新事件、加载更多事件。


不采用唯一数据源,界面事件的调用实现逻辑为:在 activity 中直接调用 viewModel 提供的 initData、freshData 和 loadMoreData 方法;


采用唯一数据源,界面事件的调用实现逻辑为,先将事件中封装在一个 Intent 中,viewModel 中提供一个统一的事件入口处理方法 dispatchIntent,在 activity 中 各个场景下都调用 viewModel#dispatchIntent,代码如下:


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}


因为有了唯一数据源这一特点,才将最新的应用架构称为 MVI,MVVM 不具备这一特点。


5,向界面公开界面状态的方式


在状态容器中定义界面状态后,下一步思考的是如何将提供的状态发送给界面。


谷歌推荐使用 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做的优点有:



  • 解耦界面元素(activity 或 fragment) 与 状态容器,如:activity 持有 viewModel 的引用,viewModel 不需要持有 activity 的引用。


无论采用 MVVM 还是 MVI,都需要向界面公开界面状态,公开的方式也可以是一样的。


6,使用界面状态


在界面中使用界面状态时,对于 LiveData,可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。


注意:在界面中使用可观察数据容器时,需要考虑界面的生命周期。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API,如:


class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

无论采用 MVVM 还是 MVI,都需要使用界面状态,使用的方式都是一样的。


2.2,数据层架构设计指导


数据层在架构中的作用


数据层包含应用数据和业务逻辑。业务逻辑决定应用的价值,它由现实世界的业务规则组成,这些规则决定着应用数据的创建、存储和更改方式。


数据层的架构设计


数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。


每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。


层次结构中的其他层不能直接访问数据源;数据层的入口点始终是存储库类。


公开 API


数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:



  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数。

  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流,对于 Java 编程语言,数据层应公开用于发出新数据的回调。


class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

多层存储库


在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。


例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository,以满足其要求。


image.png


注意:传统上,一些开发者将依赖于其他存储库类的存储库类称为 manager,例如称为 UserManager 而非 UserRepository。


数据层生命周期


如果该类的职责作用于应用至关重要,可以将该类的实例的作用域限定为 Application 类。


如果只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity。


数据层定位思考


数据层不应该是页面级别的(一个页面对应一个数据层),而应该是应用级别的(数据层有多个存储仓库,每种数据类型有一个对应的存储仓库,不同的界面层可以复用存储仓库)。


比如我做的应用是运动健康app,用户的睡眠相关的数据有一个 SleepResposity,用户体重相关的数据有一个 WeightReposity,由于应用中很多界面都可能需要展示用户的睡眠数据和体重数据,所以 SleepResposity 和 WeightReposity 可以供不同界面层使用。


二,MVVM


1,MVVM 架构图


image.png


2,MVVM 实现一个具体业务


使用上面提到的列表页面业务,按照 MVVM 架构实现如下:


2.1,界面层的实现


界面层实现时,需要遵循以下几点。


1,选择实现界面的元素


界面元素可以用 view 或 compose 来实现,这里用 view 实现。


2,提供一个状态容器


这里使用 ViewModel 作为状态容器;状态容器用来存放界面状态变量;ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。


3,定义界面状态


这个需求中我们根据业务描述,定义出多个界面状态。


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

4,公开界面状态


这里选择数据流 StateFlow 公开界面状态。当然也可以选择 LiveData 公开界面状态。


5,使用/订阅界面状态


我这里使用的是数据流 StateFlow 公开的界面状态,所以在界面层相对应的使用 flow#collect 订阅界面状态。


6,数据模型驱动界面


结合上面几点,界面层的实现代码为:


界面元素的实现:


class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.refreshNewsData()
}
}

private fun initData() {
mViewModel.getNewsData()
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.isLoading.collect {
if (it) {
mBinding?.loadingView?.visibility = View.VISIBLE
} else {
mBinding?.loadingView?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingError.collect {
if (it) {
mBinding?.loadingError?.visibility = View.VISIBLE
} else {
mBinding?.loadingError?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingFinish.collect {
if (it) {
mBinding?.refreshView?.isRefreshing = false
}
}
}
launch {
mViewModel.toastMessage.collect {
if (it.isNotEmpty()) {
showToast(it)
}
}
}
launch {
mViewModel.newsList.collect {
if (it.isNotEmpty()) {
mBinding?.loadingError?.visibility = View.GONE
mBinding?.loadingView?.visibility = View.GONE
mBinding?.refreshView?.visibility = View.VISIBLE
mAdapter?.setData(it)
}
}
}
}
}
}

}

状态容器的实现:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

fun getNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_loadingError.emit(true)
} else {
_newsList.emit(list)
}
}
}

fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_loadingFinish.emit(true)
if (list.isNullOrEmpty()) {
_toastMessage.emit("暂时没有更新数据")
} else {
_newsList.emit(list)
}
}
}
}

2.2,数据层的实现


这里的数据层只有一个新闻列表数据结构的存储仓库 NewsRepository,另外获取新闻信息属于一次性操作,根据数据层架构设计,直接使用 suspend 就好。


class NewsRepository {

suspend fun getNewsList(): MutableList<News>? {
delay(2000)

val list = mutableListOf<News>()
val news = News("标题", "描述信息")
list.add(news)
list.add(news)
list.add(news)
list.add(news)
return list
}
}

个人的一些理解:


1, 数据层不应该是界面级别的,而应该是应用级别的


数据层不应该是界面级别的,即一个页面对应一个 Repository;数据层应该是应用级别的,即一个应用有一个或多个数据层,每个数据层中有多个存储仓库 Respository,存储仓库可以在不同的界面层复用。


之前我一直认为,一个页面对应一个数据层,一个页面对应一个 Repository。但后来发现这种理解不太对。上面的例子中 NewsViewModel 只用到 NewsRepository,是因为这个新闻列表业务中只用到新闻列表数据这种数据,假如列表中还可以点赞 那我们就需要新建一种点赞存储仓库 LikeRepository,来处理点赞数据,这时 NewsViewModel 与 Repository 的关系是这样:


class NewsViewModel : ViewModel() {

private val newsRepository = NewsRepository()
private val likeRepository = LikeRepository()
}


数据层提供的 新闻列表数据处理能力 NewsRepository 和 点赞数据处理能力 LikeRepository,应该是应用界别的,可以供不同的界面复用。


2,数据层应该是“不变的”


这里的不变不是说数据层的业务逻辑不变,而是指无论是 MVP、MVVM 还是 MVI,他们应该可以共用数据层。


2.3,网域层的实现


网域层是可选的,是否具备网域层,跟架构是否为 MVVM 无关,这个案例中不适用网域层。


三,MVI


1,MVI 架构图


image.png


2,MVI 实现一个具体业务


同样使用上面 MVVM 实现的新闻业务。按照 MVI 架构实现如下:


2.1,界面层的实现


除了和 MVVM 遵循以下几点相同原则之外:


1,选择实现界面的元素


2,提供一个状态容器


3,定义界面状态


4,公开界面状态


5,使用/订阅界面状态


6,单向数据流


MVI 还需要遵循原则:


1,单一数据源


所以 MVI 需要:1,把界面状态聚合起来;2,把界面事件聚合起来。


综合上面的原则,采用 MVI 实现界面的实现如下:


界面元素、聚合界面状态、聚合界面事件 代码:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.dispatchIntent(NewsActivityIntent.RefreshDataIntent())
}
}

private fun initData() {
mViewModel.dispatchIntent(NewsActivityIntent.InitDataIntent())
}

private fun loadMoreData() {
mViewModel.dispatchIntent(NewsActivityIntent.LoadMoreDataIntent())
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.newsUiState.collect {
//更新UI
}
}
}
}
}

}


状态容器代码:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}

/**
* 初始化
*/

private fun initNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.LoadingError)
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 刷新
*/

private fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_newsUiState.emit(NewsUiState.LoadingFinish)
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.ToastMessage("暂时没有新数据"))
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 没有实现
*/

private fun loadMoreNewsData() {

}

}

2.2,数据层与网域层的实现


界面层:参考上面 MVVM 的数据层介绍,无论 MVP、MVVM、MVI,不同应用架构的数据层应该是不变的,即通用。


网域层:应用架构是否具备网域层不影响它是什么类型的架构,这里的列表业务没有网域层。


作者:cola_wang
来源:juejin.cn/post/7278659049191686196
收起阅读 »

😳骚操作玩这么花的吗?Android基于Act实现事件的录制与回放!

基于Activity封装实现录制与回放 前言 在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改, 而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在...
继续阅读 »

基于Activity封装实现录制与回放


前言


在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,


而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。


一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。


目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。


不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。


那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。


当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。


那么话不多说,Let's go


300.png


一、定义事件


在前文 ViewGr0up 的文章中,我们知道了事件的伪造与保存,如何定制伪造事件时间轴,如何分发伪造事件,本文也是一个思路。


整体思路基于前文 ViewGr0up 的例子,还是把事件用对象封装起来,只是我们封装的对象换成了 MotionEvent ,并且不需要修改内部的操作时间了,我们用事件对象的 time 时间来制作伪造事件触发的时间轴。


这样对于事件的录制我们就能直接通过 Activity 的事件分发 dispatchTouchEvent 中直接保存我们的事件对象了。


基于这个思路,我们的事件的对象封装:


public class EventState {
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

Activity的事件集合,方便后期扩展为多个Activity的事件队列,如果只需要录制一个 Activity 的事件那么则可以无需双重队列。


/**
* 以Activity为单位,以队列的形式存储MotionEvent
*/

public class ActEventStates {
/**
* 存储元素为一个队列,存放一个Act中的操作状态。如果有多个Act,则是双重队列
*/

public static Queue<Queue<EventState>> eventStates = new LinkedList<>();

public static boolean isRecord = false; //是否在录制

public static boolean isPlay = false; //是否在播放
}

为什么要用 Queue ?


首先我们只需要回放一次,如果想回放多次可以用持久化存储,对于已经回放过的事件我们不希望还存在内存中,特别是后期做多 Activity 之间的跳转之后的回放,如果之前的事件还存在内存中会有重复回放的问题,而用 List 去手动管理没有 Queue 方便。


二、录制


先定义一个开始与停止的方法:


  //开启录制
fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

基于Act的录制,直接在分发事件的时候把事件从 Activity 级别就录制进去,这样只要在 Activity 层级之下的操作都能实现录制与回放了:


    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}

每一行代码都尽量给出注释。


三、回放


其实和我们之前的 ViewGr0up 的思路是一致的,只是把自定义的事件换成原生的 MotionEvent 来保存,还是根据 Handler 分发不同事件的时间轴。


    //回放录制
fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
YYLogUtils.w("没了,回放录制完成")
} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

在当前的 Activity 中录制与回放的效果,具体的使用与效果:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

act_record01.gif


单独的 Activity 上录制与回放是可以了,但是我们的应用又不是 Compose 或 Flutter,我们大部分项目还是多 Activity 的,如何实现多 Activity 跳转之后的录制与回放才是真正的问题。


四、多Activity的录制与回放


由于我们之前定义的数据格式就是 Queue 队列,所以我们很方便的就能实现多 Activity 的录制与回放效果,只需要在每一个 Activity 的 onResume 方法中尝试录制与播放即可。


由于当前的 Queue 的数据格式的性质,回放完成之后就没有了,跳转 Activity 之后就无需从头开始播放,特别适合这个场景。


只是需要注意的点是 Activity 的返回除了 Appbar 的页面返回按钮点击,我们还能使用系统的返回键或国产OS的左侧右侧滑动返回操作,所以我们需要对系统的返回操作单独做处理,修改之后的核心代码如下:


abstract class BaseActivity<VM : BaseViewModel> : AbsActivity() {

...

// ================== 事件录制 ======================

var handler = Handler(Looper.getMainLooper())

/**
* 存放当前activity中的事件
*/

private var activityEvents: Queue<EventState>? = null

/**
* 当前activity可见之后的时间点,每次 onResume 之后都创建一个新的队列,同时也赋值新的statetime
*/

private var startTime: Long = 0


override fun onResume() {
super.onResume()
startRecord() //尝试录制
playRecord() //尝试回放
}

//开启录制
protected fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
protected fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

override fun onBackPressed() {
val state = EventState()
state.event = null
state.isBackPress = true
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
super.onBackPressed()
}

//回放录制
protected fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
if (state.isBackPress) {
YYLogUtils.w("手动调用系统返回按键")
onBackPressed() //手动调用系统返回按键
} else {
YYLogUtils.w("没了,回放录制完成")
}

} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}
}

对于事件的封装我们添加了是否是系统返回的标记:


public class EventState {
public boolean isBackPress;
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

使用的方式就没有变化,我们添加几个 Activity 的跳转试试:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

btnJump1.click {
TemperatureViewActivity.startInstance()
}
btnJump2.click {
ViewGr0up9Activity.startInstance()
}

效果:


act_record02.gif


为了区分实际手指操作与回放的操作的差异,我打开了开发者选项中的触摸反馈,第一次效果是带触摸反馈的,回放录制的效果是没有触摸反馈的,并且支持 Appbar的返回按键与系统的返回键。


如果想回放多次,则需要在停止录制的时候把事件保存到本地,如何保存对象到本地?和前文一样的思路,可以用Json,可以压缩,可以加密,甚至可以自定义数据格式与解析,这一个步骤就无需我多说了吧。


后记


回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。


当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。


比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。


好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!


而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。


言归正传,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!


Ok,这一期就此完结。



作者:Newki
来源:juejin.cn/post/7330104253825646601
收起阅读 »

拒绝代码PUA,优雅地迭代业务代码

最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
继续阅读 »

最初的美好


没有历史包袱,就没有压力,就是美好的。


假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


Ugly1.gif


这样的需求开发起来很简单:



  • 数据实体


data class Car(
var shell: Shell? = null,
var engine: Engine? = null,
var wheel: Wheel? = null,
) : Serializable {
override fun toString(): String {
return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
}
}

data class Shell(
...
) : Serializable

data class Engine(
...
) : Serializable

data class Wheel(
...
) : Serializable


  • 零件车间(以车架为例)


class ShellFactoryActivity : AppCompatActivity() {
private lateinit var btn: Button
private lateinit var back: Button
private lateinit var status: TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_shell_factory)
val car = intent.getSerializableExtra("car") as Car
status = findViewById(R.id.status)
btn = findViewById(R.id.btn)
btn.setOnClickListener {
car.shell = Shell(
id = 1,
name = "比亚迪车架",
type = 1
)
status.text = car.toString()
}
back = findViewById(R.id.back)
back.setOnClickListener {
setResult(RESULT_OK, intent.apply {
putExtra("car", car)
})
finish()
}
}
}


class EngineFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}

class WheelFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}


  • 提车车间


class MainActivity : AppCompatActivity() {
private var car: Car? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
car = Car()
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
val it = Intent(this, ShellFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_SHELL)
}
findViewById<Button>(R.id.engine).setOnClickListener {
val it = Intent(this, EngineFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_ENGINE)
}
findViewById<Button>(R.id.wheel).setOnClickListener {
val it = Intent(this, WheelFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_WHEEL)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != RESULT_OK) return
when (requestCode) {
REQUEST_SHELL -> {
Log.i(TAG, "安装车架完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_ENGINE -> {
Log.i(TAG, "安装发动机完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_WHEEL -> {
Log.i(TAG, "安装车轮完成")
car = data?.getSerializableExtra("car") as Car
}
}
refreshStatus()
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car?.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}

companion object {
private const val TAG = "MainActivity"
const val REQUEST_SHELL = 1
const val REQUEST_ENGINE = 2
const val REQUEST_WHEEL = 3
}
}

即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


开始迭代


往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


Ugly2.gif


看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


object ComputerFactoryHelper {
fun provideComputer(block: Computer.() -> Unit) {
Thread.sleep(5_000)
block(Computer())
}
}

data class Computer(
val id: Int = 1,
val name: String = "行车电脑",
val cpu: String = "麒麟90000"
) : Serializable {
override fun toString(): String {
return "$name-$cpu"
}
}

再在提车车间新增按钮和逻辑代码:


findViewById<Button>(R.id.computer).setOnClickListener {
object : Thread() {
override fun run() {
ComputerFactoryHelper.provideComputer {
car?.computer = this
runOnUiThread { refreshStatus() }
}
}
}.start()

}

目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


从迭代到崩溃


咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


记者:哦?这不是一个小需求吗?


小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


优雅地迭代业务代码?


假如咱们想要优雅地迭代业务代码,应该怎么做呢?


小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


那什么又是业务的抽象?直接上代码:


interface CarFactory {
val factory: suspend Car.() -> Car
}

造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


object ComputerFactoryHelper : CarFactory {
private suspend fun provideComputer(block: Computer.() -> Unit) {
delay(5_000)
block(Computer())
}

override val factory: suspend Car.() -> Car = {
provideComputer {
computer = this
}
this
}
}

那么,在提车车间就可以这样改:


private var computerFactory: CarFactory = ComputerFactoryHelper
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
computerFactory.factory.invoke(car)
refreshStatus()
}
}

❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


Emo时间


我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


你有没有想过,咱们正在被Activity PUA



说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



对对对!你们都没有问题,是我太菜了555555555



优雅转身


Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


❗ 这时我就要提到另外一种抽象:技术思维的抽象


Activity?F*ck off!


Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


interface CarFactory {
val factory: suspend Car.() -> Car
}

基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


open class BaseActivity : AppCompatActivity() {
private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivityForResultLauncher = StartActivityForResultLauncher(this)
}

fun startActivityForResult(
intent: Intent,
callback: (resultCode: Int, data: Intent?) -> Unit
)
{
startActivityForResultLauncher.launch(intent) {
callback.invoke(it.resultCode, it.data)
}
}
}

MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

override val factory: suspend Car.() -> Car = {
suspendCoroutine { continuation ->
val it = Intent(activity, ShellFactoryActivity::class.java)
it.putExtra("car", this)
activity.startActivityForResult(it) { resultCode, data ->
(data?.getSerializableExtra("car") as? Car)?.let {
Log.i(TAG, "安装车壳完成")
shell = it.shell
continuation.resumeWith(Result.success(this))
}
}
}
}
}

然后在提车车间,和Computer业务同样的使用方式:


private var shellFactory: CarFactory = ShellFactoryHelper(this)
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}

最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


class MainActivity : BaseActivity() {
private var car: Car = Car()
private var computerFactory: CarFactory = ComputerFactoryHelper
private var engineFactory: CarFactory = EngineFactoryHelper(this)
private var shellFactory: CarFactory = ShellFactoryHelper(this)
private var wheelFactory: CarFactory = WheelFactoryHelper(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.engine).setOnClickListener {
lifecycleScope.launchWhenResumed {
engineFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.wheel).setOnClickListener {
lifecycleScope.launchWhenResumed {
wheelFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
computerFactory.factory.invoke(car)
Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
refreshStatus()
}
}
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}
}

总结



  • 抽象是程序员保持优雅的最重要能力。

  • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

  • 有意识地对代码PUA说:No!

  • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


作者:blackfrog
来源:juejin.cn/post/7274084216286036004
收起阅读 »

终于搞明白了什么是同步屏障

背景 今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。 同步屏障机制...
继续阅读 »

背景


今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。


同步屏障机制


1. 直奔主题,同步屏障机制这几个字听起来很牛逼,能浅显的解释一下,先让大家明白它的作用是啥不?


同步屏障实际上就是字面意思,可以理解为建立一道屏障,隔离同步消息,优先处理消息队列中的异步消息进行处理,所以才叫同步屏障。


2. 第二个问题,同步消息又是啥呢?异步消息和同步消息有啥不一样呢?


要回答这个问题,我们就得了解一下 MessageMessage 的消息种类分为三种:



  • 普通消息(同步消息)

  • 异步消息

  • 同步屏障消息


我们平时使用 Handler 发送的消息基本都是普通消息,中规中矩的排到消息队列中,轮到它了再乖乖地出来执行。


考虑一个场景,我现在往 UI 线程发送了一个消息,想要绘制一个关键的 View,但是现在 UI 线程的消息队列里面消息已经爆满了,我的这条消息迟迟都没有办法得到处理,导致这个关键 View 绘制不出来,用户使用的时候很恼怒,一气之下给出差评这是什么垃圾 app,卡的要死。


此时,同步屏障就派上用场了。如果消息队列里面存在了同步屏障消息,那么它就会优先寻找我们想要先处理的消息,把它从队列里面取出来,可以理解为加急处理。那同步屏障机制怎么知道我们想优先处理的是哪条消息呢?如果一条消息如果是异步消息,那同步屏障机制就会优先对它处理。


3.那要如何设置异步消息呢?怎样的消息才算一条异步消息呢?


Message 已经提供了现成的标记位 isAsynchronous 用来标志这条消息是不是异步消息。


4.能看看源码了解下官方到底怎么实现的吗?


看看怎么往消息队列 MessageQueue 中插入同步屏障消息吧。


private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;

Message prev = null;
// 当前消息队列
Message p = mMessages;
if (when != 0) {
// 根据when找到同步屏障消息插入的位置
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
// 插入同步屏障消息
if (prev != null) {
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
// 前面没有消息的话,同步屏障消息变成队首了
mMessages = msg;
}
return token;
}
}

在代码关键位置我都做了注释,简单来说呢,其实就像是遍历一个链表,根据 when 来找到同步屏障消息应该插入的位置。


5.同步屏障消息好像只设置了when,没有target呢?


这个问题发现了华点,熟悉 Handler 的朋友都知道,插入消息到消息队列的时候,系统会判断当前的消息有没有 targettarget 的作用就是标记了这个消息最终要由哪个 Handler 进行处理,没有 target 会抛异常。


boolean enqueueMessage(Message msg, long when) {
// target不能为空
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
...
}

问题 4 的源码分析中,同步屏障消息没有设置过 target,所以它肯定不是通过 enqueueMessage() 添加到消息队列里面的啦。很明显就是通过 postSyncBarrier() 方法,把一个没有 target 的消息插入到消息队列里面的。


6.上面我都明白了,下面该说说同步屏障到底是怎么优先处理异步消息的吧?


OK,插入了同步屏障消息之后,消息队列也还是正常出队的,显然在队列获取下一个消息的时候,可能对同步屏障消息有什么特殊的判断逻辑。看看 MessageQueuenext 方法:


Message next() {
...
// msg.target == null,很明显是一个同步屏障消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
...
}

方法代码很长,看源码最主要还是看关键逻辑,也没必要一行一行的啃源码。这个方法中相信你一眼就发现了
msg.target == null,前面刚说过同步屏障消息的 target 就是空的,很显然这里就是对同步屏障消息的特殊处理逻辑。用了一个 do...while 循环,消息如果不是异步的,就遍历下一个消息,直到找到异步消息,也就是 msg.isAsynchronous() == true


7.原来如此,那如果消息队列中没有异步消息咋办?


如果队列中没有异步消息,就会休眠等待被唤醒。所以 postSyncBarrier()removeSyncBarrier() 必须成对出现,否则会导致消息队列中的同步消息不会被执行,出现假死情况。


8.系统的 postSyncBarrier() 貌似也没提供给外部访问啊?这我们要怎么使用?


确实我们没办法直接访问 postSyncBarrier() 方法创建同步屏障消息。你可能会想到不让访问我就反射调用呗,也不是不可以。


但我们也可以另辟蹊径,虽然没办法创建同步屏障消息,但是我们可以创建异步消息啊!只要系统创建了同步屏障消息,不就能找到我们自己创建的异步消息啦。


系统提供了两个方法创建异步 Handler


public static Handler createAsync(@NonNull Looper looper) {
if (looper == null) throw new NullPointerException("looper must not be null");
// 这个true就是代表是异步的
return new Handler(looper, null, true);
}

public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
if (looper == null) throw new NullPointerException("looper must not be null");
if (callback == null) throw new NullPointerException("callback must not be null");
return new Handler(looper, callback, true);
}

异步 Handler 发送的就是异步消息。


9.那系统什么时候会去添加同步屏障呢?


有对 View 的工作流程比较了解的朋友想必已经知道了,在 ViewRootImplrequestLayout 方法中,系统就会添加一个同步屏障。


不了解也没关系,这里我简单说一下。


(1)创建 DecorView


当我们启动了 Activity 后,系统最终会执行到 ActivityThreadhandleLaunchActivity 方法中:


final Activity a = performLaunchActivity(r, customIntent);

这里我们只截取了重要的一行代码,在 performLaunchActivity 中执行的就是 Activity 的创建逻辑,因此也会进行 DecorView 的创建,此时的 DecorView 只是进行了初始化,添加了布局文件,对用户来说,依然是不可见的。


(2)加载 DecorView 到 Window


onCreate 结束后,我们来看下 onResume 对应的 handleResumeActivity 方法:


@Override
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason)
{
...
// 1.performResumeActivity 回调用 Activity 的 onResume
if (!performResumeActivity(r, finalStateRequest, reason)) {
return;
}
...
final Activity a = r.activity;
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
// 2.获取 decorview
View decor = r.window.getDecorView();
// 3.decor 现在还不可见
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
...
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
// 4.decor 添加到 WindowManger中
wm.addView(decor, l);
} else {
a.onWindowAttributesChanged(l);
}
}
}
...
}

注释 4 处,DecorView 会通过 WindowManager 执行了 addView() 方法后加载到 Window 中,而该方法实际上是会最终调用到 WindowManagerGlobaladdView() 中。


(3)创建 ViewRootImpl 对象,调用 setView() 方法


// WindowManagerGlobal.ddView()
root = new ViewRootImpl(view.getContext(), display);
root.setView(view, wparams, panelParentView);

WindowManagerGlobaladdView() 会先创建一个 ViewRootImpl 实例,然后将 DecorView 作为参数传给 ViewRootImpl,通过 setView() 方法进行 View 的处理。setView() 的内部主要就是通过 requestLayout 方法来请求开始测量、布局和绘制流程


(4)requestLayout() 和 scheduleTraversals()


@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
// 主要方法
scheduleTraversals();
}
}

void scheduleTraversals() {
if (!mTraversalScheduled) {
// 1.将mTraversalScheduled标记为true,表示View的测量、布局和绘制过程已经被请求。
mTraversalScheduled = true;
// 2.往主线程发送一个同步屏障消息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 3.注册回调,当监听到VSYNC信号到达时,执行该异步消息
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

看到了吧,注释 2 的代码熟悉的很,系统调用了 postSyncBarrier() 来创建同步屏障了。那注释 3 是啥意思呢?mChoreographer 是一个 Choreographer 对象。


要理解 Choreographer 的话,还要明白 VSYNC


我们的手机屏幕刷新频率是 1s 内屏幕刷新的次数,比如 60Hz、120Hz 等。60Hz表示屏幕在一秒内刷新 60 次,也就是每隔 16.6ms 刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算,每收到 VSYNC,CPU 就开始处理各帧数据。这时 Choreographer 就上场啦,当有 VSYNC 信号到来时,会唤醒 Choreographer,触发指定的工作。它提供了一个回调功能,让业务知道 VSYNC 信号来了,可以进行下一帧的绘制了,也就是注释 3 使用的 postCallback 方法。


当监听到 VSYNC 信号后,会回调来执行 mTraversalRunnable 这个 Runnable 对象。


final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
// View的绘制入口方法
performTraversals();

if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

在这个 Runnable 里面,会移除同步屏障。然后调用 performTraversals 这个View 的工作流程的入口方法完成对 View 的绘制。


这回明白了吧,系统会在调用 requestLayout() 的时候创建同步屏障,等到下一个 VSYNC 信号到来时才会执行相应的绘制任务并移除同步屏障。所以在等待 VSYNC 信号到来的期间,就可以执行我们自己的异步消息了。


参考


requestLayout竟然涉及到这么多知识点


关于Handler同步屏障你可能不知道的问题


“终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解


作者:搬砖的代码民工
来源:juejin.cn/post/7258850748150104120
收起阅读 »

Android 居然还能这样抓捕和利用主线程碎片时间

本文作者: zy 图片来自:unsplash.com 在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿...
继续阅读 »

本文作者: zy




图片来自:unsplash.com


在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。


背景与现状


在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。


分析


为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。



上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。



VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。


具体方案


主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。


帧率耗时监控模块


分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示



帧率耗时监控模块执行步骤:



  • 步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象

  • 步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间

  • 步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来

  • 步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片


其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示



帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。


空闲时间切片


我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片


耗时任务拆分


有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集



上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图



接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)


class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}

到这里,可执行的子任务集就准备好了。


子任务智能调度


空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示



子任务智能调度执行步骤:



  • 步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑

  • 步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了

  • 步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删

  • 步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在

  • 步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程

  • 步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。


智能任务调度核心


智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示



任务队列结构


这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示



调度超时模式


空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.


总结


通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。


作者:网易云音乐技术团队
来源:juejin.cn/post/7329028515382820916
收起阅读 »

Android 开发小技巧:属性扩展让代码写起来更轻松更易读

一、优化了什么问题 在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示: button.apply { background = ContextCompat.getDrawable(this@MainActi...
继续阅读 »

一、优化了什么问题


在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示:


button.apply {
background = ContextCompat.getDrawable(this@MainActivity,R.drawable.dinosaur)
setBackgroundColor(ContextCompat.getColor(this@MainActivity,R.color.black))
}

写多了脑壳痛,那能不能像前端语言那样简洁优雅呢?之前见过一种写法就是对Int进行扩展,就会看到奇奇怪怪的代码,如下:


//扩展函数
fun Int.getColor(): Int {
return ContextCompat.getColor(appContext, this)
}

//调用
button.apply {
setBackgroundColor(R.color.purple_700.getColor())
}

那能不能把一些常用的设置控件UI的方法都改造成setter访问方法呢?


二、通过扩展属性使代码更易写易读


在XML中常用的基础控件有TextView、ImageView、Button、EditText,那先从这几个控件入手就可以了。扩展属性只能在类的基础上添加属性,并不能覆盖,这一点需要注意。只需要下面几个扩展属性的方法,代码写起来就会舒服和易读很多。


/**
* 设置文本的颜色,Button、EditText、TextView都用此方法设置textColor
*/

var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(ContextCompat.getColor(this.context, value))
}

/**
* 给View设置drawable和mipmap资源
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉背景
this.background = null
} else {
this.background = ContextCompat.getDrawable(this.context, value)
}
}

/**
* 给View设置背景颜色
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundColor: Int
get() {
return (this.background as ColorDrawable).color
}
set(value) {
this.setBackgroundColor(ContextCompat.getColor(this.context, value))
}

/**
* 给ImageView设置drawable和mipmap资源
*/

var ImageView.imageResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉图片
this.setImageDrawable(null)
} else {
this.setImageDrawable(ContextCompat.getDrawable(this.context, value))
}
}

/**
* 点击事件。可防止重复点击。
* ClickUtils.OnDebouncingClickListener是AndroidUtilCode库中的方法,项目中一般都有
*/

var View.onClick: (View) -> Unit
get() = {} //get方法无意义返回空的lambda
set(value) {
this.setOnClickListener(object : ClickUtils.OnDebouncingClickListener() {
override fun onDebouncingClick(v: View) {
value.invoke(v)
}
})
}

在TextView中使用


textView.apply {
text = getString(R.string.app_name)
//设置文本颜色
textColor = R.color.white
textSize = 15F
//设置背景颜色
backgroundColor = R.color.black
onClick = { //防止重复点击的点击事件

}
}

在ImageView中使用


imageView.apply {
//设置背景颜色
backgroundColor = R.color.black
//设置背景资源
backgroundResource = R.drawable.dinosaur
//设置src资源
imageResource = R.drawable.dinosaur
//如果项目中有创建Drawable的方法可以继续用background
background = shape(Shape.RECTANGLE) {
solid(R.color.white))
corners(8F.dp2px)
}
onClick = { //防止重复点击的点击事件

}
}

其他控件类似。


上面只是改造了常见控件的常用属性的写法,让代码写起来更简洁,更易读,并且添加了防止重复点击的方法,不至于在其他页面又写类似的逻辑。


作者:TimeFine
来源:juejin.cn/post/7311619723317510155
收起阅读 »

如何通过Kotlin协程, 简化"连续依次弹窗(Dialog队列)"的需求

效果预览 代码预览 lifecycleScope.launch { showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行 showDialog("新手任务", "做任务领20000...
继续阅读 »

效果预览


r2t33-r5h2v.gif


代码预览


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("新手任务", "做任务领20000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("首充奖励", "首充6元送神装")
}

代码实现



要做到上一个showDialig()在关闭时才继续运行下一个函数,需要用到协程挂起的特性, 然后在 OnDismiss()回调中将协程恢复, 为了将这种基于回调的方法包装成协程挂起函数, 可以使用suspendCancellableCoroutine函数



suspend fun showDialog(title: String, content: String) = suspendCancellableCoroutine { continuation ->
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
}

进阶玩法



跳过某些弹窗, 例如第二个弹窗只显示一次



suspend fun showDialogOnce(title: String, content: String) = suspendCancellableCoroutine { continuation ->
val showed = SPUtils.getInstance().getBoolean(title) // SharedPreferences工具类
if (showed) {
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
SPUtils.getInstance().put(title, true)
}

调用时只需要这样:


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币")
showDialogOnce("新手任务", "做任务领20000币")
showDialog("首充奖励", "首充6元送神装")
}

这样'新手任务'的弹窗只会首次弹出, 后续只会弹出第一和第三个


作者:Joehaivo飞羽
来源:juejin.cn/post/7275943125821571106
收起阅读 »

EdgeUtils:安卓沉浸式方案(edge to edge)封装

EdgeUtils     项目地址:github.com/JailedBird/… 1、 接入方式 EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦 接入方式: 添加jitpack仓库 maven { ur...
继续阅读 »

EdgeUtils


GitHub stars GitHub forks GitHub issues 


项目地址:github.com/JailedBird/…


1、 接入方式


EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦


接入方式:



  • 添加jitpack仓库


maven { url 'https://jitpack.io' }


  • 添加依赖


implementation 'com.github.JailedBird:EdgeUtils:1.0.0'

2、 使用方式


2-1、 布局拓展全屏


Activity中使用API edgeToEdge() 将开发者实现的布局拓展到整个屏幕, 同时为避免冲突, 将状态栏和到导航栏背景色设备为透明;


1669552233097-eacf0003-1ede-4035-a24e-ace16bfbe400.gif


注意:edgeToEdge() 的参数withScrim表示是否启用系统默认的反差色保护, 不是很熟悉的情况下直接使用默认true即可;


2-2、 系统栏状态控制


布局拓展之后, 开发者布局内容会显示在状态栏和导航栏区域, 造成布局和系统栏字体重叠(时间、电量……);


此时为确保系统栏字体可见,应该设置其字体; 设置规则:白色(浅色)背景设置黑色字体(edgeSetSystemBarLight(true)),黑色(深色)背景设置白色字体(注:系统栏字体只有黑色和白色)(edgeSetSystemBarLight(false));


如果未作夜间模式适配, 默认使用 edgeSetSystemBarLight(true)浅色模式即可!


综合1、2我们的基类可以写成如下的形式:


abstract class BasePosActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
      if (usingEdgeToEdgeTheme()) {
              defaultEdgeToEdge()
      } else {
          customThemeSetting()
      }
      super.onCreate(savedInstanceState)
  }
}

protected open fun defaultEdgeToEdge() {
    edgeToEdge(false)
    edgeSetSystemBarLight(true)
}

2-3、 解决视觉冲突


2-3-1、状态栏适配


步骤一布局拓展全屏会导致视觉上的冲突, 下面是几种常见的思路:请灵活使用



  • 布局中添加View(id="@+id/edge")使用heightToTopSystemWindowInsets API动态监听并修改View的高度为状态栏的高度


    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
          android:orientation="vertical">

          <View
              android:id="@+id/edge"
              android:layout_width="match_parent"
              android:layout_height="0dp" />
          xxx
      </LinearLayout>
    binding.edge.heightToTopSystemWindowInsets()


  • 直接获取状态栏的高度,API为:edgeStatusBarHeight; 和1不同的是,1中View的height会随状态栏高度变化而变化,2不会; 此外获取状态栏高度需要在View Attached之后才可以(否则高度为0),因此使用suspend函数等待Attached后才返回状态栏,确保在始终能获取到正确的状态栏高度!


    lifecycleScope.launch {
      val height = edgeStatusBarHeight()
      xxx
    }


  • 针对有Toolbar的布局, 可直接为Toolbar加padding(or margin), 让padding的高度为状态栏高度!如果无效, 一般都与Toolbar的高度测量有关, 可以直接在Toolbar外层包上FrameLayout,为FrameLayout加padding, 详情阅读下文了解原理,从而灵活选择;


    fun View.paddingTopSystemWindowInsets() =
      applySystemWindowInsetsPadding(applyTop = true)

    fun View.marginTopSystemWindowInsets() =
      applySystemWindowInsetsMargin(applyTop = true)



2-3-2、 导航栏适配


导航栏的适配原理和状态栏适配是非常相似的, 需要注意的是 导航栏存在三种模式:



  • 全面屏模式

  • 虚拟导航栏

  • 虚拟导航条


API已经针对导航栏高度、导航栏高度margin和padding适配做好了封装,使用者无需关心;


fun View.paddingBottomSystemWindowInsets() =
  applySystemWindowInsetsPadding(applyBottom = true)
   
fun View.marginBottomSystemWindowInsets() =
  applySystemWindowInsetsMargin(applyBottom = true)

适配思路是一致的,不再赘述;


2-4、 解决手势冲突


手势冲突和视觉冲突产生的原理是相同的,不过是前者无形而后者有形;系统级别热区(如侧滑返回)优先级是要高于View的侧滑的, 因此有时候需要避开(情况很少)


EdgeUtils主要工作只是做了视觉冲突的解决和一些API封装;使用者可以基于封装的API拓展,替换掉WindowInsetCompat.Type为你需要的类型;


fun View.applySystemWindowInsetsPadding(
  applyLeft: Boolean = false,
  applyTop: Boolean = false,
  applyRight: Boolean = false,
  applyBottom: Boolean = false,
)
{
  doOnApplyWindowInsets { view, insets, padding, _ ->
  // val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
  // 替换为Type.SYSTEM_GESTURES即可,其他类似
      val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
      val left = if (applyLeft) systemBars.left else 0
      val top = if (applyTop) systemBars.top else 0
      val right = if (applyRight) systemBars.right else 0
      val bottom = if (applyBottom) systemBars.bottom else 0

      view.setPadding(
          padding.left + left,
          padding.top + top,
          padding.right + right,
          padding.bottom + bottom
      )
  }
}

3、 Edge教程


3-1 何为edge to edge?


如何彻底理解Edge to edge的思想呢?


或许你需要官方文章 , 也可以看的我写的翻译文章doc1😘


3-2 底层是如何实现的?


了解Edge to edge原理后,你或许会好奇他是怎么实现的?


或许你需要Flywith24大佬的文章 , 也可看缩略文章doc2😘


3-3 其他杂项记录


请看doc3 , 东西多但比较杂没整理😘


3-4 如何快速上手?


EdgeUtils此框架基于androidx.core, 对WindowInsets等常见API进行封装,提供了稳定的API和细节处理;封装的API函数名称通俗易懂,理解起来很容易, 难点是需要结合 [Edge-to-edge](#Edge to edge) 的原理去进行灵活适配各种界面


项目中存在三个demo对于各种常见的场景进行了处理和演示



  • navigation-sample 基于Navigation的官方demo, 此demo展示了Navigation框架下这种单Activity多Fragment的沉浸式缺陷

  • navigation-edge-sample 使用此框架优化navigation-sample, 使其达到沉浸式的效果

  • immersion-sample 基于开源项目immersionbar中的demo进行EdgeUtils的替换处理, 完成大部分功能的替换 (注:已替换的会标记[展示OK],部分未实现)


4、 注意事项


4-1、 Toolbar通过paddingTop适配statusbar失效的问题


很多时候, 状态栏的颜色和ToolBar的颜色是一致的, 这种情况下我们可以想到为ToolBar加 paddingTop = status_bar_height但是注意如果你的Toolbar高度为固定、或者测量的时候没处理好padding,那么他就可能失真;


快速判断技巧:xml布局预览中(假设状态栏高度预估为40dp),使用tools:padding = 40dp, 通过预览查看这40dp的padding是否对预览变成预期之外的变形,如果OK那么直接使用paddingTopSystemWindowInsets为ToolBar大多是没问题的


可以看下下面的2个例子:



  • paddingTop = 0时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • UI预览可以看到是这个样子的:


image-20221124102655144



  • paddingTop = 20时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • 可以看到, Toolbar的总高度是不变的,内容高度下移20dp,这显然是不合理的;实际运行时动态为ToolBar添加statusbar的paddingTop肯定也会导致这样的问题


image-20221124103232396


解决方案:


1、 使用FrameLayout等常见ViewGr0up包住ToolBar,将paddingTop高度设置到FrameLayout中, 将颜色teal_200设置到FrameLayout


<FrameLayout
android:id="@+id/layout_tool"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="20dp"
android:background="@color/teal_200">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />

</FrameLayout>

如下:


image-20221124103542651


2、 在ToolBar外层直接封装FrameLayout(LinearLayout等也可, 下文统一用FrameLayout替代);


我相信大家一般都不会直接使用原生的Toolbar, 每个公司或多或少的都封装了一些自定义ToolBar;按照上述1的思路, 我们不难发现:



  • 如果自定义ToolBar继承自FrameLayout(或者说Toolbar最外层被FrameLayout包住), 直接将paddingTop加到自定义ToolBar即可;

  • 当然有些做的好的公司可能会直接通过继承ViewGr0up(如原生ToolBar), 这个时候可能就只能用方案1了;


当然上述几点都是具体问题具体分析, 大家可以在预览界面临时加paddingTop,看看实际是什么样的, 便于大家尽早发现问题;可以参考下BottomNavigationView的源码, 它间接继承自FrameLayout, 内部对paddingBottom自动适配了navigation_bar_height;


这个思路和ImmersionBar的 状态栏与布局顶部重叠解决方案 类似,不同的是,ImmersionBar使用的是固定的高度,而方案1是动态监听状态栏的高度并设置FrameLayout的paddingTop;


注:上述的paddingTop = 20dp, 只是方便预览添加的, 运行时请通过API动态设置paddingTop = statusBar


3、 添加空白View,通过代码设置View高度为导航栏、状态栏高度时,存在坑;约束布局中0dp有特殊含义,可能导致UI变形,需要注意哈!特别是处理导航栏的时候,全屏时导航栏高度为0,就会导致View高度为0,如果有组件依赖他,可能会出现奇怪问题,因此最好现在布局预览中排查下


4-2、 Bug&兼容性(框架已修复)


直接使用Edge to edge(参照google官方文档)存在一个大坑:调用hide隐藏状态栏后会导致状态栏变黑, 并且内容区域无法铺满


详细描述看这里:point_right: WindowInsetsControllerCompat.hide makes status bar background undrawable


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

WindowCompat.getInsetsController(this, this.decorView)?.let {
it.systemBarsBehavior = behavior
it.hide(WindowInsetsCompat.Type.statusBars())
}

具体表现下图这个样子:


image-20221125143449641


解决方案如下 :point_down: How to remove top status bar black background


object EdgeUtils {
/** To fix hide status bar black background please using this post
* youtube: https://www.youtube.com/watch?v=yukwno2GBoI
* stackoverflow: https://stackoverflow.com/a/72773422/15859474
* */

private fun Activity.edgeToEdge() {
requestWindowFeature(Window.FEATURE_NO_TITLE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode = WindowManager
.LayoutParams
.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
setWindowEdgeToEdge(this.window)
}

private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

4-3、 如何去掉scrim?


在导航栏设置为全透明时, 部分机型就会出现scrim半透明遮罩,考虑到样式有点丑陋, 直接将其修改为#01000000, 这样看起来也是完全透明的, 但是系统判定其alpha不为0, 不会主动添加scrim的; 【具体请看官方文档】


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
/** using not transparent avoid scrim*/
Color.parseColor("#01000000").let { color ->
window.statusBarColor = color
window.navigationBarColor = color
}
}

4-4 、 禁止View的多次监听


一个View只能绑定一次ApplyWindowInset的监听,多次绑定可能会导致之前的失效或者出现奇怪问题!!!



5、 参考资料



作者:JailedBird
来源:juejin.cn/post/7313742254144307236
收起阅读 »

uniapp系列-改变底部安全区-顶部的手机信号、时间、电池栏颜色样式

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢? 首先来说底部安全区域 下图是底部安全区原始状态,感觉和整个页面格格不入 修改代码配置safearea manifest.json...
继续阅读 »

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢?


首先来说底部安全区域


下图是底部安全区原始状态,感觉和整个页面格格不入



修改代码配置safearea



  • manifest.json(下面代码仅支持ios)


// 在app-plus下配置:
"safearea": { //安全区域配置,仅iOS平台生效
"background": "#F5F6F9", //安全区域外的背景颜色,默认值为"#FFFFFF"
"bottom": { // 底部安全区域配置
"offset": "none|auto" // 底部安全区域偏移,"none"表示不空出安全区域,"auto"自动计算空出安全区域,默认值为"none"
}
},


  • 页面里写(下面代码支持android)


写法一:
// #ifdef APP-PLUS
var Color = plus.android.importClass("android.graphics.Color");
plus.android.importClass("android.view.Window");
var mainActivity = plus.android.runtimeMainActivity();
var window_android = mainActivity.getWindow();
window_android.setNavigationBarColor(Color.parseColor("#eb8c76"));
// #endif
写法二:
// #ifdef APP-PLUS
let color, ac, c2int, win;
color = plus.android.newObject("android.graphics.Color")
ac = plus.android.runtimeMainActivity();
c2int = plus.android.invoke(color, "parseColor", "#000000")
win = plus.android.invoke(ac, "getWindow");
plus.android.invoke(win, "setNavigationBarColor", c2int)
// #endif



底部区域颜色已配置成功(下图仅供参考,随便选的颜色,有点丑哈哈)



接下来讲一下顶部电池栏的配置


配置顶部导航栏颜色


方案一:仅适用于原生导航配置,非自定义导航



在page.json修改需要配置的页面的navigationBarTextStyle属性



"pages": [ 
{
"path": "pages/index/index",
"style": {
// "navigationStyle": "custom"
"navigationBarTitleText": "我是原生title",
"navigationBarTextStyle": "white" ,// 仅支持 black/white
"navigationBarBackgroundColor": "#aaaaff"
}
}
],


方案二:通用,也适用于自定义导航



在页面中使用nativejs的api,native是uni内置的sdk,不需要手动引入,直接用就可以,但是需要注意调用时机和条件使用,参考下面的注意事项哦



onReady(){
plus.navigator.setStatusBarStyle("dark"); //只支持dark和light
}



注意事项



注意函数的调用时机,如果是自定义导航栏,方法只写在onReady的话,切换路由再回来以后,你的配置会失效,所以要注意调用时机



uniapp中 onReady, onLoad, onShow区别



  • onReady 页面初次渲染完成了,但是渲染完成了,你才发送请求获取数据,显得有些慢

  • onLoad 只加载一次,监听页面加载,其参数为上个页面传递的数据,参数类型为Object

  • onShow 监听页面显示。页面每次出现都触发,包括从下级页面点返回露出当前页面


目前我是这样配置(举个栗子:配置顶部导航栏背景颜色为黑色)


import { onLoad, onShow, onReady} from '@dcloudio/uni-app';
onReady(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

onShow(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

今天就写到这里啦~



  • 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • 大家要天天开心哦



欢迎大家指出文章需要改正之处~

学无止境,合作共赢



在这里插入图片描述


欢迎路过的小哥哥小姐姐们提出更好的意见哇~~


作者:tangdou369098655
来源:juejin.cn/post/7206628135005143099
收起阅读 »