注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android:实现带边框的输入框

如今市面上APP的输入框可以说是千奇百怪,不搞点花样出来貌似代表格局没打开。还在使用系统自带的输入框的兄弟可以停下脚步,哥带你实现一个简易的带边框输入框。 话不多说,直接上图: 要实现这个效果,不得不再回顾下自定义View的流程,感兴趣的童鞋可以自行网上搜...
继续阅读 »

如今市面上APP的输入框可以说是千奇百怪,不搞点花样出来貌似代表格局没打开。还在使用系统自带的输入框的兄弟可以停下脚步,哥带你实现一个简易的带边框输入框。



话不多说,直接上图:
1.gif


要实现这个效果,不得不再回顾下自定义View的流程,感兴趣的童鞋可以自行网上搜索,这里只提及该效果涉及到的内容。总体实现大致流程:



  • 继承AppCompatEditText

  • 配置可定义的资源属性

  • onDraw() 方法的重写


首先还得分析:效果图中最多只能输入6个数字,需要计算出每个文字的宽高和间隙,再分别绘制文字背景和文字本身。从中我们需要提取背景颜色、高度、边距等私有属性,通过新建attrs.xml文件进行配置:


<declare-styleable name="RoundRectEditText">
<attr name="count" format="integer"/>
<attr name="itemPading" format="dimension"/>
<attr name="strokeHight" format="dimension"/>
<attr name="strokeColor" format="color"/>/>
</declare-styleable>

这样在初始化的时候即可给你默认值:


val typedArray =context.obtainStyledAttributes(it, R.styleable.RoundRectEditText)
count = typedArray.getInt(R.styleable.RoundRectEditText_count, count)
itemPading = typedArray.getDimension(R.styleable.RoundRectEditText_itemPading,0f)
strokeHight = typedArray.getDimension(R.styleable.RoundRectEditText_strokeHight,0f)
strokeColor = typedArray.getColor(R.styleable.RoundRectEditText_strokeColor,strokeColor)
typedArray.recycle()

接下来便是重头戏,如何绘制文字和背景色。思路其实很简单,通过for循环去遍历绘制每一个数字。关键点还在于去计算每个文字的位置及宽高,只要得到了位置和宽高,绘制背景和绘制文字易如反掌。


获取每个文字宽度:


strokeWith =(width.toFloat() - paddingLeft.toFloat() - paddingRight.toFloat() - (count - 1) * itemPading) / count

文字居中需要计算出对应Y值:


val fontMetrics = paint.fontMetrics
val textHeight = fontMetrics.bottom - fontMetrics.top
val distance = textHeight / 2 - fontMetrics.bottom
val baseline = height / 2f + distance

文字的X值则根据当前index和文字宽度以及各边距得出:


private fun getIndexOfX(index: Int): Float {
return paddingLeft.toFloat() + index * (itemPading + strokeWith) + 0.5f * strokeWith
}

得到了位置,宽高接下来的步骤再简单不过了。使用drawText 绘制文字,使用drawRoundRect 绘制背景。这里有一个细节一定要注意,绘制背景一定要在绘制文字之前,否则背景会把文字给覆盖。


另外,还需要注意一点。如果onDraw方法中不注释掉超类方法,底部会多出一段输入的数字。其实很好理解,这是AppCompatEditText 自身绘制的数字,所以我们把它注释即可,包括光标也是一样。如果想要光标则需要自己在onDraw方法中绘制即可。


//隐藏自带光标
super.setCursorVisible(false)

override fun onDraw(canvas: Canvas) {
//不注释掉会显示在最底部
// super.onDraw(canvas)
......
}

以上便是实现带边框的输入框的全部类型,希望对大家有所帮助!


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

基于EdgeEffect实现RecyclerView列表阻尼滑动效果

探索EdgeEffect的花样玩法 1、EdgeEffect是什么 当用户在一个可滑动的控件内(如RecyclerView),滑动内容已经超过了内容边界时,RecyclerView通过EdgeEffect绘制一个边界图形来提醒用户,滑动已经到边界了,不要再滑动...
继续阅读 »

探索EdgeEffect的花样玩法


1、EdgeEffect是什么


当用户在一个可滑动的控件内(如RecyclerView),滑动内容已经超过了内容边界时,RecyclerView通过EdgeEffect绘制一个边界图形来提醒用户,滑动已经到边界了,不要再滑动啦。


简言之:就是通过边界图形来提醒用户,没啥内容了,别滑了。


2、EdgeEffect在RecyclerView的现象是什么


1、到达边界后的阴影效果


在RecyclerView列表中,滑动到边界还继续滑动或者快速滑动到边界,则现象如下图中的到达边界后产生的阴影效果。


滑动到边界阴影效果

2、如何去掉阴影效果


在布局中,可以设置overScrollMode的属性值为never即可。


或者在代码中设置,即可取消


recyclerView?.overScrollMode = View.OVER_SCROLL_NEVER

3、EdgeEffect在RecyclerView的实现原理是什么


1、onMove事件对应EdgeEffect的onPull


EdgeEffect在RecyclerView中大致流程可以参考下面这个图,以onMove事件举例


EdgeEffect与RecyclerView交互图

通过上面这个图,并结合下面的源码,就能对这个流程有个大致的理解。


@Override
public boolean onTouchEvent(MotionEvent e) {
...
switch (action) {
...
case MotionEvent.ACTION_MOVE: {
...
// (1) move事件
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e, TYPE_TOUCH)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
...
}
}
break;
}
}


boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
...
// (2)判断是否设置了过度滑动,所以通过布局设置overScrollMode的属性值为never就走不进了分支逻辑中了
if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
}
considerReleasingGlowsOnScroll(x, y);
}
...

if (!awakenScrollBars()) {
// 刷新当前界面
invalidate();
}
return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}

private void pullGlows(float x, float overscrollX, float y, float overscrollY) {
boolean invalidate = false;
...
// 顶部边界
if (overscrollY < 0) {
// 构建顶部边界的EdgeEffect对象
ensureTopGlow();
// 调用EdgeEffect的onPull方法 设置些属性
EdgeEffectCompat.onPull(mTopGlow, -overscrollY / getHeight(), x / getWidth());
invalidate = true;
}
...

if (invalidate || overscrollX != 0 || overscrollY != 0) {
// 刷新界面
ViewCompat.postInvalidateOnAnimation(this);
}
}

void ensureTopGlow() {
...
mTopGlow = mEdgeEffectFactory.createEdgeEffect(this, EdgeEffectFactory.DIRECTION_TOP);
// 设置边界图形的大小
if (mClipToPadding) {
mTopGlow.setSize(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
getMeasuredHeight() - getPaddingTop() - getPaddingBottom());
} else {
mTopGlow.setSize(getMeasuredWidth(), getMeasuredHeight());
}

}

// RecyclerView的绘制
@Override
public void draw(Canvas c) {
super.draw(c);
...
if (mTopGlow != null && !mTopGlow.isFinished()) {
final int restore = c.save();
if (mClipToPadding) {
c.translate(getPaddingLeft(), getPaddingTop());
}
// 调用 EdgeEffect的draw方法
needsInvalidate |= mTopGlow != null && mTopGlow.draw(c);
c.restoreToCount(restore);
}
...
}

// EdgeEffect的draw方法
public boolean draw(Canvas canvas) {
...
update();
final int count = canvas.save();
final float centerX = mBounds.centerX();
final float centerY = mBounds.height() - mRadius;

canvas.scale(1.f, Math.min(mGlowScaleY, 1.f) * mBaseGlowScale, centerX, 0);

final float displacement = Math.max(0, Math.min(mDisplacement, 1.f)) - 0.5f;
float translateX = mBounds.width() * displacement / 2;

canvas.clipRect(mBounds);
canvas.translate(translateX, 0);
mPaint.setAlpha((int) (0xff * mGlowAlpha));
// 绘制扇弧
canvas.drawCircle(centerX, centerY, mRadius, mPaint);
canvas.restoreToCount(count);
...

同理:RecyclerView的 up 及Cancel事件对应调用EdgeEffect的onRelease;fling过度滑动对应EdgeEffect的onAbsorb方法


2、EdgeEffect的onPull、onRelease、onAbsorb方法


(1)onPull


对于RecyclerView列表而言,内容已经在顶部到达边界了,此时用户仍向下滑动时,会调用onPull方法及后续流畅,来更新当前视图,提示用户已经到边界了。


(2)onRelease


对于(1)的情况,用户松开了,不向下滑动了,此时释放拉动的距离,并刷新界面消失当前的图形界面。


(3)onAbsorb


用户过度滑动时,RecyclerView调用Fling方法,把内容到达边界后消耗不掉的距离传递给onAbsorb方法,让其显示图形界面提示用户已到达内容边界。


4、使用EdgeEffect在RecyclerView中实现列表阻尼滑动等效果


(1)先看下效果


EdgeEffect的录屏

上述gif图中展示了两个效果:RecyclerView的阻尼下拉 及 复位,这就是使用上面的EdgeEffect的三个方法可以实现。


上述的gif图中,使用MultiTypeAdapter实现RecyclerView的多类型页面(ViewModel、json数据源),可以参考这篇文章快速写个RecyclerView的多类型页面


下面主要展示如何构建一个EdgeEffect,充分地使用onPull、onRelease及onAbsorb能力


(2)代码示意


// 构建一个自定义的EdgeEffectFactory 并设置给RecyclerView
recyclerView?.edgeEffectFactory = SpringEdgeEffect()

// SpringEdgeEffect
class SpringEdgeEffect : RecyclerView\.EdgeEffectFactory() {

override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {

return object : EdgeEffect(recyclerView.context) {

override fun onPull(deltaDistance: Float) {
super.onPull(deltaDistance)
handlePull(deltaDistance)
}

override fun onPull(deltaDistance: Float, displacement: Float) {
super.onPull(deltaDistance, displacement)
handlePull(deltaDistance)
}

private fun handlePull(deltaDistance: Float) {
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
val translationYDelta =
sign * recyclerView.width * deltaDistance * 0.8f
Log.d("qlli1234-pull", "deltDistance: " + translationYDelta)
recyclerView.forEach {
if (it.isVisible) {
// 设置每个RecyclerView的子item的translationY属性
recyclerView.getChildViewHolder(it).itemView.translationY += translationYDelta
}
}
}

override fun onRelease() {
super.onRelease()
Log.d("qlli1234-onRelease", "onRelease")
recyclerView.forEach {
//复位
val animator = ValueAnimator.ofFloat(recyclerView.getChildViewHolder(it).itemView.translationY, 0f).setDuration(500)
animator.interpolator = DecelerateInterpolator(2.0f)
animator.addUpdateListener { valueAnimator ->
recyclerView.getChildViewHolder(it).itemView.translationY = valueAnimator.animatedValue as Float
}
animator.start()
}
}

override fun onAbsorb(velocity: Int) {
super.onAbsorb(velocity)
val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
Log.d("qlli1234-onAbsorb", "onAbsorb")
val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
recyclerView.forEach {
if (it.isVisible) {
// 在这个可以做动画
}
}
}

override fun draw(canvas: Canvas?): Boolean {
// 设置大小之后,就不会有绘画阴影效果
setSize(0, 0)
val result = super.draw(canvas)
return result
}
}
}

这里有一个小细节,如何在使用onPull等方法时,去掉绘制的阴影部分:其实,可以重写draw方法,重置大小为0即可,如上述代码中的这一小块内容:


override fun draw(canvas: Canvas?): Boolean {
// 设置大小之后,就不会有绘画阴影效果
setSize(0, 0)
val result = super.draw(canvas)
return result
}

5、参考


1、google的motion示例中的ChessAdapter内容


2、仿QQ的recyclerview效果实现


作者:李暖光
来源:juejin.cn/post/7235463575300046903
收起阅读 »

底部弹出菜单原来这么简单

底部弹出菜单是什么 底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。 思路分析 我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dial...
继续阅读 »

底部弹出菜单是什么


底部弹出菜单,即从app界面底部弹出的一个菜单列表,这种UI形式被众多app所采用,是一种主流的布局方式。


截屏2023-09-28 14.36.51.png


截屏2023-09-28 14.37.29.png


思路分析


我们先分析一下,这样一种UI应该由哪些布局组成?首先在原界面上以一小块区域显示界面的这种形式,很明显就是对话框Dialog做的事情吧!最底部是一个取消菜单,上面的功能菜单可以是一个,也可以是两个、三个甚至更多。所以,我们可以使用RecyclerView实现。需要注意一点的是,最上面那个菜单的样式稍微有点不一样,因为它上面是圆滑的,有圆角,这样的界面显示更加和谐。我们主要考虑的就是弹出对话框的动画样式,另外注意一点就是可以多支持几个语种,让框架更加专业,这里只需要翻译“取消”文字。


开始看代码


package dora.widget

import android.app.Activity
import android.app.Dialog
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter.base.BaseQuickAdapter
import com.chad.library.adapter.base.listener.OnItemChildClickListener
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class DoraBottomMenuDialog : View.OnClickListener, OnItemChildClickListener {

private var bottomDialog: Dialog? = null
private var listener: OnMenuClickListener? = null

interface OnMenuClickListener {
fun onMenuClick(position: Int, menu: String)
}

fun setOnMenuClickListener(listener: OnMenuClickListener) : DoraBottomMenuDialog {
this.listener = listener
return this
}

fun show(activity: Activity, menus: Array<String>): DoraBottomMenuDialog {
if (bottomDialog == null && !activity.isFinishing) {
bottomDialog = Dialog(activity, R.style.DoraView_AlertDialog)
val contentView =
LayoutInflater.from(activity).inflate(R.layout.dview_dialog_content, null)
initView(contentView, menus)
bottomDialog!!.setContentView(contentView)
bottomDialog!!.setCanceledOnTouchOutside(true)
bottomDialog!!.setCancelable(true)
bottomDialog!!.window!!.setGravity(Gravity.BOTTOM)
bottomDialog!!.window!!.setWindowAnimations(R.style.DoraView_BottomDialog_Animation)
bottomDialog!!.show()
val window = bottomDialog!!.window
window!!.decorView.setPadding(0, 0, 0, 0)
val lp = window.attributes
lp.width = WindowManager.LayoutParams.MATCH_PARENT
lp.height = WindowManager.LayoutParams.WRAP_CONTENT
window.attributes = lp
} else {
bottomDialog!!.show()
}
return this
}

private fun initView(contentView: View, menus: Array<String>) {
val recyclerView = contentView.findViewById<RecyclerView>(R.id.dview_recycler_view)
val adapter = MenuAdapter()
val list = mutableListOf<BottomMenu>()
menus.forEachIndexed { index, s ->
when (index) {
0 -> {
list.add(BottomMenu(s, BottomMenu.TOP_MENU))
}
else -> {
list.add(BottomMenu(s, BottomMenu.NORMAL_MENU))
}
}
}
adapter.setList(list)
recyclerView.adapter = adapter
val decoration = DividerItemDecoration(contentView.context, DividerItemDecoration.VERTICAL)
recyclerView.addItemDecoration(decoration)
adapter.addChildClickViewIds(R.id.tv_menu)
adapter.setOnItemChildClickListener(this)
val tvCancel = contentView.findViewById<TextView>(R.id.tv_cancel)
tvCancel.setOnClickListener(this)
}

private fun dismiss() {
bottomDialog?.dismiss()
bottomDialog = null
}

override fun onClick(v: View) {
when (v.id) {
R.id.tv_cancel -> dismiss()
}
}

override fun onItemChildClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) {
listener?.onMenuClick(position, adapter.getItem(position) as String)
dismiss()
}
}

类的结构不仅可以继承,还可以使用聚合和组合的方式,我们这里就不直接继承Dialog了,使用一种更接近代理的一种方式。条条大路通罗马,能抓到老鼠的就是好猫。这里的设计是通过调用show方法,传入一个菜单列表的数组来显示菜单,调用dismiss方法来关闭菜单。最后添加一个菜单点击的事件,把点击item的内容和位置暴露给调用方。


package dora.widget

import com.chad.library.adapter.base.BaseMultiItemQuickAdapter
import com.chad.library.adapter.base.viewholder.BaseViewHolder
import dora.widget.bean.BottomMenu
import dora.widget.bottomdialog.R

class MenuAdapter : BaseMultiItemQuickAdapter<BottomMenu, BaseViewHolder>() {

init {
addItemType(BottomMenu.NORMAL_MENU, R.layout.dview_item_menu)
addItemType(BottomMenu.TOP_MENU, R.layout.dview_item_menu_top)
}

override fun convert(holder: BaseViewHolder, item: BottomMenu) {
holder.setText(R.id.tv_menu, item.menu)
}
}

多类型的列表布局我们采用BRVAH,


implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper:3.0.10")

来区分有圆角和没圆角的item条目。


<?xml version="1.0" encoding="utf-8"?>

<resources>
<style name="DoraView.AlertDialog" parent="@android:style/Theme.Dialog">
<!-- 是否启用标题栏 -->
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowNoTitle">true</item>

<!-- 是否使用背景半透明 -->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
</style>

<style name="DoraView.BottomDialog.Animation" parent="Animation.AppCompat.Dialog">
<item name="android:windowEnterAnimation">@anim/translate_dialog_in</item>
<item name="android:windowExitAnimation">@anim/translate_dialog_out</item>
</style>
</resources>

以上是对话框的样式。我们再来看一下进入和退出对话框的动画。


translate_dialog_in.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="100%"
android:toXDelta="0"
android:toYDelta="0">

</translate>

translate_dialog_out.xml


<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:duration="300"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="0"
android:toYDelta="100%">

</translate>

最后给你们证明一下我是做了语言国际化的。
截屏2023-09-28 15.08.20.png


使用方式


// 打开底部弹窗
val dialog = DoraBottomMenuDialog()
dialog.setOnMenuClickListener(object : DoraBottomMenuDialog.OnMenuClickListener {
override fun onMenuClick(position: Int, menu: String) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
startActivity(intent)
}
})
dialog.show(this, arrayOf("外部浏览器打开"))

开源项目


github.com/dora4/dview…


作者:dora
来源:juejin.cn/post/7283516197487214611
收起阅读 »

Android:LayoutAnimation的神奇效果

大家好,我是时曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家讲讲酷炫的动画成员——LayoutAnimation。话不多说,直接上一个简单的效果图: 怎么样,和往常自己写的没有动画效果的页面比起来是不是更加酷炫。效果图只展示了从右到...
继续阅读 »

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


今天给大家讲讲酷炫的动画成员——LayoutAnimation。话不多说,直接上一个简单的效果图:


Screenrecorder-2023-09-10-10-29-52-627.gif


怎么样,和往常自己写的没有动画效果的页面比起来是不是更加酷炫。效果图只展示了从右到左叠加渐变的效果,只要脑洞够大,LayoutAnimation是可以帮你实现各类动画的。接下来就让我们看看LayoutAnimation如何实现这样的效果。


首先,新建一个XML动画文件slide_from_right.xml:


<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="600">
<translate
android:fromXDelta="100%p"
android:interpolator="@android:anim/decelerate_interpolator"
android:toXDelta="0" />

<alpha
android:fromAlpha="0.5"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:toAlpha="1" />
</set>

set标签下由translate(移动)和alpha(渐变)动画组成。


其中translate(移动)动画由100%p移动到0。这里需要注意使用的是100%p,其中加这个p是指按父容器的宽度进行百分比计算。插值器就根据自己想要的效果设置,这里使用了一个decelerate_interpolator(减速)插值器。


第二个动画是alpha(渐变)动画,由半透明到不透明,其中插值器是先加速后减速的效果。


接着我们还需要创建一个layoutAnimation,其实也是一个XML文件layout_slid_from_right.xml:


<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/slide_from_right"
android:animationOrder="normal"
android:delay="15%"/>

其中animation指定的就是我们创建的第一个xml文件。animationOrder是指动画执行的顺序模式,包含normal, reverse 和random。normal就是从上到下依次进行,reverse根据名字就知道是反序,random那当然是随机了,我们就使用mormal即可。delay则是每个子视图执行动画的延迟比例,这里需要注意的是这是相对于上个子视图执行动画延时比例。


最后我们只需要在咱们的ViewGr0up中设置layoutAnimation属性即可:


android:layoutAnimation="@anim/layout_slid_from_right"

当然也可在代码中手动设置:


val lin = findViewById<LinearLayout>(R.id.linParent)
val resId = R.anim.layout_slid_from_right
lin.layoutAnimation = AnimationUtils.loadLayoutAnimation(lin.context, resId)

总结:



  • layoutAnimation可以使用在任何一个ViewGr0up上

  • 在使用set标签做动画叠加的时候一定要注意,set标签内需要添加duration属性,也就是动画时间。如果不加动画是没有效果的。

  • 使用移动动画时,在百分比后面添加p的意思是基于父容器宽度进行百分比计算


以上便是LayoutAnimation的简单使用,只要你脑洞大开,各种各样的效果都能玩出来。实现起来也很简单,赶紧在项目中使用起来吧。


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

Android自定义一个省份简称键盘

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新And...
继续阅读 »

hello啊各位老铁,这篇文章我们重新回到Android当中的自定义View,其实最近一直在搞Flutter,初步想法是,把Flutter当中的基础组件先封装一遍,然后接着各个工具类,列表,网络,统统由浅入深的搞一遍,弄完Flutter之后,再逐步的更新Android当中的技术点,回头一想,还是穿插着来吧,再系统的规划,难免也有变化,想到啥就写啥吧,能够坚持输出就行。


今天的这个知识点,是一个自定义View,一个省份的简称键盘,主要用到的地方,比如车牌输入等地方,相对来说还是比较的简单,我们先看下最终的实现效果:



实现方式呢有很多种,我相信大家也有自己的一套实现机制,这里,我采用的是组合View,用的是LinearLayout的方式。


今天的内容大致如下:


1、分析UI,如何布局


2、设置属性和方法,制定可扩展效果


3、部分源码剖析


4、开源地址及实用总结


一、分析UI,如何布局


拿到UI效果图后,其实也没什么好分析的,无非就是两块,顶部的完成按钮和底部的省份简称格子,一开始,打算用RecyclerView网格布局来实现,但是最后的删除按钮如何摆放就成了问题,直接悬浮在网格上边,动态计算位置,显然不太合适,也没有这样去搞的,索性直接抛弃这个方案,多布局的想法也实验过,但最终还是选择了最简单的LinearLayout组合View形式。


所谓简单,就是在省份简称数组的遍历中,不断的给LinearLayout进行追加子View,需要注意的是,本身的View,也就是我们自定义View,继承LinearLayout后,默认的是垂直方向的,往本身View追加的是横向属性的LinearLayout,这也是换行的效果,也就是,一行一个横向的LinearLayout,记住,横向属性的LinearLayout,才是最终添加View的直接父类。



换行的条件就是基于UI效果,当模于设置length等于0时,我们就重新创建一个水平的LinearLayout,这就可以了,是不是非常的简单。


至于最后的删除按钮,使其靠右,占据两个格子的权重设置即可。


二、设置属性和方法,制定可扩展效果


当我们绘制完这个身份简称键盘后,肯定是要给他人用的,基于灵活多变的需求,那么相对应的我们也需要动态的进行配置,比如背景颜色,文字的颜色,大小,还有边距,以及点击效果等等,这些都是需要外露,让使用者选择性使用的,目前所有的属性如下,大家在使用的时候,也可以对照设置。


设置属性


属性类型概述
lp_backgroundcolor整体的背景颜色
lp_rect_spacingdimension格子的边距
lp_rect_heightdimension格子的高度
lp_rect_margin_topdimension格子的距离上边
lp_margin_left_rightdimension左右距离
lp_margin_topdimension上边距离
lp_margin_bottomdimension下边距离
lp_rect_backgroundreference格子的背景
lp_rect_select_backgroundreference格子选择后的背景
lp_rect_text_sizedimension格子的文字大小
lp_rect_text_colorcolor格子的文字颜色
lp_rect_select_text_colorcolor格子的文字选中颜色
lp_is_show_completeboolean是否显示完成按钮
lp_complete_text_sizedimension完成按钮文字大小
lp_complete_text_colorcolor完成按钮文字颜色
lp_complete_textstring完成按钮文字内容
lp_complete_margin_topdimension完成按钮距离上边
lp_complete_margin_bottomdimension完成按钮距离下边
lp_complete_margin_rightdimension完成按钮距离右边
lp_text_click_effectboolean是否触发点击效果,true点击后背景消失,false不消失

定义方法


方法参数概述
keyboardContent回调函数获取点击的省份简称简称信息
keyboardDelete函数删除省份简称简称信息
keyboardComplete回调函数键盘点击完成
openProhibit函数打开禁止(使领学港澳),使其可以点击

三、关键源码剖析


这里只贴出部分的关键性代码,整体的代码,大家滑到底部查看源码地址即可。


定义身份简称数组


    //省份简称数据
private val mLicensePlateList = arrayListOf(
"京", "津", "渝", "沪", "冀", "晋", "辽", "吉", "黑", "苏",
"浙", "皖", "闽", "赣", "鲁", "豫", "鄂", "湘", "粤", "琼",
"川", "贵", "云", "陕", "甘", "青", "蒙", "桂", "宁", "新",
"藏", "使", "领", "学", "港", "澳",
)

遍历省份简称


mLength为一行展示多少个,当取模为0时,就需要换行,也就是再次创建一个水平的LinearLayout,添加至外层的垂直LinearLayout中,每个水平的LinearLayout中,则是一个一个的TextView。


  //每行对应的省份简称
var layout: LinearLayout? = null
//遍历车牌号
mLicensePlateList.forEachIndexed { index, s ->
if (index % mLength == 0) {
//重新创建,并添加View
layout = createLinearLayout()
layout?.weightSum = 1f
addView(layout)
val params = layout?.layoutParams as LayoutParams
params.apply {
topMargin = mRectMarginTop.toInt()
height = mRectHeight.toInt()
leftMargin = mMarginLeftRight.toInt()
rightMargin = mMarginLeftRight.toInt() - mSpacing.toInt()
layout?.layoutParams = this
}
}

//创建文字视图
val textView = TextView(context).apply {
text = s
//设置文字的属性
textSize = px2sp(mRectTextSize)
//最后五个是否禁止
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
setTextColor(mNumProhibitColor)
mTempTextViewList.add(this)
} else {
setTextColor(mRectTextColor)
}

setBackgroundResource(mRectBackGround)
gravity = Gravity.CENTER
setOnClickListener {
if (mNumProhibit && index > (mLicensePlateList.size - 6)) {
return@setOnClickListener
}
//每个格子的点击事件
changeTextViewState(this)
}
}

addRectView(textView, layout, 0.1f)
}

追加最后一个View


由于最后一个视图是一个图片,占据了两个格子的大小,所以需要特殊处理,需要做的就是,单独设置权重weight和单独设置宽度width,如下所示:


  /**
* AUTHOR:AbnerMing
* INTRODUCE:追加最后一个View
*/

private fun addEndView(layout: LinearLayout?) {
val endViewLayout = LinearLayout(context)
endViewLayout.gravity = Gravity.RIGHT
//删除按钮
val endView = RelativeLayout(context)
//添加删除按钮
val deleteImage = ImageView(context)
deleteImage.setImageResource(R.drawable.view_ic_key_delete)
endView.addView(deleteImage)

val imageParams = deleteImage.layoutParams as RelativeLayout.LayoutParams
imageParams.addRule(RelativeLayout.CENTER_IN_PARENT)
deleteImage.layoutParams = imageParams
endView.setOnClickListener {
//删除
mKeyboardDelete?.invoke()
invalidate()
}
endView.setBackgroundResource(mRectBackGround)
endViewLayout.addView(endView)
val params = endView.layoutParams as LayoutParams
params.width = (getScreenWidth() / mLength) * 2 - mMarginLeftRight.toInt()
params.height = LayoutParams.MATCH_PARENT

endView.layoutParams = params

layout?.addView(endViewLayout)
val endParams = endViewLayout.layoutParams as LayoutParams
endParams.apply {
width = (mSpacing * 3).toInt()
height = LayoutParams.MATCH_PARENT
weight = 0.4f
rightMargin = mSpacing.toInt()
endViewLayout.layoutParams = this
}


}

四、开源地址及使用总结


开源地址:github.com/AbnerMing88…


关于使用,其实就是一个类,大家可以下载源码,直接复制即可使用,还可以进行修改里面的代码,非常的方便,如果懒得下载源码,没关系,我也上传到了远程Maven,大家可以按照下面的方式进行使用。


Maven具体调用


1、在你的根项目下的build.gradle文件下,引入maven。


 allprojects {
repositories {
maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
}
}

2、在你需要使用的Module中build.gradle文件下,引入依赖。


 dependencies {
implementation 'com.vip:plate:1.0.0'
}

代码使用


   <com.vip.plate.LicensePlateView
android:id="@+id/lp_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:lp_complete_text_size="14sp"
app:lp_margin_left_right="10dp"
app:lp_rect_spacing="6dp"
app:lp_rect_text_size="19sp"
app:lp_text_click_effect="false" />


总结


大家在使用的时候,一定对照属性表进行选择性使用;关于这个省份简称自定义View,实现方式有很多种,我目前的这种也不是最优的实现方式,只是自己的一个实现方案,给大家一个作为参考的依据,好了,铁子们,本篇文章就先到这里,希望可以帮助到大家。


作者:程序员一鸣
来源:juejin.cn/post/7235484890019659834
收起阅读 »

安卓开发中如何实现一个定时任务

定时任务方式优点缺点使用场景所用的API普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)Timer定时器简单易用,可以设置固定周期或者...
继续阅读 »

定时任务方式优点缺点使用场景所用的API
普通线程sleep的方式简单易用,可用于一般的轮询Polling不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Thread.sleep(long)
Timer定时器简单易用,可以设置固定周期或者延迟执行的任务不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间的定时任务Timer.schedule(TimerTask,long)
ScheduledExecutorService灵活强大,可以设置固定周期或者延迟执行的任务,并支持多线程并发不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要多线程并发的定时任务Executors.newScheduledThreadPool(int).schedule(Runnable,long,TimeUnit)
Handler中的postDelayed方法简单易用,可以设置延迟执行的任务,并与UI线程交互不精确,不可靠,容易被系统杀死或者休眠需要在App内部执行短时间且需要与UI线程交互的定时任务Handler.postDelayed(Runnable,long)
Service + AlarmManger + BroadcastReceiver可靠稳定,可以设置精确或者不精确的闹钟,并在后台长期运行需要声明相关权限,并受系统时间影响需要在App外部执行长期且对时间敏感的定时任务AlarmManager.set(int,PendingIntent), BroadcastReceiver.onReceive(Context,Intent), Service.onStartCommand(Intent,int,int)
WorkManager可靠稳定,不受系统时间影响,并可以设置多种约束条件来执行任务需要添加依赖,并不能保证准时执行需要在App外部执行长期且对时间不敏感且需要满足特定条件才能执行的定时任务WorkManager.enqueue(WorkRequest), Worker.doWork()
RxJava简洁、灵活、支持多线程、支持背压、支持链式操作学习曲线较高、内存占用较大需要处理复杂的异步逻辑或数据流io.reactivex:rxjava:2.2.21
CountDownTimer简单易用、不需要额外的线程或handler不支持取消或重置倒计时、精度受系统时间影响需要实现简单的倒计时功能android.os.CountDownTimer
协程+Flow语法简洁、支持协程作用域管理生命周期、支持流式操作和背压需要引入额外的依赖库、需要熟悉协程和Flow的概念和用法需要处理异步数据流或响应式编程kotlinx-coroutines-core:1.5.0
使用downTo关键字和Flow实现一个定时任务1、可以使用简洁的语法创建一个倒数的范围 2 、可以使用Flow异步地发射和收集倒数的值3、可以使用onEach等操作符对倒数的值进行处理或转换1、需要注意倒数的范围是否包含0,否则可能会出现偏差 2、需要注意倒数的间隔是否与delay函数的参数一致,否则可能会出现不准确 3、需要注意取消或停止Flow的时机,否则可能会出现内存泄漏或资源浪费1、适合于需要实现简单的倒计时功能,例如显示剩余时间或进度 2、适合于需要在倒计时过程中执行一些额外的操作,例如播放声音或更新UI 3、适合于需要在倒计时结束后执行一些额外的操作,例如跳转页面或弹出对话框implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0"
Kotlin 内联函数的协程和 Flow 实现很容易离开主线程,样板代码最少,协程完全活用了 Kotlin 语言的能力,包括 suspend 方法。可以处理大量的异步数据,而不会阻塞主线程。可能会导致内存泄漏和性能问题。处理 I/O 阻塞型操作,而不是计算密集型操作。kotlinx.coroutines 和 kotlinx.coroutines.flow

安卓开发中如何实现一个定时任务


在安卓开发中,我们经常会遇到需要定时执行某些任务的需求,比如轮询服务器数据、更新UI界面、发送通知等等。那么,我们该如何实现一个定时任务呢?本文将介绍安卓开发中实现定时任务的五种方式,并比较它们的优缺点,以及适用场景。


1. 普通线程sleep的方式


这种方式是最简单也最直观的一种实现方法,就是在一个普通线程中使用sleep方法来延迟执行某个任务。例如:


// 创建一个普通线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 循环执行
while (true) {
// 执行某个任务
doSomething();
// 延迟10秒
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
// 启动线程
thread.start();


这种方式的优点是简单易懂,不需要借助其他类或组件。但是它也有很多缺点:



  • sleep方法会阻塞当前线程,导致资源浪费和性能下降。

  • sleep方法不准确,它只能保证在指定时间后醒来,但不能保证立即执行。

  • sleep方法受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • sleep方法不可靠,如果线程被异常终止或者进入休眠状态,会导致计时中断。


因此,这种方式只适合一般的轮询Polling场景。


2. Timer定时器


这种方式是使用Java API里提供的Timer类来实现定时任务。Timer类可以创建一个后台线程,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Timer对象
Timer timer = new Timer();
// 创建一个TimerTask对象
TimerTask task = new TimerTask() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
timer.schedule(task, 5000, 10000);


这种方式相比第一种方式有以下优点:



  • Timer类内部使用wait和notify方法来控制线程的执行和休眠,不会浪费资源和性能。

  • Timer类可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • Timer类可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • Timer类只创建了一个后台线程来执行所有的任务,如果其中一个任务耗时过长或者出现异常,则会影响其他任务的执行。

  • Timer类受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • Timer类不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些不太重要的定时任务。


3. ScheduledExecutorService


这种方式是使用Java并发包里提供的ScheduledExecutorService接口来实现定时任务。ScheduledExecutorService接口可以创建一个线程池,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个ScheduledExecutorService对象
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
}
};
// 设置在5秒后开始执行,并且每隔10秒重复执行一次
service.scheduleAtFixedRate(task, 5, 10, TimeUnit.SECONDS);


这种方式相比第二种方式有以下优点:



  • ScheduledExecutorService接口可以创建多个线程来执行多个任务,避免了单线程的弊端。

  • ScheduledExecutorService接口可以设置固定频率或者固定延迟来执行任务,更加灵活和准确。

  • ScheduledExecutorService接口可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • ScheduledExecutorService接口受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • ScheduledExecutorService接口不可靠,如果进程被杀死或者进入休眠状态,会导致计时中断。


因此,这种方式适合一些需要多线程并发执行的定时任务。


4. Handler中的postDelayed方法


这种方式是使用Android API里提供的Handler类来实现定时任务。Handler类可以在主线程或者子线程中发送和处理消息,在指定的时间或者周期性地执行某个任务。例如:


// 创建一个Handler对象
Handler handler = new Handler();
// 创建一个Runnable对象
Runnable task = new Runnable() {
@Override
public void run() {
// 执行某个任务
doSomething();
// 延迟10秒后再次执行该任务
handler.postDelayed(this, 10000);
}
};
// 延迟5秒后开始执行该任务
handler.postDelayed(task, 5000);


这种方式相比第三种方式有以下优点:



  • Handler类不受系统时间影响,它使用系统启动时间作为参考。

  • Handler类可以在主线程中更新UI界面,避免了线程间通信的问题。


但是这种方式也有以下缺点:



  • Handler类只能在当前进程中使用,如果进程被杀死或者进入休眠状态,会导致计时中断。

  • Handler类需要手动循环调用postDelayed方法来实现周期性地执行任务。


因此,这种方式适合一些需要在主线程中更新UI界面的定时任务.


5. Service + AlarmManager + BroadcastReceiver


这种方式是使用Android API里提供的三个组件来实现定时任务. Service组件可以在后台运行某个长期的服务;AlarmManager组件可以设置一个闹钟,在指定的时间发送一个



  • Intent,用于指定要启动的Service组件和传递一些参数。

  • AlarmManager组件可以设置一个闹钟,在指定的时间发送一个Intent给BroadcastReceiver组件。

  • BroadcastReceiver组件可以接收AlarmManager发送的Intent,并启动Service组件来执行任务。


这种方式相比第四种方式有以下优点:



  • Service组件可以在后台运行,即使进程被杀死或者进入休眠状态,也不会影响计时。

  • AlarmManager组件可以设置精确或者不精确的闹钟,根据不同的需求节省电量。

  • BroadcastReceiver组件可以在系统开机后自动注册,实现开机自启动。


但是这种方式也有以下缺点:



  • Service组件需要在AndroidManifest.xml文件中声明,并申请相关的权限。

  • AlarmManager组件受系统时间影响,如果用户修改了系统时间,会导致计时错误。

  • BroadcastReceiver组件需要在代码中动态注册和注销,避免内存泄漏。


因此,这种方式适合一些需要长期在后台执行的定时任务。


6. WorkManager


这种方式是使用Android Jetpack里提供的WorkManager库来实现定时任务. WorkManager库是一个用于管理后台任务的框架,它可以在满足一定条件下执行某个任务,并保证任务一定会被执行。例如:


// 创建一个PeriodicWorkRequest对象
PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(MyWorker.class, 15, TimeUnit.MINUTES)
.setConstraints(new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build();
// 获取一个WorkManager对象
WorkManager workManager = WorkManager.getInstance(this);
// 将PeriodicWorkRequest对象加入到队列中
workManager.enqueue(request);


这种方式相比第五种方式有以下优点:



  • WorkManager库不受系统时间影响,它使用系统启动时间作为参考。

  • WorkManager库可以设置多种约束条件来执行任务,例如网络状态、电量状态、设备空闲状态等。

  • WorkManager库可以取消或者重新安排任务,更加方便和可控。


但是这种方式也有以下缺点:



  • WorkManager库需要添加依赖并配置相关的权限。

  • WorkManager库不能保证任务准时执行,它会根据系统资源和约束条件来调度任务。


因此,这种方式适合一些对时间不敏感且需要满足特定条件才能执行的定时任务


您好,这是必应。我根据您的要求,从网络上搜集了一些关于如何使用RXJAVA、CountDownTimer、协程安卓实现一个定时任务的资料,并整理成了一篇文章。文章的结构如下:


7.如何使用RXJAVA实现定时任务


RXJAVA是一个基于观察者模式和函数式编程的库,它可以用来处理异步事件和数据流。RXJAVA提供了一些操作符,可以用来创建和处理定时任务。其中,interval操作符可以用来创建一个按照固定时间间隔发射整数序列的Observable,可以用来实现周期性的定时任务。例如,以下代码可以实现每隔1秒发射一个整数,从0开始:


//创建一个Observable,每隔1秒发射一个整数,从0开始
Observable timer = Observable.interval(1, TimeUnit.SECONDS);
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


如果要实现倒计时的效果,可以使用map操作符,将发射的整数转换为倒计时的秒数。例如,以下代码可以实现一个10秒的倒计时:


//创建一个Observable,每隔1秒发射一个整数,从0开始,共发射10个
Observable timer = Observable.interval(1, TimeUnit.SECONDS).take(10);
//使用map操作符,将发射的整数转换为倒计时的秒数
timer = timer.map(new Function() {
@Override
public Long apply(Long aLong) throws Exception {
//将发射的整数转换为倒计时的秒数,例如0转换为10,1转换为9,以此类推
return 10 - aLong;
}
});
//订阅这个Observable,打印发射的数据
timer.subscribe(new Observer() {
@Override
public void onSubscribe(Disposable d) {
//订阅时的操作,可以用来取消订阅
}

@Override
public void onNext(Long aLong) {
//收到数据时的操作,可以用来更新UI或者执行其他逻辑
Log.d("RXJAVA", "onNext: " + aLong);
}

@Override
public void onError(Throwable e) {
//发生错误时的操作,可以用来处理异常
}

@Override
public void onComplete() {
//完成时的操作,可以用来释放资源
}
});


RXJAVA的优点是可以方便地处理异步事件和数据流,可以使用各种操作符来实现复杂的逻辑,可以避免内存泄漏和线程安全


8.如何使用CountDownTimer实现定时任务


CountDownTimer是Android中提供的一个倒计时器类,它可以用来实现一个在一定时间内递减的倒计时。CountDownTimer的构造方法接受两个参数:总时间和间隔时间。例如,以下代码可以创建一个10秒的倒计时,每隔1秒更新一次:


//创建一个10秒的倒计时,每隔1秒更新一次
CountDownTimer timer = new CountDownTimer(10000, 1000) {
@Override
public void onTick(long millisUntilFinished) {
//每隔一秒调用一次,可以用来更新UI或者执行其他逻辑
Log.d("CountDownTimer", "onTick: " + millisUntilFinished / 1000);
}

@Override
public void onFinish() {
//倒计时结束时调用,可以用来释放资源或者执行其他逻辑
Log.d("CountDownTimer", "onFinish");
}
};
//开始倒计时
timer.start();
//取消倒计时
timer.cancel();


CountDownTimer的优点是使用简单,可以直接在UI线程中更新UI,不需要额外的线程或者Handler。CountDownTimer的缺点是只能实现倒计时的效果,不能实现周期性的定时任务,而且精度受系统时间的影响,可能不够准确。


9.如何使用协程实现定时任务


协程可以用来简化异步编程和线程管理。协程是一种轻量级的线程,它可以在不阻塞线程的情况下挂起和恢复执行。协程安卓提供了一些扩展函数,可以用来创建和处理定时任务。其中,delay函数可以用来暂停协程的执行一段时间,可以用来实现倒计时或者周期性的定时任务。例如,以下代码可以实现一个10秒的倒计时,每隔1秒更新一次:


//创建一个协程作用域,可以用来管理协程的生命周期
val scope = CoroutineScope(Dispatchers.Main)
//在协程作用域中启动一个协程,可以用来执行异步任务
scope.launch {
//创建一个变量,表示倒计时的秒数
var seconds = 10
//循环执行,直到秒数为0
while (seconds > 0) {
//打印秒数,可以用来更新UI或者执行其他逻辑
Log.d("Coroutine", "seconds: $seconds")
//暂停协程的执行1秒,不阻塞线程
delay(1000)
//秒数减一
seconds--
}
//倒计时结束,打印日志,可以用来释放资源或者执行其他逻辑
Log.d("Coroutine", "finish")
}
//取消协程作用域,可以用来取消所有的协程
scope.cancel()


协程安卓的优点是可以方便地处理异步任务和线程切换,可以使用简洁的语法来实现复杂的逻辑,可以避免内存泄漏和回调。协程的缺点是需要引入额外的依赖,而且需要一定的学习成本,不太适合初学者。


10.使用kotlin关键字 ‘downTo’ 搭配Flow


// 创建一个倒计时器,从10秒开始,每秒减一
val timer = object: CountDownTimer(10000, 1000) {
override fun onTick(millisUntilFinished: Long) {
// 在每个间隔,发射剩余的秒数
emitSeconds(millisUntilFinished / 1000)
}

override fun onFinish() {
// 在倒计时结束时,发射0
emitSeconds(0)
}
}

// 创建一个Flow,用于发射倒数的秒数
fun emitSeconds(seconds: Long): Flow = flow {
// 使用downTo关键字创建一个倒数的范围
for (i in seconds downTo 0) {
// 发射当前的秒数
emit(i.toInt())
}
}


11.kotlin内联函数的协程和 Flow 实现


fun FragmentActivity.timerFlow(
time: Int = 60,
onStart: (suspend () -> Unit)? = null,
onEach: (suspend (Int) -> Unit)? =
null,
onCompletion: (suspend () -> Unit)? =
null
): Job {
return (time downTo 0)
.asFlow()
.cancellable()
.flowOn(Dispatchers.Default)
.onStart { onStart?.invoke() }
.onEach {
onEach?.invoke(it)
delay(
1000L)
}.onCompletion { onCompletion?.invoke() }
.launchIn(lifecycleScope)
}


//在activity中使用
val job = timerFlow(
time = 60,
onStart = { Log.d("Timer", "Starting timer...") },
onEach = { Log.d("Timer", "Seconds remaining: $it") },
onCompletion = { Log.d("Timer", "Timer completed.") }
)

//取消计时
job.cancel()

作者:淘淘养乐多
来源:juejin.cn/post/7270173192789737487
收起阅读 »

完了,安卓项目代码被误删了......

写在前面 这是一个朋友的经历记录了下来。朋友开发完了一个公司的app,过了一段时间,在清理电脑空间的时候把该app的项目目录给删了,突然公司针对该app提出了新的需求,这不完了?幸好有之前打包好的apk,所以可以通过逆向去弥补..... Apk文件结构 apk...
继续阅读 »

写在前面


这是一个朋友的经历记录了下来。朋友开发完了一个公司的app,过了一段时间,在清理电脑空间的时候把该app的项目目录给删了,突然公司针对该app提出了新的需求,这不完了?幸好有之前打包好的apk,所以可以通过逆向去弥补.....


Apk文件结构


apk的本质是压缩包,apk解压后会生成下列所示文件夹




  • Assets:存放的是不会被编译处理的文件。

  • Lib:存放的是一些so库,native库文件

  • META-INF:存放的是签名信息,用来保证apk的完整性和系统安全。防止被重新修改打包。

  • res:存放的资源文件,图片、字符串、颜色信息等

  • AndroidManifest.xml:是Android程序的配置文件,权限和配置信息

  • Classes.dex:Android平台下的字节码文件。

  • Resources.arcs:编译后的二进制资源文件,用来记录资源文件和资源ID的关系


逆向


这里用了逆向神器——jdax。支持命令行和图形化界面,地址如下:


github.com/skylot/jadx…


下载好之后,直接解压后打开exe,将apk文件拖入进去就可以,图形化界面,更方便搜索查看,可以看到下列文件夹



先看资源文件,asset存放的是静态资源文件,一般不会被压缩,但是会占用更多的安装包空间,res文件是由Android目录下的res进行压缩得到的,所以里面的文件直接解压打开会乱码,在这个工具里打开是正常的。



话不多说直接找回我的代码,找到我写的一个类,拷贝回去,补齐里面缺失的资源文件和一些新增的接口,跟着自己之前开发的流程,一步一步的找回去,发现其中局部变量在编译的时候都被进行了优化,以便缩小体积



找到我写的最核心的代码,发现被混淆了,我在代码里没有进行代码混淆配置,还是被一些工具给我进行了混淆,只能凭借着记忆去还原了。



终于进行了不到一天多的时间,把所有的代码还原了,然后自测通过。


代码混淆


现在其实也可以看到自己的程序是非常危险的,任何人拿到我的apk进行一个逆向就可以看到大概的逻辑。所以要在Android中进行代码混淆的配置。


项目中如果含有多个module时,在主app中设置了混淆其他module都会混淆,在build.gradle中配置下列代码 proguardFiles getDefaultProguardFile


android {
...
buildTypes {
release {
minifyEnabled true // 开启代码混淆
zipAlignEnabled true // 开启Zip压缩优化
shrinkResources true // 移除未被使用的资源
//混淆文件列表,混淆规则配置
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
...
}

这里代表的是混淆文件,我们在项目里找到proguard-rules.pro,这里就是混淆规则,规定了哪些代码进行混淆,哪些不进行混淆。混淆规则一般有以下几点:



  • 混淆规则,等级、预校验、混淆算法等

  • 第三方库

  • 自定义类、控件

  • 本地的R类

  • 泛型 注解 枚举类等


示例配置如下:



#压缩等级,一般选择中间级别5
-optimizationpasses 5
#包名不混合大小写
-dontusemixedcaseclassnames
#不去忽略非公共的库类
-dontskipnonpubliclibraryclasses
#优化 不优化输入的类文件
-dontoptimize
#预校验
-dontpreverify
#混淆时采用的算法
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*
#保护注解
-keepattributes *Annotation*
#保持下面的类不被混淆(没有用到的可以删除掉,比如没有用到service则可以把service行删除)
-keep public class * extends android.app.Fragment
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.preference.Preference
-keep public class * extends android.support.v4.app.FragmentActivity
-keep public class * extends android.support.** { *;}
#如果引用了v4或者v7包
-dontwarn android.support.*
#忽略警告(开始应该注释掉,让他报错误解决,最后再打开,警告要尽量少)
-ignorewarnings
#####################记录生成的日志数据,gradle build时在本项目根目录输出################
#混淆时是否记录日志
-verbose
#apk 包内所有class 的内部结构
-dump class_files.txt
#为混淆的类和成员
-printseeds seeds.txt
#列粗从 apk 中删除的代码
-printusage unused.txt
#混淆前后的映射
-printmapping mapping.txt
#####################记录生成的日志数据,gradle build时在本项目根目录输出结束################

#本地的R类不要被混淆,不然就找不到相应的资源
-keep class **.R$*{ public static final int *; }

#保持内部类,异常类
-keepattributes Exceptions, InnerClasses
#保持泛型、注解、源代码之类的不被混淆
-keepattributes Signature, Deprecated, SourceFile
-keepattributes LineNumberTable, *Annotation*, EnclosingMethod

#保持自定义控件不被混淆(没有就不需要)
-keepclasseswithmembers class * extends android.app.Activity{
public void *(android.view.View);
}
-keepclasseswithmembers class * extends android.supprot.v4.app.Fragment{
public void *(android.view.View);
}
#保持 Parcelable 不被混淆(没有就不需要)
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
#保持 Serializable 不被混淆(没有就不需要)
-keepnames class * implements java.io.Serializable

-keepclassmembers class * {
public void *ButtonClicked(android.view.View);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}

再次打包,然后打开apk后就会发现包名类名变量名都变得很奇怪。




这样代码混淆就完成了。


作者:银空飞羽
来源:juejin.cn/post/7360903734853730356
收起阅读 »

为 App 增加清理缓存功能

为 App 增加清理缓存功能 不废话,直接上干货 功能预期 评估缓存情况,估算出缓存大小; 一键清除所有缓存 评估缓存大小 已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可...
继续阅读 »

为 App 增加清理缓存功能


不废话,直接上干货


功能预期



  1. 评估缓存情况,估算出缓存大小;

  2. 一键清除所有缓存


评估缓存大小


已知 app 的缓存目录可通过 context.getCacheDir() 获取,那么评估其内容文件的大小即可,若有其他缓存路径也可通过此方法合并计算:


public long getFolderSize(File folder) {
   long length = 0;
   File[] files = folder.listFiles();
   
   if (files != null) {
       for (File file : files) {
           if (file.isFile()) {
               length += file.length();
          } else {
               length += getFolderSize(file);
          }
      }
  }
   return length;
}

执行方法:


// 新建异步线程防止卡顿
new Thread() {
   @Override
   public void run() {
       super.run();
long cacheSize = getFolderSize(getCacheDir());
  }
}.start();

接下来需要将缓存大小按照合理的格式显示到界面上,我按照自己的需求小于 1MB 时显示 KB 单位,小于 1KB 时显示 0 KB,使用以下方法即可完成缓存大小的文本组织:


public String formatSize(long size) {
   if (size >= 1024 * 1024) {
       return (size / (1024 * 1024)) + " MB";
  } else if (size >= 1024) {
       return (size / 1024) + " KB";
  } else {
       return "0 KB";
  }
}

清理各单位缓存


WebView 的缓存清理


对于 WebView 可以直接使用 webView.clearCache(true) 方法来进行清理,但清除缓存的界面没有 WebView 实例,因此需要现场先建立一个来执行,注意 WebView 的创建不可以在异步线程进行:


WebView webView = new WebView(me);
webView.clearCache(true);

Glide 的缓存清理


只需要注意 Glide 的缓存清理必须在异步线程执行


try {
   // Glide: You must call this method on a background thread
   Glide.get(me).clearDiskCache();
} catch (Exception e) {
   e.printStackTrace();
}

其他组件请自行按照对应技术文档进行清理


综合缓存文件清理


所有缓存文件删除即可彻底完成清理步骤


File cacheDir = context.getCacheDir();
deleteDirectory(cacheDir);

删除目录方法:


private static void deleteDirectory(File dir) {
   if (dir != null && dir.isDirectory()) {
       for (File child : dir.listFiles()) {
           // 递归删除目录中的内容
           deleteDirectory(child);
      }
  }
   if (dir != null) {
       dir.delete();
  }
}

总结


其实清理缓存是个挺没必要的工作,Glide 等组件进行缓存的主要目的也在于避免重复资源的加载加快 app 的界面呈现速度,但不可避免的可能因为用户需要或者出现缓存 bug 导致界面无法正常显示等情况需要清理 app 缓存,即便系统本身自带了缓存清理功能(应用设置- app - 存储和缓存 - 清除缓存)但毕竟有些上手门槛且各家厂商操作系统操作逻辑各异不如自己做一个清除功能在 app 内了,通过上述代码即可完成缓存大小估算和清理流程,如有其他常用组件的清理操作方法也欢迎在评论区补充。


作者:Kongzue
来源:juejin.cn/post/7304932252826288180
收起阅读 »

Android串口通信蓝牙通信中数据格式转换整理

Android 定制开发版上应用开发,免不了使用一些串口通信、蓝牙通信,考虑到每次发送的数据包需要尽可能的小,约定的协议中基本上都是一些字节流表示,因此特地将之前搜集到的一些数据格式转换的方法整理出来。在此感谢将这些code发布出来的博主们。一、Byte相关的...
继续阅读 »

Android 定制开发版上应用开发,免不了使用一些串口通信、蓝牙通信,考虑到每次发送的数据包需要尽可能的小,约定的协议中基本上都是一些字节流表示,因此特地将之前搜集到的一些数据格式转换的方法整理出来。在此感谢将这些code发布出来的博主们。

一、Byte相关的数据转换

  1. 获取Byte指定下标[0 - 7]的Bit的值,和获取Byte的所有Bit的值
/**
* 获取第i位的bit值
*/

fun Byte.getPointedBit(position: Int): Int {
return (this.toInt() shr position) and 0x1
}

/**
* 通过byte获取int类型的字节list
* IntelMode 低字节在前,如 0x55-> 0101 0101
*/

fun Byte.getBitList(intelMode: Boolean = true): List<Int> {
val list = arrayListOf<Int>()
val input = this
for (i in 0 until 8) {
val index = if (intelMode) (7 - i) else i
list.add(input.getPointedBit(index))
}
return list
}
  1. Byte转16进制字符串
/**
* 十六进制字节转字符串,不足2位的字符串则在前补0
* 其实质是Byte->Int->String
*/

fun Byte.toHexString(): String {
var hexStr = Integer.toHexString(this.toInt() and 0xFF)
if (hexStr.length == 1) {
hexStr = "0$hexStr"
}
return hexStr.uppercase(Locale.getDefault())
}
  1. Byte 中修改指定位置的Bit,这个需要绕一下,先将Byte转成一个长度为8的数组,然后修改指定下标的值,然后再将这个数组转换成一个Int,最后Int可以直接转成Byte。目前还未发现其他更好的方法,如有后续补充上。
/**
* 将byte转换成bit组成的数组
*/

fun Byte.toByteArray(): ByteArray {
val bytes = ByteArray(8)
for (i in 0 until 8) {
bytes[i] = this.getPointedBit(i).toByte()
}
return bytes
}

/**
* 一个byte所代表的int值
*/

fun ByteArray.oneByteToIntSum(): Int {
var sum = 0
for (i in this.indices) {
val tmp = this[i]
// 2 的 n 次方
sum += (tmp * 2.0.pow(i.toDouble())).toInt()
}

return sum
}

二、ByteArray相关的数据转换

  1. ByteArray转Int
/**
* 有符号,int 占 2 个字节
*/

fun ByteArray.toIntWithTwo(): Int {
return (this[0].toInt() shl 8) or (this[1].toInt() and 0xFF)
}

/**
* 无符号,int 占 2 个字节
*/

fun ByteArray.toUnSignIntWithTwo(): Int {
return (this[0].toInt() and 0xFF) shl 8 or
(this[1].toInt() and 0xFF)
}

/**
* 有符号, int 占 4 个字节
*/

fun ByteArray.toIntWithFour(): Int {
return (this[0].toInt() shl 24) or
(this[1].toInt() and 0xFF) or
(this[2].toInt() shl 8) or
(this[3].toInt() and 0xFF)
}

/**
* 无符号, int 占 4 个字节
*/

fun ByteArray.toUnSignIntWithFour(): Long {
return ((this[0].toInt() and 0xFF) shl 24 or
(this[1].toInt() and 0xFF) shl 16 or
(this[2].toInt() and 0xFF) shl 8 or
(this[3].toInt() and 0xFF)).toLong()
}

/**
* 一个Int转成2个字节的byte数组
*/

fun Int.toIntArrayFor2(): List<Int> {
val list = arrayListOf<Int>()
val lowH = (this shr 8) and 0xff
val lowL = this and 0xff
list.add(lowH)
list.add(lowL)
return list
}

/**
* 一个Int转成4个字节的byte数组
*/

fun Int.toByteArray4(): ByteArray {
val byteArray = ByteArray(4)
val highH = ((this shr 24) and 0xff).toByte()
val highL = ((this shr 16) and 0xff).toByte()
val lowH = ((this shr 8) and 0xff).toByte()
val lowL = (this and 0xff).toByte()
byteArray[0] = highH
byteArray[1] = highL
byteArray[2] = lowH
byteArray[3] = lowL
return byteArray
}
  1. ByteArray转字符串
/**
* 字节数组转字符串
*/

fun ByteArray.toSimpleString(format: Charset = Charsets.UTF_8): String {
return String(this, format)
}

/**
* 字节数组转换成16进制字符串
*/

fun ByteArray.toHexString(): String {
var result = ""
for (element in this) {
var hexStr = Integer.toHexString(element.toInt() and 0xFF)
if (hexStr.length == 1) {
hexStr = "0$hexStr"
}
result += hexStr.uppercase(Locale.getDefault())
}
return result
}
  1. ByteArray转Long
/**
* 字节数组转换为long 8个byte
*/

fun ByteArray.convertToLong(): Long {
val bais = ByteArrayInputStream(this)
val dis = DataInputStream(bais)
return dis.readLong()
}

/**
* long转换为字节数组 8个byte
*/

fun Long.convertToBytes(): ByteArray {
val baos = ByteArrayOutputStream()
val dos = DataOutputStream(baos)
dos.writeLong(this)
return baos.toByteArray()
}

/**
* Long 类型转成4个字节数组
* 时间只能精确到秒
*/

fun Long.convertToBytes4(): ByteArray {
var tmp = this
val bytes = ByteArray(4)
for (i in bytes.size - 1 downTo 0) {
bytes[i] = (tmp and 0xFF).toByte()
tmp = tmp shr 8
}
return bytes
}

/**
* 4个字节数组转成Long 类型
* 时间只能精确到秒
*/

fun ByteArray.convertToLong4(): Long {
var num: Long = 0
for (i in 0 until 4) {
num = num shl 8
num = num or ((this[i].toInt() and 0xFF).toLong())
}
return num
}
  1. 两个ByteArray拼接
/**
* byte数组拼接一个byte数组
*/

fun ByteArray.appendByteArray(extraBytes: ByteArray): ByteArray {
val inputSize = this.size
val extraSize = extraBytes.size
val totalSize = inputSize + extraSize
val combineBytes = ByteArray(totalSize)
System.arraycopy(this, 0, combineBytes, 0, inputSize)
System.arraycopy(extraBytes, 0, combineBytes, inputSize, extraSize)
return combineBytes
}
  1. ByteArray转Double,此种转换较为复杂,目前未找到稳定可用的代码
none
  1. ByteArray 和 BCD 格式的时间相互转换
/**
* BCD字节数组转为字符串
*/

fun ByteArray.bcdToString(): String {
val sb = StringBuilder(this.size / 2)
for (i in 0 until this.size) {
// 高四位
sb.append((this[i].toInt() and 0xF0) ushr 4)
// 低四位
sb.append(this[i].toInt() and 0x0F)
}
val retStr = sb.toString()
return if (retStr.substring(0, 1).equals("0", ignoreCase = true)) {
retStr.substring(1)
} else {
retStr
}
}

/**
* 字符串转BCD字节数组
*/

fun String.bcdToByteArray(): ByteArray {
var len = this.length
val mod = len % 2
val srcStr = if (0 != mod) {
len += 1
"0$this"
} else this
val bytes = srcStr.toByteArray()
len = if (len >= 2) len / 2 else len
val secondBytes = ByteArray(len)
var j: Int
var k: Int
for (p in 0 until srcStr.length / 2) {
val jIndex = 2 * p
j = if (bytes[jIndex].toInt().toChar() in '0'..'9') {
bytes[jIndex].toInt().toChar() - '0'
} else if (bytes[jIndex].toInt().toChar() in 'a'..'z') {
bytes[jIndex].toInt().toChar() - 'a' + 0x0a
} else {
bytes[jIndex].toInt().toChar() - 'A' + 0x0a
}
val kIndex = 2 * p + 1
k = if (bytes[kIndex].toInt().toChar() in '0'..'9') {
bytes[kIndex].toInt().toChar() - '0'
} else if (bytes[kIndex].toInt().toChar() in 'a'..'z') {
bytes[kIndex].toInt().toChar() - 'a' + 0x0a
} else {
bytes[kIndex].toInt().toChar() - 'A' + 0x0a
}
val a = (j shl 4) + k
val b = a.toByte()
secondBytes[p] = b
}
return secondBytes
}

三、String相关的类型转换,主要是方便把二进制字节流转换成易于查看的字符串

  1. 16进制字符串转ByteArray
private fun char2Byte(input: Char): Byte {
return "0123456789ABCDEF".indexOf(input).toByte()
}

/**
* 16进制字符串转字节数组,提供3种转换方式
*/

fun String.hexStringToBytes(type: Int = 0): ByteArray {
if (this.isEmpty()) {
return ByteArray(0)
}
val hexStr = this.uppercase(Locale.getDefault())
val length = hexStr.length / 2
val outBytes = ByteArray(length)
when (type) {
0 -> {
val hexCharArr = this.toCharArray()
for (i in 0 until length) {
val p = 2 * i
val p1 = char2Byte(hexCharArr[p]).toInt() shl 4
val p2 = char2Byte(hexCharArr[p + 1])
outBytes[i] = p1.toByte() or p2
}
}
1 -> {
for (i in 0 until length step 2) {
val v1 = (this[i].digitToIntOrNull(16) ?: -1) shl 4
val v2 = this[i + 1].digitToIntOrNull(16) ?: -1
outBytes[i / 2] = (v1 + v2).toByte()
}
}
else -> {
for (i in outBytes.indices) {
val subStr = this.substring(2 * i, 2 * i + 2)
outBytes[i] = subStr.toInt(16).toByte()
}
}
}
return outBytes
}
  1. 字符串Json格式转Map
/**
* json 字符串转 Map
*/

fun String.jsonStringToMap(): HashMap? {
val jsonObject: JSONObject
try {
jsonObject = JSONObject(this)
val keyIter: Iterator = jsonObject.keys()
var key: String
var value: Any
val valueMap = HashMap()
while (keyIter.hasNext()) {
key = keyIter.next()
value = jsonObject[key] as Any
valueMap[key] = value
}
return valueMap
} catch (e: JSONException) {
e.printStackTrace()
}
return null
}


作者:pursuit_hu
来源:juejin.cn/post/7226629911350542391
收起阅读 »

Android 将json数据显示在RecyclerView

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的 本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出 Share...
继续阅读 »

json数据要通过Get请求获取,这里有个重要的知识点,get请求需要拼接url的
本次拼接url的参数为phone,由于登录的时候已经填了手机号,如果这里再收集手机号就会让客户体验变差,于是我采用了SharedPreferences进行记录并调出


SharedPreferences pref=getSharedPreferences("data",MODE_PRIVATE);
String phone=pref.getString("phone","");

得到了phone之后,我采用了okhttp请求返回json,注意:进行网络请求都需要开启线程以及一些必要操作
例如


<uses-permission android:name="android.permission.INTERNET" /> 

url为你申请的网络url


 new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient client=new OkHttpClient().newBuilder()
.connectTimeout(60000, TimeUnit.MILLISECONDS)
.readTimeout(60000,TimeUnit.MILLISECONDS).build();
//url/phone
Request request=new Request.Builder().url("url/phone"+phone).build();
try {
Response sponse=client.newCall(request).execute();
String string = sponse.body().string();
Log.d("list",string);
jsonJXDate(string);
}catch (IOException | JSONException e){
e.printStackTrace();
}
}
}).start();

由上可知,string即为所需的json


展示大概长这样


{
"code": 200,
"message": "成功",
"data": [
{
"id": "string",
"createTime": "2023-04-18T05:50:08.905+00:00",
"updateTime": "2023-04-18T05:50:08.905+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T05:50:08.905+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "烧烤",
"amount": "4",
"price": "60",
"subtotal": "240"
}
],
"total": "string"
},
{
"id": "643e9efb09ecf071b0fd2df0",
"createTime": "2023-04-18T13:28:35.889+00:00",
"updateTime": "2023-04-18T13:28:35.889+00:00",
"isDeleted": 0,
"param": {},
"phone": "15019649343",
"commercialTenant": "string",
"payTime": "2023-04-18T13:28:35.889+00:00",
"type": "string",
"paymentType": "string",
"bills": [
{
"product": "兰州拉面",
"amount": "5",
"price": "40",
"subtotal": "200"
}
],
"total": "string"
}
],
"ok": true
}

我所需要的是payTime,product,subtotal


有{}用JSONObject,有[]用JSONArray,一步步来靠近你的需要


JSONObject j1 = new JSONObject(data);
try {
JSONArray array = j1.getJSONArray("data");
for (int i=0;i<array.length();i++){
j1=array.getJSONObject(i);
Map<String,Object>map=new HashMap<>();
String payTime = j1.getString("payTime");
JSONObject bills = j1.getJSONArray("bills").getJSONObject(0);
String product = bills.getString("product");
String subtotal = bills.getString("subtotal");
map.put("payTime",payTime);
map.put("product",product);
map.put("subtotal",subtotal);
list.add(map);
}
Message msg=new Message();
msg.what=1;
handler.sendMessage(msg);

}catch (JSONException e){
e.printStackTrace();
}

}
public Handler handler=new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case 1:
//添加分割线
rv.addItemDecoration(new androidx.recyclerview.widget.DividerItemDecoration(
MeActivity.this, androidx.recyclerview.widget.DividerItemDecoration.VERTICAL));
MyAdapter recy = new MyAdapter(MeActivity.this, list);
//设置布局显示格式
rv.setLayoutManager(new LinearLayoutManager(MeActivity.this));
rv.setAdapter(recy);
break;
}
}
};

在adapter处通过常规layout显示后填入数据


 //定义时间展现格式
Map<String, Object> map = list.get(position);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
LocalDateTime dateTime = LocalDateTime.parse(map.get("payTime").toString(), formatter);
String strDate = dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

holder.produce.setText(map.get("product").toString());
holder.payTime.setText(strDate);
holder.price.setText(map.get("subtotal").toString());

就大功告成啦,由于后台那边还没把base64图片传上来,导致少了个图片,大致就是这样的


6a2d483f93267f3cd09f25576c1f29c.jpg


作者:m924
来源:juejin.cn/post/7224841852305588280
收起阅读 »

Android 开发中是否应该使用枚举?

前言在Android官方文档推出性能优化的时候,从一开始有这样一段说明:Enums often require more than twice as much memory as static constants. You should strictly av...
继续阅读 »

前言

Android官方文档推出性能优化的时候,从一开始有这样一段说明:

Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

意思是说在 Android 平台上 avoid 使用枚举,因为枚举类比一般的静态常量多占用两倍的空间。

由于枚举最终的实现原理还是类,在编译完成后,最终为每一种类型生成一个静态对象,而在内存申请方面,对象需要的内存空间远大于普通的静态常量,而且分析枚举对象的成员变量可知,每一个对象中默认都会有一个字符数组空间的申请,计算下来,枚举需要的空间远大于普通的静态变量。

如果只是使用枚举来标记类型,那使用静态常量确实更优,但是现在翻看官方文档发现,这个建议已经被删除了,这是为什么那 ? 具体看 JakeWharton 在 reddit 上的一个评论

The fact that enums are full classes often gets overlooked. They can implement interfaces. They can have methods in the enum class and/or in each constant. And in the cases where you aren't doing that, ProGuard turns them back int0 ints anyway.
The advice was wrong for application developers then. It's remains wrong now.

最重要的一句是

ProGuard turns them back int0 ints anyway.

在开启 ProGuard 优化的情况下,枚举会被转为int类型,所以内存占用问题是可以忽略的。具体可参看 ProGuard 的优化列表页面 Optimizations Page,其中就列举了 enum 被优化的项,如下所示:

class/unboxing/enum

Simplifies enum types to integer constants, whenever possible.

ProGuard官方出了一篇文章 ProGuard and R8: Comparing Optimizers(大致意思就是自己比R8强 ),既ProGuard会把枚举优化为整形.但是安卓抛弃了了ProGuard,而是使用了R8作为混淆优化工具。我们重点看下R8对枚举优化的效果如何 ?

R8对枚举优化

下面通过以下例子验证一下在真实的开发环境中R8对枚举优化的支持效果。 代码如下:

  1. 定义一个简答枚举类Language
package com.example.enum_test;

public enum Language {

English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}
}
  1. MainActivity主要代码
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Language language = null;
if (Math.random() < 0.5) {
doEnumAction(Language.English);
} else {
doEnumAction(Language.Chinese);
}
// doNumberAction(CHINESE);
}


private void doEnumAction(Language language) {
switch (language) {
case English:
System.out.println("english ");
break;
case Chinese:
System.out.println("chinese");
break;
}
System.out.println(language.name());
}

3.build.gradle.kts文件内开启混淆

buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
  1. 将编译后的apk反编译结果如下(枚举类被优化):

截屏2023-11-07 20.55.14.png

以上结果可以看出,如果是一个简单的枚举类,那么枚举类将会被优化为一个整形数字。既然ProGuard/R8会把枚举优化为整形,那是不是在Android中,就可以继续无所顾忌的使用枚举了呢? 我没有找到官方对R8枚举具体的优化场景说明 ,只找了ProGuard对枚举的优化有一定的限制条件,如果枚举类存在如下的情况,将不会有优化为整形,如下所示:

  1. 枚举实现了自定义接口。并且被调用。
  2. 代码中使用了不同签名来存储枚举。
  3. 使用instanceof指令判断。
  4. 使用枚举加锁操作。
  5. 对枚举强转。
  6. 在代码中调用静态方法valueOf方法
  7. 定义可以外部访问的方法。

参考自:ProGuard 初探 · dim's blog,另外,上面的这七种情况,我并没有找到官方的说明,如果有哪位读者知道,请在评论区里留下链接,谢谢啦~

下面我们对以上的情况进行追一验证,看下这些条件是否也会对R8编译优化产生限制 , 如下 :

  1. 枚举实现了自定义接口,并且被调用。
public interface ILanguage {
int getIndex();
}


public enum Language implements ILanguage{
English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}

@Override
public int getIndex() {
return this.ordinal();
}
}

// 调用如下
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ILanguage iLanguage = Language.Chinese;
System.out.println(iLanguage.getIndex());
}

反编译结果如下(枚举类被优化):

截屏2023-11-07 18.37.01.png

  1. 代码中使用了不同签名来存储枚举。

在对以下代码调用的时候,使用一个变量保存枚举值,由于currColor变量声明类型的不同, 导致枚举的优化结果也不同

// 枚举会被优化
Signal currColor = Signal.RED;

//发生了类型转换,变量签名不一致,枚举不会被优化
Object currColor = Signal.RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = Signal.GREEN;
break;
case GREEN:
currColor = Signal.YELLOW;
break;
case YELLOW:
currColor = Signal.RED;
break;
}
}


protected void onCreate(Bundle savedInstanceState) {

double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(Signal.YELLOW);
}
// 最终也是被优化为if语句
//switch (currColor) {
// case RED:
// System.out.println("红灯");
// break;
// case GREEN:
// System.out.println("绿灯");
// break;
// case YELLOW:
// System.out.println("黄灯");
// break;
//}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else if (currColor == YELLOW) {
System.out.println("黄灯");
}
}

Signal currColor = Signal.RED; 时 ,枚举被优化整数

截屏2023-11-10 11.41.41.png

Object currColor = Signal.RED;时 ,枚举未被优化

截屏2023-11-10 11.34.39.png

  1. 使用instanceof指令判断。 (发生了类型转换)
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
boolean result = getObj() instanceof Language;
System.out.println(result);
}

Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下(枚举类未被优化):

截屏2023-11-10 12.13.48.png

  1. 使用枚举加锁操作。
synchronized (Language.Chinese) {
System.out.println("synchronized");
}

从反编译结果如下(枚举类未被优化):

截屏2023-11-07 18.23.22.png 可以看到在该场景下枚举类没有被优化。

  1. 不要作为一个输出或打印对象
System.out.println(RED);

从反编译结果如下(枚举类未被优化):

截屏2023-11-10 12.17.11.png

  1. 对枚举强转。 比如下代码不会出现枚举优化
  @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
boolean result = (getObj()) != null;
System.out.println(result);
}

// 如果返回值类型和返回的枚举类型不一致时,也不会优化枚举。
@Nullable
Object getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下:(枚举类未被优化): 截屏2023-11-07 20.07.43.png

如果把返回值修改为Language则会发生优化

@Nullable
Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

反编译结果如下:(枚举类被优化):

截屏2023-11-07 20.15.21.png

以下代码也会出现枚举被优化,把方法的返回值类型修改为 Language ,接收变量类型改为 Object

 @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Object language = getObj();
boolean result= language != null;
System.out.println(result);
}


@Nullable
Language getObj() {
return Math.random() > 0.5 ? Language.Chinese : null;
}

截屏2023-11-07 20.34.54.png

  1. 定义可以外部访问的方法。 R8对枚举的优化并不受定义外部方法的影响,如下在枚举内定义getLanguage方法后,枚举仍被优化
package com.example.enum_test;

import androidx.annotation.Nullable;

public enum Language {
English("en", "英文"), Chinese("zh", "中文");

String webName;
String zhName;

Language(String webName, String zhName) {
this.webName = webName;
this.zhName = zhName;
}

@Nullable
public Language getLanguage(String name) {
if (English.webName.equals(name)) {
return Language.English;
} else {
return null;
}
}
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Language language = Language.getLanguage(Math.random() > 0.5f ? "en" : "zh");
boolean result= language != null;
System.out.println(result);
}

apk反编译结果如下(枚举被优化):

截屏2023-11-07 20.45.17.png

复杂多变的枚举优化

在测试中发现一个问题 ,同样的代码,放在不同的文件内,优化效果竟然也不同。

  1. 在 MainActivity定义如下方法
public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
break;
}
}

public void test() {
double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(YELLOW);
}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else {
System.out.println("黄灯");
}
}

并在onCreate方法内执行, 从下面的反编译结果中可以看到枚举被优化了。

截屏2023-11-10 14.29.32.png

  1. 相同的代码如果定义在 TrafficLight类中, 并在MainActivityonCreate方法中运行 ,如下:
package com.example.enum_test;
import static com.example.enum_test.TrafficLight.Signal.GREEN;
import static com.example.enum_test.TrafficLight.Signal.RED;
import static com.example.enum_test.TrafficLight.Signal.YELLOW;

public class TrafficLight {

enum Signal {
GREEN, YELLOW, RED;
}

private Signal currColor = RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}


public void test() {
double random = Math.random();
if (random > 0.5f) {
change(GREEN);
} else if (random > 0.5 && random < 0.7) {
change(RED);
} else {
change(YELLOW);
}

if (currColor == RED) {
System.out.println("红灯");
} else if (currColor == GREEN) {
System.out.println("绿灯");
} else {
System.out.println("黄灯");
}
}
}
// onCreate 内执行
TrafficLight trafficLight = new TrafficLight();
trafficLight.test();

截屏2023-11-10 14.43.23.png

从上面的对比中发现,相同的枚举代码操作放在Activity和 放在普通类中 ,编译结果是不同的。导致这种问题的原因还是因为Activity默认是配置了防混淆的,如果对一个类成员添加了防混淆配置,编译会尽可能的对类里面相关的枚举使用优化为一个常量,但这不是一定的,枚举的优化会受到其他因素影响,例如 锁对象、类型转换等其它条件限制。而TrafficLight默认是没有被配置防混淆的,如果类内定义了枚举变量,编译器会对类进行一系列的编译优化和函数内联等处理,枚举变量被抽取到一个工厂公共类里内部,枚举变量对象被指向一个Object类型引用,编译器不会对枚举进行优化。如果对类进行防混淆配置后,该类内部枚举代码会被优化为一个整数常量,结果如下:

配置 -keep class com.example.enum_test.TrafficLight效果

截屏2023-11-10 15.16.59.png

配置#-keepclassmembernames class com.example.enum_test.TrafficLight效果

截屏2023-11-10 15.31.18.png

引用代码

截屏2023-11-10 15.17.25.png

如果对具有引用枚举类型变量的类进行了防混淆配置处理TrafficLight内的枚举引用也全部被优化为了整数类型。

如果未对TrafficLight类进行防混淆配置,这个类的相关成员可能会被抽取到一个公共类里。 currColor 就是m0f1749b属性, 该属性是一个Object类型,这也是可能是导致枚举未完全优化为整数的原因, 从 m0 的代码中可以看到编译器将多个实例的构造统一只使用了一个Object作为引用, 方法也被编译到m0类内部,可以看到m0类不是一个TrafficLight,猜测这也是编译器在对枚举进行整型优化和枚举持有类优化一种权衡和选择吧 。

截屏2023-11-10 16.05.23.png

截屏2023-11-10 16.05.54.png

枚举 、常量

从编译结果来看,枚举由于会构建多个静态对象ordinal()values()等函数和变量的存在,确实会比普通的静态对象或常量更加占用空间和内存。但是从上面的测试结果中可以看到 ,枚举在最佳情况下可以被优化为整型,达到和常量一样的效果。

截屏2023-11-10 16.19.04.png

总结

以下场景都会阻止枚举优化 :

  1. 使用instanceof指令判断。
  2. 使用枚举作为锁对象操作
  3. System.out.println(enum) 输出
  4. 枚举作为返回值返回时,返回参数的声明类型与枚举不一致,请参考 例6
  5. 混淆优化配置影响枚举优化, 如果一个类中有变量是一个枚举类型,如果该类未在proguard-rules.pro配置混淆优化处理,该类则可能会被编译器优化掉,其变量和方法会被抽取到一个公共类或者内敛到引用类里, 且枚举类不会被优化,因为枚举变量公共类被一个Object类型变量引用持有。
  6. 常规的枚举使用,R8都会对枚举进行一定程度的优化,最好的情况下会优化成一个整数常量,性能几乎不会有任何影响。

我的结论是如果我们可以通过定义普通常量的方式代替枚举,则优先通过使用定义常量解决。因为枚举本身确实会带来导致包体积和内存的增长, 而枚举被优化的环境和条件实在是过于苛刻,例如可能在输出语造成打印了一下枚举System.out.println(enum),一不小心可能就会造成举优化失败。也不是不能使用枚举,权衡易用性和性能以及使用场景,可以考虑继续使用枚举,因为枚举在有些时候确实让代码更简洁,更容易维护,牺牲点内存也无妨。况且Android官方自己也在许多地方应用了枚举,例如Lifecycle.StateLifecycle.Event等 。

小彩蛋

前几天群里在讨论 京东金融Android瘦身探索与实践 文章,内容中一点优化是关于枚举的 。

截屏2023-11-07 21.14.13.png

我感觉他们以这个例子没有很强的说服力,原因如下 :

  1. 如果对持有枚举变量的类或者变量进行混淆配置后 ,编译器会对枚举进行优化 ,TrafficLight 内枚举的引用被替换为整数,从反编译结果可以看到优化后的代码就是普通的if语句,并不会出现所谓的占用大量体积的情况。

image.png

  1. 如果枚举相关类未进行完全优化,但是例子中的change()方法并不会导致大量增加包体 ,只是增加了4行字节码指令。但是枚举的定义的确会占用一定的包体积大小,这个毋庸置疑。

使用枚举实现以及编译后字节码如下 :

public class TrafficLight {

enum Signal {
GREEN, YELLOW, RED;
}

private Signal currColor = RED;

public void change(Signal color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}
}
// 22行字节码指令
.method public change(Lb1/a;)V
.registers 3
invoke-virtual {p1}, Ljava/lang/Enum;->ordinal()I
move-result p1
if-eqz p1, :cond_15
const/4 v0, 0x1
if-eq p1, v0, :cond_12
const/4 v0, 0x2
if-eq p1, v0, :cond_d
goto :goto_18
:cond_d
sget-object p1, Lb1/a;->a:Lb1/a;
:goto_f
iput-object p1, p0, Lcom/example/enum_test/TrafficLight;->currColor:Lb1/a;
goto :goto_18
:cond_12
sget-object p1, Lb1/a;->c:Lb1/a;
goto :goto_f
:cond_15
sget-object p1, Lb1/a;->b:Lb1/a;
goto :goto_f
:goto_18
return-void
.end method

使用常量实现相同功能编译后字节码如下 :

package com.example.enum_test;


public class TrafficLightConst {

public static final int GREEN = 0;
public static final int YELLOW = 1;
public static final int RED = 2;

private int currColor = RED;

public void change(int color) {
switch (color) {
case RED:
currColor = GREEN;
break;
case GREEN:
currColor = YELLOW;
break;
case YELLOW:
currColor = RED;
}

}
}
// 18行字节码指令
.method public change(I)V
.registers 4
const/4 v0, 0x1
if-eqz p1, :cond_10
const/4 v1, 0x2
if-eq p1, v0, :cond_d
if-eq p1, v1, :cond_9
goto :goto_12
:cond_9
const/4 p1, 0x0
iput p1, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
goto :goto_12
:cond_d
iput v1, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
goto :goto_12
:cond_10
iput v0, p0, Lcom/example/enum_test/TrafficLightConst;->currColor:I
:goto_12
return-void
.end method

参考

zhuanlan.zhihu.com/p/91459700

jakewharton.com/r8-optimiza…


作者:Lstone
来源:juejin.cn/post/7299666003364249650

收起阅读 »

android之阿拉伯语适配及注意细节

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。若未增加该元素,在xml中切换语言时,...
继续阅读 »

1.  AndroidManifest.xml配置文件中的 标签下,配置元素 android:supportsRtl="true"。此时当系统语言切换的时候,你的 App 也会跟着切换 UI 布局为镜像后的效果。

若未增加该元素,在xml中切换语言时,会提示 image.png 增加后,可在xml文件中查看反转后的效果 2.  新增value-ar文件夹

image.png

image.png

image.png 把values/strings.xml文件复制到values-ar文件中,逐条翻译即可。

  1. layout中的Left/Right修改为Start/End

可使用Android Studio中自带的工具:“工具栏”-“Refactor”-“Add right-to-Left(RTL)Support” image.png

注意事项:

  • 1).此时会把所依赖gradle里的xml文件列出,记得删除,不要转换。

image.png

  • 2). 该工具只适用于项目的app模块,无法直接应用于依赖模块。如果需要在依赖模块中进行RTL转换,要逐个打开并手动进行相应的修改。
  • 3). Start属性在LTR中对应Left,End属性在LTR中对应Right,在API 17开始支持,为了兼容低版本,可以同时有Left和Start。

    即在“Add right-to-Left(RTL)Support”工具中,不勾选“Replace Left/Right Properties with Start/End Properties”

image.png

  1. 返回icon、下一个icon等,要针对阿拉伯语新建一个文件夹,放镜像后的图片,规则如下:

mipmap-xhdpi->mipmap-ldrtl-xhdpi

drawable->drawable-ldrtl

最终镜像的图片要UI同事提供,临时修改看效果可以使用镜像图片的网站:http://www.lddgo.net/image/flip

  1. TextView、EditText:利用全局样式,在style.xml中定义,在xml里使用style=”@style/xxx”即可
  • 1). TextView
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="android:textViewStyle">@style/TextViewStyle.TextDirectionitem>
       ...
style>
<style name="TextViewStyle.TextDirection" parent="android:Widget.TextView">
        <item name="android:textDirection">localeitem>
style>
  • 2). EditText
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
       ...
       <item name="editTextStyle">@style/EditTextStyle.Alignmentitem>
       ...
style>
<style name="EditTextStyle.Alignment" parent="@android:style/Widget.EditText">
        <item name="android:textAlignment">viewStartitem>
        <item name="android:gravity">startitem>
        <item name="android:textDirection">localeitem>
style>
  1. 其他细节
  • 1).固定ltr,如阿拉伯语下的“99%”要从左到右展示,可在xml中使用
android:layoutDirection ="ltr"
  • 2).获取当前系统语言Locale.getDefault().getLanguage()

判断是否为阿拉伯语:"ar".equals(Locale.getDefault().getLanguage())

判断是否为英语:"en".equals(Locale.getDefault().getLanguage())

  • 3). drawable/xxx_selector.xml中item里有android:drawable,如勾选框。

drawable有android:autoMirrored属性,将selector的该属性设置为true,就可以让drawable在RTL布局下进行反转

image.png

  • 4).进度条的默认进度指示是从左到右,使用leftMargin;在阿拉伯语下,进度指示从右到左,使用rightMargin属性
  • 5).阿拉伯语环境下,使用SimpleDateFormat格式化时间字符串的时候,会显示为:٢٠١٥-٠٩-١٨ ٠٧:٠٣:٤٩。若要展示:2023-09-067:10:45,可以使用Locale.ENGLISH参数
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
Date now=new Date();
System.out.println(sdf .format(now));
  • 6). 加载html可用 tv.setText(Html.fromHtml(getResources().getString(R.String.xxx));
  • 7). 开机导航中设置了阿拉伯语,当前页面布局要刷新,可以重写activity的onConfigurationChanged()方法,如在该方法里重置下一步箭头、指示器样式等

image.png

  • 8).ViewPager

若是ViewPager,可使用第三方控件RtlViewPager替换: 521github.com/diego-gomez…,添加依赖,单纯替换原ViewPager即可

implementation 'com.booking:rtlviewpager:1.0.1' 

类似三方控件: 521github.com/duolingo/rt…

或者使用androidx的ViewPager2替换: developer.android.google.cn/jetpack/and…,支持RTL布局

image.png

image.png

  • 9). 固定RTL字符串的顺序

问题现象:EditText带hint,密码可见、不可见时,会调用如下方法进行设置

image.png 此时会影响hint的展示:在勾选时,hint的结束字符在右侧;不勾选时,hint的结束字符在左侧。

image.png

image.png

解决方法:此时要使用Unicode控制字符来限制整个字符串的显示方向:\u202B 和 \u202C。

image.png

有以下两种方法

a.  java代码

image.png

b.  strings.xml

image.png

最终效果:

image.png

image.png

10). Blankj的toast展示异常

android工具类Blankj的toast工具类在展示阿拉伯语时为空或者部分展示,建议使用1.30.6 及以上版本

image.png

github.com/Blankj/Andr…

11). RTL布局中出现双光标/光标截断的情形

image.png

在布局文件内加上如下两个属性即可:

android:textDirection="anyRtl"
android:textAlignment="viewStart"

若还未解决

1.可查看是否使用了android:textCursorDrawable=“@null”,若有,可尝试去掉该句。

2.在AndroidManifest.xml中查看当前App/Activity的主题,比较老的项目可能使用了android:Theme.NotitleBar/android:Theme.Light等轻量级主题,如下所示:




收起阅读 »

如何仿一个抖音极速版领现金的进度条动画?

效果演示 不仅仅是实现效果,要封装,就封装好 看完了演示的效果,你是否在思考,代码应该怎么实现?先不着急写代码,先想想哪些地方是要可以动态配置的。首先第一个,进度条的形状是不是要可以换?然后进度条的背景色和填充的颜色,以及动画的时长是不是也要可以配置?没错,...
继续阅读 »

效果演示


20230617_064552_edit.gif


不仅仅是实现效果,要封装,就封装好


看完了演示的效果,你是否在思考,代码应该怎么实现?先不着急写代码,先想想哪些地方是要可以动态配置的。首先第一个,进度条的形状是不是要可以换?然后进度条的背景色和填充的颜色,以及动画的时长是不是也要可以配置?没错,起始位置是不是也要可以换?最好还要让速度可以一会快一会慢对吧,画笔的笔帽是不是还可以选择平的或圆的?带着这些问题,我们再开始写代码。


代码实现


我们写一个自定义View,把可以动态配置的地方想好后,就可以定义自定义属性了。


<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="DoraProgressView">
<attr name="dview_progressType">
<enum name="line" value="0"/>
<enum name="semicircle" value="1"/>
<enum name="semicircleReverse" value="2"/>
<enum name="circle" value="3"/>
<enum name="circleReverse" value="4"/>
</attr>
<attr name="dview_progressOrigin">
<enum name="left" value="0"/>
<enum name="top" value="1"/>
<enum name="right" value="2"/>
<enum name="bottom" value="3"/>
</attr>
<attr format="dimension|reference" name="dview_progressWidth"/>
<attr format="color|reference" name="dview_progressBgColor"/>
<attr format="color|reference" name="dview_progressHoverColor"/>
<attr format="integer" name="dview_animationTime"/>
<attr name="dview_paintCap">
<enum name="flat" value="0"/>
<enum name="round" value="1"/>
</attr>
</declare-styleable>
</resources>

然后我们不管三七二十一,先把自定义属性解析出来。


private fun initAttrs(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
val a = context.obtainStyledAttributes(
attrs,
R.styleable.DoraProgressView,
defStyleAttr,
0
)
when (a.getInt(R.styleable.DoraProgressView_dview_progressType, PROGRESS_TYPE_LINE)) {
0 -> progressType = PROGRESS_TYPE_LINE
1 -> progressType = PROGRESS_TYPE_SEMICIRCLE
2 -> progressType = PROGRESS_TYPE_SEMICIRCLE_REVERSE
3 -> progressType = PROGRESS_TYPE_CIRCLE
4 -> progressType = PROGRESS_TYPE_CIRCLE_REVERSE
}
when (a.getInt(R.styleable.DoraProgressView_dview_progressOrigin, PROGRESS_ORIGIN_LEFT)) {
0 -> progressOrigin = PROGRESS_ORIGIN_LEFT
1 -> progressOrigin = PROGRESS_ORIGIN_TOP
2 -> progressOrigin = PROGRESS_ORIGIN_RIGHT
3 -> progressOrigin = PROGRESS_ORIGIN_BOTTOM
}
when(a.getInt(R.styleable.DoraProgressView_dview_paintCap, 0)) {
0 -> paintCap = Paint.Cap.SQUARE
1 -> paintCap = Paint.Cap.ROUND
}
progressWidth = a.getDimension(R.styleable.DoraProgressView_dview_progressWidth, 30f)
progressBgColor =
a.getColor(R.styleable.DoraProgressView_dview_progressBgColor, Color.GRAY)
progressHoverColor =
a.getColor(R.styleable.DoraProgressView_dview_progressHoverColor, Color.BLUE)
animationTime = a.getInt(R.styleable.DoraProgressView_dview_animationTime, 1000)
a.recycle()
}

解析完自定义属性,切勿忘了释放TypedArray。接下来我们考虑下一步,测量。半圆是不是不要那么大的画板对吧,我们在测量的时候就要充分考虑进去。


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
progressBgPaint.strokeWidth = progressWidth
progressHoverPaint.strokeWidth = progressWidth
if (progressType == PROGRESS_TYPE_LINE) {
// 线
var left = 0f
var top = 0f
var right = measuredWidth.toFloat()
var bottom = measuredHeight.toFloat()
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
top = (measuredHeight - progressWidth) / 2
bottom = (measuredHeight + progressWidth) / 2
progressBgRect[left + progressWidth / 2, top, right - progressWidth / 2] = bottom
} else {
left = (measuredWidth - progressWidth) / 2
right = (measuredWidth + progressWidth) / 2
progressBgRect[left, top + progressWidth / 2, right] = bottom - progressWidth / 2
}
} else if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
// 圆
var left = 0f
val top = 0f
var right = measuredWidth
var bottom = measuredHeight
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
} else {
// 半圆
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
val min = measuredWidth.coerceAtMost(measuredHeight)
var left = 0f
var top = 0f
var right = 0f
var bottom = 0f
if (isHorizontal) {
if (measuredWidth >= min) {
left = ((measuredWidth - min) / 2).toFloat()
right = left + min
}
if (measuredHeight >= min) {
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left).toInt(),
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
)
)
} else {
if (measuredWidth >= min) {
right = left + min
}
if (measuredHeight >= min) {
top = ((measuredHeight - min) / 2).toFloat()
bottom = top + min
}
progressBgRect[left + progressWidth / 2, top + progressWidth / 2, right - progressWidth / 2] =
bottom - progressWidth / 2
setMeasuredDimension(
MeasureSpec.makeMeasureSpec(
(right - left + progressWidth).toInt() / 2,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
(bottom - top).toInt(),
MeasureSpec.EXACTLY
)
)
}
}
}

View的onMeasure()方法是不是默认调用了一个


super.onMeasure(widthMeasureSpec, heightMeasureSpec)

它最终会调用setMeasuredDimension()方法来确定最终测量的结果吧。如果我们对默认的测量不满意,我们可以自己改,最后也调用setMeasuredDimension()方法把测量结果确认。半圆,如果是水平的情况下,我们的宽度就只要一半,相反如果是垂直的半圆,我们高度就只要一半。最后我们画还是照常画,只不过在最后把画到外面的部分移动到画板上显示出来。接下来就是我们最重要的绘图环节了。


override fun onDraw(canvas: Canvas) {
if (progressType == PROGRESS_TYPE_LINE) {
val isHorizontal = when(progressOrigin) {
PROGRESS_ORIGIN_LEFT, PROGRESS_ORIGIN_RIGHT -> true
else -> false
}
if (isHorizontal) {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressBgPaint)
} else {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
progressBgRect.bottom, progressBgPaint)
}
if (percentRate > 0) {
when (progressOrigin) {
PROGRESS_ORIGIN_LEFT -> {
canvas.drawLine(
progressBgRect.left,
measuredHeight / 2f,
(progressBgRect.right) * percentRate,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_TOP -> {
canvas.drawLine(measuredWidth / 2f,
progressBgRect.top,
measuredWidth / 2f,
(progressBgRect.bottom) * percentRate,
progressHoverPaint)
}
PROGRESS_ORIGIN_RIGHT -> {
canvas.drawLine(
progressWidth / 2 + (progressBgRect.right) * (1 - percentRate),
measuredHeight / 2f,
progressBgRect.right,
measuredHeight / 2f,
progressHoverPaint
)
}
PROGRESS_ORIGIN_BOTTOM -> {
canvas.drawLine(measuredWidth / 2f,
progressWidth / 2 + (progressBgRect.bottom) * (1 - percentRate),
measuredWidth / 2f,
progressBgRect.bottom,
progressHoverPaint)
}
}
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// 3/2PI ~ 2PI, 0 ~ PI/2
canvas.drawArc(progressBgRect, 270f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
// PI/2 ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, 180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
if (progressOrigin == PROGRESS_ORIGIN_LEFT) {
canvas.translate(0f, -progressBgRect.height() / 2)
// PI ~ 2PI
canvas.drawArc(progressBgRect, 180f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_TOP) {
// 3/2PI ~ PI/2
canvas.drawArc(progressBgRect, 270f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
270f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
// 2PI ~ PI
canvas.drawArc(progressBgRect, 0f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
0f,
-angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
canvas.translate(-progressBgRect.width() / 2, 0f)
// PI/2 ~ 2PI, 2PI ~ 3/2PI
canvas.drawArc(progressBgRect, 90f, -180f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
90f,
-angle.toFloat(),
false,
progressHoverPaint
)
}
} else if (progressType == PROGRESS_TYPE_CIRCLE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
angle.toFloat(),
false,
progressHoverPaint
)
} else if (progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
val deltaAngle = if (progressOrigin == PROGRESS_ORIGIN_TOP) {
90f
} else if (progressOrigin == PROGRESS_ORIGIN_RIGHT) {
180f
} else if (progressOrigin == PROGRESS_ORIGIN_BOTTOM) {
270f
} else {
0f
}
canvas.drawArc(progressBgRect, 0f, 360f, false, progressBgPaint)
canvas.drawArc(
progressBgRect,
180f + deltaAngle,
-angle.toFloat(),
false,
progressHoverPaint
)
}
}

绘图除了需要Android的基础绘图知识外,还需要一定的数学计算的功底,比如基本的几何图形的点的计算你要清楚。怎么让绘制的角度变化起来呢?这个问题问的好。这个就牵扯出我们动画的一个关键类,TypeEvaluator,这个接口可以让我们只需要指定边界值,就可以根据动画执行的时长,来动态计算出当前的渐变值。


private inner class AnimationEvaluator : TypeEvaluator<Float> {
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
return if (endValue > startValue) {
startValue + fraction * (endValue - startValue)
} else {
startValue - fraction * (startValue - endValue)
}
}
}

百分比渐变的固定写法,是不是应该记个笔记,方便以后CP?那么现在我们条件都成熟了,只需要将初始角度的百分比改变一下,我们写一个改变角度百分比的方法。


fun setPercentRate(rate: Float) {
if (animator == null) {
animator = ValueAnimator.ofObject(
AnimationEvaluator(),
percentRate,
rate
)
}
animator?.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Float
angle =
if (progressType == PROGRESS_TYPE_CIRCLE || progressType == PROGRESS_TYPE_CIRCLE_REVERSE) {
(value * 360).toInt()
} else if (progressType == PROGRESS_TYPE_SEMICIRCLE || progressType == PROGRESS_TYPE_SEMICIRCLE_REVERSE) {
(value * 180).toInt()
} else {
0 // 线不需要求角度
}
percentRate = value
invalidate()
}
animator?.interpolator = LinearInterpolator()
animator?.setDuration(animationTime.toLong())?.start()
animator?.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
percentRate = rate
listener?.onComplete()
}

override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
}

这里牵扯到了Animator。有start就一定不要忘了异常中断的情况,我们可以写一个reset的方法来中断动画执行,恢复到初始状态。


fun reset() {
percentRate = 0f
animator?.cancel()
}

如果你不reset,想连续执行动画,则两次调用的时间间隔一定要大于动画时长,否则就应该先取消动画。


涉及到的Android绘图知识点


我们归纳一下完成这个自定义View需要具备的知识点。



  1. 基本图形的绘制,这里主要是扇形

  2. 测量和画板的平移变换

  3. 自定义属性的定义和解析

  4. Animator和动画估值器TypeEvaluator的使用


思路和灵感来自于系统化的基础知识


这个控件其实并不难,主要就是动态配置一些参数,然后在计算上稍微复杂一些,需要一些数学的功底。那么你为什么没有思路呢?你没有思路最可能的原因主要有以下几个可能。



  1. 自定义View的基础绘图API不熟悉

  2. 动画估值器使用不熟悉

  3. 对自定义View的基本流程不熟悉

  4. 看的自定义View的源码不够多

  5. 自定义View基础知识没有系统学习,导致是一些零零碎碎的知识片段

  6. 数学功底不扎实


我觉得往往不是你不会,这些基础知识点你可能都看到过很多次,但是一到自己写就没有思路了。思路和灵感来自于大量源码的阅读和大量的实践。大前提就是你得先把自定义View的这些知识点系统学习一下,先保证都见过,然后才是将它们融会贯通,用的时候信手拈来。


作者:dora
来源:juejin.cn/post/7245223225575882809
收起阅读 »

按Home键时SingleInstance Activity销毁了???

前段时间,突然有朋友询问,自己写的SingleInstance Activity在按home键的时候被销毁了,刚听到这个问题的时候,我直觉怀疑是Activity在onPause或者onStop中发生了Crash导致闪退了,但是安装apk查看现象,没有发现异常日...
继续阅读 »

前段时间,突然有朋友询问,自己写的SingleInstance Activity在按home键的时候被销毁了,刚听到这个问题的时候,我直觉怀疑是Activity在onPause或者onStop中发生了Crash导致闪退了,但是安装apk查看现象,没有发现异常日志,这究竟是怎么回事呢?编写测试Demo来详细探索下


Demo代码说明


Demo日志很简单,包含MainActivity和SingleInstanceActivity两个页面,在MainActivity中的TextView点击事件中启动SingleInstanceActivity,在SingleInstanceActivity中的TextView点击事件中调用moveTaskToBack(true)切回后台,随后在MainActivity界面按Home键返回桌面,就可以看到SingleInstanceActivity被销毁了,示例代码如下所示:


 // MainActivity.kt
 class MainActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContent {
             MyApplicationTheme {
                 // A surface container using the 'background' color from the theme
                 Surface(
                     modifier = Modifier
                        .fillMaxSize()
                        .clickable { onBtnClick() },
                     color = MaterialTheme.colorScheme.background
                ) {
                     Greeting("Android")
                }
            }
        }
    }
     fun onBtnClick() {
          startActivity(Intent(this,                                  SingleInstanceActivity::class.java))
    }
 }
 ​
 @Composable
 fun Greeting(name: String, modifier: Modifier = Modifier) {
     Text(
         text = "Hello $name!",
         modifier = modifier
    )
 }
 ​
 @Preview(showBackground = true)
 @Composable
 fun GreetingPreview() {
     MyApplicationTheme {
         Greeting("Android")
    }
 }

 // SingleInstanceActivity.java
 public class SingleInstanceActivity extends ComponentActivity {
     private static final String TAG = "SingleInstanceActivity";
   @Override
   protected void onCreate(Bundle savedInstanceState) {
     Log.d(TAG,"SingleInstanceActivity onCreate method called",new Exception());
     super.onCreate(savedInstanceState);
     setContentView(R.layout.activity_single_instance);
     findViewById(R.id.move_back).setOnClickListener(new View.OnClickListener() {
         @Override
         public void onClick(View v) {
             moveTaskToBack(true);
        }
    });
  }
 ​
   @Override
   protected void onDestroy() {
     Log.d(TAG,"SingleInstanceActivity onDestroy method called",new Exception());
     super.onDestroy();
  }
 }

 <!-- AndroidManifest.xml文件中application节点的内容-->
 <application
     android:allowBackup="true"
     android:dataExtractionRules="@xml/data_extraction_rules"
     android:fullBackupContent="@xml/backup_rules"
     android:icon="@mipmap/ic_launcher"
     android:label="@string/app_name"
     android:roundIcon="@mipmap/ic_launcher_round"
     android:supportsRtl="true"
     android:theme="@style/Theme.MyApplication"
     tools:targetApi="31">
     <activity
         android:name=".SingleInstanceActivity"
         android:launchMode="singleInstance"
         android:exported="true" />
     <activity
         android:name=".MainActivity"
         android:exported="true"
         android:label="@string/app_name"
         android:theme="@style/Theme.MyApplication">
         <intent-filter>
             <action android:name="android.intent.action.MAIN" />
 ​
             <category android:name="android.intent.category.LAUNCHER" />
         </intent-filter>
     </activity>
 </application>

调用栈回溯


即然SingleInstanceActivity被销毁了,那么我们只需要在Activity生命周期中添加日志,来看下onDestroyed函数是怎么驱动调用的即可,从Activity生命周期可知,在Framework中框架通过ClientLifecycleManager类来管理Activity的生命周期变化,在该类的scheduleTransaction函数中,Activity的每一种生命周期类型均被包装成一个ClientTransaction来处理,在该函数中添加日志,打印调用栈,即可确定是那个地方销毁了SingleInstanceActivity,添加日志的代码如下:


24-2-3


随后编译framework.jar并push到设备上,查看日志,可以看到SingleInstanceActivity是在Task类的removeActivities方法中被销毁的,日志如下:


24-2-4


按照如上的思路,逐步类推,添加日志,查看调用栈,我们最终追溯到ActivityThread的handleResumeActivity,在该函数的最后,添加的IdlerHandler里面会执行RecentTasks的onActivityIdle方法,在该函数的调用流程里,会判断当前resume的Activity是不是桌面,是的话在HiddenTask不为空的情况下,就会执行removeUnreachableHiddenTasks的逻辑,销毁SingleInstanceActivity(这里的代码分支为android-13.0.0_r31)。


完整的正向调用流程如下图所示:


SingleInstance Task release process 1


remove-hidden-task机制


前文中我们已经跟踪到Activity销毁的调用流程,那么为什么要销毁SingleInstanceActivity呢?我们继续看前文中的日志,可以看出Activity销毁的原因是:remove-hidden-task。


24-2-6


那么这个remove-hidden-task到底是用来干嘛的呢?我们来看下代码提交信息:


24-2-1


从代码提交说明不难看出,这里的意思是:当我们向最近任务列表中添加一个任务时,会移除已不可达/未激活的Task,这里我们的SingleInstanceActivity所在的Task被判定为不可达/未激活状态,所以被这套机制移除了。


不可达/未激活的Task


那么为什么SingleInstanceActivity被认为是不可达的呢?我们进一步追踪代码,可以看到RencentTasks.removeUnreachableHiddenTasks移除的是mHiddenTasks中的任务,代码如下:


24-2-7


这样我们就只需要搞清楚什么样的Task会被加入mHiddenTasks中即可,mHiddenTasks.add的调用代码如下所示:


24-2-8


24-2-9


从上述代码可知,在removeForAddTask中通过findRemoveIndexForAddTask来查找当给定Task添加到最近任务列表时,需要被移除的Task,在findRemoveIndexForAddTask中最典型的一种场景就是当两个Task的TaskAffinity相同时,当后来的Task被添加到最近任务列表时,前一个Task会被销毁,这也就意味着在SingleInstanceActivity按Home键,MainActivity也会被销毁,经过实践,确实是这样。


解决方案


前文中已探讨了remove-hidden-task的运行机制,那么解决方案也就很简单了,给SingleInstanceActivity添加独立的TaskAffinity即可(注意:此时SingleInstanceActivity会显示在最近任务中,如果不想显示,请指定android:excludeFromRecents="true")。


影响范围


经排查,Google Pixel记性从Android 12开始支持该特性,针对国内定制厂商而言,大多数应该是在Android 13跟进的,大家可以测试看看。


作者:小海编码日记
来源:juejin.cn/post/7259311837463724069
收起阅读 »

普通Android应用的系统签名

一、前言 对于常见的Android开发来说,普通级别的app已经满足不了需求。对系统的要求能力越来越定制化,所以针对系统权限的需求也迫在眉睫。 那怎么通过aosp的系统签名,将普通app升级为系统权限的app,使app能访问系统资源的权限呢? 二、流程 1. ...
继续阅读 »

一、前言


对于常见的Android开发来说,普通级别的app已经满足不了需求。对系统的要求能力越来越定制化,所以针对系统权限的需求也迫在眉睫。
那怎么通过aosp的系统签名,将普通app升级为系统权限的app,使app能访问系统资源的权限呢?


二、流程


1. 手动签名apk文件


a. app设置系统权限


在app项目的AndroidManifest文件的节点新增


<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserId="android.uid.system">


b. 编译打包apk


生成对应apk


c. 准备签名文件及工具


需要准备:



  • java环境命令

  • 系统签名文件:platform.pk8、platform.x509.pem




  • signapk.jar:



    • 进入/build/tools/signapk/文件夹

    • 执行命令: mm

    • 在out/host/linux-x86/framework/目录找到signapk.jar




d. 签名打包好的apk



tips: 最好将工具等文件复制到统一文件中,比较好操作,中途会遇到各式各样的问题,操作篇尾



java -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk 

e. 安装新包测试


卸载旧包,安装新包,即可完成系统权限


2. 自动签名apk文件


每次开发时,总是要手动签名新打出的安装包,很不方便,直接在打包时完成系统签名更高效


a. pk8 私钥解密pem格式


此时会生成platform.priv.pem文件



  • [platform.priv.pem]为生成文件名称


openssl pkcs8 -in platform.pk8 -inform DER --outform PEM -out platform.priv.pem -nocrypt

b. 私钥通过公钥pem加密pk12


此时会生成platform.pk12文件



  • [platform.priv.pem]为上一步生成的文件

  • [zxxkey]为AliasName


openssl pkcs12 -export -in platform.x509.pem -inkey platform.priv.pem -out platform.pk12 -name zxxkey

需要输入两次密码:(实测store和key密码需要一致)


c. 通过java的keytool 工具生成 keystore



  • [12345678]为store密码

  • [zxxkey]为上一步设置的别名,需要与上面保持一致


jks:


keytool -importkeystore -destkeystore platform.jks -srckeystore platform.pk12 -srcstoretype PKCS12 -srcstorepass 12345678 -alias zxxkey

keystore:


keytool -importkeystore -destkeystore platform.keystore -srckeystore platform.pk12 -srcstoretype PKCS12 -srcstorepass 12345678 -alias zxxkey

d. 项目中使用签名


1)引入签名文件:


将keystore或者jks文件引入项目


2)创建keystore.properties:


keyAlias=zxxkey
keyPassword=12345678
storeFile=../key/platform.jks
storePassword=12345678

3)在app/build.gradle.kts引入signConfig:


import java.io.FileInputStream
import java.util.Properties

...

val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))

...

android {

...

signingConfigs {
create("release") {
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
}
}

buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}

...
}


三、问题


1. java版本问题


Q:版本异常?


Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: com/android/signapk/SignApk has been compiled by a more recent version of the Java Runtime (class file version 53.0), this version of the Java Runtime only recognizes class file versions up to 52.0

A:解决方案:


升级jdk版本,52.0版本为java8,选用更高版本即可。


/home/zengxiangxi/Developer/JDK/jdk-9.0.4/bin/java


/home/zengxiangxi/Developer/JDK/jdk-9.0.4/bin/java -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk 

2. 签名问题报错


Q:找不到依赖库?


Exception in thread "main" java.lang.UnsatisfiedLinkError: no conscrypt_openjdk_jni-linux-x86_64 in java.library.path
at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2541)
at java.base/java.lang.Runtime.loadLibrary0(Runtime.java:873)
at java.base/java.lang.System.loadLibrary(System.java:1857)
at org.conscrypt.NativeLibraryUtil.loadLibrary(NativeLibraryUtil.java:54)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)


A:附带以来库路径。


-Djava.library.path="/home/zengxiangxi/Project/aosp/out/soong/host/linux-x86/lib64"


stackoverflow.com/questions/4…


~/Developer/JDK/jdk-9.0.4/bin/java -Djava.library.path="/home/zengxiangxi/Project/aosp/out/soong/host/linux-x86/lib64" -jar signapk.jar platform.x509.pem platform.pk8 app-debug.apk new.apk

四、文档链接



作者:会飞de小牛人
来源:juejin.cn/post/7299991094627500072
收起阅读 »

突破自定义View性能瓶颈

在Android应用程序中,自定义View是一个非常常见的需求。自定义View可以帮助您创建独特的UI元素,以满足您的应用程序的特定需求。然而,自定义View也可能会导致性能问题,特别是在您的应用程序需要处理大量自定义View的情况下。 在本篇文章中,我们将探...
继续阅读 »

在Android应用程序中,自定义View是一个非常常见的需求。自定义View可以帮助您创建独特的UI元素,以满足您的应用程序的特定需求。然而,自定义View也可能会导致性能问题,特别是在您的应用程序需要处理大量自定义View的情况下。


在本篇文章中,我们将探讨一些Android自定义View性能优化的技巧,以确保您的应用程序在处理自定义View时保持高效和稳定。我们将从以下几个方面进行讨论:


1. 使用正确的布局


在创建自定义View时,正确的布局是至关重要的。使用正确的布局可以帮助您最大限度地减少布局层次结构,从而提高您的应用程序的性能。


例如,如果您需要创建一个具有多个子视图的自定义View,使用ConstraintLayout代替RelativeLayout和LinearLayout可以简化布局并减少嵌套。


下面是一个示例代码:


<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">




androidx.constraintlayout.widget.ConstraintLayout>

另一个重要的布局技巧是使用ViewStub。ViewStub是一个轻量级的视图,它可以用作占位符,直到需要真正的视图时才充气。这可以大大减少布局层次结构并提高性能。


2. 缓存视图


缓存视图是另一个重要的性能优化技巧。当您使用自定义View时,通常需要创建多个实例。如果您没有正确地缓存这些实例,那么您的应用程序可能会变得非常慢。


为了缓存视图,您可以使用Android的ViewHolder模式或使用自定义缓存对象。ViewHolder模式是Android开发者广泛使用的一种技术,可以在列表或网格视图中提高性能。使用自定义缓存对象可以更好地控制视图的生命周期,并减少视图的创建和销毁。


以下是ViewHolder模式的示例代码:


class CustomView(context: Context) : View(context) {
private class ViewHolder {
// 缓存视图
var textView: TextView? = null
var imageView: ImageView? = null
// 添加其他视图组件
}

private var viewHolder: ViewHolder? = null

init {
// 初始化ViewHolder
viewHolder = ViewHolder()
// 查找视图并关联到ViewHolder
viewHolder?.textView = findViewById(R.id.text_view)
viewHolder?.imageView = findViewById(R.id.image_view)
// 添加其他视图组件的查找和关联
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制视图
}
}

3. 避免过多的绘制操作


绘制操作是自定义View中最重要的性能问题之一。如果您的自定义View需要大量的绘制操作,那么您的应用程序可能会变得非常慢。


为了避免过多的绘制操作,您可以使用View的setWillNotDraw方法来禁用不必要的绘制。您还可以使用Canvas的clipRect方法来限制绘制操作的区域。此外,您还可以使用硬件加速来加速绘制操作。


以下是一个示例代码:


class CustomView(context: Context) : View(context) {
init {
setWillNotDraw(true) // 禁用绘制
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制操作
canvas.clipRect(0, 0, width, height) // 限制绘制区域
// 添加其他绘制操作
}
}

4. 使用异步任务


如果您的自定义View需要执行耗时的操作,例如从网络加载图像或处理大量数据,那么您应该使用异步任务来执行这些操作。这可以确保您的应用程序在执行这些操作时保持响应,并且不会阻塞用户界面。


以下是一个使用异步任务加载图像的示例代码:


class CustomView(context: Context) : View(context) {
private var image: Bitmap? = null

fun loadImageAsync(imageUrl: String) {
val asyncTask = object : AsyncTask<Void, Void, Bitmap>() {
override fun doInBackground(vararg params: Void?): Bitmap {
// 执行耗时操作,如从网络加载图像
return loadImageFromUrl(imageUrl)
}

override fun onPostExecute(result: Bitmap) {
super.onPostExecute(result)
// 在主线程更新UI
image = result
invalidate() // 刷新视图
}
}

asyncTask.execute()
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制图像
image?.let {
canvas.drawBitmap(it, 0f, 0f, null)
}
// 添加其他绘制操作
}
}

5. 使用适当的数据结构


在自定义View中,使用适当的数据结构可以大大提高性能。例如,如果您需要绘制大量的点或线,那么使用FloatBuffer或ByteBuffer可以提高性能。如果您需要处理大量的图像数据,那么使用BitmapFactory.Options可以减少内存使用量。


以下是一个使用FloatBuffer绘制点的示例代码:


class CustomView(context: Context) : View(context) {
private var pointBuffer: FloatBuffer? = null

init {
// 初始化点的数据
val points = floatArrayOf(0f, 0f, 100f, 100f, 200f, 200f)
pointBuffer = ByteBuffer.allocateDirect(points.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
pointBuffer?.put(points)
pointBuffer?.position(0)
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制点
pointBuffer?.let {
canvas.drawPoints(it, paint)
}
// 添加其他绘制操作
}
}

结论


在本篇文章中,我们探讨了一些Android自定义View性能优化的技巧。通过使用正确的布局,缓存视图,避免过多的绘制操作,使用异步任务和适当的数据结构,您可以确保您的应用程序在处理自定义View时保持高效和稳定。


请记住,优化自定义View的性能是一个持续的过程。您应该经常检查您的应用程序,并使用最新的技术和最佳实践来提高性能。




作者:午后一小憩
来源:juejin.cn/post/7238491755448369211
收起阅读 »

如何秒开WebView?Android性能优化全攻略!

在Android应用开发中,WebView是一个常用的组件,用于在应用中展示网页内容。然而,WebView的启动速度和性能可能会影响用户体验,特别是在一些性能较低的设备上。本文将介绍一些优化WebView启动的技巧,以提高应用的响应速度和用户体验。 在优化We...
继续阅读 »

在Android应用开发中,WebView是一个常用的组件,用于在应用中展示网页内容。然而,WebView的启动速度和性能可能会影响用户体验,特别是在一些性能较低的设备上。本文将介绍一些优化WebView启动的技巧,以提高应用的响应速度和用户体验。


在优化WebView启动的过程中,主要有以下几个方面:



  1. 加载优化:通过预加载,延迟加载,可以有效减少启动的时间。

  2. 请求优化:通过并行、拦截请求策略,可以加快网络耗时,与减少重复的耗时。

  3. 缓存优化:合理使用缓存,减少网络请求,提高加载速度。

  4. 渲染优化:合理的启动硬件加速,可以有效的提高渲染速度。

  5. 进程优化:启用多进程模式,可以避免主线程阻塞,内存泄漏、异常crash等问题。


下面我们将详细说明这些优化技巧。


加载优化


预加载技巧


在应用启动时提前初始化WebView并进行预加载,可以减少WebView首次加载页面的时间。可以在应用的启动过程中将WebView加入到IdelHandler中,等到主线程空闲的时候进行加载。


fun execute() {
// 在主线程空闲的时候初始化WebView
queue.addIdleHandler {
MyWebView(MutableContextWrapper(applicationContext)).apply {
// 设置WebView的相关配置
settings.javaScriptEnabled = true
// 进行预加载
loadUrl("about:blank")
}
false
}
}

延迟加载


延迟加载是指将一些非首屏必需的操作推迟到首屏显示后再执行。通过延迟加载,可以减少首屏加载时间,提升用户体验。例如,可以在首屏加载完成后再发起一些后台网络请求、埋点,或者在用户首次交互后再执行一些JavaScript操作。


// 延迟2秒执行上报埋点
Handler().postDelayed({
// 上报启动统计
reportStart()
}, 2000)

请求优化


并行请求


在加载H5页面时,通常会先加载模板文件,然后再获取动态数据填充到模板中。为了提升加载速度,可以在H5加载模板文件的同时,由Native端发起请求获取正文数据。一旦数据获取成功,Native端通过JavaScript将数据传递给H5页面,H5页面再将数据填充到模板中,从而实现并行请求,减少总耗时。


// 在加载模板文件时,同时发起正文数据请求
webView.loadUrl("file:///android_asset/template.html")

// 获取正文数据
val contentData = fetchDataFromServer()

// 将数据传递给H5页面
webView.evaluateJavascript("javascript:handleContentData('" + contentData + "')", null)

拦截请求


可以通过自定义WebViewClient来拦截WebView的请求。重写shouldInterceptRequest方法,可以拦截所有WebView的请求,然后进行相应的处理。


override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
// 在此处实现请求拦截的逻辑
if (needIntercept(request)) {
// 拦截请求,返回自定义的WebResourceResponse或者null
return interceptRequest(request)
} else {
// 继续原始请求
return super.shouldInterceptRequest(view, request)
}
}

缓存优化


WebView缓存池


WebView缓存池是一组预先创建的WebView实例,存储在内存中,并在需要加载网页时从缓存池中获取可用的WebView实例,而不是每次都创建新的WebView。这样可以减少初始化WebView的时间和资源消耗,提高WebView的加载速度和性能。


private const val MAX_WEBVIEW_POOL_SIZE = 5
private val webViewPool = LinkedList()

fun getWebView(): WebView {
synchronized(webViewPool) {
if (webViewPool.isEmpty()) {
return MyWebView(MutableContextWrapper(MyApp.applicationContext()))
} else {
return webViewPool.removeFirst()
}
}
}

fun recycleWebView(webView: WebView) {
synchronized(webViewPool) {
if (webViewPool.size < MAX_WEBVIEW_POOL_SIZE) {
webViewPool.addLast(webView)
} else {
webView.destroy()
}
}
}

缓存策略


WebView提供了缓存机制,可以减少重复加载相同页面的时间。可以通过设置WebView的缓存模式来优化加载速度,如使用缓存或者忽略缓存。示例代码如下:


// 在WebView的初始化代码中启用缓存
webView.settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK

共享缓存


对于一些频繁访问的数据,如公共的CSS、JavaScript文件等,可以将其缓存到应用的本地存储中,然后在多个 WebView 实例之间共享。


// 从本地存储中加载公共资源并设置给 WebView
webView.loadDataWithBaseURL("file:///android_asset/", htmlData, "text/html", "UTF-8", null)

渲染优化


启用硬件加速


启用硬件加速可以提高WebView的渲染速度,但是在一些低端设备上可能会造成性能问题,因此需要根据实际情况进行选择。


hardwareAccelerated="true" ...>
...


进程优化


启用多进程


WebView的加载和渲染可能会阻塞应用的主线程,影响用户体验。为了提升应用的性能和稳定性,可以考虑将WebView放置在单独的进程中运行,以减轻对主进程的影响。


name=".WebViewActivity"
android:process=":webview_process">
...


其它



  1. DNS优化:也就是域名解析,相同的域名解析成ip系统会进行缓存,保证端上api地址与webview的地址的域名一致,可以减少域名解析的耗时操作。

  2. 静态页面直出:由于在渲染之前有个组装html的过程,为了缩短耗时,让后端对正文数据和前端的代码进行整合,直接给出HTML文件,让其包含了所需的内容和样式,无需进行二次加工,内核可以直接渲染。

  3. http缓存:针对网络请求,增加缓存,例如,添加Cache-Control、Expires、Etag、Last-Modified等信息,定义缓存策略。


结语


以上介绍了一些 Android WebView 启动优化技巧。通过这些优化措施,可以有效提升 WebView 的启动速度,改善用户体验。


作者:午后一小憩
来源:juejin.cn/post/7358289840268116022
收起阅读 »

一种基于MVVM的Android换肤方案

一、背景 目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。 二、目标 ...
继续阅读 »

一、背景


目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。


二、目标


一个非会员购买会员后,身份是立刻发生了变更。用户点击了App内的暗夜模式按钮后,需要立刻从白天模式,切换到暗夜模式。基于以上原因,换肤的首要目标应该是及时生效的,不需要重启App.


作为一个线上成熟的产品,对稳定性也是有较高要求的 。所以换肤方案是需要觉得稳定的 ,不能因换肤产生Crash & ANR


通常设计图同学会根据不同的时节设计不同的皮肤,例如春节有对应的春节皮肤、周年庆有周年庆皮肤。所以换肤方案还应该保持一定的动态能力,皮肤可以动态下发 。


三、整体思路


基于以上提到的3大目标之一的 动态化换肤。一个可能的实现方案是把需要换肤的图片放入一个独立的工程内,然后把该工程编译出apk安装包, 在主工程加载该安装包。然后再需要获取资源的时候能够加载到皮肤包内的资源即可


3.1 技术选型


目前市场上有很多换肤方案、基本思路总结如下 :


1、通过反射AssertManager的AddAssertPath函数,创建自己的 Resources.然后通过该 Resources获取资源id ;


2、实现LayoutInflater.Factory2接口来替换系统默认的


@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);//自定义的Factory
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
}

该方案在上线后遇到了一些crash,堆栈如下:



该crash暂未找到修复方案,因此需要寻找一种新的稳定的换肤方案。从以上堆栈分析,可能和替换了LayoutInflater.Factory2有关系 。于是新的方案尝试只使用上述方案的第一步骤来获取资源ID,而不使用第二步,即不修改view的创建的逻辑


3.2 生成资源


因为项目本身基于jetpack,基本通过DataBinding实现与数据&View直接的交互。我们不打算替换系统的setFactory2,因为这块的改动涉及面会比较的大,机型的兼容性也比较的不可控,只是hook AssetManager,生成插件资源的Resource。然后我们的xml中就可以编写对应的java代码来实现换肤。


整体流程图如下


流程图 (5).jpg


3.3 获取资源


上面是我们生成Res对象的过程,下面是我们通过该Res获取具体的资源的过程,首先是资源的定义,以下是换肤能够支持的资源种类



  1. drawable

  2. color

  3. dimen

  4. mipmap

  5. string


目前是打算支持这五种的换肤,使用一个ArrayMap<String, SoftReference<ArrayMap<String, Int>>>来存储具体的缓存数据:key是上面的类型,Entry类型为SoftReference<ArrayMap>,是的对应type所有的缓存数据,每一条缓存数据的key是对应的name值与插件资源对应的Id值。例如:


color->
skin_tab->0x7Fxxxx
skin_text->0x7Fxxxx
dimen->
skin_height->0x7Fxxxx skin_width->0x7fxxxx

具体流程如下


流程图 (4).jpg


3.2使用资源


然后我们通过get系列(例如XLSkinManager.getString() :String)方法就能够拿得到对应的插件资源(正常情况下),然后就是使用它。


由于之前项目中已经有了一套会员的UI了(就是在项目中的,不是通过皮肤apk下发的),为了改动较少,就把基础换肤设置为4种,即本地自身不通过换肤插件就可以实现的。



  1. 白天非会员

  2. 夜间非会员

  3. 白天会员

  4. 夜间会员


然后我们的apk可以下发对应的资源修改对应的模式,比如需要修改白天非会员的某一个控件的颜色就下发对应的控件资源apk,然后启用该换肤插件即可。


目前项目提供了一系列的接口提供给xml使用,使用过程



  1. 在xml中设置了之后,会触发到对应View的set方法,最终可以设置到最终的View的对应属性中

  2. 同样的,在需要改属性变更的时候(例如白天切换到页面),我们也只需要修改ViewMode变更该xml中对应的ObservableField即可,或者是在View中注册对应的事件(例如白天到夜间的事件)


因为项目深度使用DataBinding,所以我们就通过自定义View的方式,利用了我们可以直接在xml中使用View的set方法的形式,比如


class DayNightMemberImageView : xxxView{
fun setDayResource(res: Int){
//....
}
}
// 我们就可以在xml中使用该View的dayResource属性
<com.xxx.DayNightMemberImageView
app:dayResource="@{R.color.xxx}"
/>

这样就可以通过传入的Id值,在setDayResource中拿到最终的插件的id值给View设置。具体的例子:


/** 每一种View的基础可用属性,即用于View的属性设置*/
interface IDayNightMember {
// 白天资源
fun setDayResource(res: Int)
//夜间资源
fun setNightResource(res: Int)
// 会员白天
fun setMemberDayResource(res: Int)
// 会员夜间
fun setMemberNightResource(res: Int)
}
// 提供给xml使用的,当该控件可以是不同的会员不同的展示就可以使用该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IMemberNotify {
fun setMemberFlag(isMember: Boolean?)
}
// 提供给xml使用的,当该控件具有白天,夜间两种模式的样式的时候,可以在xml中设置该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IDayNightNotify {
fun setDayNight(isDay: Boolean?)
}

然后具体的实现类


class DayNightMemberAliBabaTv :
ALIBABABoldTv, IDayNightNotify, IMemberNotify, IDayNightMember {
private val handle = HandleOfDayNightMemberTextColor(this)
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, -1)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setDayNight(isDay: Boolean?) {

handle.setDayNight(isDay)
}
override fun setMemberFlag(isMember: Boolean?) {
handle.setMemberFlag(isMember)
}
override fun setDayResource(res: Int) {
handle.setDayResource(res)
}
override fun setNightResource(res: Int) {
handle.setNightResource(res)
}
override fun setMemberDayResource(res: Int) {
handle.setMemberDayResource(res)
}
override fun setMemberNightResource(res: Int) {
handle.setMemberNightResource(res)
}
}

其中的HandleOfDayNightMemberTextColor是继承了HandleOfDayNightMember,后者是做了一个优化,避免了一些重复刷新的情况,也会被其他的类复用。


abstract class HandleOfDayNightMember(view: View) :
IDayNightNotify, IMemberNotify, IDayNightMember {
var isDay: Boolean? = null
var isMember: Boolean? = null
// 日,夜,会员字体颜色
var day: Int? = null
var night: Int? = null
// 假如memberHasNight=true,则要有会员日间,会员夜间两种配置
var memberDay: Int? = null
var memberNight: Int? = null
init {
if (!view.isInEditMode) {
isDay = DayNightController.isDayMode()
}
}
/** 检测是否可以刷新,避免无用的刷新 */
open fun detect() {
if (isMember.isTrue()) {
if (memberHasNight) {
if (isDay.isTrue() && memberDay == null) {
return
}
if (isDay.isFalseStrict() && memberNight == null) {
return
}
} else if (!memberHasNight && member == null) {
return
}
} else if (isDay.isTrue() && day == null) {
return
} else if (isDay.isFalseStrict() && night == null) {
return
}
handleResource()
}
override fun setMemberFlag(isMember: Boolean?) {
if (isMember == null) {
return
}
this.isMember = isMember
detect()
}
override fun setDayNight(isDay: Boolean?) {
if (isDay == null) {
return
}
this.isDay = isDay
detect()
}
override fun setDayResource(res: Int) {
this.day = res
if (isDay.isTrue() && isMember.isFalse()) {
handleResource()
}
}
//...代码省略,其他的方法也是类似的

// 获取适合当前的资源
fun getResourceInt(): Int? {
return when {
isMember.isTrue() -> {
if (memberHasNight) {
when {
isDay.isTrue() -> memberDay
isDay.isFalseStrict() -> memberNight
else -> null
}
} else {
member
}
}
isDay.isTrue() -> {
day
}
isDay.isFalseStrict() -> {
night
}
else -> null
}
}
/** 获取资源,告知外部 */
abstract fun handleResource()
}
class HandleOfDayNightMemberTextColor(private val target: TextView) :
HandleOfDayNightMember(target) {
override fun handleResource() {
val textColor = getResourceInt() ?: return
if (textColor <= 0) {
return
}
// 获取皮肤包的资源,假如插件化没有对应的资源或者是未开启换肤或者是换肤失败
// 则会返回当前apk的对应资源
target.setTextColor(XLSkinManager.getColor(textColor))
}
}

目前项目支持的换肤控件



  1. DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView

  2. 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  3. DayNightLinearLayout & DayNightRelativeLayout

  4. (1) 对背景支持四种基础样式的换肤,资源类型支持drawable & color

  5. (2) 支持padding

  6. DayNightMemberAliBabaTv,集成自ALIBABABoldTv,是阿里巴巴的字体Tv

  7. 对字体颜色支持四种基础样式的换肤,资源类型为color

  8. DayNightMemberImageView

  9. 对ImageView的Source支持四种基础样式的换肤,资源类型支持drawable & mipmap

  10. DayNightMemberTextView

  11. (1)对字体颜色支持四种基础样式的换肤,资源类型为color

  12. (2)支持padding

  13. (3) 支持背景换肤,类型为drawable

  14. (4)支持drawableEnd属性换肤,类型为drawable

  15. (5)支持夜间与白天的文字的高亮颜色设置,资源类型为color


3.4 资源组织 方式


目前项目的支持换肤的资源都是每种资源都独立在一个文件中,存放在最底层的base库。换肤的资源都是以skin开头,会员的是以skin_member开头,会员夜间的以skin_member_night,夜间以skin_night开头。



通过sourceSets把资源合并进去


android {
sourceSets {
main {
res.srcDirs = ['src/main/res', 'src/main/res-day','src/main/res-night','src/main/res-member']
}
}
}

四、总结 & 展望


经过上线运行,该方案非常稳定,满足了业务的换肤需求。


该方案使用起来,需要自定义支持换肤的View ,使用起来有一定成本 。一种低成本接入的可能方案是:



  1. 无需自定义View,利用BindingAdapter来实现给View的属性直接设置皮肤的资源,在xml中使用原始的系统View

  2. ViewModel中提供一个theme属性,xml中View的值都通过该属性的成员变量去拿到。


以上优化思路,感兴趣的读者可以去尝试下。该思路也是笔者下一步的优化方向。


作者:货拉拉技术
来源:juejin.cn/post/7314587257956417586
收起阅读 »

Android -- 投屏

本文从Android开发者的角度出发,介绍投屏的方式、常见的一些投屏方案及应用,最后详细介绍适合在Android手机上应用的一套方案:DLNA。 1. 投屏方式 按照投屏后,展示端的数据来源,可以划分成两种主要的方式:推送投屏(Screencasting)...
继续阅读 »

本文从Android开发者的角度出发,介绍投屏的方式、常见的一些投屏方案及应用,最后详细介绍适合在Android手机上应用的一套方案:DLNA。



1. 投屏方式



  • 按照投屏后,展示端的数据来源,可以划分成两种主要的方式:推送投屏(Screencasting)镜像投屏(ScreenMirroring)

  • 通过一个表格看看两者的区别


    推送投屏镜像投屏
    数据源发送端向展示端发送url,此后由展示端从服务器获取媒体数据。展示端从发送端获取数据,实时展示发送端画面。
    优缺点投屏后,手机使用不受限制,可以离开当前页面进行其他活动。但部分资源如文本等可能无法投屏。实时展示手机画面,可以突破资源使用限制。但是手机不能离开当前页面。
    使用场景投屏多为音视频资源,主要应用在娱乐场景。如爱奇艺视频投屏等app内置投屏。因不受资源限制,可以进行ppt展示等,主要应用在办公场景。如mac镜像等。



2. 投屏方案



  • 2.1 Airplay

    • Airplay是Apple推出的无线显示标准,因此其应用主要局限于Apple的生态之中。国内也有电视厂商实现了对Airplay的破解,使得Apple设备可以投屏到安卓电视上。

    • 支持推送投屏和镜像投屏。



  • 2.2 DLNA

    • DLNA(Digital Living Network Alliance)是Sony于2003年发起的非营利性标准化组织,旨在制定在局域网内部进行多媒体文件及其信息共享的通信协议标准。DLNA的应用范围比较广泛,涵盖数字媒体设备、数字电视、车载娱乐等领域。大部分App内置投屏就是用的这个方案。

    • 支持推送投屏。



  • 2.3 Miracast

    • Miracast是2012年首次由WiFi Alliance发布,其底层采用了WiFi Direct技术(点对点无线技术),因此不需要通过路由器,可以直接在两个设备之间建立P2P连接。目前对Miracast支持最好的生态就是MircoSoft的Windows系统了。

    • 支持镜像投屏。



  • 2.4 Chromecast

    • Google推出了ChromecastChromecast是一款插在电视机HDMI接口上的无线设备,内置WIFI,可以通过WIFI与其他设备连接以及访问外网,类似一个迷你机顶盒。为了推广Chromecast,Google还专门推出GoogleCastSDK帮助APP开发者整合Chromecast的推送功能。不过GoogleCastSDK依赖于Google服务,在国内受到限制。

    • 支持推送投屏。



  • 2.5 乐播投屏

    • 乐播投屏是一套投屏技术方案,据官网称市面上90%的电视已采用乐播乐联协议,App开发者只需要接入乐播发送端SDK,即可同时支持AirplayDLNA乐联(lelink)等协议,兼容程度较高。但从2022年5月30日起,乐播投屏停止对旧版SDK的维护,新版SDK需要收费,按投屏日活进行收费。

    • 详细内容可上官网查看:乐播




3. DLNA详解


DLNA(Digital Living Network Alliance)是一个组织并不是一个协议,这个组织定义了一套由基础协议组成的标准,所以常用DLNA指代投屏的一种实现方案。


DLNA是目前大部分电视机支持的投屏方案,不需要收费且有一些成熟的开源框架支持开发者接入,支持的投屏方式为推送投屏,满足App投屏的需要。下面从几个角度详细介绍DLNA



  • 3.1 核心协议 DLNA依赖UPnP协议来完成发现设备、描述设备、控制设备;而UPnP协议依赖于SSDP协议来完成发现设备。下面简单了解下这两个协议。



    • UPnP
      UPnP是一种网络协议,其全称为“通用即插即用协议”(Universal Plug and Play)。它是一种基于TCP/IP协议栈的协议。它的主要目的是让网络中的不同设备能够自动发现和连接其他设备,从而实现网络设备间的通信和协作。


      UPnP的应用场景包括打印共享、音视频传输、远程控制等,适用于各种不同类型的网络设备,包括计算机、路由器、打印机、摄像头、音频设备等。


      UPnP的核心是定义了一系列标准化的协议和接口,包括设备发现服务描述设备控制等,让支持协议的设备能够自动发现和连接其他设备。


    • SSDP
      SSDP是一种基于UDP协议的网络协议,全称为“简单服务发现协议”(Simple Service Discovery Protocol)。它的主要目的是让网络中的设备能够自动发现和连接其他设备,从而实现设备间的通信和协作。


      SSDP适用于各种不同类型的网络设备,应用场景包括UPnP、AirPlay等。


      SSDP的核心是通过广播消息来实现设备的发现和服务的注册。




  • 3.2 核心角色 利用DLNA的体系,我们可以连接不同的网络设备(下面简称CP(Control Point))。在不同CP间进行数据传输、展示,这个过程中就分化出不同的角色:发送控制端DMC、接收端DMR、数据存储服务端DMS



    • DMRDigital Media Render 顾名思义就是渲染展示媒体数据的一端,比如我们的电视机。

    • DMSDigital Media Server 用于保存音视频文件的存储服务器,属于比较范的一个概念。

    • DMCDigital Media Controller 用于发现和控制的中间设备,发现局域网中存在的DMR,然后把DMS上的资源推送到DMR上进行播放。


    如果要把手机上的照片、视频文件推动到局域网内的电视上播放出来,手机就承担了DMS+DMC的角色,而电视则是一个DMR设备;


    而如果要在手机上控制电视播放B站的视频,手机就是DMC的角色,而电视就是DMR设备。


  • 3.3 投屏的主要步骤 了解核心协议、核心角色后,我们接下来以投屏为例,从DMC的角度出发,看看DMC如何发现、控制DMR,完成投屏。



    • 发现设备 当一个新的CP加入一个局域网时,为了获取当前网段里都有哪些智能设备,CP需要遵循SSDP向默认多播IP和端口发送获取信息的请求。请求的格式如下:


      M-SEARCH * HTTP/1.1

      MX: 1 //最大时间间隔数

      ST: upnp:rootdevice //搜索的设备类型

      MAN: "ssdp:discover"

      User-Agent: iOS 10.2.1 product/version

      Connection: close

      Host: 239.255.255.250 //多播地址

      如果请求成功则返回如下信息:


      HTTP/1.1 200 OK

      Cache-control: max-age=1800

      Date: Thu, 16 Feb 2017 09:09:45 GMT

      LOCATION: http://10.2.9.152:49152/TxMediaRenderer_desc.xml //URL for UPnP description for device

      ... 省略不重要的信息

      ST: upnp:rootdevice //device type

      其中LOCATION代表一个xml文件的链接,这个文件详细描述了当前局域网内CP的信息。


      至此投屏的第一步完成,新加入的CP可以发现其他CP


    • 描述设备 在上一步中,我们得到了一个xml链接,xml文件的内容如下:


      <root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" configId="499354">
      ...
      <device>
      <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
      <friendlyName>卧室的创维盒子Q+</friendlyName>
      ...
      <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
      <serviceList>
      <service>
      <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
      <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
      <SCPDURL>/AVTransport/9c443d47158b-dmr/scpd.xml</SCPDURL>
      ...
      </service>
      ...
      </serviceList>
      </device>
      <device>
      ...
      </device>
      </root>


      • device device描述了一个CP的信息,每个device对应局域网内的一个CPdevice下的deviceType描述了当前CP的类型,MediaRenderer代表当前CP可以作为DMR用于展示媒体资源。

      • service service描述了当前CP支持的服务,一般会有多个。serviceSCPDURL指向另外一个xml文件,这个文件描述了当前service下支持的操作,如暂停、播放、快进等。


      至此投屏的第二步完成,我们获取到CP的详细描述,包括设备的类型、支持的服务等。


    • 控制设备 在上一步中,我们得到了SCPDURL这个xml链接,xml文件的内容如下:


      <scpd xmlns="urn:schemas-upnp-org:service-1-0">
      ...
      <actionList>
      <action>
      <name>SetAVTransportURI</name>
      <argumentList>
      <argument>
      <name>InstanceID</name>
      <direction>in</direction>
      <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
      </argument>
      <argument>
      <name>CurrentURI</name>
      <direction>in</direction>
      <relatedStateVariable>AVTransportURI</relatedStateVariable>
      </argument>
      <argument>
      <name>CurrentURIMetaData</name>
      <direction>in</direction>
      <relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
      </argument>
      </action>
      ...
      </scpd>


      • action 每个action代表一个操作,actionargument代表当前操作支持的参数。如上面的SetAVTransportURI就是设置媒体的url。


      执行这些action,需要按要求发起请求,请求的格式可以参考基于DLNA的移动端网络视频投屏技术初探


      至此投屏的第三步完成,我们知道目标CP支持哪些操作,利用这些操作便可以完成我们的投屏及控制。这个过程中发起投屏的CP便是DMC,展示媒体资源的CP便是DMS




  • 3.4 相关开源框架 前面我们了解了DLNA的原理,DLNA涉及的协议还是比较复杂的,人为的处理这些请求和响应,是比较麻烦的。所以社区中也有一些基于DLNA的第三方框架可供我们使用,如:



    • Platinum 是基于UPnPC++框架。

    • cling 是基于UPnPjava框架,对UPnP进行了简单的封装,不支持纯ipv6的网络。

    • cybergarage-upnp 是基于UPnPjava框架,对UPnP进行了简单的封装,不过代码结构不如cling且存在getAction方法返回一直为空的问题,需要自己把jar包拉下来,然后修改其中的代码。

    • DLNA-Cast 这个是目前发现比较完善的框架,是对cling的进一步封装,使用体验更好,目前还有在更新迭代,推荐使用。



  • 3.5 安全性问题






注意:DLNA基于UPnP,需要进行组内广播。如果某WIFI环境下一直搜不到设备,可能是WIFI不支持广播,可以切换WIFI环境再尝试。



参考文章



作者:小白鸽本鸽
来源:juejin.cn/post/7272566178446884923
收起阅读 »

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()
//