注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

RecyclerView 实现Item倒计时效果

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

前言


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


效果


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


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


功能实现


1. 倒计时功能实现

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


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


2. 设计思路

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


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


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


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


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


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


3. 倒计时列表


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


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

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


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

添加倒计时


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

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


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

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


fun clearCountDown() {  
cdMap.clear()
}

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


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

return null
}

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


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

......
}

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


4. 观察者数组实现


先创建一个观察者数组


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


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


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

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

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

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


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

5. 倒计时心跳实现


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


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

开始倒计时


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

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


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

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

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


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


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

6. 整体功能


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


object RecyclerCountDownManager {  

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

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

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

/**
* 添加viewHolder观察
*/

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

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

fun releaseHolderObservable() {
viewHolderObservables.clear()
}

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

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

/**
* 清除倒计时
*/

fun clearCountDown() {
cdMap.clear()
}

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

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

return null
}

/**
* 开始心跳
*/

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

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

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

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

/**
* 关闭心跳
*/

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

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

interface OnItemSchedule {

fun onCdSchedule()

}


}

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


调用的地方,Demo的Adapter


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

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

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

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

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

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

private var mData: RcdItemData? = null

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

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

}

}

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


image.png


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


image.png


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

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

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

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


前言


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


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

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


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

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

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

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


简单拆解Duration


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



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

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


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


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

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



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



存储容量单位换算设计



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


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


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


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


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


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

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

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


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


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

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

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


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


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

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



操作符设计


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


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


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

算术运算支持



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


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


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


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

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

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

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

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

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

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

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


逻辑运算支持



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

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


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




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



获取字符串形式


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


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

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

单元测试


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


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

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

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

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

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

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

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

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

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

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

总结


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


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


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


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


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

Android自定义定时通知实现

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

Android自定义通知实现


前言


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


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


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


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


nnotify.gif

正文


一、待办数据库


1、待办实体类


image.png

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


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


2、数据访问Dao


添加或更新一条待办事项


image.png



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



3、数据库封装


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


二、添加定时器


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


1、思路:



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

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

  3. 计算出延迟的时间。

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


2、实现


image.png


说明



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



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




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

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


3、封装


image.png



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



三、注册广播


1. 首先创建一个TodoNotifyReceiver广播


image.png



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



2. 自定义Action


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


image.png


3. 广播注册方法


image.png

4.广播注册及回调实现


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


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


image.png


/**
* 延迟5分钟
*/

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

/**
* 标记已完成
*/

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



四、自定义通知构建


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

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

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

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

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

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


步骤:



  1. 构建自定义通知布局

  2. 设置自定义的Action

  3. 传入待办数据

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

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

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

  7. 构建自定义通知布局

  8. 构建NotificationChannel

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



总结


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


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

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

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

前言


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


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

2、焦点切换无法判断

3、光标位置无法修正

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

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

6、性能差


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


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


为什么TextView是可以的呢?



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

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

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


核心步骤


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


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

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

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

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


fire_89.gif


代码实现


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


变量定义


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

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

关键状态


禁止复制、粘贴、选中


mrb62ges5a.jpeg


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

绘制逻辑


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


TextPaint paint = getPaint();

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

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

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

int color = paint.getColor();

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

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

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

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

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

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

canvas.restoreToCount(saveCount);

InsertionHandleView问题


image.png


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


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



android.widget.Editor.InsertionHandleView



解决方法其实有3种:


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


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


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

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


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


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


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

下面是解法:


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

总结


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


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


本篇全部代码


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


public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


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

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

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


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

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

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

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

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

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

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

}

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

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

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

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

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

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

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

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

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


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

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

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

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

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

int color = paint.getColor();

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

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

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

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

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

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

canvas.restoreToCount(saveCount);
}


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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}


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

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

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

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



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


1.gif


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


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



  • 沉浸式状态栏

  • 滑动组件监听


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


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

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

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

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

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


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

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


注意:



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

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


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

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

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


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

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

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

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


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


Dilog#show()


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


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

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

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


video2.gif


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


Activity#showDialog()


先来看看此方法的注释



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



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


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

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

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

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


video3.gif


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


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


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


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


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


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

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


video4.gif


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


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

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

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

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


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


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

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


Activity 如何恢复 Dialog?


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


Activity.java


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

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

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

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

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


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

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


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


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


总结


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



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



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


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

RecyclerView刷新后定位问题

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

问题描述

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

原因分析

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

int itemCount = state.getItemCount();

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

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

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

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

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


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

收起阅读 »

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

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

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


BasicUI库简介


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


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


GitHub库链接: BasicUI


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


image.png


代码实现应用内版本更新


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


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

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

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

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



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

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

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

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

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


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


结语


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


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




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


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

Androidmanifest文件加固和对抗

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

前言


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


1、Androidmanifest文件组成


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


image


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


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


Magic Number


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


image


StylesStart


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


image


StringPool


image


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


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


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


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


image


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


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


image


读取到的字符为:theme


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


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

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

③ 100+SRC漏洞分析报告

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

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

⑥ 超1800页CTF实战技巧手册

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

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


总结:


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


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


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


2、010editor模板


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


image


搜索:androidmanifest.bt


image


安装完成且运行之后:


image


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


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


用法十分简单:


java -jar AXMLPrinter2.jar AndroidManifest_origin.xml

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


image​​


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


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


使用 010editor 打开


image


将其修复


image


保存,再次尝试运行AXMLPrinter2


image


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


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


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


image


将其修改回去


image


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


image


再次去分析:


image


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


image


image


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


image


image


再次运行


image


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


总结:


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


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


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

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

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

一、整体逻辑


image.png


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



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

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

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


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


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


横向.gif


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


1、跨页吸附


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


(1)Scroll Idle


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


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

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

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

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


(2)Fling


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


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


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

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

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

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

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

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

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

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

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


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

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

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

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

完整操作:


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

2、覆盖效果实现


(1)如果使用PageTransform实现


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


image.png


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


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

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


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


image.png


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


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


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


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

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

三、竖直滑动(Scroll Mode)


垂直.gif


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


1、跨章吸附


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


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


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

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


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

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


2、跨章Fling阻断


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



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

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


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


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

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

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

四、仿真页(Flip Mode)


仿真.gif


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



  1. 确认手指滑动方向

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

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

  4. 绘制仿真页

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


1、确认手指滑动方向


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


image.png


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


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

image.png


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


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

2、遮盖效果


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


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

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


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


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

4、绘制仿真页


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


(1)生成截图


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


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

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

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

(2)绘制仿真页


绘制仿真页参考 gedoor/legadoSimulationPageDelegate



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

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

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


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


5、动画控制


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


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

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


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

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

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


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

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

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


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

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

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

前言


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


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


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


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


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

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


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


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


期望


什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式


inline fun <reified T : FLogger> flogD(block: () -> Any)

interface AppLogger : FLogger

flogD {
list.joinToString { it }
}

flogD方法打印Debug日志,传一个Flogger的子类AppLogger作为日志标识,同时传一个block来返回要打印的日志内容。


日志标识是一个类或者接口,所以管理方式比较简单不会造成tag混乱的问题,默认tag是日志标识类的短类名。生产模式下调高日志等级后,block就不会被执行了,避免了可能的性能问题。


实现分析


日志库的完整实现已经写好了,放在这里xlog



  • 支持限制日志大小,例如限制每天只能写入10MB的日志

  • 支持自定义日志格式

  • 支持自定义日志存储,即如何持久化日志


这一节主要分析一下实现过程中遇到的问题。


问题:如果App运行期间日志文件被意外删除了,怎么处理?


在Android中,用java.io的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?


有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。


检查一个文件是否存在通常是调用java.io.File.exist()方法,但是它比较耗性能,我们来做一个测试:


measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:50:33.536 MainActivity            com.sd.demo.xlog                I  time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58

可以看到1万次调用的耗时在50毫秒左右。


我们再测试一下对文件写入的耗时:


val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

14:57:56.092 MainActivity            com.sd.demo.xlog                I  time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54

可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。


还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。


其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler,关于IdleHandler这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler后,它会在主线程空闲的时候被执行。


我们可以在每次写入日志之后注册IdleHandler,等IdleHandler被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。


这里要注意每次写入日志之后注册IdleHandler,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler,库中大概的代码如下:


private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null

fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false

// 如果已经注册过了,直接返回
_idleHandler?.let { return true }

val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }

// 重置变量,等待下次注册
_idleHandler = null
false
}

// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}

这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。


问题:如何检测文件大小是否溢出


库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:


val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:56:04.090 MainActivity            com.sd.demo.xlog                I  time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78

可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。


库中支持自定义日志存储,在日志存储接口中定义了size()方法,上层通过此方法来判断当前日志的大小。


如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length(),后续通过写入的数量来增加这个变量的值,并在size()方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里


问题:文件大小溢出后怎么处理?


假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。


例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。


有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:



  1. 写入文件20231128.log

  2. 20231128.log写满5MB的时候关闭输出流,并把它重命名为20231128.log.1


这时候继续写日志的话,发现20231128.log文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1文件中避免丢失全部的日志。


分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。


问题:打印日志的性能


性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里


还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat来格式化,用它来格式化年:月:日的时候问题不大,但是如果要格式化时:分:秒.毫秒那它就比较耗性能,我们来做一个测试:


val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:05:26.920 MainActivity            com.sd.demo.xlog                I  time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193

可以看到1万次格式化耗时大概在200毫秒左右。


我们再用java.util.Calendar测试一下:


val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}

16:11:25.342 MainActivity            com.sd.demo.xlog                I  time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18


这里解释一下为什么要用两个时间戳,因为Calendar内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。


可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar来格式化时间,有更好的方案欢迎和作者交流。


问题:日志的格式如何显示


手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。



  • 优化时间显示


目前库内部是以天为单位来命名日志文件的,例如:20231128.log,所以在格式化时间戳的时候只保留了时:分:秒.毫秒,避免冗余显示当天的日期。



  • 优化日志等级显示


打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error,一般最常用的记录等级是Info,所以在格式化的时候如果等级是Info则不显示等级标志,规则如下:


private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}


  • 优化日志标识显示


如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag



  • 优化线程ID显示


如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID


经过上面的优化之后,日志打印的格式是这样的:


flogI { "1" }
flogI { "2" }
flogW { "3" }
flogI { "user debug" }
thread {
flogI { "thread" }
}

19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread

API


这一节介绍一下库的API,调用FLog.init()方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)关闭日志


常用方法


// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),

//(可选参数)自定义日志格式
formatter = AppLogFormatter(),

//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),

//(可选参数)是否异步发布日志,默认值false
async = false,
)

// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)

// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)

// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)

/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/

FLog.deleteLog(1)

打印日志


interface AppLogger : FLogger

flogV { "Verbose" }
flogD { "Debug" }
flogI { "Info" }
flogW { "Warning" }
flogE { "Error" }

// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }

配置日志标识


可以通过FLog.config方法修改某个日志标识的配置信息,例如下面的代码:


FLog.config {
// 修改日志等级
this.level = FLogLevel.Debug

// 修改tag
this.tag = "AppLoggerAppLogger"
}

自定义日志格式


class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}

interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>

/** 日志tag */
val tag: String

/** 日志内容 */
val msg: String

/** 日志等级 */
val level: FLogLevel

/** 日志生成的时间戳 */
val millis: Long

/** 日志是否在主线程生成 */
val isMainThread: Boolean

/** 日志生成的线程ID */
val threadID: String
}

自定义日志存储


日志存储是通过FLogStore接口实现的,每一个FLogStore对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory工厂为每个日志文件提供FLogStore对象。


class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}

class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}

// 返回当前日志的大小
override fun size(): Long = 0

// 关闭
override fun close() {}
}

结束


库目前还处于alpha阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。


作者:Sunday1990
来源:juejin.cn/post/7306423214493270050
收起阅读 »

Kotlin中 for in 是有序的吗?forEach呢?

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。 数组的 for in // 调用: val arr = arrayOf(1, 2, 3)...
继续阅读 »

我们要遍历一个数组、一个列表,经常会用到kotlin的 for in 语法,但是 for in 是不是有序的呢?forEach是不是有序的呢?这就需要看一下它们的本质了。


数组的 for in


// 调用:
val arr = arrayOf(1, 2, 3)
for (ele in arr) {
println(ele)
}

反编译成Java是个什么东西呢?


Integer[] arr = new Integer[]{1, 2, 3};
Integer[] var4 = arr;
int var5 = arr.length;

for(int var3 = 0; var3 < var5; ++var3) {
int ele = var4[var3];
System.out.println(ele);
}

总结:从Java代码可以看出,实际就是一个普通的for循环,是从下标0开始遍历到结束的,所以是有序的。


列表的 for in


// 调用:
val list = listOf(1, 2, 3)
for (ele in list) {
println(ele)
}

反编译成Java:


List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});
Iterator var3 = list.iterator();

while(var3.hasNext()) {
int ele = ((Number)var3.next()).intValue();
System.out.println(ele);
}

可以看出列表的for in是通过iterator实现的,和数组不一样,那这个iterator遍历是否是有序的呢?首先我们要知道这个iterator怎么来的:


// iterator 是通过调用 list.iterator() 得到的,那么这个list又是什么呢?
Iterator var3 = list.iterator();

// list
List list = CollectionsKt.listOf(new Integer[]{1, 2, 3});

// list是通过数组elements.asList()得到的
public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()

// 这里有个expect,找到对应的actual
public expect fun <T> Array<out T>.asList(): List<T>

// 对应的actual
public actual fun <T> Array<out T>.asList(): List<T> {
return ArraysUtilJVM.asList(this)
}

// 最终调用了Arrays.asList(array)
class ArraysUtilJVM {
static <T> List<T> asList(T[] array) {
return Arrays.asList(array);
}
}

public class Arrays {

// 从这里看到最终拿到的list是 Arrays 类中的 ArrayList
// 然后我们找到里面的 iterator() 方法
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private final E[] a;

@Override
public Iterator<E> iterator() {
// 最终得到的iterator是ArrayItr
// 这里的a是一个数组,也就是我们一开始传进来的1,2,3
return new ArrayItr<>(a);
}
}

private static class ArrayItr<E> implements Iterator<E> {
private int cursor;
private final E[] a;

ArrayItr(E[] a) {
this.a = a;
}

@Override
public boolean hasNext() {
return cursor < a.length;
}

@Override
public E next() {
int i = cursor;
if (i >= a.length) {
throw new NoSuchElementException();
}
cursor = i + 1;
return a[i];
}
}
}

总结:列表的for in是通过iterator实现的,这个iterator是ArrayItr,从里面的next()方法可以看出,这也是有序的,从cursor开始,cursor默认是0,也就是从下标0开始遍历。
注:这里只是分析了Arrays.ArrayList的iterator,具体的集合类需要具体分析,比如ArrayList、LinkedList等,不过从正常思维来看,iterator是一个迭代器,就应该有序的把数据一个一个丢出来。


数组的 forEach


// 调用:
val arr = arrayOf(1, 2, 3)
arr.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Array<out T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

列表的 forEach


// 调用:
val list = listOf(1, 2, 3)
list.forEach {
println(it)
}

// 点进去forEach看:
// 其实也是调用了for in,所以也是有序的。
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

作者:linq
来源:juejin.cn/post/7304562756429611046
收起阅读 »

Android文件存储

前言在Android中,对于持久化有如下4种:本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。正文先来看看内部存储空间。内部存储空间由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/...
继续阅读 »

前言

在Android中,对于持久化有如下4种:

持久化.jpg

本篇文章主要介绍一些容易出错的地方,以及访问对应空间的API。

正文

先来看看内部存储空间。

内部存储空间

由上面图可以看出内部存储空间主要由3个部分组成,其中内部存储的目录就是/data/data/<包名>/,对应的目录如下:

内部存储空间.jpg

内部存储空间有如下特点:

  • 每个应用独占一个以包名命名的私有文件夹。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。
  • 适用于私密数据。

对于内部存储空间,里面有一些默认的文件夹,而对于不同文件的访问,有着不同的API,如下:

  1. 对于data/data/<包名>/目录:
方法描述
Context#getDir(String name,int mode): File获取内部存储根目录下的文件夹,不存在则创建
  1. 对于data/data/<包名>/files/目录:
方法描述
Context#getFilesDir():File!返回files文件夹
Context#fileList(): Array!列举files目录下所有文件和文件夹,String类型为文件或者文件夹的名字
Context#openFileInput(String name):FileInputStream打开files文件下的某个文件的输入流,不存在则抛出异常:FileNotFoundException
Context#openFileOut(String name,int mode):FileOutputStream打开files文件下的某个文件的输入流,文件不存在则新建
Context#deleteFile(String name): Boolean删除文件或文件夹
  1. 对于data/data/<包名>/cache/目录:
方法描述
Context#getCacheDir():File返回cache文件夹
  1. 对于data/data/<包名>/code_cache目录:
方法描述
Context#getCodeCacheDir():File返回优化过的代码目录,如JIT优化

上述方法测试代码如下:

        val testDir = getDir("rootDir", MODE_PRIVATE)
//打印为:/data/user/0/com.wayeal.ocr/app_rootDir    
Logger.t("testFile").d("testDir = ${testDir.absolutePath}")
//打印为:/data/user/0/com.wayeal.ocr/files  
Logger.t("testFile").d("filesDir = ${filesDir.absolutePath}")
//在files目录下新建文件
val fileOutputStream = openFileOutput("filesTest", MODE_PRIVATE)
//打印为:[datastore, bugly_last_us_up_tm, local_crash_lock, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")
File(filesDir,"haha").createNewFile()
//打印为:[datastore, bugly_last_us_up_tm, haha, filesTest]
Logger.t("testFile").d("fileList = ${fileList().toMutableList()}")

外部存储空间

对于外部存储空间在使用前一般要判断是否挂载,因为早期的的Android手机是有SD卡的,是可以进行卸载SD卡的。

对于外部存储空间,也有严格的划分,如下:

外部存储空间划分.jpg

这里可以发现外部存储空间分为了公共目录和私有目录,对于公共目录特点:

  • 外部存储中除了私有目录外的其他空间。
  • 所有应用共享。
  • 在应用卸载时不会被卸载。
  • 对MediaScanner可见。
  • 适用于非私密数据,不需要随应用卸载删除。

对于私有目录,有如下特点:

  • 目录名为Android。
  • 在media和data等目录中,以包名区分各个应用。
  • 在应用卸载时被删除。
  • 对MediaScanner不可见。(对多媒体文件夹例外,要求API 21)
  • 适用于非私密数据,需要在应用卸载时删除。

这里对于公共目录storage/emulated/0/来说,其API主要是Environment类来完成,如下:

方法描述
Environment.getExternalStorageDirectory(): File获取外部存储目录
Environment.getExternalStoragePublicDirectory(name: String): File外部存储根目录下的某个文件夹
Environment.getExternalStorageState(): String外部存储的状态

对于外部空间的私有目录storage/emulated/0/Android/data/<包名>/来说,其API还是由Context,主要是方法名都携带external字样,如下:

方法描述
Context.getExternalCacheDir(): File获取cache文件夹
Context.getExternalCacheDirs(): Array多部分cache文件夹(API 18),因为外部存储空间可能有多个
Context.getExternalFilesDir(type: String): File获取files文件夹
Context.getExternalFilesDirs(type: String): Array获取多部分的files文件夹
Context.getExternalMediaDirs(): Array获取多部分多媒体文件(API 21)

上述方法测试代码和log如下:

        Logger.t("testFile")
          .d("外部公共存储根目录 = ${Environment.getExternalStorageDirectory().absolutePath}")
//外部公共存储根目录 = /storage/emulated/0
       Logger.t("testFile")
          .d("外部公共存储Pictures目录 = ${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).absolutePath}")
//外部公共存储Pictures目录 = /storage/emulated/0/Pictures
       Logger.t("testFile")
          .d("外部公共存储状态 = ${Environment.getExternalStorageState()}")
//外部公共存储状态 = mounted
       Logger.t("testFile")
          .d("外部存储私有缓存目录 = ${externalCacheDir?.absolutePath}")
//外部存储私有缓存目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/cache
       Logger.t("testFile")
          .d("外部存储私有多部分缓存目录 = ${externalCacheDirs?.toMutableList()}")
//外部存储私有多部分缓存目录 = [/storage/emulated/0/Android/data/com.wayeal.ocr/cache]
       Logger.t("testFile")
          .d("外部存储私有files的Pictures目录 = ${getExternalFilesDir(Environment.DIRECTORY_PICTURES)}")
//外部存储私有files的Pictures目录 = /storage/emulated/0/Android/data/com.wayeal.ocr/files/Pictures
       Logger.t("testFile")
          .d("外部存储私有媒体多部分目录 = ${externalMediaDirs.toMutableList()}")
//外部存储私有媒体目录 = [/storage/emulated/0/Android/media/com.wayeal.ocr]

总结

对于不同的存储空间的特点以及API要了解,在需要保存文件时选择适当的存储空间。


作者:yuanhao
来源:juejin.cn/post/7158365077488271367

收起阅读 »

借某次写需求谈Android文件存储

前言 某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。 Round 1 哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就...
继续阅读 »

前言


某天,我导让我写一个“崩溃日志本地收集”的功能,可以方便测试和开发查看崩溃原因。然后故事就开始了。


Round 1


哥们一开始,用context.getFilesDir()获取存储目录,这个方法返回的文件夹路径是data/data/包名/files,也就是Android的内部存储空间。


然后哥们很顺利的啊,把这个功能做出来了。


第二天开站会,测试提出了致命疑问:我们测试要怎么看到报错信息呢?


众所周知啊,这个路径手机不root是无法查看的。所以我看向我导:“手机root一下不就行了”


image.png


我导:改!


Round 2


哥们吸取教训啊,咱不存在内部,咱存外面还不行吗。这次我用context.getExternalFilesDir()获取存储目录,也就是外部存储的应用私有目录,路径是storage/emulated/0/Android/data/包名/files。


改个路径的事情,瞬间写好了。


我们这个日志搜集,一个是搜集Native层的报错,一个是搜集Jvm层的报错。然后经过测试,发现Jvm层的报错信息有权限取出来,而Native层的报错信息却没权限取出来


我们当时就震惊了:啊?同一个目录下存东西居然会出现两套权限?


image.png


然后另外一个Android开发的前辈就想通过adb强行把这个报错信息拿出来,但是问题是没有root过没法用su命令啊,所以这件事又绕回去了。


然后我导就让我改到根目录下。


行,哥们改!


Round 3


既然内部存储不行,存到外部存储的私有目录也不行,就只能存在公共目录了。也就是我们使用手机文件管理应用看,Music和Movie的那一层。


获取存储路径用Environment.getExternalStorageDirectory(),得到的路径是storage/emulated/0。


改完后我又发现,Native层的权限正常了,Jvm的报错信息写不进去了。


报错信息是:


java.io.FileNotFoundException:...(Opration not permitted)


我心想:啊?这个目录难道没有写权限?那Natvie的报错信息怎么写进去的?


当时复制粘贴进百度,看到了一名CSDN老哥的回答:


img_v3_027e_6922fad6-53b9-4b94-b35f-c5445a90a4eg.jpg


其实我当时就对这个回答存疑的,因为明显我能mkdir,但是.txt文本信息却写不进去。


终于,我在Stack Overflow看到了正解:


image.png


没错,真相只有一个,是文件名有问题。我将.txt改成了.log就能成功存储了。


至此,终于可以下班。


image.png


总结


Android的文件存储和权限管理是真的*蛋。


实习的每一天做需求,都像在拍走进科学,哎。


顺便复习一下Android文件存储吧:Android文件存储


作者:leiteorz
来源:juejin.cn/post/7327920541989781504
收起阅读 »

动态代理在Android中的运用

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原...
继续阅读 »

动态代理是一种在编程中非常有用的设计模式,它允许你在运行时创建一个代理对象来代替原始对象,以便在方法调用前后执行额外的逻辑。在Android开发中,动态代理可以用于各种用例,如性能监控、AOP(面向切面编程)和事件处理。本文将深入探讨Android动态代理的原理、用途和实际示例。


什么是动态代理?


动态代理是一种通过创建代理对象来代替原始对象的技术,以便在方法调用前后执行额外的操作。代理对象通常实现与原始对象相同的接口,但可以添加自定义行为。动态代理是在运行时生成的,因此它不需要在编译时知道原始对象的类型。


动态代理的原理


动态代理的原理涉及两个关键部分:



  1. InvocationHandler(调用处理器):这是一个接口,通常由开发人员实现。它包含一个方法 invoke,在代理对象上的方法被调用时会被调用。在 invoke 方法内,你可以定义在方法调用前后执行的逻辑。

  2. Proxy(代理类):这是Java提供的类,用于创建代理对象。你需要传递一个 ClassLoader、一组接口以及一个 InvocationHandlerProxy.newProxyInstance 方法,然后它会生成代理对象。


下面是一个示例代码,演示了如何创建一个简单的动态代理:


import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy

// 接口
interface MyInterface {
fun doSomething()
}

// 实现类
class MyImplementation : MyInterface {
override fun doSomething() {
println("Original method is called.")
}
}

// 调用处理器
class MyInvocationHandler(private val realObject: MyInterface) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
println("Before method is called.")
val result = method.invoke(realObject, *(args ?: emptyArray()))
println("After method is called.")
return result
}
}

fun main() {
val realObject = MyImplementation()
val proxyObject = Proxy.newProxyInstance(
MyInterface::class.java.classLoader,
arrayOf
(MyInterface::class.java),
MyInvocationHandler
(realObject)
) as MyInterface

proxyObject.doSomething()
}

运行上述代码会输出:


Before method is called.
Original method is called.
After method is called.

这里,MyInvocationHandler 拦截了 doSomething 方法的调用,在方法前后添加了额外的逻辑。


Android中的动态代理


在Android中,动态代理通常使用Java的java.lang.reflect.Proxy类来实现。该类允许你创建一个代理对象,该对象实现了指定接口,并且可以拦截接口方法的调用以执行额外的逻辑。在Android开发中,常见的用途包括性能监控、权限检查、日志记录和事件处理。


动态代理的用途


性能监控


你可以使用动态代理来监控方法的执行时间,以便分析应用程序的性能。例如,你可以创建一个性能监控代理,在每次方法调用前记录当前时间,然后在方法调用后计算执行时间。


import android.util.Log

class PerformanceMonitorProxy(private val target: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target, *(args ?: emptyArray()))
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
Log.d("Performance", "${method.name} took $duration ms to execute.")
return result
}
}

AOP(面向切面编程)


动态代理也是AOP的核心概念之一。AOP允许你将横切关注点(如日志记录、事务管理和安全性检查)从业务逻辑中分离出来,以便更好地维护和扩展代码。通过创建适当的代理,你可以将这些关注点应用到多个类和方法中。


事件处理


Android中常常需要处理用户界面上的各种事件,例如点击事件、滑动事件等。你可以使用动态代理来简化事件处理代码,将事件处理逻辑从Activity或Fragment中分离出来,使代码更加模块化和可维护。


实际示例


下面是一个简单的示例,演示了如何在Android中使用动态代理来处理点击事件:


import android.util.Log
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import android.view.View

class ClickHandlerProxy(private val target: View.OnClickListener) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
if (method.name == "onClick") {
Log.d("ClickHandler", "Click event intercepted.")
// 在事件处理前可以执行自定义逻辑
}
return method.invoke(target, *args.orEmpty())
}
}

// 使用示例
val originalClickListener = View.OnClickListener {
// 原始的点击事件处理逻辑
}

val proxyClickListener = Proxy.newProxyInstance(
originalClickListener::class.java.classLoader,
originalClickListener::
class.java.interfaces,
ClickHandlerProxy
(originalClickListener)
) as View.OnClickListener

button.setOnClickListener(proxyClickListener)

通过这种方式,你可以在原始的点击事件处理逻辑前后执行自定义逻辑,而无需修改原始的OnClickListener实现。


结论


动态代理是Android开发中强大的工具之一,它允许你在不修改原始对象的情况下添加额外的行为。在性能监控、AOP和事件处理等方面,动态代理都有广泛的应用。通过深入理解动态代理的原理和用途,你可以更好地设计和维护Android应用程序。




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

错过Android主线程空闲期,你可能损失的不仅仅是性能

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于An...
继续阅读 »

在Android应用程序的开发过程中,性能优化一直是开发者关注的焦点之一。在这个背景下,Android系统提供了一项强大的工具——IdleHandler,它能够帮助开发者在应用程序的空闲时段执行任务,从而提高应用的整体性能。IdleHandler的机制基于Android主线程的空闲状态,使得开发者能够巧妙地利用这些空闲时间执行一些耗时的操作,而不影响用户界面的流畅性。


在深入研究IdleHandler之前,让我们先了解一下它的基本原理,以及为何它成为Android性能优化的重要组成部分。


IdleHandler的基本原理


Android应用的主线程通过一个消息循环(Message Loop)来处理各种事件和任务。当主线程没有新的消息需要处理时,它就处于空闲状态。这就是IdleHandler发挥作用的时机。


通过注册IdleHandler来告诉系统在主线程空闲时执行特定的任务。当主线程进入空闲状态时,系统会依次调用注册的IdleHandler,执行相应的任务。


IdleHandler与Handler和MessageQueue密切相关。它通过MessageQueue的空闲时间来执行任务。每当主线程处理完一个消息后,系统会检查是否有注册的IdleHandler需要执行。


空闲状态的定义


了解什么时候主线程被认为是空闲的至关重要。一般情况下,Android系统认为主线程在处理完所有消息后即处于空闲状态。IdleHandler通过这个定义,能够在保证不影响用户体验的前提下执行一些耗时的操作。


	// 没有消息,判断是否有IdleHandler
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked
= true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);

....

// 执行IdleHandler
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

如何使用IdleHandler


使用IdleHandler可以执行一些轻量级的任务,例如加载数据、更新UI等。以下是使用IdleHandler的几个使用技巧:



  1. 注册IdleHandler:


Looper.myQueue().addIdleHandler(MyIdleHandler())

class MyIdleHandler : MessageQueue.IdleHandler {
override fun queueIdle(): Boolean {
// 在主线程空闲时执行的任务逻辑
performIdleTask()
// 返回 false,表示任务处理完毕,不再执行
return false
}

private fun performIdleTask() {
// 具体的任务逻辑
// ...
}
}


  1. 取消注册


当不需要继续执行任务时,可以通过removeIdleHandler方法取消注册


Looper.myQueue().removeIdleHandler(idleHandler);

IdleHandler的适用场景



  • 轻量级任务:IdleHandler主要用于执行轻量级的任务。由于它是在主线程空闲时执行,所以不适合执行耗时的任务。

  • 主线程空闲时执行:IdleHandler通过在主线程空闲时被调用,避免了主线程的阻塞。因此,适用于需要在主线程执行的任务,并且这些任务对于用户体验的影响较小。

  • 优先级较低的任务:如果有多个任务注册了IdleHandler,系统会按照注册的顺序调用它们的queueIdle方法。因此,适用于需要在较低优先级下执行的任务。


总的来说IdleHandler适用于需要在主线程空闲时执行的轻量级任务,以提升应用的性能和用户体验。


高级应用



  1. 性能监控与优化
    利用 IdleHandler 可以实现性能监控和优化,例如统计每次空闲时的内存占用情况,或者执行一些内存释放操作。

  2. 预加载数据
    在用户操作前,通过 IdleHandler 提前加载一些可能会用到的数据,提高用户体验。

  3. 动态资源加载
    利用空闲时间预加载和解析资源,减轻在用户操作时的资源加载压力。


性能优化技巧


虽然IdleHandler提供了一个方便的机制来在主线程空闲时执行任务,但在使用过程中仍需注意一些性能方面的问题。



  1. 任务的轻量级处理: 确保注册的IdleHandler中的任务是轻量级的,不要在空闲时执行过于复杂或耗时的操作,以免影响主线程的响应性能。

  2. **避免频繁注册和取消IdleHandler: **频繁注册和取消IdleHandler可能会引起性能问题,因此建议在应用的生命周期内尽量减少注册和取消的操作。可以在应用启动时注册IdleHandler,在应用退出时取消注册。

  3. **合理设置任务执行频率: **根据任务的性质和执行需求,合理设置任务的执行频率。不同的任务可能需要在不同的时间间隔内执行,这样可以更好地平衡性能和功能需求。


结语


通过深度解析 IdleHandler 的原理和高级应用,让我们更好地利用这一工具进行性能优化。在实际项目中,灵活运用 IdleHandler 可以有效提升应用的响应速度和用户体验。希望本文能够激发大家对于Android性能优化的更多思考和实践。




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

Android:优雅的处理首页弹框逻辑:责任链模式

背景 随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。 并且弹框显示还有要求,比如: 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗...
继续阅读 »

背景


随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。
并且弹框显示还有要求,比如:



  • 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框

  • 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗高,所以希望优先级高的优先显示

  • 广告弹框只展示一次

  • 等等


如何优雅的处理这个逻辑呢?请出我们的主角:责任链模式。


责任链模式


举个栗子🌰


一位男性在结婚之前有事要和父母请示,结婚之后要请示妻子,老了之后就要和孩子们商量。作为决策者的父母、妻子或孩子,只有两种选择:要不承担起责任来,允许或不允许相应的请求; 要不就让他请示下一个人,下面来看如何通过程序来实现整个流程。


先看一下类图:
未命名文件.png
类图非常简单,IHandler上三个决策对象的接口。


//决策对象的接口
public interface IHandler {
//处理请求
void HandleMessage(IMan man);
}

//决策对象:父母
public class Parent implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("孩子向父母的请求是:" + man.getRequest());
System.out.println("父母的回答是:同意");
}
}

//决策对象:妻子
public class Wife implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("丈夫向妻子的请求是:" + man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

//决策对象:孩子
public class Children implements IHandler{
@Override
public void HandleMessage(IMan man) {
System.out.println("父亲向孩子的请求是:" + man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

IMan上男性的接口:


public interface IMan {
int getType(); //获取个人状况
String getRequest(); //获取个人请示(这里就简单的用String)
}

//具体男性对象
public class Man implements IMan {
/**
* 通过一个int类型去描述男性的个人状况
* 0--幼年
* 1--成年
* 2--年迈
*/

private int mType = 0;
//请求
private String mRequest = "";

public Man(int type, String request) {
this.mType = type;
this.mRequest = request;
}

@Override
public int getType() {
return mType;
}

@Override
public String getRequest() {
return mRequest;
}
}

最后我们看下一下场景类:


public class Client {
public static void main(String[] args) {
//随机生成几个man
Random random = new Random();
ArrayList<IMan> manList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
manList.add(new Man(random.nextInt(3), "5块零花钱"));
}
//定义三个请示对象
IHandler parent = new Parent();
IHandler wife = new Wife();
IHandler children = new Children();
//处理请求
for (IMan man: manList) {
switch (man.getType()) {
case 0:
System.out.println("--------孩子向父母发起请求-------");
parent.HandleMessage(man);
break;
case 1:
System.out.println("--------丈夫向妻子发起请求-------");
wife.HandleMessage(man);
break;
case 2:
System.out.println("--------父亲向孩子发起请求-------");
children.HandleMessage(man);
break;
default:
break;
}
}
}
}

首先是通过随机方法产生了5个男性的对象,然后看他们是如何就要5块零花钱这件事去请示的,运行结果如下所示:


--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------父亲向孩子发起请求-------
父亲向孩子的请求是:5块零花钱
孩子的回答是:同意
--------孩子向父母发起请求-------
孩子向父母的请求是:5块零花钱
父母的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意


发没发现上述的代码是不是有点不舒服,有点别扭,有点想重构它的感觉?那就对了!这段代码有以下几个问题:



  • 职责界定不清晰



对孩子提出的请示,应该在父母类中做出决定,父母有责任、有义务处理孩子的请示,



因此Parent类应该是知道孩子的请求自己处理,而不是在Client类中进行组装出来,
也就是说 原本应该是父亲这个类做的事情抛给了其他类进行处理,不应该是这样的。



  • 代码臃肿



我们在Client类中写了if...else的判断条件,而且能随着能处理该类型的请示人员越多,
if...else的判断就越多,想想看,臃肿的条件判断还怎么有可读性?!




  • 耦合过重



这是什么意思呢,我们要根据Man的type来决定使用IHandler的那个实现类来处理请



求。有一个问题是:如果IHandler的实现类继续扩展怎么办?修改Client类?
与开闭原则违背了!【开闭原则:软件实体如类,模块和函数应该对扩展开放,对修改关闭】
http://www.jianshu.com/p/05196fac1…



  • 异常情况欠考虑



丈夫只能向妻子请示吗?丈夫向自己的父母请示了,父母应该做何处理?
我们的程序上可没有体现出来,逻辑失败了!


既然有这么多的问题,那我们要想办法来解决这些问题,我们先来分析一下需求,男性提出一个请示,必然要获得一个答复,甭管是同意还是不同意,总之是要一个答复的,而且这个答复是唯一的,不能说是父母作出一个决断,而妻子也作出了一个决断,也即是请示传递出去,必然有一个唯一的处理人给出唯一的答复,OK,分析完毕,收工,重新设计,我们可以抽象成这样一个结构,男性的请求先发送到父亲,父母一看是自己要处理的,就作出回应处理,如果男性已经结婚了,那就要把这个请求转发到妻子来处理,如果男性已经年迈,那就由孩子来处理这个请求,类似于如图所示的顺序处理图。
未命名文件 (1).png
父母、妻子、孩子每个节点有两个选择:要么承担责任,做出回应;要么把请求转发到后序环节。结构分析得已经很清楚了,那我们看怎么来实现这个功能,类图重新修正,如图 :
未命名文件 (2).png
从类图上看,三个实现类Parent、Wife、Children只要实现构造函数和父类中的抽象方法 response就可以了,具体由谁处理男性提出的请求,都已经转移到了Handler抽象类中,我们 来看Handler怎么实现,


public abstract class Handler {
//处理级别
public static final int PARENT_LEVEL_REQUEST = 0; //父母级别
public static final int WIFE_LEVEL_REQUEST = 1; //妻子级别
public static final int CHILDREN_LEVEL_REQUEST = 2;//孩子级别

private Handler mNextHandler;//下一个责任人

protected abstract int getHandleLevel();//具体责任人的处理级别

protected abstract void response(IMan man);//具体责任人给出的回应

public final void HandleMessage(IMan man) {
if (man.getType() == getHandleLevel()) {
response(man);//当前责任人可以处理
} else {
//当前责任人不能处理,如果有后续处理人,将请求往后传递
if (mNextHandler != null) {
mNextHandler.HandleMessage(man);
} else {
System.out.println("-----没有人可以请示了,不同意该请求-----");
}
}
}

public void setNext(Handler next) {
this.mNextHandler = next;
}
}

再看一下具体责任人的实现:Parent、Wife、Children


public class Parent extends Handler{

@Override
protected int getHandleLevel() {
return Handler.PARENT_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------孩子向父母提出请示----------");
System.out.println(man.getRequest());
System.out.println("父母的回答是:同意");
}
}

public class Wife extends Handler{
@Override
protected int getHandleLevel() {
return Handler.WIFE_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------丈夫向妻子提出请示----------");
System.out.println(man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

public class Children extends Handler{
@Override
protected int getHandleLevel() {
return Handler.CHILDREN_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------父亲向孩子提出请示----------");
System.out.println(man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

那么再看一下场景复现:
在Client中设置请求的传递顺序,先向父母请示,不是父母应该解决的问题,则由父母传递到妻子类解决,若不是妻子类解决的问题则传递到孩子类解决,最终的结果必然有一个返回,其运行结果如下所示。


----------孩子向父母提出请示----------
15块零花钱
父母的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意

结果也正确,业务调用类Client也不用去做判断到底是需要谁去处理,而且Handler抽象类的子类可以继续增加下去,只需要扩展传递链而已,调用类可以不用了解变化过程,甚至是谁在处理这个请求都不用知道。在这种模式就是责任链模式


定义


Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request.Chain the receiving objects and pass the request along the chain until an object handles it.
(使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。) 责任链模式的重点是在“链”上,由一条链去处理相似的请求在链中决定谁来处理这个请 求,并返回相应的结果,其通用类图如图所示
未命名文件 (3).png
最后总结一下,责任链的模版:
包含四个对象,Handler,Request,Level,Response:


public class Request {
//请求的等级
public Level getRequestLevel(){
return null;
}
}

public class Level {
//请求级别
}


public class Response {
//处理者返回的数据
}

//抽象处理者
public abstract class Handler {
private Handler mNextHandler;

//每个处理者都必须对请求做出处理
public final Response handleMessage(Request request) {
Response response = null;
if (getHandlerLevel().equals(request.getRequestLevel())) {
//是自己处理的级别,自己处理
response = echo(request);
} else {
//不是自己处理的级别,交给下一个处理者
if (mNextHandler != null) {
response = mNextHandler.echo(request);
} else {
//没有处理者能处理,业务自行处理
}
}
return response;
}

public void setNext(Handler next) {
this.mNextHandler = next;
}

@NotNull
protected abstract Level getHandlerLevel();

protected abstract Response echo(Request request);
}

实际应用


我们回到开篇的问题:如何设计弹框的责任链?


//抽象处理者
abstract class AbsDialog(private val context: Context) {
private var nextDialog: AbsDialog? = null

//优先级
abstract fun getPriority(): Int

//是否需要展示
abstract fun needShownDialog(): Boolean

fun setNextDialog(dialog: AbsDialog?) {
nextDialog = dialog
}

open fun showDialog() {
//这里的逻辑,我们就简单点,具体逻辑根据业务而定
if (needShownDialog()) {
show()
} else {
nextDialog?.showDialog()
}
}

protected abstract fun show()

// Sp存储, 记录是否已经展示过
open fun needShow(key: String): Boolean {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(key, true)
}

open fun setShown(key: String, show: Boolean) {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(key, !show).apply()
}

companion object {
const val LOG_TAG = "Dialog"
const val SP_NAME = "dialog"
const val POLICY_DIALOG_KEY = "policy_dialog"
const val AD_DIALOG_KEY = "ad_dialog"
const val PRAISE_DIALOG_KEY = "praise_dialog"
}
}

/**
* 模拟 隐私政策弹窗
* */

class PolicyDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 0

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如接口控制等等
// 这里通过Sp存储来模拟
return needShow(POLICY_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示隐私政策弹窗")
setShown(POLICY_DIALOG_KEY, true) //记录已经显示过
}
}

/**
* 模拟 广告弹窗
* */

class AdDialog(private val context: Context) : AbsDialog(context) {
private val ad = DialogData(1, "XX广告弹窗") // 模拟广告数据

override fun getPriority(): Int = 1

override fun needShownDialog(): Boolean {
// 广告数据通过接口获取,广告id应该是唯一的,所以根据id保持sp
return needShow(AD_DIALOG_KEY + ad.id)
}

override fun show() {
Log.d(LOG_TAG, "显示广告弹窗:${ad.name}")
setShown(AD_DIALOG_KEY + ad.id, true)
}
}

/**
* 模拟 好评弹窗
* */

class PraiseDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 2

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如用户使用7天等
// 这里通过Sp存储来模拟
return needShow(PRAISE_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示好评弹窗")
setShown(PRAISE_DIALOG_KEY, true)
}
}

//模拟打开app
val dialogs = mutableListOf<AbsDialog>()
dialogs.add(PolicyDialog(this))
dialogs.add(PraiseDialog(this))
dialogs.add(AdDialog(this))
//根据优先级排序
dialogs.sortBy { it.getPriority() }
//创建链条
for (i in 0 until dialogs.size - 1) {
dialogs[i].setNextDialog(dialogs[i + 1])
}
dialogs[0].showDialog()

第一次打开
image.png


第二次打开
image.png


第三次打开
image.png


总结:



  • 优点


责任链模式非常显著的优点是将请求和处理分开。请求者可以不用知道是谁处理的,处理者可以不用知道请求的全貌,两者解耦,提高系统的灵活性。



  • 缺点


责任链有两个非常显著的缺点:一是性能问题,每个请求都是从链头遍历到链尾,特别是在链比较长的时候,性能是一个非常大的问题。二是调试不很方便,特别是链条比较长, 环节比较多的时候,由于采用了类似递归的方式,调试的时候逻辑可能比较复杂。



  • 注意事项


链中节点数量需要控制,避免出现超长链的情况,一般的做法是在Handler中设置一个最大节点数量,在setNext方法中判断是否已经是超过其阈值,超过则不允许该链建立,避免无意识地破坏系统性能。


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

记一次安卓广播引起的ANR死锁问题

年初遇到个bug,设备安装应用宝之后打开使用一段时间(浏览和安装应用),大概率会卡死,然后整个系统重启(表现为卡死后过一段时间开机动画出现,重新进入系统)。非常容易复现。 看到这种软重启首先考虑ANR,分析抓到的log   到日志中查找watchdog关键词 ...
继续阅读 »

年初遇到个bug,设备安装应用宝之后打开使用一段时间(浏览和安装应用),大概率会卡死,然后整个系统重启(表现为卡死后过一段时间开机动画出现,重新进入系统)。非常容易复现。


看到这种软重启首先考虑ANR,分析抓到的log


  到日志中查找watchdog关键词
01-25 11:02:42.032 22774 22796 W Watchdog: *** WATCHDOG KILLING SYSTEM PROCESS: Blocked in handler on foreground thread (android.fg), Blocked in handler on main thread (main), Blocked in handler on ActivityManager (ActivityManager), Blocked in handler on PowerManagerService (PowerManagerService)
01-25 11:02:42.033 22774 22796 W Watchdog: android.fg annotated stack trace:
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.am.ActivityManagerService.bindServiceInstance(ActivityManagerService.java:12853)
01-25 11:02:42.037 22774 22796 W Watchdog: - waiting to lock <0x02af09e1> (a com.android.server.am.ActivityManagerService)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.am.ActivityManagerService.bindServiceInstance(ActivityManagerService.java:12810)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.app.ContextImpl.bindServiceCommon(ContextImpl.java:2035)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.app.ContextImpl.bindService(ContextImpl.java:1958)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.bindService(ServiceConnector.java:343)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.enqueueJobThread(ServiceConnector.java:462)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl.lambda$enqueue$1$com-android-internal-infra-ServiceConnector$Impl(ServiceConnector.java:445)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.internal.infra.ServiceConnector$Impl$$ExternalSyntheticLambda2.run(Unknown Source:4)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Handler.handleCallback(Handler.java:942)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:99)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.037 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.037 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.038 22774 22796 W Watchdog: main annotated stack trace:
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.alarm.AlarmManagerService$AlarmHandler.handleMessage(AlarmManagerService.java:4993)
01-25 11:02:42.038 22774 22796 W Watchdog: - waiting to lock <0x0edf48f2> (a java.lang.Object)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:106)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.SystemServer.run(SystemServer.java:968)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.SystemServer.main(SystemServer.java:653)
01-25 11:02:42.038 22774 22796 W Watchdog: at java.lang.reflect.Method.invoke(Native Method)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920)
01-25 11:02:42.038 22774 22796 W Watchdog: ActivityManager annotated stack trace:
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue.processNextBroadcast(BroadcastQueue.java:1154)
01-25 11:02:42.038 22774 22796 W Watchdog: - waiting to lock <0x02af09e1> (a com.android.server.am.ActivityManagerService)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue.-$$Nest$mprocessNextBroadcast(Unknown Source:0)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.am.BroadcastQueue$BroadcastHandler.handleMessage(BroadcastQueue.java:224)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:106)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.038 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.038 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.039 22774 22796 W Watchdog: PowerManagerService annotated stack trace:
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService.handleSandman(PowerManagerService.java:3257)
01-25 11:02:42.039 22774 22796 W Watchdog: - waiting to lock <0x0f550be5> (a java.lang.Object)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService.-$$Nest$mhandleSandman(Unknown Source:0)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.power.PowerManagerService$PowerManagerHandlerCallback.handleMessage(PowerManagerService.java:5103)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Handler.dispatchMessage(Handler.java:102)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Looper.loopOnce(Looper.java:201)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.Looper.loop(Looper.java:288)
01-25 11:02:42.039 22774 22796 W Watchdog: at android.os.HandlerThread.run(HandlerThread.java:67)
01-25 11:02:42.039 22774 22796 W Watchdog: at com.android.server.ServiceThread.run(ServiceThread.java:44)
01-25 11:02:42.039 22774 22796 W Watchdog: *** GOODBYE!

可以很清楚的看到watchdog提示waiting to lock </xxxxx/> 关键词已经确定大概率是死锁问题了。
导出Anr日志继续进行分析,一般先从group="main"分析起


"main" prio=5 tid=1 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x71c515d0 self=0xb4000078c0a0abe0
| sysTid=2257 nice=-2 cgrp=foreground sched=0/0 handle=0x7a80db94f8
| state=S schedstat=( 67235248988 24733272110 200587 ) utm=3966 stm=2756 core=7 HZ=100
| stack=0x7fca926000-0x7fca928000 stackSize=8188KB
| held mutexes=
at com.android.server.am.ActivityManagerService.broadcastIntentWithFeature(ActivityManagerService.java:14615)
- waiting to lock <0x0185c3b1> (a com.android.server.am.ActivityManagerService) held by thread 16
at android.app.ActivityManager.broadcastStickyIntent(ActivityManager.java:4620)
at android.app.ActivityManager.broadcastStickyIntent(ActivityManager.java:4610)
at com.android.server.BatteryService.lambda$sendBatteryChangedIntentLocked$0(BatteryService.java:780)
at com.android.server.BatteryService.$r8$lambda$r64V5AVg_Okl7PnB1VjeN4oyo1I(unavailable:0)
at com.android.server.BatteryService$$ExternalSyntheticLambda5.run(unavailable:2)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at com.android.server.SystemServer.run(SystemServer.java:968)
at com.android.server.SystemServer.main(SystemServer.java:653)
at java.lang.reflect.Method.invoke(Native method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:920)

main它在等待thread 16的lock <0x0185c3b1>,所以我们去tid=16再看看


"android.display" prio=5 tid=16 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x14340db8 self=0xb4000078c0a40a10
| sysTid=2349 nice=-3 cgrp=top-app sched=0/0 handle=0x76c8933cb0
| state=S schedstat=( 1805684706 1655206467 5034 ) utm=99 stm=81 core=7 HZ=100
| stack=0x76c8830000-0x76c8832000 stackSize=1039KB
| held mutexes=
at com.android.server.wm.ActivityTaskManagerService$LocalService.onProcessAdded(ActivityTaskManagerService.java:5740)
- waiting to lock <0x033a7977> (a com.android.server.wm.WindowManagerGlobalLock) held by thread 198
at com.android.server.am.ProcessList$MyProcessMap.put(ProcessList.java:699)
at com.android.server.am.ProcessList.addProcessNameLocked(ProcessList.java:2946)
- locked <0x09f229e4> (a com.android.server.am.ActivityManagerProcLock)
at com.android.server.am.ProcessList.newProcessRecordLocked(ProcessList.java:3039)
at com.android.server.am.ProcessList.startProcessLocked(ProcessList.java:2487)
at com.android.server.am.ActivityManagerService.startProcessLocked(ActivityManagerService.java:2854)
at com.android.server.am.ActivityManagerService$LocalService.startProcess(ActivityManagerService.java:17450)
- locked <0x0185c3b1> (a com.android.server.am.ActivityManagerService)
at com.android.server.wm.ActivityTaskManagerService$$ExternalSyntheticLambda11.accept(unavailable:27)
at com.android.internal.util.function.pooled.PooledLambdaImpl.doInvoke(PooledLambdaImpl.java:363)
at com.android.internal.util.function.pooled.PooledLambdaImpl.invoke(PooledLambdaImpl.java:204)
at com.android.internal.util.function.pooled.OmniFunction.run(OmniFunction.java:97)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.os.HandlerThread.run(HandlerThread.java:67)
at com.android.server.ServiceThread.run(ServiceThread.java:44)

可以看到waiting to lock <0x033a7977>,它在等待threa198的锁释放。因此再去tid=198看看


"binder:2257_1C" prio=5 tid=198 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x143443a0 self=0xb4000078c0bb49f0
| sysTid=6550 nice=-10 cgrp=foreground sched=0/0 handle=0x7638d60cb0
| state=S schedstat=( 12455767235 6173702511 27824 ) utm=695 stm=550 core=4 HZ=100
| stack=0x7638c69000-0x7638c6b000 stackSize=991KB
| held mutexes=
at com.android.server.display.DisplayManagerService.setDisplayPropertiesInternal(DisplayManagerService.java:2019)
- waiting to lock <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot) held by thread 130
at com.android.server.display.DisplayManagerService.-$$Nest$msetDisplayPropertiesInternal(unavailable:0)
at com.android.server.display.DisplayManagerService$LocalService.setDisplayProperties(DisplayManagerService.java:3811)
at com.android.server.wm.DisplayContent.applySurfaceChangesTransaction(DisplayContent.java:4878)
at com.android.server.wm.RootWindowContainer.applySurfaceChangesTransaction(RootWindowContainer.java:1022)
at com.android.server.wm.RootWindowContainer.performSurfacePlacementNoTrace(RootWindowContainer.java:824)
at com.android.server.wm.RootWindowContainer.performSurfacePlacement(RootWindowContainer.java:785)
at com.android.server.wm.WindowSurfacePlacer.performSurfacePlacementLoop(WindowSurfacePlacer.java:177)
at com.android.server.wm.WindowSurfacePlacer.performSurfacePlacement(WindowSurfacePlacer.java:126)
at com.android.server.wm.WindowManagerService.relayoutWindow(WindowManagerService.java:2501)
- locked <0x033a7977> (a com.android.server.wm.WindowManagerGlobalLock)
at com.android.server.wm.Session.relayout(Session.java:253)
at com.android.server.wm.Session.relayoutAsync(Session.java:267)
at android.view.IWindowSession$Stub.onTransact(IWindowSession.java:757)
at com.android.server.wm.Session.onTransact(Session.java:178)
at android.os.Binder.execTransactInternal(Binder.java:1285)
at android.os.Binder.execTransact(Binder.java:1244)

198在等待130来释放锁,waiting to lock <0x0faa6d02>,不要嫌麻烦再跟去130


"binder:2257_4" prio=5 tid=130 Blocked
| group="main" sCount=1 ucsCount=0 flags=1 obj=0x15288e48 self=0xb4000078c0b32400
| sysTid=2541 nice=0 cgrp=foreground sched=0/0 handle=0x76489a8cb0
| state=S schedstat=( 8323462229 6484203506 25835 ) utm=482 stm=349 core=7 HZ=100
| stack=0x76488b1000-0x76488b3000 stackSize=991KB
| held mutexes=
at com.android.server.am.ActivityManagerService.registerReceiverWithFeature(ActivityManagerService.java:13285)
- waiting to lock <0x0185c3b1> (a com.android.server.am.ActivityManagerService) held by thread 16
at android.app.ContextImpl.registerReceiverInternal(ContextImpl.java:1816)
at android.app.ContextImpl.registerReceiver(ContextImpl.java:1750)
at android.app.ContextImpl.registerReceiver(ContextImpl.java:1738)
at com.android.server.display.DisplayPowerController.<init>(DisplayPowerController.java:674)
at com.android.server.display.DisplayManagerService.addDisplayPowerControllerLocked(DisplayManagerService.java:2710)
at com.android.server.display.DisplayManagerService.handleLogicalDisplayAddedLocked(DisplayManagerService.java:1561)
at com.android.server.display.DisplayManagerService.-$$Nest$mhandleLogicalDisplayAddedLocked(unavailable:0)
at com.android.server.display.DisplayManagerService$LogicalDisplayListener.onLogicalDisplayEventLocked(DisplayManagerService.java:2811)
at com.android.server.display.LogicalDisplayMapper.sendUpdatesForDisplaysLocked(LogicalDisplayMapper.java:759)
at com.android.server.display.LogicalDisplayMapper.updateLogicalDisplaysLocked(LogicalDisplayMapper.java:733)
at com.android.server.display.LogicalDisplayMapper.handleDisplayDeviceAddedLocked(LogicalDisplayMapper.java:580)
at com.android.server.display.LogicalDisplayMapper.onDisplayDeviceEventLocked(LogicalDisplayMapper.java:201)
at com.android.server.display.DisplayDeviceRepository.sendEventLocked(DisplayDeviceRepository.java:214)
at com.android.server.display.DisplayDeviceRepository.handleDisplayDeviceAdded(DisplayDeviceRepository.java:158)
- locked <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot)
at com.android.server.display.DisplayDeviceRepository.onDisplayDeviceEvent(DisplayDeviceRepository.java:87)
at com.android.server.display.DisplayManagerService.createVirtualDisplayLocked(DisplayManagerService.java:1418)
at com.android.server.display.DisplayManagerService.createVirtualDisplayInternal(DisplayManagerService.java:1381)
- locked <0x0faa6d02> (a com.android.server.display.DisplayManagerService$SyncRoot)
at com.android.server.display.DisplayManagerService.-$$Nest$mcreateVirtualDisplayInternal(unavailable:0)
at com.android.server.display.DisplayManagerService$BinderService.createVirtualDisplay(DisplayManagerService.java:3180)
at android.hardware.display.IDisplayManager$Stub.onTransact(IDisplayManager.java:659)
at android.os.Binder.execTransactInternal(Binder.java:1280)
at android.os.Binder.execTransact(Binder.java:1244)

130在等待16释放锁,这下子就明朗了,画个它们之间的关系图,这样就很明朗了他们三个之间死锁引起main主线程等待超时。


graph TD
binder2257_4 --> binder2257_1c --> android.display --> binder2257_4 & main主线程

前面这里只是分析出问题原因。下面来说问题为什么会发生。从main主线程开始追到binder2257_4然后开始循环。合理怀疑问题是先从binder2257_4出现的。都是binder是因为他们之间是用binder通信的。
看到binder2257_4这个anr日志中出现


at com.android.server.display.DisplayPowerController.<init>(DisplayPowerController.java:674)

竟然是在displaypowercontroller init中出现的,去到这个文件的674行,发现这里在注册广播接收器。


image.png


尝试退回这条提交之后果然问题没有复现了,分析这条提交,它是想在displaypowercontroller中注册一个广播接收器。用于接收挂电话的时候发出的广播。当收到这条广播之后就会通知displaypowercontroller亮屏。其实就是实现了一个通话时对方挂断电话之后自动亮屏。它是在创建阶段进行的注册。要分析为什么这个会导致死锁。


作者:用户8081391597591
来源:juejin.cn/post/7353158088730165259
收起阅读 »

Android使用Hilt依赖注入,让人看不懂你代码

前言 之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt...
继续阅读 »

前言


之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt不了解或是想了解得更多,那么接下来的内容将助力你玩转Hilt。


通过本篇文章,你将了解到:




  1. 什么是依赖注入?

  2. Hilt 的引入与基本使用

  3. Hilt 的进阶使用

  4. Hilt 原理简单分析

  5. Android到底该不该使用DI框架?



1. 什么是依赖注入?


什么是依赖?


以手机为例,要组装一台手机,我们需要哪些部件呢?

从宏观上分类:软件+硬件。

由此我们可以说:手机依赖了软件和硬件。

而反映到代码的世界:


class FishPhone(){
val software = Software()
val hardware = Hardware()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
//软件
class Software() {
fun handle(){}
}
//硬件
class Hardware() {
fun handle(){}
}

FishPhone 依赖了两个对象:分别是Software和Hardware。

Software和Hardware是FishPhone的依赖(项)。


什么是注入?


上面的Demo,FishPhone内部自主构造了依赖项的实例,考虑到依赖的变化挺大的,每次依赖项的改变都要改动到FishPhone,容易出错,也不是那么灵活,因此考虑从外部将依赖传进来,这种方式称之为:依赖注入(Dependency Injection 简称DI)

有几种方式:




  1. 构造函数传入

  2. SetXX函数传入

  3. 从其它对象间接获取



构造函数依赖注入:


class FishPhone(val software: Software, val hardware: Hardware){
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone的功能比较纯粹就是打电话功能,而依赖项都是外部传入提升了灵活性。


为什么需要依赖注入框架?


手机制造出来后交给客户使用。


class Customer() {
fun usePhone() {
val software = Software()
val hardware = Hardware()
FishPhone(software, hardware).call()
}
}

用户想使用手机打电话,还得自己创建软件和硬件,这个手机还能卖出去吗?

而不想创建软件和硬件那得让FishPhone自己负责去创建,那不是又回到上面的场景了吗?


你可能会说:FishPhone内部就依赖了两个对象而已,自己负责创建又怎么了?


解耦


再看看如下Demo:


interface ISoftware {
fun handle()
}

//硬件
interface IHardware {
fun handle()
}

//软件
class SoftwareImpl() : ISoftware {
override fun handle() {}
}

//硬件
class HardwareImpl : IHardware {
override fun handle() {}
}

class FishPhone() {
val software: ISoftware = SoftwareImpl()
val hardware: IHardware = HardwareImpl()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

FishPhone 只关注软件和硬件的接口,至于具体怎么实现它不关心,这就达到了解耦的目的。
既然要解耦,那么SoftwareImpl()、HardwareImpl()就不能出现在FishPhone里。

应该改为如下形式:


class FishPhone(val software: ISoftware, val hardware: IHardware) {
fun call() {
//打电话
software.handle()
hardware.handle()
}
}

消除模板代码


即使我们不考虑解耦,假若HardwareImpl里又依赖了cpu、gpu、disk等模块:


//硬件
class HardwareImpl : IHardware {
val cpu = CPU(Regisgter(), Cal(), Bus())
val gpu = GPU(Image(), Video())
val disk = Disk(Block(), Flash())
//...其它模块
override fun handle() {}
}

现在仅仅只是三个模块,若是依赖更多的模块或者模块的本身也需要依赖其它子模块,比如CPU需要依赖寄存器、运算单元等等,那么我们就需要写更多的模板代码,要是我们只需要声明一下想要使用的对象而不用管它的创建就好了。


class HardwareImpl(val cpu: CPU, val gpu: GPU, val disk: Disk) : IHardware {
override fun handle() {}
}

可以看出,下面的代码比上面的简洁多了。




  1. 从解耦和消除模板代码的角度看,我们迫切需要一个能够自动创建依赖对象并且将依赖注入到目标代码的框架,这就是依赖注入框架

  2. 依赖注入框架能够管理依赖对象的创建,依赖对象的注入,依赖对象的生命周期

  3. 使用者仅仅只需要表明自己需要什么类型的对象,剩下的无需关心,都由框架自动完成



先想想若是我们想要实现这样的框架需要怎么做呢?

相信很多小伙伴最朴素的想法就是:使用工厂模式,你传参告诉我想要什么对象我给你构造出来。

这个想法是半自动注入,因为我们还要调用工厂方法去获取,而全自动的注入通常来说是使用注解标注实现的。


2. Hilt 的引入与基本使用


Hilt的引入


从Dagger到Dagger2再到Hilt(Android专用),配置越来越简单也比较容易上手。

前面说了依赖注入框架的必要性,我们就想迫不及待的上手,但难度可想而知,还好大神们早就造好了轮子。

以AGP 7.0 以上为例,来看看Hilt框架是如何引入的。


一:project级别的build.gradle 引入如下代码:


plugins {
//指定插件地址和版本
id 'com.google.dagger.hilt.android' version '2.48.1' apply false
}

二:module级别的build.gradle引入如下代码:


plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
//使用插件
id 'com.google.dagger.hilt.android'
//kapt生成代码
id 'kotlin-kapt'
}
//引入库
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'

实时更新最新版本以及AGP7.0以下的引用请参考:Hilt最新版本配置


Hilt的简单使用


前置步骤整好了接下来看看如何使用。


一:表明该App可以使用Hilt来进行依赖注入,添加如下代码:


@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}

@HiltAndroidApp 添加到App的入口,即表示依赖注入的环境已经搭建好。


二:注入一个对象到MyApp里:

有个类定义如下:


class Software {
val name = "fish"
}

我们不想显示的构造它,想借助Hilt注入它,那得先告诉Hilt这个类你帮我注入一下,改为如下代码:


class Software @Inject constructor() {
val name = "fish"
}

在构造函数前添加了@Inject注解,表示该类可以被注入。

而在MyApp里使用Software对象:


@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: Software

override fun onCreate() {
super.onCreate()
println("inject result:${software.name}")
}
}

对引用的对象使用@Inject注解,表示期望Hilt帮我将这个对象new出来。

最后查看打印输出正确,说明Software对象被创建了。


这是最简单的Hilt应用,可以看出:




  1. 我们并没有显式地创建Software对象,而Hilt在适当的时候就帮我们创建好了

  2. @HiltAndroidApp 只用于修饰Application



如何注入接口?


一:错误示范
上面提到过,使用DI的好处之一就是解耦,而我们上面注入的是类,现在我们将Software抽象为接口,很容易就会想到如下写法:


interface ISoftware {
fun printName()
}

class SoftwareImpl @Inject constructor(): ISoftware{
override fun printName() {
println("name is fish")
}
}

@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: ISoftware

override fun onCreate() {
super.onCreate()
println("inject result:${software.printName()}")
}
}

不幸的是上述代码编译失败,Hilt提示说不能对接口使用注解,因为我们并没有告诉Hilt是谁实现了ISoftware,而接口本身不能直接实例化,因此我们需要为它指定具体的实现类。


二:正确示范

再定义一个类如下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftware(impl: SoftwareImpl):ISoftware
}



  1. @Module 表示该类是一个Hilt的Module,固定写法

  2. @InstallIn 表示模块在哪个组件生命周期内生效,SingletonComponent::class指的是全局

  3. 一个抽象类,类名随意

  4. 抽象方法,方法名随意,返回值是需要被注入的对象类型(接口),而参数是该接口的实现类,使用@Binds注解标记,



如此一来我们就告诉了Hilt,SoftwareImpl是ISoftware的实现类,于是Hilt注入ISoftware对象的时候就知道使用SoftwareImpl进行实例化。
其它不变运行一下:
image.png


可以看出,实际注入的是SoftwareImpl。



@Binds 适用在我们能够修改类的构造函数的场景



如何注入第三方类


上面的SoftwareImpl是我们可以修改的,因为使用了@Inject修饰其构造函数,所以可以在其它地方注入它。

在一些时候我们不想使用@Inject修饰或者说这个类我们不能修改,那该如何注入它们呢?


一:定义Provides模块


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():Hardware {
return Hardware()
}
}



  1. @Module和@InstallIn 注解是必须的

  2. 定义object类

  3. 定义函数,方法名随意,返回类型为我们需要注入的类型

  4. 函数体里通过构造或是其它方式创建具体实例

  5. 使用@Provides注解函数



二:依赖使用

而Hardware定义如下:


class Hardware {
fun printName() {
println("I'm fish")
}
}

在MyApp里引用Hardware:

在这里插入图片描述


虽然Hardware构造函数没有使用@Inject注解,但是我们依然能够使用依赖注入。


当然我们也可以注入接口:


interface IHardware {
fun printName()
}

class HardwareImpl : IHardware {
override fun printName() {
println("name is fish")
}
}

想要注入IHardware接口,需要定义provides模块:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}


@Provides适用于无法修改类的构造函数的场景,多用于注入第三方的对象



3. Hilt 的进阶使用


限定符


上述 ISoftware的实现类只有一个,假设现在有两个实现类呢?

比如说这些软件可以是美国提供,也可以是中国提供的,依据上面的经验我们很容易写出如下代码:


class SoftwareChina @Inject constructor() : ISoftware {
override fun printName() {
println("from china")
}
}

class SoftwareUS @Inject constructor() : ISoftware {
override fun printName() {
println("from US")
}
}

@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

//依赖注入:
@Inject
lateinit var software: ISoftware

兴高采烈的进行编译,然而却报错:
image.png


也就是说Hilt想要注入ISoftware,但不知道选择哪个实现类,SoftwareChina还是SoftwareUS?没人告诉它,所以它迷茫了,索性都绑定了。


这个时候我们需要借助注解:@Qualifier 限定符注解来对实现类进行限制。

改造一下:


@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
@China
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware

@Binds
@US
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class US

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class China

定义新的注解类,使用@Qualifier修饰。

而后在Module里,分别使用注解类修饰返回的函数,如bindSoftwareCh函数指定返回SoftwareChina来实现ISoftware接口。


最后在引用依赖注入的地方分别使用@China @US修饰。


    @Inject
@US
lateinit var software1: ISoftware

@Inject
@China
lateinit var software2: ISoftware

此时,虽然software1、software2都是ISoftware类型,但是由于我们指定了限定符@US、@China,因此最后真正的实现类分别是SoftwareChina、SoftwareUS。



@Qualifier 主要用在接口有多个实现类(抽象类有多个子类)的注入场景



预定义限定符


上面提及的限定符我们还可以扩展其使用方式。

你可能发现了,上述提及的可注入的类构造函数都是无参的,很多时候我们的构造函数是需要有参数的,比如:


class Software @Inject constructor(val context: Context) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
//注入
@Inject
lateinit var software: Software

这个时候编译会报错:

image.png
意思是Software依赖的Context没有进行注入,因此我们需要给它注入一个Context。


由上面的分析可知,Context类不是我们可以修改的,只能通过@Provides方式提供其注入实例,并且Context有很多子类,我们需要使用@Qualifier指定具体实现类,因此很容易我们就想到如下对策。

先定义Module:


@Module
@InstallIn(SingletonComponent::class)
object MyContextModule {
@Provides
@GlobalContext
fun provideContext(): Context? {
return MyApp.myapp
}
}

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GlobalContext

再注入Context:


class Software @Inject constructor(@GlobalContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

可以看出,借助@Provides和@Qualifier,可以实现全局的Context。

当然了,实际上我们无需如此麻烦,因为这部分工作Hilt已经预先帮我们弄了。

与我们提供的限定符注解GlobalContext类似,Hilt预先提供了:


@Qualifier
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ApplicationContext {}

因此我们只需要在需要的地方引用它即可:


class Software @Inject constructor(@ApplicationContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}

如此一来我们无需重新定义Module。




  1. 除了提供Application级别的上下文:@ApplicationContext,Hilt还提供了Activity级别的上下文:@ActivityContext,因为是Hilt内置的限定符,因此称为预定义限定符。

  2. 如果想自己提供限定符,可以参照GlobalContext的做法。



组件作用域和生命周期


Hilt支持的注入点(类)


以上的demo都是在MyApp里进行依赖,MyApp里使用了注解:@HiltAndroidApp 修饰,表示当前App支持Hilt依赖,Application就是它支持的一个注入点,现在想要在Activity里使用Hilt呢?


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {

除了Application和Activity,Hilt内置支持的注入点如下:
image.png


除了Application和ViewModel,其它注入点都是通过使用@AndroidEntryPoint修饰。



注入点其实就是依赖注入开始的点,比如Activity里需要注入A依赖,A里又需要注入B依赖,B里又需要注入C依赖,从Activity开始我们就能构建所有的依赖



Hilt组件的生命周期


什么是组件?在Dagger时代我们需要自己写组件,而在Hilt里组件都是自动生成的,无需我们干预。
依赖注入的本质实际上就是在某个地方悄咪咪地创建对象,这个地方的就是组件,Hilt专为Android打造,因此势必适配了Android的特性,比如生命周期这个Android里的重中之重。

因此Hilt的组件有两个主要功能:




  1. 创建、注入依赖的对象

  2. 管理对象的生命周期



Hilt组件如下:
image.png


可以看出,这些组件的创建和销毁深度绑定了Android常见的生命周期。

你可能会说:上面貌似没用到组件相关的东西,看了这么久也没看懂啊。

继续看个例子:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@InstallIn(SingletonComponent::class) 表示把模块安装到SingletonComponent组件里, SingletonComponent组件顾名思义是全局的,对应的是Application级别。因此安装的这个模块可在整个App里使用。


问题来了:SingletonComponent是不是表示@Provides修饰的函数返回的实例是同一个?

答案是否定的。


这就涉及到组件的作用域。


组件的作用域


想要上一小结的代码提供全局唯一实例,则可用组件作用域注解修饰函数:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@Singleton
fun provideHardware():IHardware {
return HardwareImpl()
}
}

当我们在任何地方注入IHardware时,获取到的都是同一个实例。

除了@Singleton表示组件的作用域,还有其它对应组件的作用域:

image.png


简单解释作用域:

@Singleton 被它修饰的构造函数或是函数,返回的始终是同一个实例

@ActivityRetainedScoped 被它修饰的构造函数或是函数,在Activity的重建前后返回同一实例

@ActivityScoped 被它修饰的构造函数或是函数,在同一个Activity对象里,返回的都是同一实例

@ViewModelScoped 被它修饰的构造函数或是函数,与ViewModel规则一致




  1. Hilt默认不绑定任何作用域,由此带来的结果是每一次注入都是全新的对象

  2. 组件的作用域要么不指定,要指定那必须和组件的生命周期一致



以下几种写法都不符合第二种限制:


@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityComponent::class)
object HardwareModule {
@Provides
@Singleton//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

@Module
@InstallIn(ActivityRetainedComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}

除了修饰Module,作用域还可以用于修饰构造函数:


@ActivityScoped
class Hardware @Inject constructor(){
fun printName() {
println("I'm fish")
}
}

@ActivityScoped表示不管注入几个Hardware,在同一个Activity里注入的实例都是一致的。


构造函数里无法注入的字段


一个类的构造函数如果被@Inject注入,那么构造函数的其它参数都需要支持注入。


class Hardware @Inject constructor(val context: Context) {
fun printName() {
println("I'm fish")
}
}

以上代码是无法编译通过的,因为Context不支持注入,而通过上面的分析可知,我们可以使用限定符:


class Hardware @Inject constructor(@ApplicationContext val context: Context) {
fun printName() {
println("I'm fish")
}
}

这就可以成功注入了。


再看看此种场景:


class Hardware @Inject constructor(
@ApplicationContext val context: Context,
val version: String,
) {
fun printName() {
println("I'm fish")
}
}

很显然String不支持注入,当然我们可以向@ApplicationContext 一样也给String提供一个@Provides和@Qualifier注解,但可想而知很麻烦,关键是String是动态变化的,我们确实需要Hardware构造的时候传入合适的String。


由此引入新的写法:辅助注入


class Hardware @AssistedInject constructor(
@ApplicationContext val context: Context,
@Assisted
val version: String,
) {

//辅助工厂类
@AssistedFactory
interface Factory{
//不支持注入的参数都可以放这,返回值为待注入的类型
fun create(version: String):Hardware
}

fun printName() {
println("I'm fish")
}
}

在引用注入的地方不能直接使用Hardware,而是需要通过辅助工厂进行创建:


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {
private lateinit var binding: ActivitySecondBinding
@Inject
lateinit var hardwareFactory : Hardware.Factory

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySecondBinding.inflate(layoutInflater)
setContentView(binding.root)

val hardware = hardwareFactory.create("3.3.2")
println("${hardware.printName()}")
}
}

如此一来,通过辅助注入,我们还是可以使用Hilt,值得一提的是辅助注入不是Hilt独有,而是从Dagger继承来的功能。


自定义注入点


Hilt仅仅内置了常用的注入点:Application、Activity、Fragment、ViewModel等。

思考一种场景:小明同学写的模块都是需要注入:


class Hardware @Inject constructor(
val gpu: GPU,
val cpu: CPU,
) {
fun printName() {
println("I'm fish")
}
}

class GPU @Inject constructor(val videoStorage: VideoStorage){}

//显存
class VideoStorage @Inject constructor() {}

class CPU @Inject constructor(val register: Register) {}

//寄存器
class Register @Inject() constructor() {}

此时小刚需要引用Hardware,他有两种选择:




  1. 使用注入方式很容易就引用了Hardware,可惜的是他没有注入点,仅仅只是工具类。

  2. 不选注入方式,则需要构造Hardware实例,而Hardware依赖GPU和CPU,它们又分别依赖VideoStorage和Register,想要成功构造Hardware实例需要将其它的依赖实例都手动构造出来,可想而知很麻烦。



这个时候适合小刚的方案是:



自定义注入点



方案实施步骤:

一:定义入口点


@InstallIn(SingletonComponent::class)
interface HardwarePoint {
//该注入点负责返回Hardware实例
fun getHardware(): Hardware
}

二:通过入口点获取实例


class XiaoGangPhone {
fun getHardware(context: Context):Hardware {
val entryPoint = EntryPointAccessors.fromApplication(context, HardwarePoint::class.java)
return entryPoint.getHardware()
}
}

三:使用Hardware


        val hardware = XiaoGangPhone().getHardware(this)
println("${hardware.printName()}")

注入object类


定义了object类,但在注入的时候也需要,可以做如下处理:


object MySystem {
fun getSelf():MySystem {
return this
}
fun printName() {
println("I'm fish")
}
}

@Module
@InstallIn(SingletonComponent::class)
object MiddleModule {
@Provides
@Singleton
fun provideSystem():MySystem {
return MySystem.getSelf()
}
}
//使用注入
class Middleware @Inject constructor(
val mySystem:MySystem
) {
}

4. Hilt 原理简单分析


@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {}

Hilt通过apt在编译时期生成代码:


public abstract class Hilt_SecondActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {

private boolean injected = false;

Hilt_SecondActivity() {
super();
//初始化注入监听
_initHiltInternal();
}

Hilt_SecondActivity(int contentLayoutId) {
super(contentLayoutId);
_initHiltInternal();
}

private void _initHiltInternal() {
addOnContextAvailableListener(new OnContextAvailableListener() {
@Override
public void onContextAvailable(Context context) {
//真正注入
inject();
}
});
}

protected void inject() {
if (!injected) {
injected = true;
//通过manager获取组件,再通过组件注入
((SecondActivity_GeneratedInjector) this.generatedComponent()).injectSecondActivity(UnsafeCasts.<SecondActivity>unsafeCast(this));
}
}
}

在编译期,SecondActivity的父类由AppCompatActivity变为Hilt_SecondActivity,因此当SecondActivity构造时就会调用父类的构造器监听create()的回调,回调调用时进行注入。



由此可见,Activity.onCreate()执行后,Hilt依赖注入的字段才会有值



真正注入的过程涉及到不少的类,都是自动生成的类,有兴趣可以对着源码查找流程,此处就不展开说了。


5. Android到底该不该使用DI框架?


有人说DI比较复杂,还不如我直接构造呢?

又有人说那是你项目不复杂,用不到,在后端流行的Spring全家桶,依赖注入大行其道,Android复杂的项目也需要DI来解耦。


从个人的实践经验看,Android MVVM/MVI 模式还是比较适合引入Hilt的。
image.png


摘抄官网的:现代Android 应用架构

通常来说我们这么设计UI层到数据层的架构:


class MyViewModel @Inject constructor(
val repository: LoginRepository
) :ViewModel() {}

class LoginRepository @Inject constructor(
val rds : RemoteDataSource,
val lds : LocalDataSource
) {}

//远程来源
class RemoteDataSource @Inject constructor(
val myRetrofit: MyRetrofit
) {}

class MyRetrofit @Inject constructor(
) {}

//本地来源
class LocalDataSource @Inject constructor(
val myDataStore: MyDataStore
) {}

class MyDataStore @Inject constructor() {}

可以看出,层次比较深,使用了Hilt简洁了许多。


本文基于 Hilt 2.48.1

参考文档:

dagger.dev/hilt/gradle…

developer.android.com/topic/archi…

repo.maven.apache.org/maven2/com/…


作者:小鱼人爱编程
来源:juejin.cn/post/7294965012749320218
收起阅读 »

Android应用保活全攻略:30个实用技巧助你突破后台限制

在Android系统中,保活(保持应用进程一直存活)通常是为了让应用在后台持续运行,以实现某些特定的功能,如实时消息推送、定位服务等。然而,由于Android系统为了节省资源和保护用户隐私,通常会限制后台应用的运行。因此,开发者需要采取一些策略来实现保活。以下...
继续阅读 »

在Android系统中,保活(保持应用进程一直存活)通常是为了让应用在后台持续运行,以实现某些特定的功能,如实时消息推送、定位服务等。然而,由于Android系统为了节省资源和保护用户隐私,通常会限制后台应用的运行。因此,开发者需要采取一些策略来实现保活。以下是30个常见的Android保活手段,帮助你突破后台限制:


1. 前台服务(Foreground Service)


将应用的Service设置为前台服务,这样系统会认为这个服务是用户关心的,不容易被杀死。前台服务需要显示一个通知,告知用户当前服务正在运行。通过调用startForeground(int id, Notification notification)方法将服务设置为前台服务。


2. 双进程守护


创建两个Service,分别运行在不同的进程中。当一个进程被杀死时,另一个进程可以通过监听onServiceDisconnected(ComponentName name)方法来感知,并重新启动被杀死的进程。这样可以相互守护,提高应用的存活率。


3. 使用系统广播拉活


使用系统广播拉活。监听系统广播,如开机广播、网络变化广播、应用安装卸载广播等。当收到广播时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。


4. JobScheduler


使用JobScheduler定时启动应用。JobScheduler是Android 5.0引入的一种任务调度机制,可以在满足特定条件下执行任务。通过创建一个Job,设置触发条件,然后将Job提交给JobScheduler。当触发条件满足时,JobScheduler会启动应用。


5. 白名单


引导用户将应用加入系统的白名单,如省电白名单、自启动白名单等。加入白名单的应用不会受到系统的限制,可以在后台持续运行。


6. 第三方推送服务


使用第三方推送服务,如极光推送、小米推送等。这些推送服务通常使用保活技巧,可以保证消息的实时推送。


7. 静态广播监听


在AndroidManifest.xml中注册静态广播,监听系统广播,如电池状态改变、屏幕解锁等。当收到广播时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。需要注意的是,从Android 8.0开始,静态广播的使用受到了限制,部分隐式广播无法通过静态注册来接收。


8. 合理利用Activity


在必要时,将应用的Activity设置为singleTask或singleInstance模式,确保应用在后台时只有一个实例。这可以减少系统对应用的限制,提高应用在后台的存活率。


9. 使用AlarmManager定时唤醒


使用AlarmManager定时唤醒应用。通过设置一个定时任务,当到达指定时间时,使用PendingIntent启动应用。需要注意的是,从Android 6.0开始,AlarmManager的行为受到了限制,当设备处于低电量模式时,定时任务可能会被延迟。


10. 合理设置进程优先级


Android系统会根据进程的优先级来决定是否回收进程。通过合理设置进程优先级,可以降低系统回收进程的概率。例如,可以将Service设置为前台服务,或者将进程与用户正在交互的Activity绑定。


11. 使用sticky广播


使用sticky广播在一定程度上可以提高广播接收器的优先级。当发送一个sticky广播时,系统会将该广播存储在内存中,这样即使应用被杀死,也可以在重新启动时收到广播。但需要注意的是,从Android 5.0开始,sticky广播的使用受到了限制,部分广播无法使用sticky模式发送。


12. 使用WorkManager


WorkManager是Android Architecture Components的一部分,它为后台任务提供了一种统一的解决方案。WorkManager可以自动选择最佳的执行方式,即使应用退出或设备重启,它仍然可以确保任务完成。WorkManager在保活方面的效果可能不如其他方法,但它是一种更符合Android系统规范的解决方案,可以避免系统限制和用户体验问题。


13. 合理使用WakeLock


在某些特定场景下,可以使用WakeLock(电源锁)来防止CPU进入休眠状态,从而确保应用能够在后台持续运行。但请注意,WakeLock可能会导致设备电量消耗增加,因此应谨慎使用,并在不需要时尽快释放锁。


14. 合理使用SyncAdapter


SyncAdapter是Android提供的一种同步框架,用于处理数据同步操作。SyncAdapter可以根据设备的网络状态、电池状态等条件来自动调度同步任务。虽然SyncAdapter并非专门用于保活,但它可以在一定程度上提高应用在后台的存活率。


15. 使用AccountManager


通过在应用中添加一个账户,并将其与SyncAdapter关联,可以在一定程度上提高应用的存活率。当系统触发同步操作时,会启动与账户关联的应用进程。但请注意,这种方法可能会对用户造成困扰,因此应谨慎使用。


16. 适配Doze模式和App Standby


从Android 6.0(API级别23)开始,系统引入了Doze模式和App Standby,以优化设备的电池使用。在这些模式下,系统会限制后台应用的网络访问和CPU使用。为了保证应用在这些模式下正常运行,您需要适配这些特性,如使用高优先级的Firebase Cloud Messaging(FCM)消息来唤醒应用。


17. 使用Firebase Cloud Messaging(FCM)


对于需要实时消息推送的应用,可以使用Firebase Cloud Messaging(FCM)服务。FCM是一种跨平台的消息推送服务,可以实现高效且可靠的消息传递。通过使用FCM,您可以确保应用在后台时接收到实时消息,而无需采取过多的保活手段。


18. 遵循Android系统的最佳实践


在开发过程中,遵循Android系统的最佳实践和推荐方案,可以提高应用的兼容性和稳定性。例如,合理使用后台任务、避免长时间运行的服务、优化内存使用等。这样可以降低系统对应用的限制,从而提高应用在后台的存活率。


19. 及时适配新系统版本


随着Android系统版本的更新,系统对后台应用的限制可能会发生变化。为了确保应用在新系统版本上能够正常运行,您需要及时适配新系统版本,并根据需要调整保活策略。


20. 与用户建立信任


在实际开发中,应尽量遵循系统的规范和限制,避免过度使用保活手段。与用户建立信任,告知用户应用在后台运行的原因和目的。在用户授权的情况下,采取适当的保活策略,以实现所需功能。


21. 使用Binder机制


Binder是Android中的一种跨进程通信(IPC)机制。通过在Service中创建一个Binder对象,并在其他进程中获取这个Binder对象,可以使得两个进程建立连接,从而提高Service的存活率。


22. 使用native进程


通过JNI技术,创建一个native进程来守护应用进程。当应用进程被杀死时,native进程可以感知到这个事件,并重新启动应用进程。这种方法需要C/C++知识,并且可能会增加应用的复杂性和维护成本。


23. 使用反射调用隐藏API


Android系统中有一些隐藏的API和系统服务,可以用于提高应用的存活率。例如,通过反射调用ActivityManager的addPersistentProcess方法,可以将应用设置为系统进程,从而提高应用的优先级。然而,这种方法存在很大的风险,可能会导致应用在某些设备或系统版本上无法正常运行。


24 监听系统UI


监听系统UI的变化,如状态栏、导航栏等。当系统UI变化时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。


25. 使用多进程


在AndroidManifest.xml中为Service或Activity设置android:process属性,使其运行在单独的进程中。这样,即使主进程被杀死,其他进程仍然可以存活。


26. 使用Provider


在AndroidManifest.xml中注册一个Provider,并在其他应用中通过ContentResolver访问这个Provider。这样,即使应用在后台,只要有其他应用访问Provider,应用就可以保持存活。


27. 关注Android开发者文档和官方博客


Android开发者文档和官方博客是获取保活策略和系统更新信息的重要途径。关注这些资源,以便了解最新的系统特性、开发者指南和最佳实践。


28. 性能优化


优化应用的性能,降低内存、CPU和电池的消耗。这样,系统在资源紧张时可能会优先回收其他消耗较高的应用,从而提高您的应用在后台的存活率。


29. 用户反馈


关注用户的反馈,了解他们在使用应用过程中遇到的问题。根据用户的反馈,调整保活策略,以实现最佳的用户体验。


30. 使用NotificationListenerService


通过实现一个NotificationListenerService并在AndroidManifest.xml中注册,可以监听系统通知栏的变化。当收到新的通知时,检查应用进程是否存活,如果已经被杀死,则重新启动应用。这种方法可以利用系统通知的变化来触发应用的启动,从而提高应用在后台的存活率。需要注意的是,为了使用NotificationListenerService,用户需要在设置中授权应用访问通知权限。


请注意,保活策略可能会导致系统资源消耗增加、用户体验下降,甚至引发系统限制或用户卸载应用。因此,在实际开发中,应根据功能需求和用户体验来权衡保活策略,尽量遵循系统的规范和限制。在可能的情况下,优先考虑使用系统推荐的解决方案,如前台服务、JobScheduler等。


作者:程序员陆业聪
来源:juejin.cn/post/7352079364611276819
收起阅读 »

APP端上通用安全体系建设

背景:APP端上安全在谈什么 APP的每个业务场景都有其既定的运行模式,若被人为破坏就可认为是不安全的。举个栗子,比如秒杀场景:大量用户在特定时间点,通过点击抢购来秒杀优惠商品,从而营造一种紧迫而有噱头的营销场景,但如果能通过非法手段自动抢购、甚至提前开始刷接...
继续阅读 »

背景:APP端上安全在谈什么


APP的每个业务场景都有其既定的运行模式,若被人为破坏就可认为是不安全的。举个栗子,比如秒杀场景:大量用户在特定时间点,通过点击抢购来秒杀优惠商品,从而营造一种紧迫而有噱头的营销场景,但如果能通过非法手段自动抢购、甚至提前开始刷接口抢购,那就彻底破坏了业务的玩法,这就是一种不安全的运行模式。再比如常用的用户拉新场景:新客获取成本高达200左右,所有产品的拉新投入都蛮高,如何获得真正的新用户而不是羊毛党也是拉新必须处理的事,一般而言,新设备+新账户是新用户的基本条件,但新账户的成本其实不高,大部分是要靠新设备来识别的,但如果能通过非法手段不断模拟新设备,那拉新投入获取的可能大部分都是无效的羊毛党,这也可看做是一种不安全的运行场景,甚至还有二次篡改,构建马甲APP等各种场景。而APP端上安全要做的就是甄别并防范这种异常场景的发生,简而言之它就是:一种确保官方APP既定业务模型中运行的能力。


APP端上安全体系应该具备哪些能力


一个安全体系要具备哪些能力呢,简单说可分两块:甄别与防御。即一:甄别运行环境是否安全的能力,二:针对不同的场景作出不同的防御的能力,场景千变万化,所以防御手段也没有一剑破万法的能力,基本都要根据具体的风险场景,产出不同的应对方案,但是整体涉及的流程基本一致,如下:


image.png


要做的事情就是围绕四个阶段构建不同的能力。先看第一阶段:风险场景的假设,预判有哪些风险场景,从AppStore或者官方应用市场下载,安装、正常使用,这是理想中的运行模式,但不安分的用户千千万,异常场景也多变,不存在一剑破万法的说法,这里人为做了一些场景归类:


image.png


从实践来看,安全模型必须覆盖的场景包含上述四类,即



一: 运行的APP非官方应用



这种情况一般是非法用户为了谋求特定的权益,对原装APP进行魔改二次打包后发布,这对于一些广告类、离线付费类APP是一种毁灭性的打击,最常见的就是一些APP会在闪屏页面投放广告,从而获取收益,但一旦这个业务逻辑被篡改绕过,那么广告收益直接归零;还有些情况APP被碰瓷,魔改一些功能,跳转到非官方设定的网站,比如在比较火的APP添加劫持逻辑,跳转到一些黄赌网站,这给官方APP带来的负面影响是难易估量的,如果不能自证清白,甚至还会面临法律上的追责。



二:业务的运转模型被篡改



简单的讲就是没有按照产品的既定玩法进行,就像文章开头的秒杀场景【电商平台的茅台抢购】,如果能通过API直接组单,那APP里点击的那批用户无论如何也抢不过插件;再比如有些签到的场景是为了促活,如果通过自动签到工具自动打卡,那促活的目的也无法达到,这也是要重点防控的一个场景。



三:运行的设备非目标设备



这种场景主要影响一些拉新、优惠权益等,甚至直接影响整体运营策略,简单举个例子,拉新场景中会投放大量的新人免单、直减券,基本等于免费领取权益,比如我们都经历过的打车软件、外卖软件、新电商产品上线等。全国有无数的专业羊毛党专注于这一场景,他们掌握大量手机号,可以注册大量新账号,同时能通过虚拟机、应用多开、复刻设备等手段无限冒充新设备,如果不加反制,优惠策略一上线,各种券基本全被这批人掠夺,被这种手段折磨致死的产品数不胜数,也几乎是每个电商平台的噩梦。



四:一些核心逻辑的泄漏



这不是端上特有的风险,比如代码泄漏这种,各方都有这种风险,只不过端上风险更高,因为APP是要上架发出去的,虽然经历过各种混淆与保护,但是用户最终还是能拿到可执行的APP包的,里面各种的核心的逻辑、秘钥都有潜在泄漏的风险,一旦泄漏,也是一种毁灭性的打击,比如某些音视频软件特有滤镜、转场、特效,这些算法是一个产品的核心,一旦破解,产品优势不再,如果防止逻辑代码的泄漏与破解也是安全必须关注的点。


覆盖上述场景的意思是要提供甄别的能力,针对不同的场景,抽象特征值并搜集上报,建立特定的模型,推导C端操作环境是否安全,之后上线不同的应对策略:比如直接退出应用、标记为风险用户等。


建设方案


甄别与防御是体系的核心,建设方案主要是围绕这两个主题展开,虽说名称是“端上安全体系”,但只依靠端自己是无法解决所有问题的,也无法将价值发挥到最大,仍需多端系统配合来完成整个体系的搭建,分工的基本原则是:端上侧重特征信息的搜集,云端负责整体策略的执行,根据上述场景,搭建示意如下:


image.png


按层次跟功能大致分四块,端、网关、业务后台、数据风控中心:



  • 端是信息的来源,负责信息采集上报,是安全体系建设的基石,所以端上采集信息的真实性、为完整性至关重要,同时端上也可以执行部分风险低,但收益高的拦截略,比如对于一些已经侦测到的马甲包,在上报用户信息之后,可以选择Crash,对于一些机器点击类的签到、秒杀场景,可以主动拦截请求,降低带宽压力。

  • 网关是第二层,一般处理一些具体规则类的拦截与信息采集,比如有些简单的规则检验,Header里是否携带必备的校验字段,如多开标识、模拟器标志等,如果携带则可以在这一层直接拦截,并沉淀到数据中心,既保证了信息的采集,又能减轻业务后台的压力,尤其对于一些秒杀类的场景非常有效。

  • 业务后台跟数据平台可以一起看做第三层,负责更复杂的模型建设跟业务落地,比如在什么样的节点,才有什么样的策略,比如在组单的时候,业务后台可根据风控侧的判断决定用户的优惠力度等


针对各个具体的场景会有具体的建设方案,



如何应对非官方APP:APP包识别



非法人员总是出于自己的权益来破解官方APP,定制一些逻辑再二次打包发布,比如对一些付费类APP,含广告类工具APP等,通过破解代码,并二次打包后就可以官方造成冲击,甚至是毁灭性的打击,对于这种场景如何甄别,又如何处理呢?拿Android为例,检测手段有签名校验、文件校验、包完整性校验等,一旦检测到风险就可以做出响应处理,在处理方式上也需要根据不同产品不同场景随机变动,比如工具类APP就Crash阻断,而对于一些有用户体系类的APP则可以先回传用户信息,用作用户画像,再做响应的处理,而处理的手段也可以根据风险等级的不同再做定制,甄别的技术手段可能是死的,但获取的收益一定要灵活。



如何应对非理想设备:设备识别与设备指纹



非理想设备最经典的就是文章开头的场景,拉新拉来一堆老羊毛党,说到底其实就是对于用户设备的定位追踪能力不足,对于这种场景的如何应对?这里单独说说设备指纹,设备指纹主要解决的如何定位一台设备的问题,在理想情况下,一台设备只有一个身份信息,它不因APP卸载、升级、HOOK伪装所改变,这在现在的互联产品生态中是非常困难的,困难主要来源于两个方面:一技术上的、一是法规上的,从技术上来讲,以Android端为例,它是一个开源的系统,每一行代码都不可信,任何通过官方API拿到的信息都可以被HOOK篡改,而指纹很大程度依赖API获得的设备特征信息,如果这些信息都不可信,那指纹的可信程度也会降低。另一方面,从法规上来讲,现在注重保护个人隐私,不可以随意获取用户信息,这一点有利于用户【包括羊毛党】,但是对于运营方却是不利的,信息越少越难定位到,因此,在隐私合规的前提下,仍需要多维度的获取更多的用户信息,从更多的维度定位到该用户。


具体如何执行?以Android为例,定位一台设备的信息有MAC、IMEI、IMSI、序列号、AndroidID、IP+UA、OAID、各种设备型号等,虽然信息很多,但单独任何一条的可信度都不高,比如之前的某盾、某盟都曾用MAC地址作为指纹,甚至有些产品直接用IMEI作为指纹,但网上利用XPOSED来篡改的插件比比皆是,通过官方API获取的分分钟被破解,但是可以对多种信息进行整合生成一个唯一可信的ID,这种方式获取的ID的稳定性要比单一的稳定性要高,原理示意如下:


image.png


简单来说:只有篡改了全部的设备特征信息,才会导致设备指纹更新,这会大幅提高设备逃逸的难度。设备指纹的另一个难题是如何识别虚拟设备,这里特指模拟器,每个新开的模拟器都可以看做是新设备,如果不能识别,同样无法解决设备跟踪的问题,尤其对于国内的Android生态来讲,问题更加严重,各种游戏厂商都有对应的手游模拟器,不仅支持多开,还原生支持篡改各种设备特征信息,可以算得上助纣为虐,在模拟器甄别与防控的层面能做的有如下几种:



  • 通过特征信息甄别【容易绕过】

  • 通过CPU架构甄别【ARM与SimpleX86】

  • 限定APP的运行平台


这里简单介绍下通过CPU架构甄别方式,就目前的硬件市场,几乎99.9%以上的手机设备都是基于ARM处理,而模拟器大部分是面向x86平台设计的,采用的是simplex86架构,两者采用的不一样缓存机制,ARM采用的哈弗架构将指令存储跟数据存储分开,分为I-Cache(指令缓存)与D-Cahce(数据缓存),CPU无法直接修改I-Cache【同步延迟导致不一致】,但Simpled X86架构的模拟器只有一块缓存,这一点导致两者在运行Self-Modifying Code【自修改代码】时会有不同的表现,可以借助这个特性进行甄别,示意如下


image.png


至此,设备的定位与跟踪能力基本已经具备,在用户在领券的节点,就可以从更多维度判断他当前的设备是否有资格享受这个权益,保障业务按既定模型运转。


image.png


当然还有更多的场景,比如应用多开、应用分身等,都要具体问题具体分析,但思路一致:特征搜集、甄别、防控,因为所有的不轨行为一定有迹可循,



如何应对非设定业务场景:场景识别与校验



每种业务都有其既定的运行模式,只有照章办事,运营才能获取最大的收益,这里特指一些可以通过自己的参与获得收益的场景,比如秒杀、签到、预约摇号等。一般而言,在这类场景下,破坏者可钻的空子有两个方向,一个是便利:通过插件自动预约,免得用户自己操作,适合摇号、签到类【签到领积分】;一个是速度,通过插件直接API请求,抢跑下单,获得收益,适合限时秒杀类的场景【各大平台强茅台】。以秒杀为例,通过营造紧迫而又刺激的氛围可以让活动更有意思,但如果能直接刷接口/或者通过插件抢跑,那就会破坏其公平性,影响用户的参与感,造成资产及口碑受损,这类场景如何应对?其实要做的事情分两块:一 识别请求是从APP发出来 , 二 识别是真实用户操作的,这两快一般会整体考虑,非APP端的请求往往伴随着非用户触发,多归结于脚本,所以识别“人”与识别“场景”殊途同归,具体有哪些手段可以用呢?



  • 扩展核心API接口的能力,承载更多逻辑

  • 通过埋点、用户操作轨迹分析识别用户

  • 启用端上特有能力校验,如短信验证码、行程码分析


如何拓展API接口的能力?比如预约接口其基础能力就是预约,如不特殊处理,PC上就完全可以复制APP端发出的请求,进而通过脚本预约,如要加以限制就必须拓展端上API能力,让其携带更多端上独有特征,同时服务端可以完成校验,形成一个闭环,比较容易理解的就是让APP端与服务端协商一套加解密通信协议,并假定协议无法破解,避免接口直刷,从而确保请求是从APP发出的,即使不是从APP发出的,也能被甄别出来,进而提高APP与服务端通信阶段的安全性。当然,无法破解只是理论上,实际上只要舍得投入成本,暴力破解并不是问题,这种就需要通过更多元的手段,不断更新迭代,持续做攻防,例如,为了保证加密算法的保密性,可以将其用c实现,并通混淆、加固、防探测等手段保证这个策略的正确执行;暴力堆积加密的类型、节点,提升秘钥的更新频率也是一种应对手段,而且,惩罚手段上也可以多元,同直接拦截相比,隐秘的搜捕,诱捕也是一种灵活收益的手段。


其次,基于埋点、用户操作行为的大数据分析是另一种更高级的防御手段,对于识别用户操作场景更加科学,正常的用户轨迹与插件类的访问轨迹会有很大的差异,直刷的目标明确,主攻几个关键接口,但正常用户访问会有一系列的曝光、点击等行为,并且每次的点击也会有各种零零散散的活体特征可以采集,比如点击的点位置、数量、力度、频率等,这些维度为用户识别提供了更广的操作空间。基于以上几点的模型示意如下:


image.png


最后一点,启用端上特有的校验能力,这个已经是最终的防御手段,在实在没有办法的情况才会采用的,因为这种手段很影响用户体验,由于采用的是端上特有的能力,比如短信验证码,必须真机才能收到,这就从根本上避免了插件类的直刷,所以可靠性确实所有手段中最高的,但体验差,成本高,所以算是最后一道防线。



如何应对核心逻辑的泄漏



这一块主要关注的是APP端的一些核心逻辑的破解或泄密,可以分两个方向,对外与对内,对外主要是APP包的逆向与破解,不法人员从发布上架的APP包中获取核心业务实现或其他敏感信息;而对内主要指工程安全,核心源码或秘钥的泄漏、误改等。


相应的防范策略也是分两块,对外的线上防破解可以从以下几点入手:



  • 利用代码混淆防APP逆向,一般而言官方会提供相应的能力,也可借助三方加固来提高混淆的力度

  • 核心源码、秘钥下沉,采用更难破解的方式实现,同时增加防外部调用的防范策略,比如Android采用C+混淆来处理

  • 为线上APP添加防调试与HOOK的能力,防止动态调试探测,

  • 添加防止代理与中间人劫持的能力,例如SSLPING等技术,避免被抓包探测

  • 从二次打包入手,添加签名、完整性检测的能力,防止被探测、篡改


而对内主要从工程安全角度推进,主要是做好代码的权责管理



  • 采用组件化开发模式,不同等级的基础能力、业务、核心逻辑做好隔离

  • 仓库单独部署,同时做好权责划分,代码、文档做好权限隔离

  • 加强秘钥、KEY的管控,开发与生产环境严格隔离


上述手段基本涵盖大部可预见的风险场景,即使未覆盖,也大概有类似的手段作为参考,无非就是抽象、搜集、判断、处理。


线上执行方案


最后一步是上线执行,上述的手段多种多样,但相互之间并非孤立运行,彼此可以相互穿插,灵活配合,不存在特定的章法,全看使用方的意图,如何探测,探测之后如何处理,是全杀还是放一部分,都看操刀者自己的运作,以应用多开场景为例,除了利用多开基础的多开检测手段,还可以配合设备指纹做更多的事情,有时虽然没有检测到多开,但是基于设备指纹的补刀,也能定位到问题设备,而在最后一步惩治处理中,不同处理手段也会获得不一样的收益:


类型处理方式最终收益优缺点
被动拦截端上部署检测规则,检测到风险,100%在端上拦截处理【如Crash】效果明显,但易被发现,徒增防御成本
被动捕获检测到风险,在端上不处理,只上报,后端隐形标记或拦截不易被发现,但长期运行收益比较局限
主动诱捕人为制造有迹可循的漏洞,捕获后在端上不拦截或部分拦截,并上报,后端隐形标记不易被发现,虚虚实实,操作空间更大,收益更大

理论上讲,APP技术层面不存在100%有效的安防策略,虚虚实实才是王道,敬畏,才是最有效的防御手段


总结与展望


目前国内APP的生态环境并不健康,甚至可以说野蛮,随着隐私策略收紧,APP所能获取的信息越来越少,安防也越来越难做,反之,刷子却越活越滋润,技术所面临的的挑战也更加棘手,安防注定是一个长期攻防的领域。最后,技术不能解决所有问题,最终还是要依赖法律的健全与全民意识提升。


作者:看书的小蜗牛
来源:juejin.cn/post/7350354672861052980
收起阅读 »

深入研究Kotlin运行时的泛型

深入研究Kotlin运行时的泛型 通过前面的学习,对Kotlin的泛型已经有了比较全面的了解了,泛型的目的是让通用的代码更加的类型安全。现在我们离写出类型安全的泛型代码还差最后一块拼图,那就是泛型的类型擦除,今天就来深入地学习一下运行时的泛型,彻底的弄懂类型...
继续阅读 »

深入研究Kotlin运行时的泛型


通过前面的学习,对Kotlin的泛型已经有了比较全面的了解了,泛型的目的是让通用的代码更加的类型安全。现在我们离写出类型安全的泛型代码还差最后一块拼图,那就是泛型的类型擦除,今天就来深入地学习一下运行时的泛型,彻底的弄懂类型擦除的前因后果,并学会如何在运行时做类型检查和类型转换,以期完成拼图掌握泛型,写出类型安全的通用代码。





关于泛型话题的一系列文章:



泛型类型擦除(Type erasure)


泛型的类型安全性(包括类型检查type check,和类型转换type casting)都是由编译器在编译时做的,为了保持在JVM上的兼容性,编译器在保障完类型安全性后会对泛型类型进行擦除(Type erasure)。在运行时泛型类型的实例并不包含其类型信息,也就是说它不知道具体的类型参数,比如Foo和Foo都被擦除成了Foo<*>,在虚拟机(JVM)来看,它们的类型是一样的。


因为泛型Foo的类型参数T会被擦除(erased),所以与类型参数相关的类型操作(类型检查is T和类型转换as T)都是不允许的。


可行的类型检查和转换


虽然类型参数会被擦除,但并不是说对泛型完全不能进行类型操作。


星号类型操作


因为所有泛型会被擦除成为星号无界通配Foo<*>,它相当于Foo,是所有Foo泛型的基类,类型参数Any?是根基类,所以可以进行类型检查和类型转换:


if (something is List<*>) {
 something.forEach { println(it) } // 元素被视为Any?类型
}

针对星号通配做类型操作,类型参数会被视为Any?。但其实这种类型操作没有任何意义,毕竟Any是根基类,任何类当成Any都是没有问题的。


完全已知具体的类型参数时


另外一种情况就是,整个方法的上下文中已经完全知道了具体的类型参数时,不涉及泛型类型时,也是可以进行类型操作的,说的比较绕,我们来看一个🌰:


fun handleStrings(list: MutableList<String) {
 if (list is ArrayList) {
  // list is smart-cast to ArrayList
 }
}

这个方法并不涉及泛型类型,已经知道了具体的类型参数是String,所以类型操作也是可行的,因为编译器知道具体的类型,能对类型进行检查 保证是类型安全的。并且因为具体类型参数String可以推断出来,所以是可以省略的。


未检查的转换


当编译器能推断出具体的类型时,进行类型转换就是安全的,这就是被检查的转型(checked cast),如上面的🌰。


如果无法推断出类型时,比如涉及泛型类型T时,因为类型会被擦除,编译器不知道具体的类型,这时as T或者as List都是不安全的,编译器会报错,这就是未检查转型(unchecked cast)。


但如果能确信是类型转换是安全的,可以用注解@Suppress("UNCHECKED_CAST")来忽略。


用关键reified修饰inline泛型函数


要想能够对泛型类型参数T做类型操作,只能是在用关键字reified修饰了的inline泛型函数,在这种函数体内可以对泛型类型参数T做类型操作,🌰如:


inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

val somePair: Pair = "items" to listOf(123)


val stringToSomething = somePair.asPairOf()
val stringToInt = somePair.asPairOfInt>()

需要注意的是关键字reified能够让针对类型参数T的操作得到编译器的检查,保证安全,是允许的。但是对于泛型仍是不允许的,🌰如:


inline fun <reified T> List<*>.asListOfType(): List? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List else
        null

这个inline泛型函数用关键字reified修饰了,因此针对类型参数T是允许类型检查类型转换,如第2行是允许的。但泛型仍是不合法,如第4行,这时可以用上一小节提到的注解@Suppress("UNCHECKED_CAST")来忽略未检查类型转换。


inline和reified的原理


对于一些泛型工厂方法,就非常适合使用inline和reified,以保证转换为类型参数(因为工厂方法最终肯定要as T)是允许的且是安全的:


inline fun <reified T> logger(): Logger = LoggerFactory.getLogger(T::class.java)

class User {
    private val log = logger()
    // ...
}

关键字reified其实也没有什么神秘的,因为这是inline函数,这种函数是会把函数体嵌入到任何调用它的地方(call site),而每个调用泛型函数的地方必然会有明确的具体类型参数,那么编译器就知道了具体的类型能保证类型安全(checked cast)。上面的工厂方法在调用时就会大概变成酱紫:


class User {
 private val log = LoggerFactory.getLogger(User.class.java)
}

这时其实在函数体内已经知道了具体的类型参数User,编译器能够进行类型检查,所以是安全的。


总结


本文深入的讨论一下运行时泛型的一些特性,泛型类型在运行时会被擦除,无法做泛型相关的类型操作,因为编译器无法保证其类型安全。例外就是在用reified修饰的inline函数中可以对类型参数T做类型操作,但泛型类型(带尖括号的)仍是会被擦除,可以用注解@Suppress("UNCHECKED_CAST")来忽略unchecked cast。


参考资料



作者:稀有猿诉
来源:toughcoder.net/blog/2024/03/16/deep-dive-int0-kotlin-generics-runtime
收起阅读 »

Android:优雅的处理首页弹框逻辑:责任链模式

背景 随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。 并且弹框显示还有要求,比如: 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗...
继续阅读 »

背景


随着业务的发展,首页的弹窗越来越多,隐私政策弹窗,广告弹窗,好评弹窗,应用内更新弹窗等等。
并且弹框显示还有要求,比如:



  • 用户本次使用app,只能显示一个弹框,毕竟谁都不愿意打开app就看到一堆弹框

  • 这些弹框有优先级:如隐私政策弹窗优先级肯定比好评弹窗高,所以希望优先级高的优先显示

  • 广告弹框只展示一次

  • 等等


如何优雅的处理这个逻辑呢?请出我们的主角:责任链模式。


责任链模式


举个栗子🌰


一位男性在结婚之前有事要和父母请示,结婚之后要请示妻子,老了之后就要和孩子们商量。作为决策者的父母、妻子或孩子,只有两种选择:要不承担起责任来,允许或不允许相应的请求; 要不就让他请示下一个人,下面来看如何通过程序来实现整个流程。


先看一下类图:
未命名文件.png
类图非常简单,IHandler上三个决策对象的接口。


//决策对象的接口
public interface IHandler {
//处理请求
void HandleMessage(IMan man);
}

//决策对象:父母
public class Parent implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("孩子向父母的请求是:" + man.getRequest());
System.out.println("父母的回答是:同意");
}
}

//决策对象:妻子
public class Wife implements IHandler {
@Override
public void HandleMessage(IMan man) {
System.out.println("丈夫向妻子的请求是:" + man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

//决策对象:孩子
public class Children implements IHandler{
@Override
public void HandleMessage(IMan man) {
System.out.println("父亲向孩子的请求是:" + man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

IMan上男性的接口:


public interface IMan {
int getType(); //获取个人状况
String getRequest(); //获取个人请示(这里就简单的用String)
}

//具体男性对象
public class Man implements IMan {
/**
* 通过一个int类型去描述男性的个人状况
* 0--幼年
* 1--成年
* 2--年迈
*/

private int mType = 0;
//请求
private String mRequest = "";

public Man(int type, String request) {
this.mType = type;
this.mRequest = request;
}

@Override
public int getType() {
return mType;
}

@Override
public String getRequest() {
return mRequest;
}
}

最后我们看下一下场景类:


public class Client {
public static void main(String[] args) {
//随机生成几个man
Random random = new Random();
ArrayList<IMan> manList = new ArrayList<>();
for (int i = 0; i < 5; i++) {
manList.add(new Man(random.nextInt(3), "5块零花钱"));
}
//定义三个请示对象
IHandler parent = new Parent();
IHandler wife = new Wife();
IHandler children = new Children();
//处理请求
for (IMan man: manList) {
switch (man.getType()) {
case 0:
System.out.println("--------孩子向父母发起请求-------");
parent.HandleMessage(man);
break;
case 1:
System.out.println("--------丈夫向妻子发起请求-------");
wife.HandleMessage(man);
break;
case 2:
System.out.println("--------父亲向孩子发起请求-------");
children.HandleMessage(man);
break;
default:
break;
}
}
}
}

首先是通过随机方法产生了5个男性的对象,然后看他们是如何就要5块零花钱这件事去请示的,运行结果如下所示:


--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意
--------父亲向孩子发起请求-------
父亲向孩子的请求是:5块零花钱
孩子的回答是:同意
--------孩子向父母发起请求-------
孩子向父母的请求是:5块零花钱
父母的回答是:同意
--------丈夫向妻子发起请求-------
丈夫向妻子的请求是:5块零花钱
妻子的回答是:同意


发没发现上述的代码是不是有点不舒服,有点别扭,有点想重构它的感觉?那就对了!这段代码有以下几个问题:



  • 职责界定不清晰



对孩子提出的请示,应该在父母类中做出决定,父母有责任、有义务处理孩子的请示,



因此Parent类应该是知道孩子的请求自己处理,而不是在Client类中进行组装出来,
也就是说 原本应该是父亲这个类做的事情抛给了其他类进行处理,不应该是这样的。



  • 代码臃肿



我们在Client类中写了if...else的判断条件,而且能随着能处理该类型的请示人员越多,
if...else的判断就越多,想想看,臃肿的条件判断还怎么有可读性?!




  • 耦合过重



这是什么意思呢,我们要根据Man的type来决定使用IHandler的那个实现类来处理请



求。有一个问题是:如果IHandler的实现类继续扩展怎么办?修改Client类?
与开闭原则违背了!【开闭原则:软件实体如类,模块和函数应该对扩展开放,对修改关闭】
http://www.jianshu.com/p/05196fac1…



  • 异常情况欠考虑



丈夫只能向妻子请示吗?丈夫向自己的父母请示了,父母应该做何处理?
我们的程序上可没有体现出来,逻辑失败了!


既然有这么多的问题,那我们要想办法来解决这些问题,我们先来分析一下需求,男性提出一个请示,必然要获得一个答复,甭管是同意还是不同意,总之是要一个答复的,而且这个答复是唯一的,不能说是父母作出一个决断,而妻子也作出了一个决断,也即是请示传递出去,必然有一个唯一的处理人给出唯一的答复,OK,分析完毕,收工,重新设计,我们可以抽象成这样一个结构,男性的请求先发送到父亲,父母一看是自己要处理的,就作出回应处理,如果男性已经结婚了,那就要把这个请求转发到妻子来处理,如果男性已经年迈,那就由孩子来处理这个请求,类似于如图所示的顺序处理图。
未命名文件 (1).png
父母、妻子、孩子每个节点有两个选择:要么承担责任,做出回应;要么把请求转发到后序环节。结构分析得已经很清楚了,那我们看怎么来实现这个功能,类图重新修正,如图 :
未命名文件 (2).png
从类图上看,三个实现类Parent、Wife、Children只要实现构造函数和父类中的抽象方法 response就可以了,具体由谁处理男性提出的请求,都已经转移到了Handler抽象类中,我们 来看Handler怎么实现,


public abstract class Handler {
//处理级别
public static final int PARENT_LEVEL_REQUEST = 0; //父母级别
public static final int WIFE_LEVEL_REQUEST = 1; //妻子级别
public static final int CHILDREN_LEVEL_REQUEST = 2;//孩子级别

private Handler mNextHandler;//下一个责任人

protected abstract int getHandleLevel();//具体责任人的处理级别

protected abstract void response(IMan man);//具体责任人给出的回应

public final void HandleMessage(IMan man) {
if (man.getType() == getHandleLevel()) {
response(man);//当前责任人可以处理
} else {
//当前责任人不能处理,如果有后续处理人,将请求往后传递
if (mNextHandler != null) {
mNextHandler.HandleMessage(man);
} else {
System.out.println("-----没有人可以请示了,不同意该请求-----");
}
}
}

public void setNext(Handler next) {
this.mNextHandler = next;
}
}

再看一下具体责任人的实现:Parent、Wife、Children


public class Parent extends Handler{

@Override
protected int getHandleLevel() {
return Handler.PARENT_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------孩子向父母提出请示----------");
System.out.println(man.getRequest());
System.out.println("父母的回答是:同意");
}
}

public class Wife extends Handler{
@Override
protected int getHandleLevel() {
return Handler.WIFE_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------丈夫向妻子提出请示----------");
System.out.println(man.getRequest());
System.out.println("妻子的回答是:同意");
}
}

public class Children extends Handler{
@Override
protected int getHandleLevel() {
return Handler.CHILDREN_LEVEL_REQUEST;
}

@Override
protected void response(IMan man) {
System.out.println("----------父亲向孩子提出请示----------");
System.out.println(man.getRequest());
System.out.println("孩子的回答是:同意");
}
}

那么再看一下场景复现:
在Client中设置请求的传递顺序,先向父母请示,不是父母应该解决的问题,则由父母传递到妻子类解决,若不是妻子类解决的问题则传递到孩子类解决,最终的结果必然有一个返回,其运行结果如下所示。


----------孩子向父母提出请示----------
15块零花钱
父母的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意
----------丈夫向妻子提出请示----------
15块零花钱
妻子的回答是:同意
----------父亲向孩子提出请示----------
15块零花钱
孩子的回答是:同意

结果也正确,业务调用类Client也不用去做判断到底是需要谁去处理,而且Handler抽象类的子类可以继续增加下去,只需要扩展传递链而已,调用类可以不用了解变化过程,甚至是谁在处理这个请求都不用知道。在这种模式就是责任链模式


定义


Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request.Chain the receiving objects and pass the request along the chain until an object handles it.
(使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。) 责任链模式的重点是在“链”上,由一条链去处理相似的请求在链中决定谁来处理这个请 求,并返回相应的结果,其通用类图如图所示
未命名文件 (3).png
最后总结一下,责任链的模版:
包含四个对象,Handler,Request,Level,Response:


public class Request {
//请求的等级
public Level getRequestLevel(){
return null;
}
}

public class Level {
//请求级别
}


public class Response {
//处理者返回的数据
}

//抽象处理者
public abstract class Handler {
private Handler mNextHandler;

//每个处理者都必须对请求做出处理
public final Response handleMessage(Request request) {
Response response = null;
if (getHandlerLevel().equals(request.getRequestLevel())) {
//是自己处理的级别,自己处理
response = echo(request);
} else {
//不是自己处理的级别,交给下一个处理者
if (mNextHandler != null) {
response = mNextHandler.echo(request);
} else {
//没有处理者能处理,业务自行处理
}
}
return response;
}

public void setNext(Handler next) {
this.mNextHandler = next;
}

@NotNull
protected abstract Level getHandlerLevel();

protected abstract Response echo(Request request);
}

实际应用


我们回到开篇的问题:如何设计弹框的责任链?


//抽象处理者
abstract class AbsDialog(private val context: Context) {
private var nextDialog: AbsDialog? = null

//优先级
abstract fun getPriority(): Int

//是否需要展示
abstract fun needShownDialog(): Boolean

fun setNextDialog(dialog: AbsDialog?) {
nextDialog = dialog
}

open fun showDialog() {
//这里的逻辑,我们就简单点,具体逻辑根据业务而定
if (needShownDialog()) {
show()
} else {
nextDialog?.showDialog()
}
}

protected abstract fun show()

// Sp存储, 记录是否已经展示过
open fun needShow(key: String): Boolean {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
return sp.getBoolean(key, true)
}

open fun setShown(key: String, show: Boolean) {
val sp: SharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE)
sp.edit().putBoolean(key, !show).apply()
}

companion object {
const val LOG_TAG = "Dialog"
const val SP_NAME = "dialog"
const val POLICY_DIALOG_KEY = "policy_dialog"
const val AD_DIALOG_KEY = "ad_dialog"
const val PRAISE_DIALOG_KEY = "praise_dialog"
}
}

/**
* 模拟 隐私政策弹窗
* */

class PolicyDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 0

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如接口控制等等
// 这里通过Sp存储来模拟
return needShow(POLICY_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示隐私政策弹窗")
setShown(POLICY_DIALOG_KEY, true) //记录已经显示过
}
}

/**
* 模拟 广告弹窗
* */

class AdDialog(private val context: Context) : AbsDialog(context) {
private val ad = DialogData(1, "XX广告弹窗") // 模拟广告数据

override fun getPriority(): Int = 1

override fun needShownDialog(): Boolean {
// 广告数据通过接口获取,广告id应该是唯一的,所以根据id保持sp
return needShow(AD_DIALOG_KEY + ad.id)
}

override fun show() {
Log.d(LOG_TAG, "显示广告弹窗:${ad.name}")
setShown(AD_DIALOG_KEY + ad.id, true)
}
}

/**
* 模拟 好评弹窗
* */

class PraiseDialog(context: Context) : AbsDialog(context) {
override fun getPriority(): Int = 2

override fun needShownDialog(): Boolean {
// 这里可以根据业务逻辑判断是否需要显示弹窗,如用户使用7天等
// 这里通过Sp存储来模拟
return needShow(PRAISE_DIALOG_KEY)
}

override fun show() {
Log.d(LOG_TAG, "显示好评弹窗")
setShown(PRAISE_DIALOG_KEY, true)
}
}

//模拟打开app
val dialogs = mutableListOf<AbsDialog>()
dialogs.add(PolicyDialog(this))
dialogs.add(PraiseDialog(this))
dialogs.add(AdDialog(this))
//根据优先级排序
dialogs.sortBy { it.getPriority() }
//创建链条
for (i in 0 until dialogs.size - 1) {
dialogs[i].setNextDialog(dialogs[i + 1])
}
dialogs[0].showDialog()

第一次打开
image.png


第二次打开
image.png


第三次打开
image.png


总结:



  • 优点


责任链模式非常显著的优点是将请求和处理分开。请求者可以不用知道是谁处理的,处理者可以不用知道请求的全貌,两者解耦,提高系统的灵活性。



  • 缺点


责任链有两个非常显著的缺点:一是性能问题,每个请求都是从链头遍历到链尾,特别是在链比较长的时候,性能是一个非常大的问题。二是调试不很方便,特别是链条比较长, 环节比较多的时候,由于采用了类似递归的方式,调试的时候逻辑可能比较复杂。



  • 注意事项


链中节点数量需要控制,避免出现超长链的情况,一般的做法是在Handler中设置一个最大节点数量,在setNext方法中判断是否已经是超过其阈值,超过则不允许该链建立,避免无意识地破坏系统性能。


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

2024年的安卓现代开发

大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀 如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化. 免责声明 📝 本文反映了我的个人观点和专业见解, 并参考...
继续阅读 »


大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀


如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.


免责声明


📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.


🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.


Kotlin 无处不在 ❤️



Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.


无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.


请查看Kotlin 官方文档


Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.




KotlinConf ‘23


Kotlin 2.0 要来了


另一个需要强调的重要事件是Kotlin 2.0的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4



新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.


请查看 KotlinConf '23 的回顾, 你可以找到更多信息.


Compose 🚀




Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.




Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.


Jetpack Compose 的一些主要功能包括



  1. 声明式UI

  2. 可定制的小部件

  3. 与现有代码(旧视图系统)轻松集成

  4. 实时预览

  5. 改进的性能.


资源:



Android Jetpack ⚙️




Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.


_ Android Jetpack 文档



其中最常用的工具有:



Material You / Material Design 🥰



Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.



目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.


代码仓库


使用 Material 3 创建主题


SplashScreen API



Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.


Clean架构



Clean架构的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.


特点



  1. 独立于框架.

  2. 可测试.

  3. 独立于UI

  4. 独立于数据库

  5. 独立于任何外部机构.


依赖规则


作者在他的博文Clean代码中很好地描述了依赖规则.



依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.




安卓中的Clean架构:


Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.


Presentation层的架构模式


架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.


在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:



  • MVVM

  • MVI


我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅


此外, 你还可以查看应用架构指南.



依赖注入


依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.



模块化


模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.



模块化的优势


可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.


严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.


自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.


可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.


易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.


易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.


改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.


改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.


构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.


更多信息请参阅官方文档.


网络



序列化


在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.



MoshiKotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.


图像加载



要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.


_ 官方安卓文档




响应/线程管理




说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.


对于新项目, 请始终选择Kotlin协程❤️. 可以在这里探索一些Kotlin协程相关的概念.


本地存储


在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.



建议:



测试 🕵🏼



软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::



工具文档的测试部分


截屏测试 📸



Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.



R8 优化


R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard规则文件禁用某些任务或自定义 R8 的行为.




  • 代码缩减

  • 缩减资源

  • 混淆

  • 优化


第三方工具



  • DexGuard


Play 特性交付





Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.




自适应布局



随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, MediumExpanded.


Window Size Class




支持不同的屏幕尺寸


我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.



其他相关资源



Form-Factor培训


本地化 🌎



本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.


注: BCP 47 是安卓系统使用的国际化标准.


参考资料



性能 🔋⚙️



在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:



应用内更新



当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.


运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.


- 应用内更新文档




应用内评论



Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.


一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.


*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论评论提示的设计的规定.


- 应用内评论文档




可观察性 👀



在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.


工具



辅助功能



辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.


考虑因素:



  • 增加文字的可视性(颜色对比度, 可调整文字大小)

  • 使用大而简单的控件

  • 描述每个UI元素


更多详情请查看辅助功能 - Android 文档


安全性 🔐



在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.



  • 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.

  • 加密敏感数据和文件: 使用EncryptedSharedPreferencesEncryptedFile.

  • 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.


android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
name="my_custom_permission_name"
android:protectionLevel="signature" />


  • 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用local.properties.

  • 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.


res/xml/network_security_config.xml


"1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.comdomain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPinpin>

<pin digest="SHA-256">ReplaceWithYourPinpin>
pin-set>
domain-config>
network-security-config>


  • 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:



    • 代码混淆.

    • 根检测.

    • 篡改/应用钩子检测.

    • 防止逆向工程攻击.

    • 反调试技术.

    • 虚拟环境检测

    • 应用行为的运行时分析.




想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.


版本目录


Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.


优点:



  • 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.

  • 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.

  • 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".

  • 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.


请查看更多信息


Secret Gradle 插件


Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties 文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.


日志


日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.



Linter / 静态代码分析器



Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.



Google Play Instant



Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.



新设计中心



安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.


点击查看新的设计中心


人工智能



GeminiPalM 2是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.



人工智能编码助手工具


Studio Bot



Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.


Studio Bot



Github Copilot


GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.


Amazon CodeWhisperer


这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.


Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀



最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀


如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:



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

【手把手教Android聊天室uikit集成-kotlin 第一期】

前言:环信提供一个开源的 ChatroomUIKit 示例项目,演示了如何使用该 UIKit 快速搭建聊天室页面,实现完整业务。本文展示如何编译并运行 Android 平台的聊天室 UIKit 示例项目。#一、详细步骤导入uikit二、遇到集成报错解决&nbs...
继续阅读 »

前言:环信提供一个开源的 ChatroomUIKit 示例项目,演示了如何使用该 UIKit 快速搭建聊天室页面,实现完整业务。

本文展示如何编译并运行 Android 平台的聊天室 UIKit 示例项目。

一、详细步骤导入uikit
二、遇到集成报错解决 
1. 从github下载的附件我们打开以后 会有两个 一个是ChatRoomService ,另外一个是ChatroomUIKit

2.先倒入UIkit的本地库(引导的内容可以参考标题1. 的绿色箭头第二个文件夹)

3.然后在导入ChatRoomservice 选择文件后也点击Finish 注: 一共两个文件 都需要导入
4.填写settings.gradle
include(":ChatroomUIKit")
include(":ChatroomService")

5.添加:build.gradle(app)
implementation(project(mapOf("path" to ":ChatroomUIKit")))

6.如果遇到该报错如下:
遇到报错如下:
Dependency 'androidx.activity:activity:1.8.0' requires libraries and applications that depend on it to compile against version 34 or later of the Android APIs.
:app is currently compiled against android-33.
Also, the maximum recommended compile SDK version for Android Gradle
plugin 7.4.2 is 33.
Recommended action: Update this project's version of the Android Gradle
plugin to one that supports 34, then update this project to use
compileSdkVerion of at least 34.
Note that updating a library or application's compileSdkVersion (which
allows newer APIs to be used) can be done separately from updating
targetSdkVersion (which opts the app in to new runtime behavior) and
minSdkVersion (which determines which devices the app can be installed
解决方案: 注意一下自己app的 targetSDK版本号以及compilesdk 都给到 34 大概在报错信息也能提示到是 需要强制到34
7.初始化UIkit

(1)appkey管理后台位置

8.客户端登录调用
ChatroomUIKitClient.getInstance().login("4","YWMtFTJV-OXGEe6LxEWLvu_JdPqlsNlfrUUAh3km7oObq2HVh7Pgj9ER7JuEZ0XLQ13UAwMAAAGOVbV_AAWP1AB9sFv_7oIlDyK7Jay0Coha-HnF5o0PnXttL7r4gxryCA", onSuccess = {
val intent = Intent(this@MainActivity, As::class.java)
startActivity(intent)

}, onError = {
code, error ->


})


(1)参数管理后台具体位置 ,每次点击查看token的token内容都是不同的,这个不必担心。


(2)跳转到Asactivity 后遇到了一个问题!
继承ComponentActivity() 无法拿到setContent
解决办法:将这个依赖升级到 1.8.0 刚才用了1.7.0版本 无法拿到这个setContent
implementation("androidx.activity:activity-compose:1.8.0")
9.展示进入聊天室逻辑
class As : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent{
ComposeChatroom(roomId = "242681589596161",roomOwner = UserInfoProtocol)
}
(1)参数roomId 在管理后台可以查看

(2)roomOwner 为 UserInfoProtocol 类型 ,可以自己定义编辑属性将参数存入方法内


收起阅读 »

UNIAPP开发电视app教程

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。 开发难点 如何方便的开发调试 如何使需要被聚焦的元素获取聚焦状态 如何使被聚焦的元素滚动到视图中心位置 如何在切换路由时,缓存聚焦的状态 如...
继续阅读 »

目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。


开发难点



  1. 如何方便的开发调试

  2. 如何使需要被聚焦的元素获取聚焦状态

  3. 如何使被聚焦的元素滚动到视图中心位置

  4. 如何在切换路由时,缓存聚焦的状态

  5. 如何启用wgt和apk两种方式的升级


一、如何方便的开发调试


之前我在论坛看到人家说,没办法呀,电脑搬到电视,然后调试。


其实大可不必,安装android studio里边创建一个模拟器就可以了。


注意:最好安装和电视系统相同的版本号,我这里是长虹电视,安卓9所以使用安卓9的sdk


二、如何使需要被聚焦的元素获取聚焦状态


uniapp的本质上是webview, 因此我们可以在它的元素上添加tabIndex, 就可以获取焦点了。


  <view class="card" tabindex="0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>
</view>


.card {
border-radius: 1.25vw;
overflow: hidden;
}
.card:focus {
box-shadow: 0 0 0 0.3vw #fff, 0 0 1vw 0.3vw #333;
outline: none;
transform: scale(1.03);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}


三、如何使被聚焦的元素滚动到视图中心位置


使用renderjs进行实现如下


<script  module="homePage" lang="renderjs">
export default {
mounted() {
let isScrolling = false; // 添加一个标志位,表示是否正在滚动
document.body.addEventListener('focusin', e => {
if (!isScrolling) {
// 检查是否正在滚动
isScrolling = true; // 设置滚动标志为true
requestAnimationFrame(() => {
// @ts-ignore
e.target.scrollIntoView({
behavior: 'smooth', // @ts-ignore
block: e.target.dataset.index ? 'end' : 'center'
});
isScrolling = false; // 在滚动完成后设置滚动标志为false
});
}
});
}
};
</script>

就可以使被聚焦元素滚动到视图中心,requestAnimationFrame的作用是缓存


四、如何在切换路由时,缓存聚焦的状态


通过设置tabindex属性为0和1,会有不同的效果:



  1. tabindex="0":将元素设为可聚焦,并按照其在文档中的位置来确定焦点顺序。当使用Tab键进行键盘导航时,tabindex="0"的元素会按照它们在源代码中的顺序获取焦点。这可以用于将某些非交互性元素(如
    等)设为可聚焦元素,使其能够被键盘导航。

  2. tabindex="1":将元素设为可聚焦,并将其置于默认的焦点顺序之前。当使用Tab键进行键盘导航时,tabindex="1"的元素会在默认的焦点顺序之前获取焦点。这通常用于重置焦点顺序,或者将某些特定的元素(如重要的输入字段或操作按钮)置于首位。


需要注意的是,如果给多个元素都设置了tabindex属性,那么它们的焦点顺序将取决于它们的tabindex值,数值越小的元素将优先获取焦点。如果多个元素具有相同的tabindex值,则它们将按照它们在文档中的位置来确定焦点顺序。同时,负数的tabindex值也是有效的,它们将优先于零和正数值获取焦点。


我们要安装缓存插件,如pinia或vuex,需要缓存的页面单独配置


import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({ home_active_tag: 'active0', hot_active_tag: 'hot0', dish_active_tag: 'dish0' })
});


更新一下业务代码


组件区域
<view class="card" :tabindex="home_active_tag === 'packagecard' + props.id ? 1 : 0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">&yen; {{ props.price }}</text>
</div>
</view>

</view>

const { home_active_tag } = storeToRefs(useGlobalStore());

页面区域

<view class="content">
<FoodCard
v-for="_package in list.dishes"
@click="goShopByFood(_package)"
:id="_package.id"
:name="_package.name"
:image="_package.image"
:tags="_package.tags"
:price="_package.price"
:shop_name="_package.shop_name"
:shop_id="_package.shop_id"
:key="_package.id"
></FoodCard>
<image
class="card"
@click="goMore"
:tabindex="home_active_tag === 'more' ? 1 : 0"
style="width: 29.375vw; height: 25.9375vw"
src="/static/home/more.png"
mode="aspectFill"
/>
</view>

const goShopByFood = async (row: Record<string, any>) => {
useGlobalStore().home_active_tag = 'foodcard' + row.id;
uni.navigateTo({
url: `/pages/shop/index?shop_id=${row.shop_id}`,
animationDuration: 500,
animationType: 'zoom-fade-out'
});
};


如果,要设置启动默认焦点 id和index可默认设置,推荐启动第一个焦点组用index,它可以确定


  <view class="active">
<image
v-for="(active, i) in list.active"
:key="active.id"
@click="goActive(active, i)"
:tabindex="home_active_tag === 'active' + i ? 1 : 0"
:src="`${VITE_URL}${active.image}`"
data-index="0"
fade-show
lazy-load
mode="aspectFill"
class="card"
></image>
</view>

import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
home_active_tag: 'active0', //默认选择
hot_active_tag: 'hot0',
dish_active_tag: 'dish0'
})
});


对于多层级的,要注意销毁,在前往之前设置默认焦点


const goHot = (index: number) => {
useGlobalStore().home_active_tag = 'hotcard' + index;
useGlobalStore().hot_active_tag = 'hot0';
uni.navigateTo({ url: `/pages/hot/index?index=${index}`, animationDuration: 500, animationType: 'zoom-fade-out' });
};


五、如何启用wgt和apk两种方式的升级


pages.json


{
"path": "components/update/index",
"style": {
"disableScroll": true,
"backgroundColor": "#0068d0",
"app-plus": {
"backgroundColorTop": "transparent",
"background": "transparent",
"titleNView": false,
"scrollIndicator": false,
"popGesture": "none",
"animationType": "fade-in",
"animationDuration": 200
}
}
}


组件


<template>
<view class="update">
<view class="content">
<view class="content-top">
<text class="content-top-text">发现版本</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="@/static/bg_top.png"> </image>
</view>
<text class="message"> {{ message }} </text>
<view class="progress-box">
<progress
class="progress"
border-radius="35"
:percent="progress.progress"
activeColor="#3DA7FF"
show-info
stroke-width="10"
/>

<view class="progress-text">
<text>安装包正在下载,请稍后,系统会自动重启</text>
<text>{{ progress.totalBytesWritten }}MB/{{ progress.totalBytesExpectedToWrite }}MB</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import { reactive, ref } from 'vue';
const message = ref('');
const progress = reactive({ progress: 0, totalBytesExpectedToWrite: '0', totalBytesWritten: '0' });
onLoad((query: any) => {
message.value = query.content;
const downloadTask = uni.downloadFile({
url: `${import.meta.env.VITE_URL}/${query.url}`,
success(downloadResult) {
plus.runtime.install(
downloadResult.tempFilePath,
{ force: false },
() => {
plus.runtime.restart();
},
e => {}
);
}
});
downloadTask.onProgressUpdate(res => {
progress.progress = res.progress;
progress.totalBytesExpectedToWrite = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
progress.totalBytesWritten = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
});
});
</script>
<style lang="less">
page {
background: transparent;
.update {
/* #ifndef APP-NVUE */
display: flex; /* #endif */
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
.content {
position: relative;
top: 0;
width: 50vw;
height: 50vh;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
border-radius: 2vw;
.content-top {
position: absolute;
top: -5vw;
left: 0;
image {
width: 50vw;
height: 30vh;
}
.content-top-text {
width: 50vw;
top: 6.6vw;
left: 3vw;
font-size: 3.8vw;
font-weight: bold;
color: #f8f8fa;
position: absolute;
z-index: 1;
}
}
}
.message {
position: absolute;
top: 15vw;
font-size: 2.5vw;
}
.progress-box {
position: absolute;
width: 45vw;
top: 20vw;
.progress {
width: 90%;
border-radius: 35px;
}
.progress-text {
margin-top: 1vw;
font-size: 1.5vw;
}
}
}
}
</style>


App.vue


import { onLaunch } from '@dcloudio/uni-app';
import { useRequest } from './hooks/useRequest';
import dayjs from 'dayjs'; onLaunch(() => {
// #ifdef APP-PLUS
plus.runtime.getProperty('', async app => { const res: any = await useRequest('GET', '/api/tv/app'); if (res.code === 2000 && res.row.version > (app.version as string)) { uni.navigateTo({ url: `/components/update/index?url=${res.row.url}&type=${res.row.type}&content=${res.row.content}`, fail: err => { console.error('更新弹框跳转失败', err); } }); } });
// #endif
});

如果要获取启动参数


plus.android.importClass('android.content.Intent');
const MainActivity = plus.android.runtimeMainActivity();
const Intent = MainActivity.getIntent();
const roomCode = Intent.getStringExtra('roomCode');
if (roomCode) {
uni.setStorageSync('roomCode', roomCode);
} else if (!uni.getStorageSync('roomCode') && !roomCode) {
uni.setStorageSync('roomCode', '8888');
}

作者:Rjl_CLI
来源:juejin.cn/post/7272348543625445437
收起阅读 »

如何移植 JsBridge 到鸿蒙

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。 关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL 和 对象注...
继续阅读 »

相信大多数小伙伴的项目都已经有了线上稳定运行的 JsBridge 方案,那么对于鸿蒙来说,最好的方案肯定是不需要前端同学的改动,就可以直接运行,这个兼容任务就得我们自己来做了。


关于 JsBridge 的通信原理,现在主流的技术方案有 拦截 URL对象注入 两种,我们分别看一下如何在鸿蒙上实现。


拦截 URL


在安卓上,拦截 URL 这个技术方案的代表作一定是 github.com/lzyzsd/JsBr… ,相信有不少小伙伴都使用了这个开源库。


我这里就以该开源库为例,介绍一下如何在鸿蒙上无缝迁移。


首先,在页面加载完成后注入通信需要的 JS 代码。在 Android 中,是 WebViewClient.onPageFinished(),在鸿蒙中对应 Web组件的 onPageEnd()方法。


Web({ src: this.url, controller: this.controller })
.onPageEnd(() => {
this.onPageEnd()
BridgeUtil.webViewLoadLocalJs(getContext(), this.controller, BridgeUtil.toLoadJs)
})

鸿蒙中本地资源文件放在 resouce/rawfile 目录下,通过以下代码读取:


rawFile2Str(context: Context, file: string): string {
try {
let data = context.resourceManager.getRawFileContentSync(file)
let decoder = util.TextDecoder.create("utf-8")
let str = decoder.decodeWithStream(data, { stream: false })
return str
} catch (e) {
return ""
}
}

读取到的 JS 代码,通过系统能力动态执行。在 Android 中,通过 WebView.loadUrl() 或者 WebView.evaluateJavaScript() 来实现。在鸿蒙中,对应的是 WebviewController.runJavaScriptExt()


webViewLoadLocalJs(context: Context, controller: WebviewController, path: string) {
let jsContent = BridgeUtil.rawFile2Str(context, path)
controller.runJavaScriptExt(BridgeUtil.JAVASCRIPT_STR + jsContent, (err, result) => {
...
})
}

JS 代码注入完成后,就是核心的拦截 URL 了。在 Android 中,通过 WebViewClient.shouldOverrideUrlLoading() 实现,看一下具体的代码:


    @Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
try {
url = URLDecoder.decode(url, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}

if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
webView.handlerReturnData(url);
return true;
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) { //
webView.flushMessageQueue();
return true;
} else {
return super.shouldOverrideUrlLoading(view, url);
}
}

拦截到所有的 URL,判断是否是 H5 通过 iFrame.src 发送的指定特征的 URL,来完成通信流程。


在鸿蒙中,对应的是 Web 组件的 onInterceptRequest()方法。


Web({ src: this.url, controller: this.controller })
.onInterceptRequest((event) => {
if (event) {
let url = event.request.getRequestUrl()
if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) {
this.ytoJsBridge.handlerReturnData(url)
} else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) {
this.ytoJsBridge.flushMessageQueue()
} else {
return null
}
}
return null
})

核心逻辑就这样,剩下的工作量就是苦逼的翻译代码。好在代码量并不大,大概五六个文件。


移植过程中,也踩了一些坑,印象最深的是 ArkTs 中关于接口的写法。


export interface CallBackFunction {
onCallBack(data: string): void
}

这是在 Java/Kotlin 中很常见的一种写法,顺手在 ArkTs 也这么写,但是在使用过程中尝试去写实现的时候就犯了难。如果直接按照传统的前端写法:


let responseFunction: CallBackFunction
if (callBackId != undefined)
{
responseFunction = {
onCallBack: (data: string): void => {
...
}
}
}

你会得到一个 lint 错误 Object literal must correspond to some explicitly declared class or interface (arkts-no-untyped-obj-literals) 。


你可以使用箭头函数来解决这个问题。


export interface CallBackFunction {
onCallBack: (data: string) => void
// onCallBack(data: string): void
}

这也是 ArkTs 目前比较割裂的地方,基于 TS,但是禁用了很多特性。


设想一下如果可以继续兼容 Java/Kotlin,那么这篇文章都不会存在了,压根不存在迁移成本,海量移动端类库无缝衔接......


对象注入


对象注入在 Android WebView 中的实现是 WebView.addJavascriptInterface(Object object, String name) 方法 。


addJavascriptInterface(JsBridge(this@MainActivity, webView), "JsBridge")

class JsBridge(private val activity: Activity, private val webView: WebView) {

@JavascriptInterface
fun webCallNative(message: String) {
Log.e("JsBridge", "webCallNative: ${Thread.currentThread().name}")
Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()
}
}

在鸿蒙中,可以通过 Web 组件的 javaScriptProxy() 方法,或者 WebviewController.registerJavaScriptProxy() 方法。


      Web({ src: this.url, controller: this.controller })
.javaScriptAccess(true)
.javaScriptProxy({
object: this.testObj,
name: "objName",
methodList: ["test", "toString"],
controller: this.controller,
})

这种方式只支持注入一个对象,如果需要注入多个对象,要用 WebviewController.registerJavaScriptProxy()


this.controller.registerJavaScriptProxy(this.testObjtest, "objName", ["test", "toString", "testNumber", "testBool"]);
this.controller.registerJavaScriptProxy(this.webTestObj, "objTestName", ["webTest", "webString"]);

这个方法的调用时机需要注意,必须发生在 controller 和 Web 组件绑定之后,建议放在 Web.onPageEnd()。注册之后需要调用 WebviewController.refresh() 才会生效。


总结


一入鸿蒙深似海,波涛汹涌无尽头。

云涛翻滚遮日月,雾霭弥漫掩星楼。

仙禽异兽齐飞舞,灵草神木共清幽。

鸿蒙奥秘难穷尽,探寻真道意未休。


Write by 文心一言,如有雷同,请...


作者:MobileDeveloper
来源:juejin.cn/post/7345071687309180962
收起阅读 »

移动端APP版本治理

1、背景 在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。 只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。 而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中...
继续阅读 »

1、背景


在许多公司,APP版本都是不受重视的,产品忙着借鉴,开发埋头编码,测试想着不粘锅。


只有在用户反馈app不能用的时候,你回复客服说,让用户升级最新版本,是不是很真实。


而且业界也很少有听说版本治理的,但其实需求上线并不是终点,在用户数据回传之前,这中间还有一个更新升级的空档期,多数公司在这里都是一个“三不管”地带,而这个空档期,我称之为版本交付的最后一公里



2、价值



2.1、业务侧


总有人会挑战“有什么业务价值?”对吧,那就先从业务价值来看。


尽管有些app的有些业务是动态发布的,但也一定会有些功能是依赖跟版的,也就是说,你没办法让所有用户都用上你的新功能,那对于产运团队来说,业务指标就还有提升的空间。


举两个例子:



  1. 饿了么免单活动需要8.+版本以上的app用户才能参与,现在参与用户占比80%,治理一波后,免单用户参与占比提升到90%,对业务来说,免单数没变,但是订单量却是有实实在在的提升的。

  2. 再来一个,酷狗音乐8.+的app用户才可以使用扫码登录,app低版本治理之后,扫码登录的用户占比势必也会提升,那相应的,登录成功率也可以提升,登录流程耗时也会缩短,这都是实实在在的指标提升。



虚拟数据,不具备真实参考性。



2.2、技术侧


说完业务看技术,在技术侧也可以分为三个维度来看:



  1. 稳定性,老版本的crash、anr之类的问题在新版本大概率是修复了的,疑难杂症可能久一点;

  2. 性能优化,比如启动、包大小、内存,可以预见是比老版本表现更好的,个别指标一两个版本可能会有微量劣化,但是一直开倒车的公司早晚会倒闭;

  3. 安全合规,不管是老的app版本还是老的服务接口,都可能会存在安全问题,那么黑产就可能抓住这个漏洞从而对我们服务的稳定性造成隐患,甚至产生资损;


2.3、其他方面


除了上面提到的业务指标和用户体验之外,还有没有?


有没有想过,老版本的用户升上来之后,那些兼容老版本的接口、系统服务等,是不是可以下线了,除了减少人力维护成本之外,还能减少机器成本啊,这也都是实打实的经费支出。


对于项目本身来说,也可以去掉一些无用代码,减少项目复杂度,提升健壮性、可维护性。


3、方案



3.1、升级交互


采用新的设计语言和新的交互方式。


3.1.1、弹窗样式


样式上要符合app整体的风格,信息展示明确,主次分明。


bad casegood case

3.1.2、操作表达


按钮的样式要凸显出来,并放在常规易操作的位置上。


bad casegood case

3.1.3、提醒链路


从一级菜单到二级页面的更新提醒链路,并保持统一。



3.1.4、进度感知


下载进度一定要可查看并准确,如果点了按钮之后什么提示都没有,用户会进入一个很迷茫的状态,体验很差。



3.2、提醒策略


我们需要针对不同的用户下发不同的提醒策略,这种更细致的划分,不光是为了稳定性和目标的达成,也是为了更好的用户体验,毕竟反复提醒容易引起用户的反感。


3.2.1、提醒时机


提醒时机其实是有讲究的,原则上是不能阻塞用户的行为。


特别是有强制行为的情况,比如强更,肯定不能在app启动就无脑拉起弹窗。



bad case:双十一那天,用户正争分夺秒准备下单呢,结果你让人升级,这不扯淡的吗。



时机的考虑上有两个维度:



  1. 平台:峰时谷时;

  2. 用户:闲时忙时;


3.2.2、逻辑引擎


为什么需要逻辑引擎呢?逻辑引擎的好处是跨端,双端逻辑可以保持一致,也可以动态下发。




可以使用接口平替,约定好协议即可。



3.2.3、软强更


强制更新虽然有可以预见的好效果,但是也伴随着投诉风险,要做好风险管控。


而在2023-02-27日,工信部更是发布了《关于进一步提升移动互联网应用服务能力》的通知,其中第四条更是明确表示“窗口关闭用户可选”,所以强更弹窗并不是我们的最佳选择。



虽然强更不可取,但是我们还可以提高用户操作的费力度,在取消按钮上增加倒计时,再根据低版本用户的分层,来配置不同的倒计时梯度,比如5s、10s、20s这样。


用户一样可以选择取消,但是却要等待一会,所以称之为软强更



3.2.4、策略字段



  • 标题

  • 内容

  • 最新版本号

  • 取消倒计时时长

  • 是否提醒

  • 是否强更

  • 双端最低支持的系统版本

  • 最大提醒次数

  • 未更新最大版本间隔

  • 等等


3.3、提示文案


升级文案属于是ROI很高的了,只需要总结一下新版本带来了哪些新功能、有哪些提升,然后配置一下就好了,但是对用户来说,却是实打实的信息冲击,他们可以明确的感知到新版本中的更新,对升级意愿会有非常大的提升。



虽然说roi很高,但是也要花点心思,特别是大的团队,需要多方配合。


首先是需求要有精简的价值点,然后运营同学整合所有的需求价值点,根据优先级,出一套面向用户的提醒话术,也就是提示的升级文案了。



3.4、更新渠道


iOS用户一般在App Store更新应用,但是对于Android来说,厂商比较多,对应的渠道也多,还有一些三方的,这些碎片化的渠道自然就把用户人群给分流了,为了让每一个用户都有渠道可以更新到最新版本,那就需要在渠道的运营上下点功夫了,尽可能的多覆盖。



除了拓宽更新渠道之外,在应用市场的介绍也要及时更新。


还有一点细节是,优化应用市场的搜索关键词。


最后,别忘了自有渠道-官网。


3.5、触达投放


如果我们做了上面这么多,还是有老版本的用户不愿意升级怎么办?我们还有哪些方式可以告诉他需要升级?


触达投放是一个很好的方式,就像游戏里的公告、全局喇叭一样,但是像发短信这种需要预算的方式一般都是放在最后使用的,尽可能的控制成本。




避免过度打扰,做好人群细分,控制好成本。



3.6、其他方案


类型介绍效果
更新引导更新升级操作的新手引导😀
操作手册描述整个升级流程,可以放在帮助页面,也可以放在联系客服的智能推荐🙂
营销策略升级给会员体验、优惠券之类的😀
内卷策略您获得了珍贵的内测机会,您使用的版本将领先同行98%😆
选择策略「88%的用户选择升级」,替用户做选择😆
心理策略「预计下载需要15秒」,给用户心理预期😀
接口拦截在使用某个功能或者登录的时候拦截,引导下载最新版本😆
自动下载设置里面加个开关,wifi环境下自动下载安装包😀
版本故事类似于微信,每个版本都有一个功能介绍的东西,点击跳转加上新手引导🙂

好的例子:


淘宝拼多多

4、长效治理


制定流程SOP,形成一个完整链路的组合拳打法🐶。


白话讲,就是每当一个新版本发布的时候,明确在每个时间段要做什么事,达成什么样的目标。


让更多需要人工干预的环节,都变成流程化,自动化。



5、最后


一张图总结APP版本治理依次递进的动作和策略。



作者:yechaoa
来源:juejin.cn/post/7299799127625170955
收起阅读 »

Android-桌面小组件RemoteViews播放动画

一、前言 前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器! 我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~) 咳咳,扯远了,说回正题 我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能...
继续阅读 »

一、前言


前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器!


我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~)


咳咳,扯远了,说回正题


我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能。好嘛,用户的话就是圣旨,那必须要安排上,正好我也练练手。


老规矩,先来看下我实现的效果



这个功能看着很简单对吧,却也花了我一天半的时间。主要用来实现敲击动画了!!


二、代码实现


1、新建小组件



 2、修改界面样式


主要会生成3个关键文件(文件名根据你设置的来)

①、APPWidget  类,继承于 AppWidgetProvider,本质是一个 BroadCastReceiver


②、layout/widget.xml ,小组件布局文件


③、xml/widget_info.xml ,小组件信息说明文件


同时会在 AndroidManifest中注册好


类似如下代码:


     <receiver
android:name=".receiver.MuyuAppWidgetBig"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.fyspring.bluetooth.receiver.action_appwidget_muyu_knock" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_info_big" />
</receiver>

3、添加敲木鱼逻辑代码


通过 APPWidget 的模板代码我们知道,内部通过 RemoteViews 来进行更新View,而我们都知道 RemoteViews 是无法通过 findViewById 来转成对应的 view,更无法对其添加 Animator。那么我们该怎么办来给桌面木鱼组件添加一个 缩放动画呢?


给你三秒时间考虑下,这里我可花了一天时间来研究....


通过 layoutAnimation !!!


layoutAnimation 是在 ViewGr0up 创建之后,显示时作用的,作用时间是:ViewGr0up 的首次创建显示,之后再有改变就不行了。


虽然 RemoteViews 不能执行 findViewById,但它提供了两个关键方法: remoteViews.removeAllViews  和  remoteViews.addView 。如果我们在点击时,向组件布局中添加一个带有 layoutAnimation 的布局,不是就可以间接播放动画了么?


关键代码:


private fun doAnimation(context: Context?, remoteViews: RemoteViews?) {
remoteViews?.removeAllViews(R.id.muyu_rl)
val remoteViews2 = RemoteViews(context?.packageName, R.layout.anim_layout)
remoteViews2.setImageViewResource(R.id.widget_muyu_iv, R.mipmap.ic_muyu)
remoteViews?.addView(R.id.muyu_rl, remoteViews2)
}

小组件布局:


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.BlueToothDemo.AppWidget.Container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_round_bg"
android:theme="@style/Theme.BlueToothDemo.AppWidgetContainer">

<LinearLayout
android:layout_width="140dp"
android:layout_height="140dp"
android:gravity="center_horizontal"
android:orientation="vertical">

<TextView
android:id="@+id/appwidget_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:contentDescription="测试桌面木鱼"
android:text="已敲0次"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />

<RelativeLayout
android:id="@+id/muyu_rl"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_margin="15dp"
android:src="@mipmap/ic_muyu" />

</RelativeLayout>
</LinearLayout>
</RelativeLayout>

添加替换的动画布局(anim_layout.xml),注意两边的木鱼ImgView 的 ID保持一致,因为要统一设置点击事件!!


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layoutAnimation="@anim/muyu_anim">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_muyu2" />
</RelativeLayout>

动画文件:(muyu_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/scale_anim"/>


动画文件:(scale_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="100"
android:fromXScale="0.9"
android:fromYScale="0.9"
android:interpolator="@android:anim/accelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1"
android:toYScale="1" />

关键动画代码就是以上这些,如果有问题欢迎私信。希望大家在新的一年里,木鱼一敲,烦恼全消~


欢迎体验下我做的木鱼,记得搜  我要敲木鱼  哦~~


作者:今夜太冷不宜私奔丶
来源:juejin.cn/post/7323025855154962459
收起阅读 »

Android开发中“真正”的仓库模式

原文标题:The “Real” Repository Pattern in Android 原文地址:proandroiddev.com/the-real-re… 原文发表日期:2019.9.5 作者:Denis Brandi 翻译:tommwq 翻译日期:2...
继续阅读 »

  • 原文标题:The “Real” Repository Pattern in Android

  • 原文地址:proandroiddev.com/the-real-re…

  • 原文发表日期:2019.9.5

  • 作者:Denis Brandi

  • 翻译:tommwq

  • 翻译日期:2024.1.3



Figure 1: 仓库模式


多年来我见过很多仓库模式的实现,我想其中大部分是错误而无益的。


下面是我所见最多的5个错误(一些甚至出现在Android官方文档中):



  1. 仓库返回DTO而非领域模型。

  2. 数据源(如ApiService、Dao等)使用同一个DTO。

  3. 每个端点集合使用一个仓库,而非每个实体(或DDD聚合根)使用一个仓库。

  4. 仓库缓存全部模型,即使是频繁更新的域。

  5. 数据源被多个仓库共享使用。


那么要如何把仓库模式做对呢?


1. 你需要领域模型


这是仓库模式的关键点,我想开发者难以正确实现仓库模式的原因在于他们不理解领域是什么。


引用Martin Fowler的话,领域模型是:



领域中同时包含行为和数据的对象模型。



领域模型基本上表示企业范围的业务规则。


对于不熟悉领域驱动设计构建块或分层架构(六边形架构,洋葱架构,干净架构等)的人来说,有三种领域模型:



  1. 实体:实体是具有标识(ID)的简单对象,通常是可变的。

  2. 值对象:没有标识的不可变对象。

  3. 聚合根(仅限DDD):与其他实体绑定在一起的实体(通常是一组关联对象的聚合)。


对于简单领域,这些模型看起来与数据库和网络模型(DTO)很像,不过它们也有很多差异:



  • 领域模型包含数据和过程,其结构最适于应用程序。

  • DTO是表示JSON/XML格式请求/应答或数据库表的对象模型,其结构最适于远程通信。


Listing 1: 领域模型示例


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

Listing 2: 网络DTO示例


// Network DTO
data class NetworkProduct(
@SerializedName("id")
val id: String?,
@SerializedName("name")
val name: String?,
@SerializedName("nowPrice")
val nowPrice: Double?,
@SerializedName("wasPrice")
val wasPrice: Double?
)

Listing 3: 数据库DTO示例


// Database DTO
@Entity(tableName = "Product")
data class DBProduct(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "nowPrice")
val nowPrice: Double,
@ColumnInfo(name = "wasPrice")
val wasPrice: Double
)

如你所见,领域模型不依赖框架,对象字段提倡使用多值属性(正如你看到的Price逻辑分组),并使用空对象模式(域不可为空)。而DTO则与框架(Gson、Room)耦合。


幸好有这样的隔离:



  • 应用程序的开发变得更容易,因为不需要检查空值,多值属性也减少了字段数量。

  • 数据源变更不会影响高层策略。

  • 避免了“上帝模型”,带来更多的关注点分离。

  • 糟糕的后端接口不会影响高层策略(想象一下,如果你需要执行两个网络请求,因为后端无法在一个接口中提供所有信息。你会让这个问题影响你的整个代码库吗?)


2. 你需要数据转换器


这是将DTO转换成领域模型,以及进行反向转换的地方。


多数开发者认为这种转换是无趣又无效的,他们喜欢将整个代码库,从数据源到界面,与DTO耦合。


这也许能让第一个版本更快交付,但不在表示层中隐藏业务规则和用例,而是省略领域层并将界面与数据源耦合会产生一些只会在生产环境遇到的故障(比如后端没有发送空字符串,而是发送null,并因此引发NPE)。


以我所见,转换器写起来快,测起来也简单。即使实现过程缺乏趣味,它也能保护我们不会因数据源行为的改变而受到意外影响。


如果你没有时间(或者干脆懒得)进行数据转换,你可以使用对象转换框架,比如如modelmapper.org/ ,来加速开发过程。


我不喜欢在代码中使用框架,为减少样板代码,我建立了一个泛型转换接口,以免为每个转换器建立独立接口:


interface Mapper<I, O> {
fun map(input: I): O
}

以及一组泛型列表转换器,以免实现特定的“列表到列表”转换:


// Non-nullable to Non-nullable
interface ListMapper<I, O>: Mapper<List<I>, List<O>>

class ListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : ListMapper<I, O> {
override fun map(input: List<I>): List<O> {
return input.map { mapper.map(it) }
}
}


// Nullable to Non-nullable
interface NullableInputListMapper<I, O>: Mapper<List<I>?, List<O>>

class NullableInputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableInputListMapper<I, O> {
override fun map(input: List<I>?): List<O> {
return input?.map { mapper.map(it) }.orEmpty()
}
}


// Non-nullable to Nullable
interface NullableOutputListMapper<I, O>: Mapper<List<I>, List<O>?>

class NullableOutputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableOutputListMapper<I, O> {
override fun map(input: List<I>): List<O>? {
return if (input.isEmpty()) null else input.map { mapper.map(it) }
}
}

注:在这篇文章中我展示了如何使用简单的函数式编程,以更少的样板代码实现相同的功能。


3. 你需要为每个数据源建立独立模型


假设在网络和数据库中使用同一个模型:


@Entity(tableName = "Product")
data class ProductDTO(
@PrimaryKey
@ColumnInfo(name = "id")
@SerializedName("id")
val id: String?,
@ColumnInfo(name = "name")
@SerializedName("name")
val name: String?,
@ColumnInfo(name = "nowPrice")
@SerializedName("nowPrice")
val nowPrice: Double?,
@ColumnInfo(name = "wasPrice")
@SerializedName("wasPrice")
val wasPrice: Double?
)

刚开始你可能会认为这比使用两个模型开发起来要快得多,但是你注意到它的风险了吗?


如果没有,我可以为你列出一些:



  • 你可能会缓存不必要的内容。

  • 在响应中添加新字段将需要变更数据库(除非添加@Ignore注解)。

  • 所有不应当在请求中发送的字段都需要添加@Transient注解。

  • 除非使用新字段,否则必须要求网络和数据库中的同名字段使用相同的数据类型(例如你无法解析网络响应中的字符串nowPrice并缓存双精度浮点数nowPrice)。


如你所见,这种方法最终将比独立模型需要更多的维护工作。


4. 你应该只缓存所需内容


如果要显示存储在远程目录中的产品列表,并且对本地保存的愿望清单中的每个产品显示经典的心形图标。


对于这个需求,需要:



  • 获取产品列表。

  • 检查本地存储,确认产品是否在愿望清单中。


这个领域模型很像前面例子,添加了一个字段表示产品是否在愿望清单中:


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

网络模型也和前面的示例类似,数据库模型则不再需要。


对于本地的愿望清单,可以将产品id保存在SharedPreferences中。不要使用数据库把简单的事情复杂化。


最后是仓库代码:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getProducts(): Single<Result<List<Product>>> {
return productApiService.getProducts().map {
when(it) {
is Result.Success -> Result.Success(mapProducts(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapProducts(networkProductList: List<NetworkProduct>): List<Product> {
return networkProductList.map {
productDataMapper.map(DataProduct(it, productPreferences.isFavourite(it.id)))
}
}
}

其中依赖的类定义如下:


// A wrapper for handling failing requests
sealed class Result<T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure<T>(val throwable: Throwable) : Result<T>()
}

// A DataSource for the SharedPreferences
interface ProductPreferences {
fun isFavourite(id: String?): Boolean
}

// A DataSource for the Remote DB
interface ProductApiService {
fun getProducts(): Single<Result<List<NetworkProduct>>>
fun getWishlist(productIds: List<String>): Single<Result<List<NetworkProduct>>>
}

// A cluster of DTOs to be mapped int0 a Product
data class DataProduct(
val networkProduct: NetworkProduct,
val isFavourite: Boolean
)

现在,如果只想获取愿望清单中的产品要怎么做呢?实现方式是类似的:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getWishlist(): Single<Result<List<Product>>> {
return productApiService.getWishlist(productPreferences.getFavourites()).map {
when (it) {
is Result.Success -> Result.Success(mapWishlist(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapWishlist(wishlist: List<NetworkProduct>): List<Product> {
return wishlist.map {
productDataMapper.map(DataProduct(it, true))
}
}
}

5. 后记


我多次熟练使用这种模式,我想它是一个时间节约神器,尤其在大型项目中。


然而我多次看到开发者使用这种模式仅仅是因为“不得不”,而非他们了解它的真正优势。


希望你觉得这篇文章有趣也有用。


作者:tommwq
来源:juejin.cn/post/7319698586421542953
收起阅读 »

安卓拍照、裁切、选取图片实践

安卓拍照、裁切、选取图片实践 前言 最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。 更新 最近花了点时间把拍照...
继续阅读 »

安卓拍照、裁切、选取图片实践


前言


最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。


更新


最近花了点时间把拍照、裁切的功能整理了下,并解决了下Android11上裁切闪退的问题、相册裁切闪退问题,就不多写一篇文章了,可以看我github的demo:


TakePhotoFragment.kt


BitmapFileUtil.kt


拍照


本来拍照是没什么难度的,不就是调用intent去系统相机拍照么,但是由于文件权限问题,Uri这东西就能把人很头疼。下面是代码(onActivityResult见后文):


    private fun openCamera() {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// 应用外部私有目录:files-Pictures
val picFile = createFile("Camera")
val photoUri = getUriForFile(picFile)
// 保存路径,不要uri,读取bitmap时麻烦
picturePath = picFile.absolutePath
// 给目标应用一个临时授权
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
//android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
startActivityForResult(intent, REQUEST_CAMERA_CODE)
}

private fun createFile(type: String): File {
// 在相册创建一个临时文件
val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
"${type}_${System.currentTimeMillis()}.jpg")
try {
if (picFile.exists()) {
picFile.delete()
}
picFile.createNewFile()
} catch (e: IOException) {
e.printStackTrace()
}

// 临时文件,后面会加long型随机数
// return File.createTempFile(
// type,
// ".jpg",
// requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
// )

return picFile
}

private fun getUriForFile(file: File): Uri {
// 转换为uri
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
FileProvider.getUriForFile(
requireActivity(),
"com.xxx.xxx.fileProvider", file
)
} else {
Uri.fromFile(file)
}
}

简单说明


这里的file是使用getExternalFilesDir和Environment.DIRECTORY_PICTURES生成的,它的文件保存在应用外部私有目录:files-Pictures里面。这里要注意不能存放在内部的私有目录里面,不然是无法访问的,外部私有目录虽然也是私有的,但是外面是可以访问的,这里拿官网上的说明:



在搭载 Android 9(API 级别 28)或更低版本的设备上,只要您的应用具有适当的存储权限,就可以访问属于其他应用的应用专用文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。



Uri的获取


再一个比较麻烦的就是Uri的获取了,网上有一大堆资料,不过我这也贴一下,网上的可能有问题。



manifest.xml



        <provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.xxx.xxx.fileProvider"
android:exported="false"
android:grantUriPermissions="true">

<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>

</provider>


res -> xml -> file_paths.xml



<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!--1、对应内部内存卡根目录:Context.getFileDir()-->
<files-path
name="int_root"
path="/" />

<!--2、对应应用默认缓存根目录:Context.getCacheDir()-->
<cache-path
name="app_cache"
path="/" />

<!--3、对应外部内存卡根目录:Environment.getExternalStorageDirectory()-->
<external-path
name="ext_root"
path="/" />

<!--4、对应外部内存卡根目录下的APP公共目录:Context.getExternalFileDir(String)-->
<external-files-path
name="ext_pub"
path="/" />

<!--5、对应外部内存卡根目录下的APP缓存目录:Context.getExternalCacheDir()-->
<external-cache-path
name="ext_cache"
path="/" />

</paths>

ps. 注意authorities这个最好填自己的包名,不然有两个应用用了同样的authorities,后面的应用会安装不上。


path里面填 “/” 和 “*” 是有区别的,前者包含了子目录,后面只包含当前目录,最好就是用 “/”,不然创建个子文件夹,到时候访问搞出了线上问题,那就凉凉喽(还好我遇到的时候测试测出来了)。


打开相册


这里打开相册用的是SAF框架,使用intent去选取(onActivityResult见后文)。


    private fun openAlbum() {
val intent = Intent()
intent.type = "image/*"
intent.action = "android.intent.action.GET_CONTENT"
intent.addCategory("android.intent.category.OPENABLE")
startActivityForResult(intent, REQUEST_ALBUM_CODE)
}

裁切


裁切这里比较麻烦,参数比较多,而且Uri那里有坑,不能使用provider,再一个就是图片传递那因为安卓版本变更,不会传略缩图了,很坑。


    private fun cropImage(path: String) {
cropImage(getUriForFile(File(path)))
}

private fun cropImage(uri: Uri) {
val intent = Intent("com.android.camera.action.CROP")
// Android 7.0需要临时添加读取Url的权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.setDataAndType(uri, "image/*")
// 使图片处于可裁剪状态
intent.putExtra("crop", "true")
// 裁剪框的比例(根据需要显示的图片比例进行设置)
// if (Build.MANUFACTURER.contains("HUAWEI")) {
// //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
// intent.putExtra("aspectX", 9999)
// intent.putExtra("aspectY", 9998)
// } else {
// //其他手机一般默认为方形
// intent.putExtra("aspectX", 1)
// intent.putExtra("aspectY", 1)
// }

// 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
// intent.putExtra("circleCrop", true);
// 让裁剪框支持缩放
intent.putExtra("scale", true)
// 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
// intent.putExtra("outputX", 400)
// intent.putExtra("outputY", 400)

// 生成临时文件
val cropFile = createFile("Crop")
// 裁切图片时不能使用provider的uri,否则无法保存
// val cropUri = getUriForFile(cropFile)
val cropUri = Uri.fromFile(cropFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
// 记录临时位置
cropPicPath = cropFile.absolutePath

// 设置图片的输出格式
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

// return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
intent.putExtra("return-data", false)

startActivityForResult(intent, REQUEST_CROP_CODE)
}

回调处理


下面是对上面三个操作的回调处理,一开始我觉得uri没什么用,还制造麻烦,后面发现可以通过流打开uri,再去获取bitmap,好像又不是那么麻烦了。


    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
when(requestCode) {
REQUEST_CAMERA_CODE -> {
// 通知系统文件更新
// requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
// Uri.fromFile(File(picturePath))))
if (!enableCrop) {
val bitmap = getBitmap(picturePath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(picturePath)
}
}
REQUEST_ALBUM_CODE -> {
data?.data?.let { uri ->
if (!enableCrop) {
val bitmap = getBitmap("", uri)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(uri)
}
}
}
REQUEST_CROP_CODE -> {
val bitmap = getBitmap(cropPicPath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}
}
}
}

private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
var bitmap: Bitmap?
val options = BitmapFactory.Options()
// 先不读取,仅获取信息
options.inJustDecodeBounds = true
if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}

// 预获取信息,大图压缩后加载
val width = options.outWidth
val height = options.outHeight
Log.d("TAG", "before compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 尺寸压缩
var size = 1
while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
size *= 2
}
options.inSampleSize = size
options.inJustDecodeBounds = false
bitmap = if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}
Log.d("TAG", "after compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 质量压缩
val baos = ByteArrayOutputStream()
bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val bais = ByteArrayInputStream(baos.toByteArray())
options.inSampleSize = 1
bitmap = BitmapFactory.decodeStream(bais, null, options)

return bitmap
}

这里还做了一个图片的质量压缩和采样压缩,需要注意的是采样压缩的采样率只能是2的倍数,如果需要按任意比例采样,需要用到Matrix,不是很难,读者可以研究下。


权限问题


如果你发现你没有申请权限,那你的去申请一下相机权限;如果你发现你还申请了储存权限,那你可以试一下去掉储存权限,实际还是可以使用的,因为这里并没有用到外部储存,都是应用的私有储存内,具体关于储存的适配,可以看我转载的这几篇文章,我觉得写的非常好:


Android 存储基础


Android 10、11 存储完全适配(上)


Android 10、11 存储完全适配(下)


结语


以上代码都经过我这里实践了,确认了可用,可能写法不是最优,可以避免使用绝对路径,只使用Uri。至于请求码、布局什么的,读者自己改一下加一个就行,核心部分已经在这了。如果需要完整代码,可以看下篇文章末尾!


Android 不申请权限储存、删除相册图片


作者:方大可
来源:juejin.cn/post/7222874734186037285
收起阅读 »

如何写一个无侵入式的动态权限申请Android框架?

1、核心逻辑 在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功,申请失败,多次拒绝。以上就是使用者需要做的。 简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。 public class MainActivi...
继续阅读 »

1、核心逻辑


在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功申请失败多次拒绝。以上就是使用者需要做的。


简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。


public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void permissionRequestTest(View view) {
testRequest();
}
// 申请权限 函数名可以随意些
@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200)
public void testRequest() {
Toast.makeText(this, "权限申请成功...", Toast.LENGTH_SHORT).show();
}
// 权限被取消 函数名可以随意些
@PermissionCancel
public void testCancel() {
Toast.makeText(this, "权限被拒绝", Toast.LENGTH_SHORT).show();
}

// 多次拒绝,还勾选了“不再提示”
@PermissionDenied
public void testDenied() {
Toast.makeText(this, "权限被拒绝(用户勾选了 不再提示),注意:你必须要去设置中打开此权限,否则功能无法使用", Toast.LENGTH_SHORT).show();
}


2、实现


需要用到的技术有Aspect、注解、反射


2.1、Aspect


它的作用就是劫持被注释的方法的执行。比如上方testRequest()是用来请求权限的,但是我在ASPECT中配置拦截@permission注释的方法。先做判断。


如果没有听过Aspect的话,AOP面向切面编程,大家应该听说过,它可以用来配置事务、做日志、权限验证、在用户请求时做一些处理等等。而用@Aspect做一个切面,就可以直接实现。


2.2、PermissionAspect

我们会创建一个PermissionAspect类,整一个切点,让@Permission被劫持。


// AOP 思维 切面的思维
// 切点 --- 是注解 @
// * * 任何函数 都可以使用 此注解
//(..) 我要带参数 带的参数就是后面那个 @annotation(permission)意思就是 参数里是permission。这样我就拿到了Permission注解它里面的参数
//这样通过切点就拿到了下面这个注解
//@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200) 这就是 Permission permission
@Pointcut
("execution(@com.derry.premissionstudy.permission.annotation.Permission * *(..)) && @annotation(permission)")
//那么这个
// @Permission == permission
public void pointActionMethod(Permission permission) {

} // 切点函数

//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this


这样@Permission就被切点劫持了,然后方法就会跑到切面aProceedingJoinPoint。然后获取上下文Context,把权限请求交给一个透明的Activity来做。做完之后判断结果,用户是同意了还是拒绝了还是曲线了。同意了直接执行point.proceed(),其他方式则通过Activity或者fragment获取带注解的方法,反射执行即可。


//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this

Context context = null;
// MainActivity this == thisObject
final Object thisobject = point.getThis();

// context初始化
if(thisobject instanceof Context){
context = (Context) thisobject;
} else if(thisobject instanceof Fragment){
context = ((Fragment) thisobject).getActivity();
}

// 判断是否为null
if (null == context || permission == null) {
throw new IllegalAccessException("null == context || permission == null is null");
}


//trestRequest 次函数被控制了 不会执
//
//动态申请 危险权限 透明的空白的Ativity
//这里一定要得知接口三个状态 已经授权 取消授权 拒绝授权
//调用 空白的Actiivty 开始授权

MyPermissionActivity.requestPermissionAction(context, permission.value(), permission.requestCode(), new IPermission() {
@Override
public void ganted() {
try {
point.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
@Override
public void cancel() {
PermissionUtils.invokeAnnotion(thisobject, PermissionCancel.class);
}

@Override
public void denied() {
PermissionUtils.invokeAnnotion(thisobject, PermissionDenied.class);
}
});
}

2.3、空白执行权限的Activity


执行请求权限的Activity的的相应方法会流到PermissionAspect,然后到空白Activity请求。请求完之后的结果,再通过回调传回去就好了。



public class MyPermissionActivity extends AppCompatActivity {



// 定义权限处理的标记, ---- 接收用户传递进来的
private final static String PARAM_PERMSSION = "param_permission";
private final static String PARAM_PERMSSION_CODE = "param_permission_code";
public final static int PARAM_PERMSSION_CODE_DEFAULT = -1;


private String[] permissions;
private int requestCode;


// 方便回调的监听 告诉外交 已授权,被拒绝,被取消
private static IPermission iPermissionListener;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my_permission);

permissions = getIntent().getStringArrayExtra(PARAM_PERMSSION);
requestCode = getIntent().getIntExtra(PARAM_PERMSSION_CODE, PARAM_PERMSSION_CODE_DEFAULT);


if(permissions == null){
this.finish();
return;
}

// 能够走到这里,就开始去检查,是否已经授权了
boolean permissionRequest = PermissionUtils.hasPermissionRequest(this,permissions);
if (permissionRequest) {
// 通过监听接口,告诉外交,已经授权了
iPermissionListener.ganted();

this.finish();
return;
}
ActivityCompat.requestPermissions(this,permissions,requestCode);
}


@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

if(PermissionUtils.requestPermissionSuccess(grantResults)){
iPermissionListener.ganted();//已经授权成功了 告知AspectJ
this.finish();
}

// 没有成功,可能是用户 不听话
// 如果用户点击了,拒绝(勾选了”不再提醒“) 等操作
if(!PermissionUtils.shouldShowRequestPermissionRationable(this,permissions)){
iPermissionListener.denied();

this.finish();
return;
}
// 取消
iPermissionListener.cancel(); // 接口告知 AspectJ
this.finish();
return;
}


// 让此Activity不要有任何动画
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}


public static void requestPermissionAction(Context context, String[] permissions, int requestCode, IPermission iPermissionLIstener){

MyPermissionActivity.iPermissionListener = iPermissionLIstener;
Intent intent = new Intent(context,MyPermissionActivity.class);
//效果
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Bundle bundle = new Bundle();

Log.d("TAG", "requestPermissionAction: "+requestCode);
bundle.putInt(PARAM_PERMSSION_CODE,requestCode);
bundle.putStringArray(PARAM_PERMSSION,permissions);
intent.putExtras(bundle);
context.startActivity(intent);
}
}

2.4、其它


app gradle中


 
apply plugin: 'com.android.application'


buildscript {
repositories {
mavenCentral()
}

dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "com.netease.premissionstudy"
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

dependencies {

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

implementation 'org.aspectj:aspectjrt:1.8.13'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->

if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile

javaCompile.doLast {

String[] args = ["-showWeaveInfo",

"-1.8",

"-inpath", javaCompile.destinationDir.toString(),

"-aspectpath", javaCompile.classpath.asPath,

"-d", javaCompile.destinationDir.toString(),

"-classpath", javaCompile.classpath.asPath,

"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]

log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);

new Main().run(args, handler);

for (IMessage message : handler.getMessages(null, true)) {

switch (message.getKind()) {

case IMessage.ABORT:

case IMessage.ERROR:

case IMessage.FAIL:

log.error message.message, message.thrown

break;

case IMessage.WARNING:

log.warn message.message, message.thrown

break;

case IMessage.INFO:

log.info message.message, message.thrown

break;

case IMessage.DEBUG:

log.debug message.message, message.thrown

break;
}
}
}
}

项目 gradle中



buildscript {
repositories {
google()
jcenter()

}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.1'

classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()

}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

3、总结


其实核心就是,用aspect去劫持注解,然后让一个公共的Activity来处理这个事情,然后回调,再反射其它方法执行。
等有时间了给他打成一个包。


作者:KentWang
来源:juejin.cn/post/7245836790039445562
收起阅读 »

什么情况下Activity会被杀掉呢?

首先一个报错来作为开篇: Caused by androidx.fragment.app.Fragment$InstantiationException Unable to instantiate fragment xxx: could not find Fr...
继续阅读 »

首先一个报错来作为开篇:


Caused by androidx.fragment.app.Fragment$InstantiationException
Unable to instantiate fragment xxx: could not find Fragment constructor

这个报错原因就是Fragment如果重载了有参的构造方法,没有实现默认无参构造方法。Activity被回收又回来尝试重新恢复Fragment的时候报错的。


那如何模拟Activity被回收呢?

可能有人知道,一个方便快捷的方法就是:打开 开发者选项 - 不保留活动,这样每次Activity回到后台都会被回收,也就可以很方便的测试这种case。


但抛开这种方式我怎么来复现这种情况呢?

这里我提出一种方式:我是不是可以打开我的App,按Home回到后台,然后疯狂的打开手机里其他的大型应用或者游戏这类的能占用大量手机内存的App,等手机内存占用大的时候是不是可以复现这种情况呢?


结论是不可以,不要混淆两个概念,系统内存不足App内存不足,两者能引起的后果也是不同的



  • 系统内存不足 -> 杀掉应用进程

  • App内存不足 -> 杀掉后台Activity


首先明确一点,Android框架对进程创建与管理进行了封装,对于APP开发者只需知道Android四大组件的使用。当Activity, Service, ContentProvider, BroadcastReceiver任一组件启动时,当其所承载的进程存在则直接使用,不存在则由框架代码自动调用startProcessLocked创建进程。所以说对APP来说进程几乎是透明的,但了解进程对于深刻理解Android系统是至关关键的。


1. 系统内存不够 -> 杀掉应用进程


1.1. LKM简介

Android底层还是基于Linux,在Linux中低内存是会有oom killer去杀掉一些进程去释放内存,而Android中的lowmemorykiller就是在此基础上做了一些调整来的。因为手机上的内存毕竟比较有限,而Android中APP在不使用之后并不是马上被杀掉,虽然上层ActivityManagerService中也有很多关于进程的调度以及杀进程的手段,但是毕竟还需要考虑手机剩余内存的实际情况,lowmemorykiller的作用就是当内存比较紧张的时候去及时杀掉一些ActivityManagerService还没来得及杀掉但是对用户来说不那么重要的进程,回收一些内存,保证手机的正常运行。


lowmemkiller中会涉及到几个重要的概念:

/sys/module/lowmemorykiller/parameters/minfree:里面是以”,”分割的一组数,每个数字代表一个内存级别

/sys/module/lowmemorykiller/parameters/adj: 对应上面的一组数,每个数组代表一个进程优先级级别


比如:

/sys/module/lowmemorykiller/parameters/minfree:18432, 23040, 27648, 32256, 55296, 80640

/sys/module/lowmemorykiller/parameters/adj: 0, 100, 200, 300, 900, 906


代表的意思是两组数一一对应:



  • 当手机内存低于80640时,就去杀掉优先级906以及以上级别的进程

  • 当内存低于55296时,就去杀掉优先级900以及以上的进程


可能每个手机的配置是不一样的,可以查看一下手头的手机,需要root。


1.2. 如何查看ADJ

如何查看进程的ADJ呢?比如我们想看QQ的adj


-> adb shell ps | grep "qq" 
UID PID PPID C STIME TTY TIME CMD
u0_a140 9456 959 2 10:03:07 ? 00:00:22 com.tencent.mobileqq
u0_a140 9987 959 1 10:03:13 ? 00:00:07 com.tencent.mobileqq:mini3
u0_a140 16347 959 0 01:32:48 ? 00:01:12 com.tencent.mobileqq:MSF
u0_a140 21475 959 0 19:47:33 ? 00:01:25 com.tencent.mobileqq:qzone

# 看到QQ的PID为 9456,这个时候打开QQ,让QQ来到前台
-> adb shell cat /proc/9456/oom_score_adj
0

# 随便打开一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
700

# 再随便打开另外一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
900

我们可以看到adj是在根据用户的行为不断变化的,前台的时候是0,到后台是700,回到后台后再打开其他App后是900

常见ADJ级别如下:


ADJ级别取值含义
NATIVE_ADJ-1000native进程
SYSTEM_ADJ-900仅指system_server进程
PERSISTENT_PROC_ADJ-800系统persistent进程
PERSISTENT_SERVICE_ADJ-700关联着系统或persistent进程
FOREGROUND_APP_ADJ0前台进程
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_APP_ADJ200可感知进程,比如后台音乐播放
BACKUP_APP_ADJ300备份进程
HEAVY_WEIGHT_APP_ADJ400重量级进程
SERVICE_ADJ500服务进程
HOME_APP_ADJ600Home进程
PREVIOUS_APP_ADJ700上一个进程
SERVICE_B_ADJ800B List中的Service
CACHED_APP_MIN_ADJ900不可见进程的adj最小值
CACHED_APP_MAX_ADJ906不可见进程的adj最大值

So,当系统内存不足的时候会kill掉整个进程,皮之不存毛将焉附,Activity也就不在了,当然也不是开头说的那个case。


2. App内存不足 -> 杀掉后台Activity


上面分析了是直接kill掉进程的情况,一旦出现进程被kill掉,说明内存情况已经到了万劫不复的情况了,抛开内存泄漏的情况下,framework也需要一些策略来避免无内存可用的情况。下面我们来找一找fw里面回收Activity的逻辑(代码Base Android-30)。



Android Studio查看源码无法查看com.android.internal包名下的代码,双击Shift,勾选右上角Include non-prject Items.



入口定位到ActivityThreadattach方法,ActivityThread是App的入口程序,main方法中创建并调用atttach


// ActivityThread.java
private void attach(boolean system, long startSeq) {
...
// Watch for getting close to heap limit.
BinderInternal.addGcWatcher(new Runnable() {
@Override public void run() {
// mSomeActivitiesChanged在生命周期变化的时候会修改为true
if (!mSomeActivitiesChanged) {
return;
}
Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) {
mSomeActivitiesChanged = false;
try {
ActivityTaskManager.getService().releaseSomeActivities(mAppThread);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
});
...
}

这里关注BinderInternal.addGcWatcher, 下面有几个点需要理清:



  1. addGcWatcher是干嘛的,这个Runnable什么时候会被执行。

  2. 这里的maxMemory() / totalMemory() / freeMemory()都怎么理解,值有什么意义

  3. releaseSomeActivities()做了什么事情,回收Activity的逻辑是什么。


还有一个小的点是这里还用了mSomeActivitiesChanged这个标记位来标记让检测工作不会过于频繁的执行,检测到需要releaseSomeActivities后会有一个mSomeActivitiesChanged = false;赋值。而所有的mSomeActivitiesChanged = true操作都在handleStartActivity/handleResumeActivity...等等这些操作Activity声明周期的地方。控制了只有Activity声明周期变化了之后才会继续去检测是否需要回收。


2.1. GcWatcher

BinderInternal.addGcWatcher是个静态方法,相关代码如下:


public class BinderInternal {
private static final String TAG = "BinderInternal";
static WeakReference<GcWatcher> sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
static Runnable[] sTmpWatchers = new Runnable[1];

static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
handleGc();
sLastGcTime = SystemClock.uptimeMillis();
synchronized (sGcWatchers) {
sTmpWatchers = sGcWatchers.toArray(sTmpWatchers);
}
for (int i=0; i<sTmpWatchers.length; i++) {
if (sTmpWatchers[i] != null) {
sTmpWatchers[i].run();
}
}
sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
}
}

public static void addGcWatcher(Runnable watcher) {
synchronized (sGcWatchers) {
sGcWatchers.add(watcher);
}
}
...
}

两个重要的角色:sGcWatcherssGcWatcher



  • sGcWatchers保存了调用BinderInternal.addGcWatcher后需要执行的Runnable(也就是检测是否需要kill Activity的Runnable)。

  • sGcWatcher是个装了new GcWatcher()的弱引用。


弱引用的规则是如果一个对象只有一个弱引用来引用它,那GC的时候就会回收这个对象。那很明显new出来的这个GcWatcher()只会有sGcWatcher这一个弱引用来引用它,所以每次GC都会回收这个GcWatcher对象,而回收的时候会调用这个对象的finalize()方法,finalize()方法中会将之前注册的Runnable来执行掉。
注意哈,这里并没有移除sGcWatcher中的Runnable,也就是一开始通过addGcWatcher(Runnable watcher)进来的runnable一直都在,不管执行多少次run的都是它。


为什么整个系统中addGcWatcher只有一个调用的地方,但是sGcWatchers确实一个List呢?我在自己写了这么一段代码并且想着怎么能反射搞到系统当前的BinderInternal一探究竟的时候明白了一点点,我觉着他们就是怕有人主动调用了addGcWatcher给弄了好多个GcWatcher导致系统的失效了才搞了个List吧。。


2.2. App可用的内存

上面的Runnable是如何检测当前的系统内存不足的呢?通过以下的代码


        Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) { ... }

看变量名字就知道,在使用的内存到达总内存的3/4的时候去做一些事情,这几个方法的注释如下:


    /**
* Returns the amount of free memory in the Java Virtual Machine.
* Calling the gc method may result in increasing the value returned by freeMemory.
* @return an approximation to the total amount of memory currently available for future allocated objects, measured in bytes.
*/

public native long freeMemory();

/**
* Returns the total amount of memory in the Java virtual machine.
* The value returned by this method may vary over time, depending on the host environment.
* @return the total amount of memory currently available for current and future objects, measured in bytes.
*/

public native long totalMemory();

/**
* Returns the maximum amount of memory that the Java virtual machine will attempt to use.
* If there is no inherent limit then the value java.lang.Long#MAX_VALUE will be returned.
* @return the maximum amount of memory that the virtual machine will attempt to use, measured in bytes
*/

public native long maxMemory();

首先确认每个App到底有多少内存可以用,这些Runtime的值都是谁来控制的呢?


可以使用adb shell getprop | grep "dalvik.vm.heap"命令来查看手机给每个虚拟机进程所分配的堆配置信息:


yocn@yocn ~ % adb shell getprop | grep "dalvik.vm.heap"
[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]

这些值分别是什么意思呢?



  • [dalvik.vm.heapgrowthlimit]和[dalvik.vm.heapsize]都是当前应用进程可分配内存的最大限制,一般heapgrowthlimit < heapsize,如果在Manifest中的application标签中声明android:largeHeap=“true”,APP直到heapsize才OOM,否则达到heapgrowthlimit就OOM

  • [dalvik.vm.heapstartsize] Java堆的起始大小,指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小,后面再根据需要逐渐向系统申请更多的物理内存,直到达到MAX

  • [dalvik.vm.heapminfree] 堆最小空闲值,GC后

  • [dalvik.vm.heapmaxfree] 堆最大空闲值

  • [dalvik.vm.heaptargetutilization] 堆目标利用率


比较难理解的就是heapminfree、heapmaxfree和heaptargetutilization了,按照上面的方法来说:
在满足 heapminfree < freeMemory() < heapmaxfree的情况下使得(totalMemory() - freeMemory()) / totalMemory()接近heaptargetutilization


所以一开始的代码就是当前使用的内存到达分配的内存的3/4的时候会调用releaseSomeActivities去kill掉某些Activity.


2.3. releaseSomeActivities

releaseSomeActivities在API 29前后差别很大,我们来分别看一下。


2.3.1. 基于API 28的版本的releaseSomeActivities实现如下:

// step①:ActivityManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized(this) {
final long origId = Binder.clearCallingIdentity();
try {
ProcessRecord app = getRecordForAppLocked(appInt);
mStackSupervisor.releaseSomeActivitiesLocked(app, "low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// step②:ActivityStackSupervisor.java
void releaseSomeActivitiesLocked(ProcessRecord app, String reason) {
TaskRecord firstTask = null;
ArraySet<TaskRecord> tasks = null;
for (int i = 0; i < app.activities.size(); i++) {
ActivityRecord r = app.activities.get(i);
// 如果当前有正在销毁状态的Activity,Do Nothing
if (r.finishing || r.state == DESTROYING || r.state == DESTROYED) {
return;
}
// 只有Activity在可以销毁状态的时候才继续往下走
if (r.visible || !r.stopped || !r.haveState || r.state == RESUMED || r.state == PAUSING
|| r.state == PAUSED || r.state == STOPPING) {
continue;
}
if (r.task != null) {
if (firstTask == null) {
firstTask = r.task;
} else if (firstTask != r.task) {
// 2.1 只有存在两个以上的Task的时候才会到这里
if (tasks == null) {
tasks = new ArraySet<>();
tasks.add(firstTask);
}
tasks.add(r.task);
}
}
}
// 2.2 只有存在两个以上的Task的时候才不为空
if (tasks == null) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Didn't find two or more tasks to release");
return;
}
// If we have activities in multiple tasks that are in a position to be destroyed,
// let's iterate through the tasks and release the oldest one.
// 2.3 遍历找到ActivityStack释放最旧的那个
final int numDisplays = mActivityDisplays.size();
for (int displayNdx = 0; displayNdx < numDisplays; ++displayNdx) {
final ArrayList<ActivityStack> stacks = mActivityDisplays.valueAt(displayNdx).mStacks;
// Step through all stacks starting from behind, to hit the oldest things first.
// 从后面开始遍历,从最旧的开始匹配
for (int stackNdx = 0; stackNdx < stacks.size(); stackNdx++) {
final ActivityStack stack = stacks.get(stackNdx);
// Try to release activities in this stack; if we manage to, we are done.
// 尝试在这个stack里面销毁这些Activities,如果成功就返回。
if (stack.releaseSomeActivitiesLocked(app, tasks, reason) > 0) {
return;
}
}
}
}

上面代码都加了注释,我们来理一理重点需要关注的点。整个流程可以观察tasks的走向



  • 2.1 & 2.2: 第一次循环会给firstTask赋值,当firstTask != r.task的时候才会给tasks赋值,后续会继续对tasks操作。所以单栈的应用不会回收,如果tasks为null,就直接return了,什么都不做

  • 2.3: 这一大段的双重for循环其实都没有第一步遍历出来的tasks参与,真正释放Activity的操作在ActivityStack中,所以尝试找到这些tasks对应的ActivityStack,让ActivityStack去销毁tasks,直到成功销毁。


继续查看releaseSomeActivitiesLocked:


// step③ ActivityStack.java
final int releaseSomeActivitiesLocked(ProcessRecord app, ArraySet<TaskRecord> tasks, String reason) {
// Iterate over tasks starting at the back (oldest) first.
int maxTasks = tasks.size() / 4;
if (maxTasks < 1) {
maxTasks = 1;
}
// 3.1 maxTasks至少为1,至少清理一个
int numReleased = 0;
for (int taskNdx = 0; taskNdx < mTaskHistory.size() && maxTasks > 0; taskNdx++) {
final TaskRecord task = mTaskHistory.get(taskNdx);
if (!tasks.contains(task)) {
continue;
}
int curNum = 0;
final ArrayList<ActivityRecord> activities = task.mActivities;
for (int actNdx = 0; actNdx < activities.size(); actNdx++) {
final ActivityRecord activity = activities.get(actNdx);
if (activity.app == app && activity.isDestroyable()) {
destroyActivityLocked(activity, true, reason);
if (activities.get(actNdx) != activity) {
// Was removed from list, back up so we don't miss the next one.
// 3.2 destroyActivityLocked后续会调用TaskRecord.removeActivity(),所以这里需要将index--
actNdx--;
}
curNum++;
}
}
if (curNum > 0) {
numReleased += curNum;
// 移除一个,继续循环需要判断 maxTasks > 0
maxTasks--;
if (mTaskHistory.get(taskNdx) != task) {
// The entire task got removed, back up so we don't miss the next one.
// 3.3 如果整个task都被移除了,这里同样需要将获取Task的index--。移除操作在上面3.1的destroyActivityLocked,移除Activity过程中,如果task为空了,会将task移除
taskNdx--;
}
}
}
return numReleased;
}


  • 3.1: ActivityStack利用maxTasks 保证,最多清理tasks.size() / 4,最少清理1个TaskRecord,同时,至少要保证保留一个前台可见TaskRecord,比如如果有两个TaskRecord,则清理先前的一个,保留前台显示的这个,如果三个,则还要看看最老的是否被有效清理,也就是是否有Activity被清理,如果有则只清理一个,保留两个,如果没有,则继续清理次老的,保留一个前台展示的,如果有四个,类似,如果有5个,则至少两个清理。一般APP中,很少有超过两个TaskRecord的。

  • 3.2: 这里清理的逻辑很清楚,for循环,如果定位到了期望的activity就清理掉,但这里这个actNdx--是为什么呢?注释说activity从list中移除了,为了能继续往下走,需要index--,但在这个方法中并没有将activity从lsit中移除的操作,那肯定是在destroyActivityLocked方法中。继续追进去可以一直追到TaskRecord.java#removeActivity(),从当前的TaskRecord的mActivities中移除了,所以需要index--。

  • 3.3: 我们弄懂了上面的actNdx--之后也就知道这里为什么要index--了,在ActivityStack.java#removeActivityFromHistoryLocked()中有


	if (lastActivity) {
removeTask(task, reason, REMOVE_TASK_MODE_DESTROYING);
}

如果task中没有activity了,需要将这个task移除掉。


以上就是基于API 28的releaseSomeActivities分析。


2.3.2. 基于29+的版本的releaseSomeActivities实现如下:

// ActivityTaskManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized (mGlobalLock) {
final long origId = Binder.clearCallingIdentity();
try {
final WindowProcessController app = getProcessController(appInt);
app.releaseSomeActivities("low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// WindowProcessController.java
void releaseSomeActivities(String reason) {
// Examine all activities currently running in the process. Candidate activities that can be destroyed.
// 检查进程里所有的activity,看哪些可以被关掉
ArrayList<ActivityRecord> candidates = null;
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Trying to release some activities in " + this);
for (int i = 0; i < mActivities.size(); i++) {
final ActivityRecord r = mActivities.get(i);
// First, if we find an activity that is in the process of being destroyed,
// then we just aren't going to do anything for now; we want things to settle
// down before we try to prune more activities.
// 首先,如果我们发现一个activity正在执行关闭中,在关掉这个activity之前什么都不做
if (r.finishing || r.isState(DESTROYING, DESTROYED)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Abort release; already destroying: " + r);
return;
}
// Don't consider any activities that are currently not in a state where they can be destroyed.
// 如果当前activity不在可关闭的state的时候,不做处理
if (r.mVisibleRequested || !r.stopped || !r.hasSavedState() || !r.isDestroyable()
|| r.isState(STARTED, RESUMED, PAUSING, PAUSED, STOPPING)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Not releasing in-use activity: " + r);
continue;
}

if (r.getParent() != null) {
if (candidates == null) {
candidates = new ArrayList<>();
}
candidates.add(r);
}
}

if (candidates != null) {
// Sort based on z-order in hierarchy.
candidates.sort(WindowContainer::compareTo);
// Release some older activities
int maxRelease = Math.max(candidates.size(), 1);
do {
final ActivityRecord r = candidates.remove(0);
r.destroyImmediately(true /*removeFromApp*/, reason);
--maxRelease;
} while (maxRelease > 0);
}
}

新版本的releaseSomeActivities放到了ActivityTaskManagerService.java这个类中,这个类是API 29新添加的,承载部分AMS的工作。
相比API 28基于Task栈的回收Activity策略,新版本策略简单清晰, 也激进了很多。


遍历所有Activity,刨掉那些不在可销毁状态的Activity,按照Activity堆叠的顺序,也就是Z轴的顺序,从老到新销毁activity。


有兴趣的读者可以自行编写测试代码,分别在API 28和API 28+的手机上测试看一下回收策略是否跟上面分析的一致。

也可以参考我写的TestKillActivity,单栈和多栈的情况下在高于API 28和低于API 28的手机上的表现。


总结:



  1. 系统内存不足时LMK会根据内存配置项来kill掉进程释放内存

  2. kill时会按照进程的ADJ规则来kill

  3. App内存不足时由GcWatcher来决定回收Activity的时机

  4. 可以使用getprop命令来查看当前手机的JVM内存分配和OOM配置

  5. releaseSomeActivities在API 28和API 28+的差别很大,低版本会根据Task数量来决定清理哪个task的。高版本简单粗暴,遍历activity,按照z order排序,优先release掉更老的activity。


参考资料:
Android lowmemorykiller分析
解读Android进程优先级ADJ算法
http://www.jianshu.com/p/3233c33f6…
juejin.cn/post/706306…
Android可见APP的不可见任务栈(TaskRecord)销毁分析


作者:Yocn
来源:juejin.cn/post/7231742100844871736
收起阅读 »

Android架构设计 搞懂应用架构设计原则,不要再生搬硬套的使用MVVM、MVI

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。 对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVV...
继续阅读 »

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。


对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVVM 和 MVI。



首先看看现在最新的官方应用架构指南,是如何建议我们搭建应用架构的。


一,官方应用架构指南


1,架构的原则


应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。谷歌建议按照以下原则设计应用架构。


1,分离关注点


2,通过数据模型驱动界面


3,单一数据源


4,单向数据流


2,谷歌推荐的应用架构


每个应用应至少有两个层:



  • 界面层 - 在屏幕上显示应用数据。

  • 数据层 - 包含应用的业务逻辑并公开应用数据。


可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。


总结下来,我们的应用架构应该有三层:界面层、网域层、数据层。


其中网域层可选,即无论你的应用中有没有网域层,与你的应用架构是 MVVM 还是 MVI 无关。


image.png


个人的理解是:界面层、网域层、数据层应该是应用级别的,而不是页面级别的。


如果我认为分层是页面级别的,那么我在接到一个业务需求 A 时(一般一个业务会新建一个页面activity 或 fragment 来承接),我的实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 新建数据层 A_Repository


这样的话,数据层 A_Repository 和 界面层:界面元素 A_Activity + 状态容器 A_ViewModel 强相关,数据层无复用性可言。


如果我认为分层是应用级别的,那么在接到业务A 时,实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 首先从应用的数据层寻找业务 A 需要使用的 业务数据 是否有对应的存储仓库 Respository,如果有,则复用(A_ViewModel 中依赖 Repository),拿到业务数据后,转成 UI 数据;如果无,则创建 这种业务数据 的存储仓库 Repository,后续其他界面层如果页使用到这种业务数据,可以直接复用这种业务数据对应的 Repository。


2.1,界面层架构设计指导


界面层在架构中的作用


界面的作用是在屏幕上显示应用数据,并充当主要的用户互动点。


从数据层获取是业务数据,有时候需要界面层将业务数据转换成 UI 数据供界面元素显示。


界面层的组成


界面层由以下两部分组成:



  • 界面元素:在屏幕上呈现数据的界面元素可以使用 View 或 Jetpack Compose 函数实现。

  • 状态容器:用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。


image.png


界面层的架构设计遵循的原则


这里以一个常见的列表页面为案例进行讲解,这个列表页面有以下交互:



  • 打开页面时,网络数据回来之前展示一个加载中 view。

  • 首次打卡页面,如果没有数据或者网络请求发生错误,展示一个错误 view。

  • 具备下拉刷新能力,刷新后,如果有数据,则替换列表数据;如果无返回数据,则弹出一个 Toast。


接着我们用这个业务,按照以下原则进行分析:


1, 定义界面状态


界面元素 加上 界面状态 才是用户看到的界面。


image.png


上面说的列表页面,根据它的业务需求,需要有以下界面状态



  • 展示加载中 view 的界面状态

  • 展示加载错误 view 的界面状态

  • 列表数据 view 界面状态

  • Toast view 界面状态

  • 刷新完成 view 界面状态


无论采用 MVVM 还是 MVI,都需要这些界面状态,只是他们的实现细节不同,具体可以看下面的讲解。


2,定义状态容器


状态容器:就是存放我们定义的界面状态,并且包含执行相应任务所必需的逻辑的类。


ViewModel 类型是推荐的状态容器,用于管理屏幕级界面状态,具有数据层访问权限。但并不是只能用 ViewModel作为状态容器。


无论采用 MVVM 还是 MVI,都需要定义状态容器,来存放界面状态。


3,使用单向数据流管理状态


看看官方在界面层的架构指导图:


image.png


界面状态数据流动是单向的,只能从 状态容器 到 界面元素。


界面发生的事件 events(如刷新、加载更多等事件)流动是单向的,只能从 界面元素 到 状态容器。


无论采用 MVVM 还是 MVI,都需要使用单向数据流管理状态。


4,唯一数据源


唯一数据源针对的是:定义的界面状态 和 界面发生的事件。


界面状态唯一数据源指的是将定义的多个界面状态,封装在一个类中,如上面的列表业务,不采用唯一数据源,界面状态的声明为:


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

采用唯一数据源声明界面状态时,代码如下:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)


界面发生的事件的唯一数据源指的是将界面发生的事件封装在一个类中,然后统一处理。比如上面描述的列表业务,它的界面事件有 初始化列表事件(首屏请求网络数据)、刷新事件、加载更多事件。


不采用唯一数据源,界面事件的调用实现逻辑为:在 activity 中直接调用 viewModel 提供的 initData、freshData 和 loadMoreData 方法;


采用唯一数据源,界面事件的调用实现逻辑为,先将事件中封装在一个 Intent 中,viewModel 中提供一个统一的事件入口处理方法 dispatchIntent,在 activity 中 各个场景下都调用 viewModel#dispatchIntent,代码如下:


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}


因为有了唯一数据源这一特点,才将最新的应用架构称为 MVI,MVVM 不具备这一特点。


5,向界面公开界面状态的方式


在状态容器中定义界面状态后,下一步思考的是如何将提供的状态发送给界面。


谷歌推荐使用 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做的优点有:



  • 解耦界面元素(activity 或 fragment) 与 状态容器,如:activity 持有 viewModel 的引用,viewModel 不需要持有 activity 的引用。


无论采用 MVVM 还是 MVI,都需要向界面公开界面状态,公开的方式也可以是一样的。


6,使用界面状态


在界面中使用界面状态时,对于 LiveData,可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。


注意:在界面中使用可观察数据容器时,需要考虑界面的生命周期。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API,如:


class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

无论采用 MVVM 还是 MVI,都需要使用界面状态,使用的方式都是一样的。


2.2,数据层架构设计指导


数据层在架构中的作用


数据层包含应用数据和业务逻辑。业务逻辑决定应用的价值,它由现实世界的业务规则组成,这些规则决定着应用数据的创建、存储和更改方式。


数据层的架构设计


数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。


每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。


层次结构中的其他层不能直接访问数据源;数据层的入口点始终是存储库类。


公开 API


数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:



  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数。

  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流,对于 Java 编程语言,数据层应公开用于发出新数据的回调。


class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

多层存储库


在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。


例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository,以满足其要求。


image.png


注意:传统上,一些开发者将依赖于其他存储库类的存储库类称为 manager,例如称为 UserManager 而非 UserRepository。


数据层生命周期


如果该类的职责作用于应用至关重要,可以将该类的实例的作用域限定为 Application 类。


如果只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity。


数据层定位思考


数据层不应该是页面级别的(一个页面对应一个数据层),而应该是应用级别的(数据层有多个存储仓库,每种数据类型有一个对应的存储仓库,不同的界面层可以复用存储仓库)。


比如我做的应用是运动健康app,用户的睡眠相关的数据有一个 SleepResposity,用户体重相关的数据有一个 WeightReposity,由于应用中很多界面都可能需要展示用户的睡眠数据和体重数据,所以 SleepResposity 和 WeightReposity 可以供不同界面层使用。


二,MVVM


1,MVVM 架构图


image.png


2,MVVM 实现一个具体业务


使用上面提到的列表页面业务,按照 MVVM 架构实现如下:


2.1,界面层的实现


界面层实现时,需要遵循以下几点。


1,选择实现界面的元素


界面元素可以用 view 或 compose 来实现,这里用 view 实现。


2,提供一个状态容器


这里使用 ViewModel 作为状态容器;状态容器用来存放界面状态变量;ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。


3,定义界面状态


这个需求中我们根据业务描述,定义出多个界面状态。


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

4,公开界面状态


这里选择数据流 StateFlow 公开界面状态。当然也可以选择 LiveData 公开界面状态。


5,使用/订阅界面状态


我这里使用的是数据流 StateFlow 公开的界面状态,所以在界面层相对应的使用 flow#collect 订阅界面状态。


6,数据模型驱动界面


结合上面几点,界面层的实现代码为:


界面元素的实现:


class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.refreshNewsData()
}
}

private fun initData() {
mViewModel.getNewsData()
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.isLoading.collect {
if (it) {
mBinding?.loadingView?.visibility = View.VISIBLE
} else {
mBinding?.loadingView?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingError.collect {
if (it) {
mBinding?.loadingError?.visibility = View.VISIBLE
} else {
mBinding?.loadingError?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingFinish.collect {
if (it) {
mBinding?.refreshView?.isRefreshing = false
}
}
}
launch {
mViewModel.toastMessage.collect {
if (it.isNotEmpty()) {
showToast(it)
}
}
}
launch {
mViewModel.newsList.collect {
if (it.isNotEmpty()) {
mBinding?.loadingError?.visibility = View.GONE
mBinding?.loadingView?.visibility = View.GONE
mBinding?.refreshView?.visibility = View.VISIBLE
mAdapter?.setData(it)
}
}
}
}
}
}

}

状态容器的实现:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

fun getNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_loadingError.emit(true)
} else {
_newsList.emit(list)
}
}
}

fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_loadingFinish.emit(true)
if (list.isNullOrEmpty()) {
_toastMessage.emit("暂时没有更新数据")
} else {
_newsList.emit(list)
}
}
}
}

2.2,数据层的实现


这里的数据层只有一个新闻列表数据结构的存储仓库 NewsRepository,另外获取新闻信息属于一次性操作,根据数据层架构设计,直接使用 suspend 就好。


class NewsRepository {

suspend fun getNewsList(): MutableList<News>? {
delay(2000)

val list = mutableListOf<News>()
val news = News("标题", "描述信息")
list.add(news)
list.add(news)
list.add(news)
list.add(news)
return list
}
}

个人的一些理解:


1, 数据层不应该是界面级别的,而应该是应用级别的


数据层不应该是界面级别的,即一个页面对应一个 Repository;数据层应该是应用级别的,即一个应用有一个或多个数据层,每个数据层中有多个存储仓库 Respository,存储仓库可以在不同的界面层复用。


之前我一直认为,一个页面对应一个数据层,一个页面对应一个 Repository。但后来发现这种理解不太对。上面的例子中 NewsViewModel 只用到 NewsRepository,是因为这个新闻列表业务中只用到新闻列表数据这种数据,假如列表中还可以点赞 那我们就需要新建一种点赞存储仓库 LikeRepository,来处理点赞数据,这时 NewsViewModel 与 Repository 的关系是这样:


class NewsViewModel : ViewModel() {

private val newsRepository = NewsRepository()
private val likeRepository = LikeRepository()
}


数据层提供的 新闻列表数据处理能力 NewsRepository 和 点赞数据处理能力 LikeRepository,应该是应用界别的,可以供不同的界面复用。


2,数据层应该是“不变的”


这里的不变不是说数据层的业务逻辑不变,而是指无论是 MVP、MVVM 还是 MVI,他们应该可以共用数据层。


2.3,网域层的实现


网域层是可选的,是否具备网域层,跟架构是否为 MVVM 无关,这个案例中不适用网域层。


三,MVI


1,MVI 架构图


image.png


2,MVI 实现一个具体业务


同样使用上面 MVVM 实现的新闻业务。按照 MVI 架构实现如下:


2.1,界面层的实现


除了和 MVVM 遵循以下几点相同原则之外:


1,选择实现界面的元素


2,提供一个状态容器


3,定义界面状态


4,公开界面状态


5,使用/订阅界面状态


6,单向数据流


MVI 还需要遵循原则:


1,单一数据源


所以 MVI 需要:1,把界面状态聚合起来;2,把界面事件聚合起来。


综合上面的原则,采用 MVI 实现界面的实现如下:


界面元素、聚合界面状态、聚合界面事件 代码:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.dispatchIntent(NewsActivityIntent.RefreshDataIntent())
}
}

private fun initData() {
mViewModel.dispatchIntent(NewsActivityIntent.InitDataIntent())
}

private fun loadMoreData() {
mViewModel.dispatchIntent(NewsActivityIntent.LoadMoreDataIntent())
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.newsUiState.collect {
//更新UI
}
}
}
}
}

}


状态容器代码:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}

/**
* 初始化
*/

private fun initNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.LoadingError)
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 刷新
*/

private fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_newsUiState.emit(NewsUiState.LoadingFinish)
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.ToastMessage("暂时没有新数据"))
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 没有实现
*/

private fun loadMoreNewsData() {

}

}

2.2,数据层与网域层的实现


界面层:参考上面 MVVM 的数据层介绍,无论 MVP、MVVM、MVI,不同应用架构的数据层应该是不变的,即通用。


网域层:应用架构是否具备网域层不影响它是什么类型的架构,这里的列表业务没有网域层。


作者:cola_wang
来源:juejin.cn/post/7278659049191686196
收起阅读 »

😳骚操作玩这么花的吗?Android基于Act实现事件的录制与回放!

基于Activity封装实现录制与回放 前言 在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改, 而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在...
继续阅读 »

基于Activity封装实现录制与回放


前言


在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,


而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。


一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。


目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。


不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。


那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。


当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。


那么话不多说,Let's go


300.png


一、定义事件


在前文 ViewGr0up 的文章中,我们知道了事件的伪造与保存,如何定制伪造事件时间轴,如何分发伪造事件,本文也是一个思路。


整体思路基于前文 ViewGr0up 的例子,还是把事件用对象封装起来,只是我们封装的对象换成了 MotionEvent ,并且不需要修改内部的操作时间了,我们用事件对象的 time 时间来制作伪造事件触发的时间轴。


这样对于事件的录制我们就能直接通过 Activity 的事件分发 dispatchTouchEvent 中直接保存我们的事件对象了。


基于这个思路,我们的事件的对象封装:


public class EventState {
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

Activity的事件集合,方便后期扩展为多个Activity的事件队列,如果只需要录制一个 Activity 的事件那么则可以无需双重队列。


/**
* 以Activity为单位,以队列的形式存储MotionEvent
*/

public class ActEventStates {
/**
* 存储元素为一个队列,存放一个Act中的操作状态。如果有多个Act,则是双重队列
*/

public static Queue<Queue<EventState>> eventStates = new LinkedList<>();

public static boolean isRecord = false; //是否在录制

public static boolean isPlay = false; //是否在播放
}

为什么要用 Queue ?


首先我们只需要回放一次,如果想回放多次可以用持久化存储,对于已经回放过的事件我们不希望还存在内存中,特别是后期做多 Activity 之间的跳转之后的回放,如果之前的事件还存在内存中会有重复回放的问题,而用 List 去手动管理没有 Queue 方便。


二、录制


先定义一个开始与停止的方法:


  //开启录制
fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

基于Act的录制,直接在分发事件的时候把事件从 Activity 级别就录制进去,这样只要在 Activity 层级之下的操作都能实现录制与回放了:


    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}

每一行代码都尽量给出注释。


三、回放


其实和我们之前的 ViewGr0up 的思路是一致的,只是把自定义的事件换成原生的 MotionEvent 来保存,还是根据 Handler 分发不同事件的时间轴。


    //回放录制
fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
YYLogUtils.w("没了,回放录制完成")
} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

在当前的 Activity 中录制与回放的效果,具体的使用与效果:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

act_record01.gif


单独的 Activity 上录制与回放是可以了,但是我们的应用又不是 Compose 或 Flutter,我们大部分项目还是多 Activity 的,如何实现多 Activity 跳转之后的录制与回放才是真正的问题。


四、多Activity的录制与回放


由于我们之前定义的数据格式就是 Queue 队列,所以我们很方便的就能实现多 Activity 的录制与回放效果,只需要在每一个 Activity 的 onResume 方法中尝试录制与播放即可。


由于当前的 Queue 的数据格式的性质,回放完成之后就没有了,跳转 Activity 之后就无需从头开始播放,特别适合这个场景。


只是需要注意的点是 Activity 的返回除了 Appbar 的页面返回按钮点击,我们还能使用系统的返回键或国产OS的左侧右侧滑动返回操作,所以我们需要对系统的返回操作单独做处理,修改之后的核心代码如下:


abstract class BaseActivity<VM : BaseViewModel> : AbsActivity() {

...

// ================== 事件录制 ======================

var handler = Handler(Looper.getMainLooper())

/**
* 存放当前activity中的事件
*/

private var activityEvents: Queue<EventState>? = null

/**
* 当前activity可见之后的时间点,每次 onResume 之后都创建一个新的队列,同时也赋值新的statetime
*/

private var startTime: Long = 0


override fun onResume() {
super.onResume()
startRecord() //尝试录制
playRecord() //尝试回放
}

//开启录制
protected fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
protected fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

override fun onBackPressed() {
val state = EventState()
state.event = null
state.isBackPress = true
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
super.onBackPressed()
}

//回放录制
protected fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
if (state.isBackPress) {
YYLogUtils.w("手动调用系统返回按键")
onBackPressed() //手动调用系统返回按键
} else {
YYLogUtils.w("没了,回放录制完成")
}

} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}
}

对于事件的封装我们添加了是否是系统返回的标记:


public class EventState {
public boolean isBackPress;
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

使用的方式就没有变化,我们添加几个 Activity 的跳转试试:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

btnJump1.click {
TemperatureViewActivity.startInstance()
}
btnJump2.click {
ViewGr0up9Activity.startInstance()
}

效果:


act_record02.gif


为了区分实际手指操作与回放的操作的差异,我打开了开发者选项中的触摸反馈,第一次效果是带触摸反馈的,回放录制的效果是没有触摸反馈的,并且支持 Appbar的返回按键与系统的返回键。


如果想回放多次,则需要在停止录制的时候把事件保存到本地,如何保存对象到本地?和前文一样的思路,可以用Json,可以压缩,可以加密,甚至可以自定义数据格式与解析,这一个步骤就无需我多说了吧。


后记


回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。


当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。


比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。


好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!


而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。


言归正传,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!


Ok,这一期就此完结。



作者:Newki
来源:juejin.cn/post/7330104253825646601
收起阅读 »

拒绝代码PUA,优雅地迭代业务代码

最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
继续阅读 »

最初的美好


没有历史包袱,就没有压力,就是美好的。


假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


Ugly1.gif


这样的需求开发起来很简单:



  • 数据实体


data class Car(
var shell: Shell? = null,
var engine: Engine? = null,
var wheel: Wheel? = null,
) : Serializable {
override fun toString(): String {
return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
}
}

data class Shell(
...
) : Serializable

data class Engine(
...
) : Serializable

data class Wheel(
...
) : Serializable


  • 零件车间(以车架为例)


class ShellFactoryActivity : AppCompatActivity() {
private lateinit var btn: Button
private lateinit var back: Button
private lateinit var status: TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_shell_factory)
val car = intent.getSerializableExtra("car") as Car
status = findViewById(R.id.status)
btn = findViewById(R.id.btn)
btn.setOnClickListener {
car.shell = Shell(
id = 1,
name = "比亚迪车架",
type = 1
)
status.text = car.toString()
}
back = findViewById(R.id.back)
back.setOnClickListener {
setResult(RESULT_OK, intent.apply {
putExtra("car", car)
})
finish()
}
}
}


class EngineFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}

class WheelFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}


  • 提车车间


class MainActivity : AppCompatActivity() {
private var car: Car? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
car = Car()
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
val it = Intent(this, ShellFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_SHELL)
}
findViewById<Button>(R.id.engine).setOnClickListener {
val it = Intent(this, EngineFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_ENGINE)
}
findViewById<Button>(R.id.wheel).setOnClickListener {
val it = Intent(this, WheelFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_WHEEL)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != RESULT_OK) return
when (requestCode) {
REQUEST_SHELL -> {
Log.i(TAG, "安装车架完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_ENGINE -> {
Log.i(TAG, "安装发动机完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_WHEEL -> {
Log.i(TAG, "安装车轮完成")
car = data?.getSerializableExtra("car") as Car
}
}
refreshStatus()
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car?.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}

companion object {
private const val TAG = "MainActivity"
const val REQUEST_SHELL = 1
const val REQUEST_ENGINE = 2
const val REQUEST_WHEEL = 3
}
}

即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


开始迭代


往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


Ugly2.gif


看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


object ComputerFactoryHelper {
fun provideComputer(block: Computer.() -> Unit) {
Thread.sleep(5_000)
block(Computer())
}
}

data class Computer(
val id: Int = 1,
val name: String = "行车电脑",
val cpu: String = "麒麟90000"
) : Serializable {
override fun toString(): String {
return "$name-$cpu"
}
}

再在提车车间新增按钮和逻辑代码:


findViewById<Button>(R.id.computer).setOnClickListener {
object : Thread() {
override fun run() {
ComputerFactoryHelper.provideComputer {
car?.computer = this
runOnUiThread { refreshStatus() }
}
}
}.start()

}

目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


从迭代到崩溃


咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


记者:哦?这不是一个小需求吗?


小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


优雅地迭代业务代码?


假如咱们想要优雅地迭代业务代码,应该怎么做呢?


小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


那什么又是业务的抽象?直接上代码:


interface CarFactory {
val factory: suspend Car.() -> Car
}

造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


object ComputerFactoryHelper : CarFactory {
private suspend fun provideComputer(block: Computer.() -> Unit) {
delay(5_000)
block(Computer())
}

override val factory: suspend Car.() -> Car = {
provideComputer {
computer = this
}
this
}
}

那么,在提车车间就可以这样改:


private var computerFactory: CarFactory = ComputerFactoryHelper
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
computerFactory.factory.invoke(car)
refreshStatus()
}
}

❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


Emo时间


我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


你有没有想过,咱们正在被Activity PUA



说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



对对对!你们都没有问题,是我太菜了555555555



优雅转身


Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


❗ 这时我就要提到另外一种抽象:技术思维的抽象


Activity?F*ck off!


Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


interface CarFactory {
val factory: suspend Car.() -> Car
}

基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


open class BaseActivity : AppCompatActivity() {
private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivityForResultLauncher = StartActivityForResultLauncher(this)
}

fun startActivityForResult(
intent: Intent,
callback: (resultCode: Int, data: Intent?) -> Unit
)
{
startActivityForResultLauncher.launch(intent) {
callback.invoke(it.resultCode, it.data)
}
}
}

MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

override val factory: suspend Car.() -> Car = {
suspendCoroutine { continuation ->
val it = Intent(activity, ShellFactoryActivity::class.java)
it.putExtra("car", this)
activity.startActivityForResult(it) { resultCode, data ->
(data?.getSerializableExtra("car") as? Car)?.let {
Log.i(TAG, "安装车壳完成")
shell = it.shell
continuation.resumeWith(Result.success(this))
}
}
}
}
}

然后在提车车间,和Computer业务同样的使用方式:


private var shellFactory: CarFactory = ShellFactoryHelper(this)
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}

最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


class MainActivity : BaseActivity() {
private var car: Car = Car()
private var computerFactory: CarFactory = ComputerFactoryHelper
private var engineFactory: CarFactory = EngineFactoryHelper(this)
private var shellFactory: CarFactory = ShellFactoryHelper(this)
private var wheelFactory: CarFactory = WheelFactoryHelper(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.engine).setOnClickListener {
lifecycleScope.launchWhenResumed {
engineFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.wheel).setOnClickListener {
lifecycleScope.launchWhenResumed {
wheelFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
computerFactory.factory.invoke(car)
Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
refreshStatus()
}
}
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}
}

总结



  • 抽象是程序员保持优雅的最重要能力。

  • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

  • 有意识地对代码PUA说:No!

  • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


作者:blackfrog
来源:juejin.cn/post/7274084216286036004
收起阅读 »

终于搞明白了什么是同步屏障

背景 今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。 同步屏障机制...
继续阅读 »

背景


今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。


同步屏障机制


1. 直奔主题,同步屏障机制这几个字听起来很牛逼,能浅显的解释一下,先让大家明白它的作用是啥不?


同步屏障实际上就是字面意思,可以理解为建立一道屏障,隔离同步消息,优先处理消息队列中的异步消息进行处理,所以才叫同步屏障。


2. 第二个问题,同步消息又是啥呢?异步消息和同步消息有啥不一样呢?


要回答这个问题,我们就得了解一下 MessageMessage 的消息种类分为三种:



  • 普通消息(同步消息)

  • 异步消息

  • 同步屏障消息


我们平时使用 Handler 发送的消息基本都是普通消息,中规中矩的排到消息队列中,轮到它了再乖乖地出来执行。


考虑一个场景,我现在往 UI 线程发送了一个消息,想要绘制一个关键的 View,但是现在 UI 线程的消息队列里面消息已经爆满了,我的这条消息迟迟都没有办法得到处理,导致这个关键 View 绘制不出来,用户使用的时候很恼怒,一气之下给出差评这是什么垃圾 app,卡的要死。


此时,同步屏障就派上用场了。如果消息队列里面存在了同步屏障消息,那么它就会优先寻找我们想要先处理的消息,把它从队列里面取出来,可以理解为加急处理。那同步屏障机制怎么知道我们想优先处理的是哪条消息呢?如果一条消息如果是异步消息,那同步屏障机制就会优先对它处理。


3.那要如何设置异步消息呢?怎样的消息才算一条异步消息呢?


Message 已经提供了现成的标记位 isAsynchronous 用来标志这条消息是不是异步消息。


4.能看看源码了解下官方到底怎么实现的吗?


看看怎么往消息队列 MessageQueue 中插入同步屏障消息吧。


private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;

Message prev = null;
// 当前消息队列
Message p = mMessages;
if (when != 0) {
// 根据when找到同步屏障消息插入的位置
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
// 插入同步屏障消息
if (prev != null) {
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
// 前面没有消息的话,同步屏障消息变成队首了
mMessages = msg;
}
return token;
}
}

在代码关键位置我都做了注释,简单来说呢,其实就像是遍历一个链表,根据 when 来找到同步屏障消息应该插入的位置。


5.同步屏障消息好像只设置了when,没有target呢?


这个问题发现了华点,熟悉 Handler 的朋友都知道,插入消息到消息队列的时候,系统会判断当前的消息有没有 targettarget 的作用就是标记了这个消息最终要由哪个 Handler 进行处理,没有 target 会抛异常。


boolean enqueueMessage(Message msg, long when) {
// target不能为空
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
...
}

问题 4 的源码分析中,同步屏障消息没有设置过 target,所以它肯定不是通过 enqueueMessage() 添加到消息队列里面的啦。很明显就是通过 postSyncBarrier() 方法,把一个没有 target 的消息插入到消息队列里面的。


6.上面我都明白了,下面该说说同步屏障到底是怎么优先处理异步消息的吧?


OK,插入了同步屏障消息之后,消息队列也还是正常出队的,显然在队列获取下一个消息的时候,可能对同步屏障消息有什么特殊的判断逻辑。看看 MessageQueuenext 方法:


Message next() {
...
// msg.target == null,很明显是一个同步屏障消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
...
}

方法代码很长,看源码最主要还是看关键逻辑,也没必要一行一行的啃源码。这个方法中相信你一眼就发现了
msg.target == null,前面刚说过同步屏障消息的 target 就是空的,很显然这里就是对同步屏障消息的特殊处理逻辑。用了一个 do...while 循环,消息如果不是异步的,就遍历下一个消息,直到找到异步消息,也就是 msg.isAsynchronous() == true


7.原来如此,那如果消息队列中没有异步消息咋办?


如果队列中没有异步消息,就会休眠等待被唤醒。所以 postSyncBarrier()removeSyncBarrier() 必须成对出现,否则会导致消息队列中的同步消息不会被执行,出现假死情况。


8.系统的 postSyncBarrier() 貌似也没提供给外部访问啊?这我们要怎么使用?


确实我们没办法直接访问 postSyncBarrier() 方法创建同步屏障消息。你可能会想到不让访问我就反射调用呗,也不是不可以。


但我们也可以另辟蹊径,虽然没办法创建同步屏障消息,但是我们可以创建异步消息啊!只要系统创建了同步屏障消息,不就能找到我们自己创建的异步消息啦。


系统提供了两个方法创建异步 Handler


public static Handler createAsync(@NonNull Looper looper) {
if (looper == null) throw new NullPointerException("looper must not be null");
// 这个true就是代表是异步的
return new Handler(looper, null, true);
}

public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
if (looper == null) throw new NullPointerException("looper must not be null");
if (callback == null) throw new NullPointerException("callback must not be null");
return new Handler(looper, callback, true);
}

异步 Handler 发送的就是异步消息。


9.那系统什么时候会去添加同步屏障呢?


有对 View 的工作流程比较了解的朋友想必已经知道了,在 ViewRootImplrequestLayout 方法中,系统就会添加一个同步屏障。


不了解也没关系,这里我简单说一下。


(1)创建 DecorView


当我们启动了 Activity 后,系统最终会执行到 ActivityThreadhandleLaunchActivity 方法中:


final Activity a = performLaunchActivity(r, customIntent);

这里我们只截取了重要的一行代码,在 performLaunchActivity 中执行的就是 Activity 的创建逻辑,因此也会进行 DecorView 的创建,此时的 DecorView 只是进行了初始化,添加了布局文件,对用户来说,依然是不可见的。


(2)加载 DecorView 到 Window


onCreate 结束后,我们来看下 onResume 对应的 handleResumeActivity 方法:


@Override
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason)
{
...
// 1.performResumeActivity 回调用 Activity 的 onResume
if (!performResumeActivity(r, finalStateRequest, reason)) {
return;
}
...
final Activity a = r.activity;
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
// 2.获取 decorview
View decor = r.window.getDecorView();
// 3.decor 现在还不可见
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
...
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
// 4.decor 添加到 WindowManger中
wm.addView(decor, l);
} else {
a.onWindowAttributesChanged(l);
}
}
}
...
}

注释 4 处,DecorView 会通过 WindowManager 执行了 addView() 方法后加载到 Window 中,而该方法实际上是会最终调用到 WindowManagerGlobaladdView() 中。


(3)创建 ViewRootImpl 对象,调用 setView() 方法


// WindowManagerGlobal.ddView()
root = new ViewRootImpl(view.getContext(), display);
root.setView(view, wparams, panelParentView);

WindowManagerGlobaladdView() 会先创建一个 ViewRootImpl 实例,然后将 DecorView 作为参数传给 ViewRootImpl,通过 setView() 方法进行 View 的处理。setView() 的内部主要就是通过 requestLayout 方法来请求开始测量、布局和绘制流程


(4)requestLayout() 和 scheduleTraversals()


@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
// 主要方法
scheduleTraversals();
}
}

void scheduleTraversals() {
if (!mTraversalScheduled) {
// 1.将mTraversalScheduled标记为true,表示View的测量、布局和绘制过程已经被请求。
mTraversalScheduled = true;
// 2.往主线程发送一个同步屏障消息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 3.注册回调,当监听到VSYNC信号到达时,执行该异步消息
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

看到了吧,注释 2 的代码熟悉的很,系统调用了 postSyncBarrier() 来创建同步屏障了。那注释 3 是啥意思呢?mChoreographer 是一个 Choreographer 对象。


要理解 Choreographer 的话,还要明白 VSYNC


我们的手机屏幕刷新频率是 1s 内屏幕刷新的次数,比如 60Hz、120Hz 等。60Hz表示屏幕在一秒内刷新 60 次,也就是每隔 16.6ms 刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算,每收到 VSYNC,CPU 就开始处理各帧数据。这时 Choreographer 就上场啦,当有 VSYNC 信号到来时,会唤醒 Choreographer,触发指定的工作。它提供了一个回调功能,让业务知道 VSYNC 信号来了,可以进行下一帧的绘制了,也就是注释 3 使用的 postCallback 方法。


当监听到 VSYNC 信号后,会回调来执行 mTraversalRunnable 这个 Runnable 对象。


final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
// View的绘制入口方法
performTraversals();

if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

在这个 Runnable 里面,会移除同步屏障。然后调用 performTraversals 这个View 的工作流程的入口方法完成对 View 的绘制。


这回明白了吧,系统会在调用 requestLayout() 的时候创建同步屏障,等到下一个 VSYNC 信号到来时才会执行相应的绘制任务并移除同步屏障。所以在等待 VSYNC 信号到来的期间,就可以执行我们自己的异步消息了。


参考


requestLayout竟然涉及到这么多知识点


关于Handler同步屏障你可能不知道的问题


“终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解


作者:搬砖的代码民工
来源:juejin.cn/post/7258850748150104120
收起阅读 »

Android 居然还能这样抓捕和利用主线程碎片时间

本文作者: zy 图片来自:unsplash.com 在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿...
继续阅读 »

本文作者: zy




图片来自:unsplash.com


在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。


背景与现状


在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。


分析


为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。



上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。



VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。


具体方案


主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。


帧率耗时监控模块


分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示



帧率耗时监控模块执行步骤:



  • 步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象

  • 步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间

  • 步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来

  • 步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片


其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示



帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。


空闲时间切片


我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片


耗时任务拆分


有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集



上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图



接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)


class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}

到这里,可执行的子任务集就准备好了。


子任务智能调度


空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示



子任务智能调度执行步骤:



  • 步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑

  • 步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了

  • 步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删

  • 步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在

  • 步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程

  • 步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。


智能任务调度核心


智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示



任务队列结构


这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示



调度超时模式


空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.


总结


通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。


作者:网易云音乐技术团队
来源:juejin.cn/post/7329028515382820916
收起阅读 »

Android 开发小技巧:属性扩展让代码写起来更轻松更易读

一、优化了什么问题 在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示: button.apply { background = ContextCompat.getDrawable(this@MainActi...
继续阅读 »

一、优化了什么问题


在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示:


button.apply {
background = ContextCompat.getDrawable(this@MainActivity,R.drawable.dinosaur)
setBackgroundColor(ContextCompat.getColor(this@MainActivity,R.color.black))
}

写多了脑壳痛,那能不能像前端语言那样简洁优雅呢?之前见过一种写法就是对Int进行扩展,就会看到奇奇怪怪的代码,如下:


//扩展函数
fun Int.getColor(): Int {
return ContextCompat.getColor(appContext, this)
}

//调用
button.apply {
setBackgroundColor(R.color.purple_700.getColor())
}

那能不能把一些常用的设置控件UI的方法都改造成setter访问方法呢?


二、通过扩展属性使代码更易写易读


在XML中常用的基础控件有TextView、ImageView、Button、EditText,那先从这几个控件入手就可以了。扩展属性只能在类的基础上添加属性,并不能覆盖,这一点需要注意。只需要下面几个扩展属性的方法,代码写起来就会舒服和易读很多。


/**
* 设置文本的颜色,Button、EditText、TextView都用此方法设置textColor
*/

var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(ContextCompat.getColor(this.context, value))
}

/**
* 给View设置drawable和mipmap资源
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉背景
this.background = null
} else {
this.background = ContextCompat.getDrawable(this.context, value)
}
}

/**
* 给View设置背景颜色
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundColor: Int
get() {
return (this.background as ColorDrawable).color
}
set(value) {
this.setBackgroundColor(ContextCompat.getColor(this.context, value))
}

/**
* 给ImageView设置drawable和mipmap资源
*/

var ImageView.imageResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉图片
this.setImageDrawable(null)
} else {
this.setImageDrawable(ContextCompat.getDrawable(this.context, value))
}
}

/**
* 点击事件。可防止重复点击。
* ClickUtils.OnDebouncingClickListener是AndroidUtilCode库中的方法,项目中一般都有
*/

var View.onClick: (View) -> Unit
get() = {} //get方法无意义返回空的lambda
set(value) {
this.setOnClickListener(object : ClickUtils.OnDebouncingClickListener() {
override fun onDebouncingClick(v: View) {
value.invoke(v)
}
})
}

在TextView中使用


textView.apply {
text = getString(R.string.app_name)
//设置文本颜色
textColor = R.color.white
textSize = 15F
//设置背景颜色
backgroundColor = R.color.black
onClick = { //防止重复点击的点击事件

}
}

在ImageView中使用


imageView.apply {
//设置背景颜色
backgroundColor = R.color.black
//设置背景资源
backgroundResource = R.drawable.dinosaur
//设置src资源
imageResource = R.drawable.dinosaur
//如果项目中有创建Drawable的方法可以继续用background
background = shape(Shape.RECTANGLE) {
solid(R.color.white))
corners(8F.dp2px)
}
onClick = { //防止重复点击的点击事件

}
}

其他控件类似。


上面只是改造了常见控件的常用属性的写法,让代码写起来更简洁,更易读,并且添加了防止重复点击的方法,不至于在其他页面又写类似的逻辑。


作者:TimeFine
来源:juejin.cn/post/7311619723317510155
收起阅读 »

如何通过Kotlin协程, 简化"连续依次弹窗(Dialog队列)"的需求

效果预览 代码预览 lifecycleScope.launch { showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行 showDialog("新手任务", "做任务领20000...
继续阅读 »

效果预览


r2t33-r5h2v.gif


代码预览


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("新手任务", "做任务领20000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("首充奖励", "首充6元送神装")
}

代码实现



要做到上一个showDialig()在关闭时才继续运行下一个函数,需要用到协程挂起的特性, 然后在 OnDismiss()回调中将协程恢复, 为了将这种基于回调的方法包装成协程挂起函数, 可以使用suspendCancellableCoroutine函数



suspend fun showDialog(title: String, content: String) = suspendCancellableCoroutine { continuation ->
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
}

进阶玩法



跳过某些弹窗, 例如第二个弹窗只显示一次



suspend fun showDialogOnce(title: String, content: String) = suspendCancellableCoroutine { continuation ->
val showed = SPUtils.getInstance().getBoolean(title) // SharedPreferences工具类
if (showed) {
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
SPUtils.getInstance().put(title, true)
}

调用时只需要这样:


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币")
showDialogOnce("新手任务", "做任务领20000币")
showDialog("首充奖励", "首充6元送神装")
}

这样'新手任务'的弹窗只会首次弹出, 后续只会弹出第一和第三个


作者:Joehaivo飞羽
来源:juejin.cn/post/7275943125821571106
收起阅读 »

EdgeUtils:安卓沉浸式方案(edge to edge)封装

EdgeUtils     项目地址:github.com/JailedBird/… 1、 接入方式 EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦 接入方式: 添加jitpack仓库 maven { ur...
继续阅读 »

EdgeUtils


GitHub stars GitHub forks GitHub issues 


项目地址:github.com/JailedBird/…


1、 接入方式


EdgeUtils是基于androidx.core,对edge to edge沉浸式方案封装 📦


接入方式:



  • 添加jitpack仓库


maven { url 'https://jitpack.io' }


  • 添加依赖


implementation 'com.github.JailedBird:EdgeUtils:1.0.0'

2、 使用方式


2-1、 布局拓展全屏


Activity中使用API edgeToEdge() 将开发者实现的布局拓展到整个屏幕, 同时为避免冲突, 将状态栏和到导航栏背景色设备为透明;


1669552233097-eacf0003-1ede-4035-a24e-ace16bfbe400.gif


注意:edgeToEdge() 的参数withScrim表示是否启用系统默认的反差色保护, 不是很熟悉的情况下直接使用默认true即可;


2-2、 系统栏状态控制


布局拓展之后, 开发者布局内容会显示在状态栏和导航栏区域, 造成布局和系统栏字体重叠(时间、电量……);


此时为确保系统栏字体可见,应该设置其字体; 设置规则:白色(浅色)背景设置黑色字体(edgeSetSystemBarLight(true)),黑色(深色)背景设置白色字体(注:系统栏字体只有黑色和白色)(edgeSetSystemBarLight(false));


如果未作夜间模式适配, 默认使用 edgeSetSystemBarLight(true)浅色模式即可!


综合1、2我们的基类可以写成如下的形式:


abstract class BasePosActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
      if (usingEdgeToEdgeTheme()) {
              defaultEdgeToEdge()
      } else {
          customThemeSetting()
      }
      super.onCreate(savedInstanceState)
  }
}

protected open fun defaultEdgeToEdge() {
    edgeToEdge(false)
    edgeSetSystemBarLight(true)
}

2-3、 解决视觉冲突


2-3-1、状态栏适配


步骤一布局拓展全屏会导致视觉上的冲突, 下面是几种常见的思路:请灵活使用



  • 布局中添加View(id="@+id/edge")使用heightToTopSystemWindowInsets API动态监听并修改View的高度为状态栏的高度


    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
          android:orientation="vertical">

          <View
              android:id="@+id/edge"
              android:layout_width="match_parent"
              android:layout_height="0dp" />
          xxx
      </LinearLayout>
    binding.edge.heightToTopSystemWindowInsets()


  • 直接获取状态栏的高度,API为:edgeStatusBarHeight; 和1不同的是,1中View的height会随状态栏高度变化而变化,2不会; 此外获取状态栏高度需要在View Attached之后才可以(否则高度为0),因此使用suspend函数等待Attached后才返回状态栏,确保在始终能获取到正确的状态栏高度!


    lifecycleScope.launch {
      val height = edgeStatusBarHeight()
      xxx
    }


  • 针对有Toolbar的布局, 可直接为Toolbar加padding(or margin), 让padding的高度为状态栏高度!如果无效, 一般都与Toolbar的高度测量有关, 可以直接在Toolbar外层包上FrameLayout,为FrameLayout加padding, 详情阅读下文了解原理,从而灵活选择;


    fun View.paddingTopSystemWindowInsets() =
      applySystemWindowInsetsPadding(applyTop = true)

    fun View.marginTopSystemWindowInsets() =
      applySystemWindowInsetsMargin(applyTop = true)



2-3-2、 导航栏适配


导航栏的适配原理和状态栏适配是非常相似的, 需要注意的是 导航栏存在三种模式:



  • 全面屏模式

  • 虚拟导航栏

  • 虚拟导航条


API已经针对导航栏高度、导航栏高度margin和padding适配做好了封装,使用者无需关心;


fun View.paddingBottomSystemWindowInsets() =
  applySystemWindowInsetsPadding(applyBottom = true)
   
fun View.marginBottomSystemWindowInsets() =
  applySystemWindowInsetsMargin(applyBottom = true)

适配思路是一致的,不再赘述;


2-4、 解决手势冲突


手势冲突和视觉冲突产生的原理是相同的,不过是前者无形而后者有形;系统级别热区(如侧滑返回)优先级是要高于View的侧滑的, 因此有时候需要避开(情况很少)


EdgeUtils主要工作只是做了视觉冲突的解决和一些API封装;使用者可以基于封装的API拓展,替换掉WindowInsetCompat.Type为你需要的类型;


fun View.applySystemWindowInsetsPadding(
  applyLeft: Boolean = false,
  applyTop: Boolean = false,
  applyRight: Boolean = false,
  applyBottom: Boolean = false,
)
{
  doOnApplyWindowInsets { view, insets, padding, _ ->
  // val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
  // 替换为Type.SYSTEM_GESTURES即可,其他类似
      val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
      val left = if (applyLeft) systemBars.left else 0
      val top = if (applyTop) systemBars.top else 0
      val right = if (applyRight) systemBars.right else 0
      val bottom = if (applyBottom) systemBars.bottom else 0

      view.setPadding(
          padding.left + left,
          padding.top + top,
          padding.right + right,
          padding.bottom + bottom
      )
  }
}

3、 Edge教程


3-1 何为edge to edge?


如何彻底理解Edge to edge的思想呢?


或许你需要官方文章 , 也可以看的我写的翻译文章doc1😘


3-2 底层是如何实现的?


了解Edge to edge原理后,你或许会好奇他是怎么实现的?


或许你需要Flywith24大佬的文章 , 也可看缩略文章doc2😘


3-3 其他杂项记录


请看doc3 , 东西多但比较杂没整理😘


3-4 如何快速上手?


EdgeUtils此框架基于androidx.core, 对WindowInsets等常见API进行封装,提供了稳定的API和细节处理;封装的API函数名称通俗易懂,理解起来很容易, 难点是需要结合 [Edge-to-edge](#Edge to edge) 的原理去进行灵活适配各种界面


项目中存在三个demo对于各种常见的场景进行了处理和演示



  • navigation-sample 基于Navigation的官方demo, 此demo展示了Navigation框架下这种单Activity多Fragment的沉浸式缺陷

  • navigation-edge-sample 使用此框架优化navigation-sample, 使其达到沉浸式的效果

  • immersion-sample 基于开源项目immersionbar中的demo进行EdgeUtils的替换处理, 完成大部分功能的替换 (注:已替换的会标记[展示OK],部分未实现)


4、 注意事项


4-1、 Toolbar通过paddingTop适配statusbar失效的问题


很多时候, 状态栏的颜色和ToolBar的颜色是一致的, 这种情况下我们可以想到为ToolBar加 paddingTop = status_bar_height但是注意如果你的Toolbar高度为固定、或者测量的时候没处理好padding,那么他就可能失真;


快速判断技巧:xml布局预览中(假设状态栏高度预估为40dp),使用tools:padding = 40dp, 通过预览查看这40dp的padding是否对预览变成预期之外的变形,如果OK那么直接使用paddingTopSystemWindowInsets为ToolBar大多是没问题的


可以看下下面的2个例子:



  • paddingTop = 0时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • UI预览可以看到是这个样子的:


image-20221124102655144



  • paddingTop = 20时候, 如下的代码:


<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
android:paddingTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />


  • 可以看到, Toolbar的总高度是不变的,内容高度下移20dp,这显然是不合理的;实际运行时动态为ToolBar添加statusbar的paddingTop肯定也会导致这样的问题


image-20221124103232396


解决方案:


1、 使用FrameLayout等常见ViewGr0up包住ToolBar,将paddingTop高度设置到FrameLayout中, 将颜色teal_200设置到FrameLayout


<FrameLayout
android:id="@+id/layout_tool"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="20dp"
android:background="@color/teal_200">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/teal_200"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="高度测试" />

</FrameLayout>

如下:


image-20221124103542651


2、 在ToolBar外层直接封装FrameLayout(LinearLayout等也可, 下文统一用FrameLayout替代);


我相信大家一般都不会直接使用原生的Toolbar, 每个公司或多或少的都封装了一些自定义ToolBar;按照上述1的思路, 我们不难发现:



  • 如果自定义ToolBar继承自FrameLayout(或者说Toolbar最外层被FrameLayout包住), 直接将paddingTop加到自定义ToolBar即可;

  • 当然有些做的好的公司可能会直接通过继承ViewGr0up(如原生ToolBar), 这个时候可能就只能用方案1了;


当然上述几点都是具体问题具体分析, 大家可以在预览界面临时加paddingTop,看看实际是什么样的, 便于大家尽早发现问题;可以参考下BottomNavigationView的源码, 它间接继承自FrameLayout, 内部对paddingBottom自动适配了navigation_bar_height;


这个思路和ImmersionBar的 状态栏与布局顶部重叠解决方案 类似,不同的是,ImmersionBar使用的是固定的高度,而方案1是动态监听状态栏的高度并设置FrameLayout的paddingTop;


注:上述的paddingTop = 20dp, 只是方便预览添加的, 运行时请通过API动态设置paddingTop = statusBar


3、 添加空白View,通过代码设置View高度为导航栏、状态栏高度时,存在坑;约束布局中0dp有特殊含义,可能导致UI变形,需要注意哈!特别是处理导航栏的时候,全屏时导航栏高度为0,就会导致View高度为0,如果有组件依赖他,可能会出现奇怪问题,因此最好现在布局预览中排查下


4-2、 Bug&兼容性(框架已修复)


直接使用Edge to edge(参照google官方文档)存在一个大坑:调用hide隐藏状态栏后会导致状态栏变黑, 并且内容区域无法铺满


详细描述看这里:point_right: WindowInsetsControllerCompat.hide makes status bar background undrawable


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

WindowCompat.getInsetsController(this, this.decorView)?.let {
it.systemBarsBehavior = behavior
it.hide(WindowInsetsCompat.Type.statusBars())
}

具体表现下图这个样子:


image-20221125143449641


解决方案如下 :point_down: How to remove top status bar black background


object EdgeUtils {
/** To fix hide status bar black background please using this post
* youtube: https://www.youtube.com/watch?v=yukwno2GBoI
* stackoverflow: https://stackoverflow.com/a/72773422/15859474
* */

private fun Activity.edgeToEdge() {
requestWindowFeature(Window.FEATURE_NO_TITLE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode = WindowManager
.LayoutParams
.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
setWindowEdgeToEdge(this.window)
}

private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}

4-3、 如何去掉scrim?


在导航栏设置为全透明时, 部分机型就会出现scrim半透明遮罩,考虑到样式有点丑陋, 直接将其修改为#01000000, 这样看起来也是完全透明的, 但是系统判定其alpha不为0, 不会主动添加scrim的; 【具体请看官方文档】


private fun setWindowEdgeToEdge(window: Window) {
WindowCompat.setDecorFitsSystemWindows(window, false)
/** using not transparent avoid scrim*/
Color.parseColor("#01000000").let { color ->
window.statusBarColor = color
window.navigationBarColor = color
}
}

4-4 、 禁止View的多次监听


一个View只能绑定一次ApplyWindowInset的监听,多次绑定可能会导致之前的失效或者出现奇怪问题!!!



5、 参考资料



作者:JailedBird
来源:juejin.cn/post/7313742254144307236
收起阅读 »

uniapp系列-改变底部安全区-顶部的手机信号、时间、电池栏颜色样式

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢? 首先来说底部安全区域 下图是底部安全区原始状态,感觉和整个页面格格不入 修改代码配置safearea manifest.json...
继续阅读 »

uniapp 的默认安全区域的颜色是白色,如果我们做了沉浸式页面,背景色也是白色的话,就会看不到电池栏,等的颜色,如何修改呢?


首先来说底部安全区域


下图是底部安全区原始状态,感觉和整个页面格格不入



修改代码配置safearea



  • manifest.json(下面代码仅支持ios)


// 在app-plus下配置:
"safearea": { //安全区域配置,仅iOS平台生效
"background": "#F5F6F9", //安全区域外的背景颜色,默认值为"#FFFFFF"
"bottom": { // 底部安全区域配置
"offset": "none|auto" // 底部安全区域偏移,"none"表示不空出安全区域,"auto"自动计算空出安全区域,默认值为"none"
}
},


  • 页面里写(下面代码支持android)


写法一:
// #ifdef APP-PLUS
var Color = plus.android.importClass("android.graphics.Color");
plus.android.importClass("android.view.Window");
var mainActivity = plus.android.runtimeMainActivity();
var window_android = mainActivity.getWindow();
window_android.setNavigationBarColor(Color.parseColor("#eb8c76"));
// #endif
写法二:
// #ifdef APP-PLUS
let color, ac, c2int, win;
color = plus.android.newObject("android.graphics.Color")
ac = plus.android.runtimeMainActivity();
c2int = plus.android.invoke(color, "parseColor", "#000000")
win = plus.android.invoke(ac, "getWindow");
plus.android.invoke(win, "setNavigationBarColor", c2int)
// #endif



底部区域颜色已配置成功(下图仅供参考,随便选的颜色,有点丑哈哈)



接下来讲一下顶部电池栏的配置


配置顶部导航栏颜色


方案一:仅适用于原生导航配置,非自定义导航



在page.json修改需要配置的页面的navigationBarTextStyle属性



"pages": [ 
{
"path": "pages/index/index",
"style": {
// "navigationStyle": "custom"
"navigationBarTitleText": "我是原生title",
"navigationBarTextStyle": "white" ,// 仅支持 black/white
"navigationBarBackgroundColor": "#aaaaff"
}
}
],


方案二:通用,也适用于自定义导航



在页面中使用nativejs的api,native是uni内置的sdk,不需要手动引入,直接用就可以,但是需要注意调用时机和条件使用,参考下面的注意事项哦



onReady(){
plus.navigator.setStatusBarStyle("dark"); //只支持dark和light
}



注意事项



注意函数的调用时机,如果是自定义导航栏,方法只写在onReady的话,切换路由再回来以后,你的配置会失效,所以要注意调用时机



uniapp中 onReady, onLoad, onShow区别



  • onReady 页面初次渲染完成了,但是渲染完成了,你才发送请求获取数据,显得有些慢

  • onLoad 只加载一次,监听页面加载,其参数为上个页面传递的数据,参数类型为Object

  • onShow 监听页面显示。页面每次出现都触发,包括从下级页面点返回露出当前页面


目前我是这样配置(举个栗子:配置顶部导航栏背景颜色为黑色)


import { onLoad, onShow, onReady} from '@dcloudio/uni-app';
onReady(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

onShow(() =>
/* #ifdef APP-PLUS */ 
plus.navigator.setStatusBarStyle('dark'); 
/* #endif */
});

今天就写到这里啦~



  • 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • 大家要天天开心哦



欢迎大家指出文章需要改正之处~

学无止境,合作共赢



在这里插入图片描述


欢迎路过的小哥哥小姐姐们提出更好的意见哇~~


作者:tangdou369098655
来源:juejin.cn/post/7206628135005143099
收起阅读 »

字节开源安卓开发利器-CodeLocator

CodeLocator登场 CodeLocator 是字节跳动开源的一个包含 Android SDK 与 Android Studio 插件的 Android 工具集。个人使用之后感觉是安卓开发人员的利器,推荐给大家。(mac、windows都可以用) Cod...
继续阅读 »

CodeLocator登场


CodeLocator 是字节跳动开源的一个包含 Android SDK 与 Android Studio 插件的 Android 工具集。个人使用之后感觉是安卓开发人员的利器,推荐给大家。(mac、windows都可以用)


CodeLocator的丰富功能可以让安卓应用人员受益,下面这个GIF展示了一些CodeLocator的功能。


CodeLocator转存失败,建议直接上传图片文件

快速上手



  1. 在Android Studio中安装CodeLocator插件(点此下载最新版插件)

  2. 工程中集成CodeLocator


// 集成基础能力, 只需要添加一行依赖即可
dependencies {
// 依赖androidx, 已升级AndroidX的项目集成下面的依赖
implementation "com.bytedance.tools.codelocator:codelocator-core:2.0.3"
// 未升级AndroidX的项目集成下面的依赖 support版本不再维护 请升级androidx
implementation "com.bytedance.tools.codelocator:codelocator-core-support:2.0.0"
}


  • 目前官网描述的代码跳转的能力,需要集成Lancet,但是Lancet的引入有关于Gradle 版本AGP 版本的要求



集成Lancet 插件和依赖的项目,关于Gradle 版本AGP 版本不能适配超过7.2,不建议高版本去适配,已经帮大家踩了很多坑了😢




还有一坑就是,CodeLocatorcompose支持不是友好🐶



当工程的依赖和Android Studio的插件都到位之后,便可以启动开发app,然后使用抓取功能和调试开发。


使用功能和场景


这里我讲述下自己在使用CodeLocator的一些场景。


UI相关功能


UI界面功能



当抓取了app当前的界面之后,直接可以在界面上点击,然后查看一些组件尺寸和间距的情况。这里在界面上有几种点击模式:



  • 直接单击: 会按照可点击属性查找View, 上层可点击View会覆盖底部View。

  • control(Alt) + 单击: 会去查看view的深度,z轴的情况。

  • Shift + 单击: 多选View, 同时可对比最后选中的两个View的间距,大家在安卓XML开发的时候,在真机测试下,这里的间距和尺寸观察就十分有用了。


实时修改ui


在界面上,点击view组件之后,可以直接右键选择修改属性,当然这里选中view之后右键还有很多好用的功能。



直接修改view组件的属性: 字符内容、字体大小、颜色、可见性、内外边距等等




CodeLocator还有复制窗口功能,复制窗口之后还有diff模式,比对ui的差别。



追溯抓取历史


CodeLocator抓取历史最多可以有三十条,其中每一条数据都带有时间和缩略图浏览。你可以在显示历史抓取功能里选择之前抓取的界面,然后对比属性。这里还可以直接保存抓取数据,文件会以projectName_XXXX_XXXX.codeLocator保存,之后想要使用便可以加载。


跳转界面对应的activity和fragment


CodeLocator可以在界面上,根据你抓取的界面和view组件,来判断它是在哪个activity、fragment和对应的XML组件名,并且直接选择跳转。


一些项目上,想快速知道这个页面到底归属哪个activity、fragment或者XML组件的时候,这个功能的优越性就体现出来了。



快速启动charles


一键启动charles,并且在Android Studio随开随关,不需要你去手机上专门开启和关闭代理



  • 开启




  • 关闭



上图工具箱中的集成功能也很丰富,也是在Android Studio随开随关。


工具箱


值得一提的是工具箱中的集成功能也很丰富,也是随开随关。




集成lanct有的功能


如果CodeLocator集成了lancet相关依赖和插件之后,可以有更强大的代码跳转能力:



  • 跳转findViewById

  • 跳转clickListener

  • 跳转touchListener

  • 跳转XML

  • 跳转viewHolder

  • 跳转startActivity

  • 跳转相应的dialog、toast


作者:weiran1999
来源:juejin.cn/post/7280787122012405794
收起阅读 »

又要用Compose来做Loading了,不过这次是带小火苗的

本篇文章已同步更新至个人公众号:Coffeeee 今年第一篇Compose动效开发,继续回归老本行,来一起做个Loading,老实说Loading动效个人已经做麻了,去年做了十几个,这次主要是想实现一个带火苗的Loading,因为之前看到过有位博主用Thre...
继续阅读 »

本篇文章已同步更新至个人公众号:Coffeeee



今年第一篇Compose动效开发,继续回归老本行,来一起做个Loading,老实说Loading动效个人已经做麻了,去年做了十几个,这次主要是想实现一个带火苗的Loading,因为之前看到过有位博主用Threejs实现过一个火焰的效果,然后又是职业病啊,想试试看用Compose实现一个火焰效果到底难不难


源码地址


扩散效果


第一步,先别去想啥Loading,先想想火是啥样子的,颜色以红黄为主,也有蓝色的,绿色的,然后从火源开始逐渐向外燃烧扩散,那么这里首先就要想办法把扩散的效果做出来,先上基础代码


image.png

先创建出代表画布的宽高widthheightCanvas创建出来之后会得到宽高的具体值,然后宽高的一半就是画布的中心坐标centerxcentery,radius是整个Loading的半径,接着我们先随意在中心位置画一个实心圆


image.png
image.png

现在如果想要让这个实心圆动起来的话,通常会使用animateFloatAsState这个api,比如这里想要改变它的横坐标,可以这么写


image.png
0109aa1.gif

也可以使用循环动画让圆点在那一直动


image.png
0109aa2.gif

但是以上两种方式如果是作用在有限数量的视图上,是没啥问题的,但是像我们要做的这个扩散效果,有大量元素的,并且每个元素动画的轨迹方向都不一样,那么就不能使用上面这种动画api了,性能问题先不说,写起来也是个麻烦,所以得想个其他办法,那么既然不能用动画api来改变元素的位置,我们就手动改嘛,先来定义个model,代表每个元素,这个model有以下几个属性


image.png

其中



  • startX:代表元素初始位置的x坐标

  • startY:代表元素初始位置的y坐标

  • endx:代表元素移动结束后的x坐标

  • endy:代表元素移动结束后的y坐标

  • angle:代表元素移动的方向,也就是角度

  • dis:代表元素每次移动的距离

  • size:代表元素的大小,如果是圆就当作半径,如果是方块就当作宽高

  • color:代表元素的颜色


然后给Particle里面添加一些更新位置的代码,第一处在初始化函数中,目的是当Particle刚创建出来时候,根据startXstartY来计算出第一次位移的终点endxendy


image.png

pointXpointY分别是通过起点,角度,半径来计算终点坐标的函数,代码如下


image.png

除了刚才在初始化函数中加的代码之外,还要增加一个update函数,每次调用这个函数的时候,都会重新把上一次的终点作为起点,重新计算新的终点坐标,这样才能做到让元素移动的效果


image.png

这样我们Particle的基础功能就开发完成了,接下来就要去创建我们需要扩散的元素,由于数量较多,我们得循环创建这些元素才行,首先创建的事情我们放在副作用函数LaunchedEffect中进行


image.png

其中ANGLES表示0到360的一个范围,调用random()函数来随机取出一个值当作元素移动的方向角度,上述代码中还缺点东西,首先需要有一个数组来保存创建好之后的Particle,我们这里新建一个数组,将创建好之后的Particle添加到数组中


image.png

其次这个LaunchedEffect函数体由于keytrue,所以无论重组几次都只会执行一次,那么我们的元素只会创建一次,而我们想要的效果是每过10毫秒都创建个元素,所以得把key值改成一个会改变的值,只有key改变了才会触发LaunchedEffect再执行一遍内部的代码,那么这个key我们就改成particleList这个数组的大小,每创建一个新元素,particleList的大小都会改变,改变之后下一次又会重新再去创建新元素,代码修改为


image.png

现在每过10毫秒,我们就会多一个Particle元素,但是现在只是创建了元素,元素还没动起来,要让它们动起来的话这个时候就要用到之前我们创建的update函数了,我们在重组的过程中遍历particleList中的元素,每个元素都执行一遍update,这样元素就动起来了


image.png

整个扩散效果到这里就算完工了,来看看效果咋样


0109aa3.gif

定制扩散的样式


扩散的效果做出来了,但是可以看到现在是无限往四周扩散的,咱要做的火苗可不能无限扩散,那不得发大火了吗,所以得让我们这些元素扩散到一定范围之后“看不见”,在Canvas中让一个元素看不见除了不去绘制之外,就是让它的透明度为0,那么在Particle中再新增一个属性alpha来表示元素的透明度


image.png

默认值为1,然后在update函数中,每次都减去一点透明值,直到透明值变为0,那么该元素就看不见了


image.png

CanvasdrawCircle函数中也添加alpha属性


image.png
0109aa4.gif

现在这个扩散的范围看起来又太小了,不过没事,可以通过设置dis属性来增加整个扩散的区域


image.png

还可以给每个元素设置不同的大小和颜色来改变整个效果的外观,先创建个半径的范围


image.png

再创建个颜色的集合


image.png

然后在创建Particle的时候,随机从半径范围与颜色集合中取出一个值作为Particlesizecolor


image.png

再来看下现在的效果


0109aa5.gif

制作loading效果


到这里为止我们的扩散的起始为止都是一个固定的点,现在要让这个固定的点变成可以变化的,绕着圆周转圈,那么首先就要获得圆周上的角度,这里使用循环动画创建一个0到360度循环改变的值当成角度


image.png

获得角度之后,使用pointXpointY函数来计算出这个角度在圆周上的x坐标tapx与y坐标tapy,将创建元素用到的centerxcentery替换成tapx,tapy


image.png

现在扩散效果就绕着画布中心转圈了


0109aa6.gif

看起来有点别扭啊,首先这个转圈一顿一顿的,然后尾巴貌似分叉的太开了,不过没事,这些都可以优化,分叉的太开主要是我们扩散的角度是0到360度,将这个范围变小一点就好了


image.png

动画一顿一顿的是因为我们的动画设置的是两秒,它只有到了两秒以后才会进行下一次动画,但是变化的角度不到两秒的时候就已经到达360度了,所以才会在360度的位置停滞了一段时间,解决办法就是将动画规格从补间动画改成关键帧动画,将到达360度的那一帧设置在2000毫秒的位置上


image.png
0109aa7.gif

转圈不顿了,但是现在离火苗的效果还是有点出入的,我们这个loading的头部位置相当于火苗的燃烧源头,而燃烧源相对来讲都是比较大的,然后逐渐朝着燃烧的方向变小,所以还得继续优化下,现在元素的半径还太小,得变大


image.png

其次在update函数中,也对半径size做递减处理,直到半径变为0


image.png

再来看下效果


0109aa8.gif

还差最后一步,将整个画布设置下模糊效果,设置一下blur函数,内部参数越大,模糊的效果越严重,调了一下后7.dp比较合适


image.png

加了模糊效果后的效果如下


0109aa9.gif

一团小火苗就做出来了,感觉效果比较空,我们可以再加一个火苗,现在圆周上只有一个定点在转,我们再加一个,颜色设置成偏蓝,刚好一个火焰一个冰焰


image.png
image.png

最终效果如下


0109aa10.gif

总结


到这里一个火焰Loading的动效就完成了,还是很容易的其实,里面最主要的就是通过那几个参数来控制好元素扩散的效果,甚至我们可以尝试着去更改一些参数或者实现方式,来做一些其他不一样的动效,这些大家如果有兴趣的可以自己去试试看。


作者:Coffeeee
来源:juejin.cn/post/7329433979806810146
收起阅读 »

Android:布局动画和共享动画的结合效果

大家好,我是时曾相识2022。不喜欢唱跳,但对杰伦的Rap却情有独钟。 今天给大家带来能够提升用户体验感的交互动画,使用起来非常简单,体验效果非常赞。其中仅使用到布局动画和共享动画。废话不多说,直接上效果图: 怎么样,效果看起来还不错吧。这其实都是官方提供...
继续阅读 »

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



今天给大家带来能够提升用户体验感的交互动画,使用起来非常简单,体验效果非常赞。其中仅使用到布局动画和共享动画。废话不多说,直接上效果图:


Screenrecorder-2023-09-12-12-00-04-706.gif


怎么样,效果看起来还不错吧。这其实都是官方提供的效果,接下来让我给大家简单分享下整套效果实现的过程和其中遇到的一些问题。


首先是布局动画,何为布局动画呢?


布局动画的作用于ViewGr0up,执行动画效果的是内部的子View。布局动画在Android中可以通过LayoutAnimationLayoutTransition来实现。咱们这里直接使用LayoutAnimation方式。在项目目录res下新建anim文件夹,并在其中新建layout_slid_from_right.xml文件和slide_from_right.xml两个文件:


//Gr0upView中设置动画文件
android:layoutAnimation="@anim/layout_slid_from_right"

//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%"/>

//slide_from_right.xml文件
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="600">
<translate
android:fromYDelta="100%p"
android:interpolator="@android:anim/decelerate_interpolator"
android:toYDelta="0" />

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

<scale
android:fromXScale="20%"
android:fromYScale="20%"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="100%"
android:toYScale="100%" />

<rotate
android:fromDegrees="-5"
android:interpolator="@android:anim/accelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="0" />
</set>

其中set标签下可包含多个动画,运行时动画就是同时进行的。具体实现步骤可以参考我之前的文章:Android:LayoutAnimal的神奇效果



  • translate :平移动画

  • alpha:渐变动画

  • scale:缩放动画

  • rotate:旋转动画


接下来是共享动画,其实就是两个页面都包含了同一个元素,进行的一种转场动画。这是Android5.0以后Google推出Material Design设计风格中包含的功能。


如何使用呢?



  • 第一个ActivityXML文件中咱们将ImageView作为共享元素


<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="250dp"
app:riv_corner_radius="10dp" />


  • 第二个ActivityXML文件中需要添加一个transitionName属性,在跳转页面的时候也要用到它。


<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:transitionName="share"/>


  • 跳转页面时使用ActivityOptionsCompat设置共享信息并传输给下个页面:


val optionsCompat = ActivityOptionsCompat.makeSceneTransitionAnimation(this, iv, "share")//iv是当前点击的图片  share字符串是第二个activity布局中设置的**transitionName**属性
startActivity(Intent(this, MainActivity10::class.java).apply {
putExtra("data", url) //这里仍然可以正常传值
}, optionsCompat.toBundle()) //注意这里是转化为了bundle


  • 当然关闭页面的时候不再使用finish() 方法而是使用如下方式:


ActivityCompat.finishAfterTransition(this)

到此运行程序,就能达到和上面一样的动画效果。


遇到的坑:



  • 设置布局动画的时候,一定要记得在set标签内添加duration属性并赋值,否则不会有动画效果

  • 布局动画作用于所有的Gr0upView

  • 转场动画在选用共享属性的时候最好选用原生View。笔者之前尝试过一些第三方的ImageView,在跳到目标页的时候即便XML中将图片宽高设置为了match_parent,结果却只展示了图片本身的宽高。很有可能是自定义过程中计算和官方有冲突。

  • 官方的转场动画从5.0开始支持


好了,以上便是布局动画和共享动画的结合效果的全部内容。大家可以根据自己的需求和喜好实现更多酷炫的效果,希望这篇内容能给大家带来收获!


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

Android 当你需要读一个 47M 的 json.gz 文件

ChangeLog 2023/7/19: 修复 Python 序列化 protobuf,Koltin 反序列化,Array 长度不一致的问题。解决方案:统一使用 Koltin 进行序列化和反序列化的操作 使用数据库读取 Array 的数据 补充每种方式读取所...
继续阅读 »

ChangeLog


2023/7/19:



  1. 修复 Python 序列化 protobuf,Koltin 反序列化,Array 长度不一致的问题。解决方案:统一使用 Koltin 进行序列化和反序列化的操作

  2. 使用数据库读取 Array 的数据

  3. 补充每种方式读取所占用的磁盘空间大小


背景


事情是这样的,最近在做一个 emoji-search 的个人 Project,为了减少服务器的搭建及维护工作,我把 emoji 的 embedding 数据放到了本地,即 Android 设备上。这个文件的原始大小为 123M,使用 gzip 压缩之后,大小为 47.1M,文件每行都可以解析成一个 Json 的 Bean。文件的具体内容可以查看该 链接


// 文件行数为:3753 
// embed 向量维度为:1536
{"emoji": "\ud83e\udd47", "message": "1st place medal", "embed": [-0.018469301983714104, -0.004823130089789629, ...]}
{"emoji": "\ud83e\udd48", "message": "2nd place medal", "embed": [-0.023217657580971718, -0.0019081177888438106, ...]}


emoji 的 embedding 数据,记录了每个 emoji 的 token 向量。用来做 emoji 的搜索。将用户输入的 embedding 和 emoji 的 embedding 数据做点积,得到点积较大的 emoji,即用户的搜索结果。



Android 测试机配置如下:



hw.cpu 高通 SDM765G

hw.cpu.ncore 8

hw.device.name OPPO Reno3 Pro 5G

hw.ramSize 8G

image.androidVersion.api 33



小胆尝试


为了方便读取,我将文件放在了 raw 文件夹下,命名为 emoji_embeddings.gz。关键代码如下,这里我将 .gz 文件一次性加载到内存,然后逐行读取。


override suspend fun process(context: Context) = withContext(Dispatchers.IO) {
context.resources.openRawResource(R.raw.emoji_embeddings).use { inputStream ->
GZIPInputStream(inputStream).bufferedReader().use { bufferedReader ->
bufferedReader.readLines().forEachIndexed { index, line ->
val entity = gson.fromJson(line, EmojiJsonEntity::class.java)
// process entity
}
}
}
}

结果可想而知,由于文件比较大,读取文件到内存的时间大概在 13s 左右。


并且在读取的过程中,内存抖动比较严重,这非常影响用户体验。


将文件一次性加载到内存,占用的内存也比较大,大概在 260M 左右,内存紧张的情况下容易出现 OOM。



onPageScrolled


于是,接下来的工作,就是优化内存的使用和减少加载的耗时了。


优化内存使用



  • 逐行加载文件


    很显然,我们最好不要将文件一次性加载到内存中,这样内存占用比较大,容易 OOM,我们可以使用 ReaderuseLines API。类似于这样调用 bufferedReader().useLines{ } ,其原理为 Sequence + reader.readLine() 的实现。再使用 Flow 简单切一下线程,数据读取在 IO Dispatcher,数据处理在 Default Dispatcher。代码如下:


    override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
    flow {
    context.resources.openRawResource(R.raw.emoji_embeddings_json).use { inputStream ->
    GZIPInputStream(inputStream).use { gzipInputStream ->
    gzipInputStream.bufferedReader().useLines { lines ->
    for (line in lines) {
    emit(line)
    }
    }
    }
    }
    }.flowOn(Dispatchers.IO)
    .collect {
    val entity = gson.fromJson(it, EmojiJsonEntity::class.java)
    // process entity
    }
    }

    但这样会导致另一个问题,那就是内存抖动。因为逐行加载到内存中,当前行使用完之后,就会等待 GC,这里暂时无法解决。


    完成之后,加载时的内存可以从 260M 减少到 140M 左右,加载时间控制在 9s 左右。




onPageScrolled



  • 减少内存抖动


    通过查看代码,并使用 Profile 进行调试,我们可以发现,其实主要的 GC 操作频繁,主要是由这行代码导致的: line.toBean<EmojiJsonEntity>() 。这里会存在 EmojiJsonEntity 对象的创建操作,但是 EmojiJsonEntity 只作为中间变量进行存在和使用,所以创建完成之后,就会进行回收。那要怎么解决这个问题呢?


    笔者暂时没找到较好的解法,这里需要保证代码逻辑不过于复杂的同时,消除中间变量的创建。暂时先这样吧😜。有时间可以使用对象池试试。



减少加载耗时



  • 找到最长耗时路径


    测试下来,IO 大概耗时 3.8s,但是总的耗时在 9s。这里我指定了 IO 使用 IO 协程调度器,数据处理使用 Default 协程调度器,IO 和数据处理是并行的。所以总的来说,是数据处理在拖后腿。数据处理主要是这部分代码 line.toBean<EmojiJsonEntity>() 的耗时,使用 Gson 库进行一次 fromJson 的操作。这里我们一步一步来,先来解决 IO 耗时的问题。


  • 加快 IO 操作


    笔者暂时想到了以下两种处理方式:



    1. 单个流分段读取


      在 GZIP 文件中,数据被压缩成连续的块,并且每个块的压缩是相对于前一个块的数据进行的。这就意味我们不能只读取文件的一部分并解压它,因为我们需要前面的数据来正确解码当前的块。所以,对于 GZIP 文件来说,实现分段读取有一些困难。这个想法,暂时先搁置吧。


    2. 多个流分段读取



      • 同一个文件开启多个流


        回到 GZIP 的讨论,同一个文件开启多个流也是徒劳的。因为即使多个线程处理各自的流,然后每个线程处理该文件的一部分,这也需要每个流从头开始对 GZIP 文件进行解压,然后跳过自己无需处理的部分。这么算下来,其实并不能加快总的 IO 速度,同时也会造成 CPU 资源的浪费。


      • 将文件拆分成多个文件之后开启多个流


        考虑这样的一种实现方式:对原有的 GZIP 文件进行拆分,拆分成多个小的 GZIP 文件,使用多线程读取,利用多核 CPU 加快 IO。听起来似乎可行,我们赶紧实现一下:


        override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
        val mutex = Mutex()

        List(STREAM_SIZE) { i ->
        flow {
        val resId = getEmbeddingResId(i) // 获取当前的资源文件 Id
        context.resources.openRawResource(resId).use { inputStream ->
        GZIPInputStream(inputStream).use { gzipInputStream ->
        gzipInputStream.bufferedReader().useLines { lines ->
        for (line in lines) {
        emit(line)
        }
        }
        }
        }
        }.flowOn(Dispatchers.IO)
        }.asFlow()
        .flattenMerge(STREAM_SIZE)
        .collect { data ->
        val entity = gson.fromJson(data, EmojiJsonEntity::class.java)
        mutex.withLock {
        // process entity
        }
        }
        }

        笔者将之前的 json.gz 拆分成了 5 个文件,每个文件启动一个流去加载。之后再将这 5 个流通过 flattenMerge 合并成一个流,来进行数据处理。由于 flattenMerge 有多线程操作,所以这里我们使用协程的 Mutex 加个锁,保证数据操作的原子性。


        实际测试下来,如此操作的 IO 耗时在 2s,缩短为原来的一半,但总的耗时还是稳定在了 9s 左右,这多出来的 2s 具体花在哪里了暂时未知,咱接着优化一下数据处理吧😵‍💫。






    onPageScrolled


  • 缩短数据处理时间的方案分析


    先明确一下需求:我们需要将文件一次性加载到内存中,文件大小为 40M+,其中有每行都有一个 1536 个元素的 float 数组。了解了一圈下来,目前知道的可行的方案有两个,而且大概率需要更换数据结构和存储方式:



    1. 数据库(如 Room):在一些特定的情况下,使用数据库可能会有利,如当我们需要进行复杂查询、更新数据、或者需要随机访问数据的时候。如果需要使用数据库来缩短数据处理时间,那么我们需要在写入时就处理好数据格式,比如当前情况下,我们需要将 Float 数组使用 ByteArray 来存储。然而,在当前需求下,我们的数据相对简单,且只需要进行读操作。而且,我们的数据包含大量的浮点数数组,使用 ByteArray 来存储也会较为复杂。因此,数据库可能不是最理想的选择。但评论区大家对数据库比较看好,所以我们还是用数据库试试。

    2. Protocol Buffers (PB):PB 是一个二进制格式,比文本格式(如 JSON)更紧凑,更快,特别擅长存储和读取大量的数值数据(如 embed 数组)。我们的需求主要是读取数据,并且需要一次性将整个文件加载到内存中。因此,PB 可能是一个不错的选择。虽然 PB 数据不易于阅读和编辑,也不适合需要复杂查询或随机访问的情况。



    onPageScrolled


    如上是 PB 和 Json 序列化和反序列化的对比 ref。可以看到,在一次反序列化操作的情况下, PB 是 Json 的 5 倍。次数越多,差距越大。


    关于为什么二进制文件(PB)会比文本文件(Json) 体积更小,读写更快。这里就不过多赘述了,笔者个人理解,简单来说,是信息密度的差异,具体的大家可以去搜索,了解更多。




  • 使用 Room 存储 embedding 数据


    使用 Room 存储 embedding 数据都是进行一些常规的 CRDU 操作,这里就不赘述了,基本思路就是我们将 Json 数据存储在数据库中,在需要使用的时候,直接读取数据库即可。


    简单贴一下读取的代码:


    override suspend fun process(context: Context) = withContext(Dispatchers.IO) {
    val embeddingDao = getEmbeddingEntityDao(context)
    embeddingDao.queryAll()?.forEachIndexed { index, emojiEmbeddingEntity ->
    // process entity
    }

    Unit
    }

    实在是过于简单了,读取就完事了,多线程由数据库底层来处理。


    值得关注的是关于 Float 数组的存储和读取:


    class EmbeddingEntityConverter {
    @TypeConverter
    fun fromFloatArray(floatArray: FloatArray): ByteArray {
    val byteBuffer = ByteBuffer.allocate(floatArray.size * 4) // Float 是 4 字节
    floatArray.forEach { byteBuffer.putFloat(it) }
    return byteBuffer.array()
    }

    @TypeConverter
    fun toFloatArray(byteArray: ByteArray): FloatArray {
    val byteBuffer = ByteBuffer.wrap(byteArray)
    return FloatArray(byteArray.size / 4) { byteBuffer.float } // Float 是 4 字节
    }
    }

    笔者使用了 Room 的 @TypeConverter 注解,会在存储时,将 FloatArray 转换为 ByteArray 存储到数据库中,读取时,将 ByteArray 转换为 FloatArray 供上层使用。


    数据库读写的效果确实很惊艳,耗时 1.2s,稳定后内存占用 169MB 的样子,而且还不需要我自己处理多线程读写的问题,有点舒服。



    onPageScrolled


  • 使用 Protocol Buffers (PB) 存储 embedding 数据


    PB 文件比 Json 文件的读取要复杂不少,首先我们需要定义一下 proto 文件的格式。


    这里的 repeated float 可以理解成 float 类型的 List


    // emoji_embedding.proto
    syntax = "proto3";

    message EmojiEmbedding {
    string emoji = 1;
    string message = 2;
    repeated float embed = 3;
    }

    定义好之后,就可以进行数据的序列化操作了。值得一提的是,pb.gz 文件是 json.gz 文件的一半大小,只有 18.6M。在数据序列化的时候,笔者使用了 writeDelimitedTo API,该 API 会在写入数据时带上该条数据的长度,方便之后的数据反序列化操作。这里我们直接看一下 Android 反序列化 PB 文件的代码:


    override suspend fun process(context: Context) = withContext(Dispatchers.Default) {
    flow {
    context.resources.openRawResource(R.raw.emoji_embeddings_proto).use { inputStream ->
    GZIPInputStream(inputStream).buffered().use { gzipInputStream ->
    while (true) {
    EmojiEmbeddingOuterClass.EmojiEmbedding.parseDelimitedFrom(gzipInputStream)?.let {
    emit(it)
    } ?: break
    }
    }
    }
    }.flowOn(Dispatchers.IO)
    .buffer()
    .flatMapMerge { byteArray ->
    flow { emit(readEmojiData(byteArray)) }
    }.collect {}
    }

    private fun readEmojiData(entity: EmojiEmbeddingOuterClass.EmojiEmbedding) {
    // process entity
    }

    这里因为有生成的 EmojiEmbeddingOuterClass 代码,所以解析起来还算方便,解析完操作 entity 即可。值得注意的是,我使用 flatMapMerge 来实现多线程处理,而不是使用 launch/async ,这里的目的是减少协程的创建,减少上下文的切换,减少并发数,来提高数据处理的速度。因为实际测试下来,flatMapMerge 的速度会更快。


    那么这么做的实际效果如何呢?1.5s,和数据库读取相差不大。 (这里由于开了 build with Profile,会比实际的慢一点)。稳定下来时,内存占用 129 M。


    onPageScrolled



总结


大文件的读写,咱还是老老实实用字节码文件存储吧。小文件可以使用 Json,反序列化速度够用,可读性也可以有明显的提升。至于是用 PB 还是数据库,可以根据个人喜好及具体的业务场景分析。两者在读写速度上都是没有差别的,但是数据库在内存和磁盘空间上会占用更多。使用 PB 需要自行处理多线程相关问题,难度会较大一点。


具体的性能对比,图表如下:


json.gz + 一次性加载json.gz + 逐行加载拆分 json.gz + 逐行加载数据库加载pb.gz 加载
耗时13s9s9s1.2s1.5s
内存(加载后)260M140M148M169M129M
磁盘占用47.1M47.1M47.1M29.5M18.6M

用到的资源文件:github.com/sunnyswag/e…


源代码可查看:Github


REFERENCE


深入理解gzip原理 - 简书


Protobuf 和 JSON对比分析 - 掘金


Android Studio 配置并使用Protocol Buffer生成java文件 - CSDN博客


作者:很好奇
来源:juejin.cn/post/7253744712409071673
收起阅读 »

Android进程间大数据通信:LocalSocket

前言 说起Android进行间通信,大家第一时间会想到AIDL,但是由于Binder机制的限制,AIDL无法传输超大数据。 那么我们如何在进程间传输大数据呢? Android中给我们提供了另外一个机制:LocalSocket 它会在本地创建一个socket通道...
继续阅读 »

前言


说起Android进行间通信,大家第一时间会想到AIDL,但是由于Binder机制的限制,AIDL无法传输超大数据。


那么我们如何在进程间传输大数据呢?


Android中给我们提供了另外一个机制:LocalSocket


它会在本地创建一个socket通道来进行数据传输。


那么它怎么使用?


首先我们需要两个应用:客户端和服务端


服务端初始化


override fun run() {
server = LocalServerSocket("xxxx")
remoteSocket = server?.accept()
...
}

先创建一个LocalServerSocket服务,参数是服务名,注意这个服务名需要唯一,这是两端连接的依据。


然后调用accept函数进行等待客户端连接,这个函数是block线程的,所以例子中另起线程。


当客户端发起连接后,accept就会返回LocalSocket对象,然后就可以进行传输数据了。


客户端初始化


var localSocket = LocalSocket()
localSocket.connect(LocalSocketAddress("xxxx"))

首先创建一个LocalSocket对象


然后创建一个LocalSocketAddress对象,参数是服务名


然后调用connect函数连接到该服务即可。就可以使用这个socket传输数据了。


数据传输


两端的socket对象是一个类,所以两端的发送和接受代码逻辑一致。


通过localSocket.inputStreamlocalSocket.outputStream可以获取到输入输出流,通过对流的读写进行数据传输。


注意,读写流的时候一定要新开线程处理。


因为socket是双向的,所以两端都可以进行收发,即读写


发送数据


var pool = Executors.newSingleThreadExecutor()
var runnable = Runnable {
try {
var out = xxxxSocket.outputStream
out.write(data)
out.flush()
} catch (e: Throwable) {
Log.e("xxx", "xxx", e)
}
}
pool.execute(runnable)

发送数据是主动动作,每次发送都需要另开线程,所以如果是多次,我们需要使用一个线程池来进行管理


如果需要多次发送数据,可以将其进行封装成一个函数


接收数据


接收数据实际上是进行while循环,循环进行读取数据,这个最好在连接成功后就开始,比如客户端


localSocket.connect(LocalSocketAddress("xxx"))
var runnable = Runnable {
while (localSocket.isConnected){
var input = localSocket.inputStream
input.read(data)
...
}
}
Thread(runnable).start()

接收数据实际上是一个while循环不停的进行读取,未读到数据就继续循环,读到数据就进行处理再循环,所以这里只另开一个线程即可,不需要线程池。


传输复杂数据


上面只是简单事例,无法传输复杂数据,如果要传输复杂数据,就需要使用DataInputStreamDataOutputStream


首先需要定义一套协议。


比如定义一个简单的协议:传输的数据分两部分,第一部分是一个int值,表示后面byte数据的长度;第二部分就是byte数据。这样就知道如何进行读写


写数据


var pool = Executors.newSingleThreadExecutor()
var out = DataOutputStream(xxxSocket.outputStream)
var runnable = Runnable {
try {
out.writeInt(data.size)
out.write(data)
out.flush()
} catch (e: Throwable) {
Log.e("xxx", "xxx", e)
}
}
pool.execute(runnable)

读数据


var runnable = Runnable {
var input = DataInputStream(xxxSocket.inputStream)
var outArray = ByteArrayOutputStream()
while (true) {
outArray.reset()
var length = input.readInt()
if(length > 0) {
var buffer = ByteArray(length)
input.read(buffer)
...
}
}

}
Thread(runnable).start()

这样就可以传输复杂数据,不会导致数据错乱。


传输超大数据


上面虽然可以传输复杂数据,但是当我们的数据过大的时候,也会出现问题。


比如传输图片或视频,假设byte数据长度达到1228800,这时我们通过


var buffer = ByteArray(1228800)
input.read(buffer)

无法读取到所有数据,只能读到一部分。而且会造成后面数据的混乱,因为读取位置错位了。


读取的长度大约是65535个字节,这是因为TCP被IP包包着,也会有包大小限制65535。


但是注意!写数据的时候如果数据过大就会自动进行分包,但是读数据的时候如果一次读取貌似无法跨包,这样就导致了上面的结果,只能读一个包,后面的就错乱了。


那么这种超大数据该如何传输呢,我们用循环将其一点点写入,也一点点读出,并根据结果不断的修正偏移。代码:


写入


var pool = Executors.newSingleThreadExecutor()
var out = DataOutputStream(xxxSocket.outputStream)
var runnable = Runnable {
try {
out.writeInt(data.size)
var offset = 0
while ((offset + 1024) <= data.size) {
out.write(data, offset, 1024)
offset += 1024
}
out.write(data, offset, data.size - offset)
out.flush()
} catch (e: Throwable) {
Log.e("xxxx", "xxxx", e)
}

}

pool.execute(runnable)

读取


var input = DataInputStream(xxxSocket.inputStream)
var runnable = Runnable {
var outArray = ByteArrayOutputStream()
while (true) {
outArray.reset()
var length = input.readInt()
if(length > 0) {
var buffer = ByteArray(1024)
var total = 0
while (total + 1024 <= length) {
var count = input.read(buffer)
outArray.write(buffer, 0, count)
total += count
}
var buffer2 = ByteArray(length - total)
input.read(buffer2)
outArray.write(buffer2)
var result = outArray.toByteArray()
...
}
}
}
Thread(runnable).start()

这样可以避免因为分包而导致读取的长度不匹配的问题


作者:BennuCTech
来源:juejin.cn/post/7215100409169625148
收起阅读 »

Android MVI框架搭建与使用

前言   有一段时间没有去写过框架了,最近新的框架MVI,其实出来有一段时间了,只不过大部分项目还没有切换过去,对于公司的老项目来说,之前的MVC、MVP也能用,没有替换的必要,而对于新建的项目来说还是可以替换成功MVVM、MVI等框架的。本文完成后的效果图:...
继续阅读 »

前言


  有一段时间没有去写过框架了,最近新的框架MVI,其实出来有一段时间了,只不过大部分项目还没有切换过去,对于公司的老项目来说,之前的MVC、MVP也能用,没有替换的必要,而对于新建的项目来说还是可以替换成功MVVM、MVI等框架的。本文完成后的效果图:


在这里插入图片描述


正文


  每当一个新的框架出来,都会解决掉上一个框架所存在的问题,但同时也会产生新的问题,瑕不掩瑜,可以在实际开发中,解决掉产生的问题,就能够更好的使用框架,那么MVI解决了MVVM的什么问题呢?


  MVI同样是基于观察者模式,只不过数据通信方面是单向的,解决了MVVM双向通信所带来的问题,实际上MVVM也能做成单向通讯,但是这样就不是纯粹的MVVM,当然了,仁者见仁,智者见智。MVI框架适用于UI变化很多的项目,通过数据去驱动UI,MVI就是Model、View、Intent。



  • Model 这里的Model有所不同,里面还包含UI的状态。

  • View 还是视图,例如Activity、Fragment等。

  • Intent 意图,这个和Activity的意图要区分开,我觉得说成是行为可能更妥当,表示去做什么。


多说无益,我们还是进入实操环节吧。


一、创建项目


首先创建一个名为MviDemo的项目


在这里插入图片描述


项目创建好了,下面我们需要先进行项目的基本配置。


① 配置AndroidManifest.xml


  文章中会通过一个网络API接口,拿到数据来进行MVI框架的搭建与使用,接口地址如下:


http://service.picasso.adesk.com/v1/vertical/vertical?limit=30&skip=180&adult=false&first=0&order=hot

通过浏览器打开可以得到很多数据,如图所示:


在这里插入图片描述


  这些数据都是JSON格式的,后面我们还会用到这些数据。因为接口使用的是http,而不是https,所以在xml文件夹下新建一个network_security_config.xml,代码如下:


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>

然后在AndroidManifest.xml中的application标签中配置它,如图所示:


在这里插入图片描述


  从Android 9.0起,默认使用https进行网络访问,如果要进行http访问则需要添加这个配置。还需要添加一个网络访问静态权限:


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

添加位置如下图所示:


在这里插入图片描述


项目正常搭建还需要一些依赖库和其他的一些设置,下面我们配置app模块下的build.gradle。


② 配置app的build.gradle


  请注意,这里是配置app的build.gradle,而不是项目的build.gradle,很多人会配置错误,所以我再次强调一下,将你的项目切换到Android模式,如下图所示:


在这里插入图片描述


  这里我标注了一下,你看到有两个build.gradle文件,两个文件的后面有灰色的文字说明,就很清楚的知道这两个build.gradle分别是项目和模块的。下面打开app模块下的build.gradle,在里面找到dependencies{}闭包,闭包中添加如下依赖:


    // lifecycle
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
//glide
implementation 'com.github.bumptech.glide:glide:4.14.2'
//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
//retrofit moshi
implementation "com.squareup.retrofit2:converter-moshi:2.6.2"
//moshi used KotlinJsonAdapterFactory
implementation "com.squareup.moshi:moshi-kotlin:1.9.3"
//Coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"

添加位置如下图所示:


在这里插入图片描述


然后再打开viewBinding,在android{}闭包下添加如下代码:


    buildFeatures {
viewBinding true
}

添加位置如下图所示:


在这里插入图片描述


  添加之后你会看到右上角有一个Sync Now,点击它进行依赖的载入配置,配置好之后进入下一步,为了确保你的项目没有问题,你可以现在运行一下看看。


二、网络请求


  当我们使用Kotlin时,网络访问就变得更简单了,只需要Retrofit和协程即可,首先我们在com.llw.mvidemo包下新建一个data包,然后在data包下新建一个model包,model包下我们可以通过刚才使用网页访问API拿到的JSON数据来生成一个数据类。


① 生成数据类


生成数据类,这里我们可以使用一个插件,搜索JSON To Kotlin Class,如下图所示:


在这里插入图片描述


  下载安装之后,如果需要重启,你就重启AS,重启之后,右键点击model → New → Kotlin data class File from JSON,如图所示:


在这里插入图片描述


在出现的弹窗中复制通过网页请求得到的JSON数据字符串,如图所示:


在这里插入图片描述


  这里如果觉得看起来不舒服,点击 Format 进行JSON数据格式化,然后我们需要设置数据类的名称,这里输入Wallpaper,因为我们需要使用Moshi,将JSON数据直接转成数据类,所以这里我们点击Advanced,如图所示:


在这里插入图片描述


  这里默认是,选择MoShi(Reflect),其他的不用更改,点击OK,此弹窗关闭,回到之前的弹窗,然后点击 Generate 生成数据类,你会发现有三个数据类,分别是Wallpaper、Res和Vertical,我们看一下Wallpaper的代码:


package com.llw.mvidemo.data.model

import com.squareup.moshi.Json

data class Wallpaper(
@Json(name = "code")
val code: Int,
@Json(name = "msg")
val msg: String,
@Json(name = "res")
val res: Res
)

  这里每一个字段上都有一个@Json注解,这里是MoShi依赖库的注解,主要检查一下导包的问题,这里还有一个小故事,Google 的Gson库,算是推出比较早的,从事Gson库的开发人员,后面离职去了Square,也就是OkHttp、Retrofit的开发者。Retrofit一开始是支持Gson转换的,后面增加了MoShi的转换,Moshi拥有出色的Kotlin支持以及编译时代码生成功能,可以使应用程序更快更小。这个故事我也是听说的,你可以自己去求证,下面继续。


② 接口类


  现在数据类有了,那么我们就需要根据这个数据类来写一个接口类,在com.llw.mvidemo包下新建一个network包,network包下创建一个接口类ApiService,代码如下所示:


interface ApiService {

/**
* 获取壁纸
*/

@GET("v1/vertical/vertical?limit=30&skip=180&adult=false&first=0&order=hot")
suspend fun getWallPaper(): Wallpaper
}

这里属于Retrofit的使用方式,增加了协程的使用而已,就取代了RxJava的线程调度。


③ 网络请求工具类


现在有接口,下面我们来做网络请求,在network包下新建一个NetworkUtils类,代码如下:


package com.llw.mvidemo.network

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

/**
* 网络工具类
*/

object NetworkUtils {

private const val BASE_URL = "http://service.picasso.adesk.com/"

/**
* 通过Moshi 将JSON转为为 Kotlin 的Data class
*/

private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()

/**
* 构建Retrofit
*/

private fun getRetrofit() = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()

/**
* 创建Api网络请求服务
*/

val apiService: ApiService = getRetrofit().create(ApiService::class.java)
}

  由于担心你看的时候导错包,现在贴代码我会将导包的信息也贴出来,这样你总不会再导错包了吧。下面简单说明一下这个类,首先我定义了一个常量BASE_URL。作为网络接口请求的地址头,然后构建了MoShi,通过MoShi去进行JSON转Kotlin数据类的处理,之后就是构建Retrofit,将MoShi设置进去,最后就是通过Retrofit创建一个网络请求服务。


三、意图与状态


  之前我们说MVI的I 是Intent,表示意图或行为,和ViewModel一样,我们在使用Intent的时候,也是一个Intent对应一个Activity/Fragment。


① 创建意图


data包下创建一个intent包,intent包下新建一个MainIntent类,代码如下所示:


package com.llw.mvidemo.data.intent

/**
* 页面意图
*/

sealed class MainIntent {
/**
* 获取壁纸
*/

object GetWallpaper : MainIntent()
}

  这里只有一个GetWallpaper,表示获取壁纸的动作,你还可以添加其他的,例如保存图片、下载图片等,现在意图有了,下面来创建状态,一个意图有用多个状态。


② 创建状态


data包下创建一个state包,state包下新建一个MainState类,代码如下:


package com.llw.mvidemo.data.state

import com.llw.mvidemo.data.model.Wallpaper

/**
* 页面状态
*/

sealed class MainState {
/**
* 空闲
*/

object Idle : MainState()

/**
* 加载
*/

object Loading : MainState()

/**
* 获取壁纸
*/

data class Wallpapers(val wallpaper: Wallpaper) : MainState()

/**
* 错误信息
*/

data class Error(val error: String) : MainState()
}

  这里可以看到四个状态,获取壁纸属于其中的一个状态,通过状态可以去更改页面中的UI,后面我们会看到这一点,这里的状态你还可以再进行细分,例如每一个网络请求你可以增加一个请求中、请求成功、请求失败。


四、ViewModel


  在MVI模式中,ViewModel的重要性又提高了,不过我们同样要添加Repository,作为数据存储库。


① 创建存储库


data包下创建一个repository包,repository包下新建一个MainRepository类,代码如下:


package com.llw.mvidemo.data.repository

import com.llw.mvidemo.network.ApiService

/**
* 数据存储库
*/

class MainRepository(private val apiService: ApiService) {

/**
* 获取壁纸
*/

suspend fun getWallPaper() = apiService.getWallPaper()
}

  这里的代码就没什么好说的,下面我们写ViewModel,和MVVM模式中没什么两样的。


② 创建ViewModel


  下面在com.llw.mvidemo包下新建一个ui包,ui包下新建一个adapter包,adapter包下新建一个MainViewModel类,代码如下:


package com.llw.mvidemo.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.llw.mvidemo.data.repository.MainRepository
import com.llw.mvidemo.data.intent.MainIntent
import com.llw.mvidemo.data.state.MainState
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch

/**
* @link MainActivity
*/

class MainViewModel(private val repository: MainRepository) : ViewModel() {

//创建意图管道,容量无限大
val mainIntentChannel = Channel<MainIntent>(Channel.UNLIMITED)

//可变状态数据流
private val _state = MutableStateFlow<MainState>(MainState.Idle)

//可观察状态数据流
val state: StateFlow<MainState> get() = _state

init {
viewModelScope.launch {
//收集意图
mainIntentChannel.consumeAsFlow().collect {
when (it) {
//发现意图为获取壁纸
is MainIntent.GetWallpaper -> getWallpaper()
}
}
}
}

/**
* 获取壁纸
*/

private fun getWallpaper() {
viewModelScope.launch {
//修改状态为加载中
_state.value = MainState.Loading
//网络请求状态
_state.value = try {
//请求成功
MainState.Wallpapers(repository.getWallPaper())
} catch (e: Exception) {
//请求失败
MainState.Error(e.localizedMessage ?: "UnKnown Error")
}
}
}
}

  这里首先创建一个意图管道,然后是一个可变的状态数据流和一个不可变观察状态数据流,观察者模式。在初始化的时候就进行意图的收集,你可以理解为监听,当收集到目标意图MainIntent.GetWallpaper时就进行相应的意图处理,调用getWallpaper()函数,这里面修改可变的状态_state,而当_state发生变化,state就观察到了,就会进行相应的动作,这个通过是在View中进行,也就是Activity/Fragment中进行。这里对_state首先赋值为Loading,表示加载中,然后进行一个网络请求,结果就是成功或者失败,如果成功,则赋值Wallpapers,View中收集到这个状态后就可以进行页面数据的渲染了,请求失败,也要更改状态。


③ 创建ViewModel工厂


在viewmodel包下新建一个ViewModelFactory类,代码如下:


package com.llw.mvidemo.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.llw.mvidemo.network.ApiService
import com.llw.mvidemo.data.repository.MainRepository

/**
* ViewModel工厂
*/

class ViewModelFactory(private val apiService: ApiService) : ViewModelProvider.Factory {

override fun <T : ViewModel> create(modelClass: Class<T>): T {
// 判断 MainViewModel 是不是 modelClass 的父类或接口
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(MainRepository(apiService)) as T
}
throw IllegalArgumentException("UnKnown class")
}
}

五、UI


  前面我们写好基本的框架内容,下面来进行使用,简单来说,请求数据然后渲染出来,因为这里请求的是壁纸数据,所以我需要写一个适配器。


① 列表适配器


  在创建适配器之前首先我们需要创建一个适配器所对应的item布局,在layout下新建一个item_wallpaper_rv.xml,代码如下图所示:


<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.imageview.ShapeableImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/iv_wall_paper"
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_margin="4dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/roundedImageStyle" />


这里使用了ShapeableImageView,这个控件的优势就在于可以自己设置圆角,在themes.xml中添加如下代码:


    <!-- 圆角图片 -->
<style name="roundedImageStyle">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">24dp</item>
</style>

添加位置如下图所示:


在这里插入图片描述


下面进行我们在ui包下新建一个adapter包,adapter包下新建一个WallpaperAdapter类,里面的代码如下所示:


package com.llw.mvidemo.ui.adapter

import android.view.LayoutInflater
import android.view.ViewGr0up
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.llw.mvidemo.data.model.Vertical
import com.llw.mvidemo.databinding.ItemWallpaperRvBinding

/**
* 壁纸适配器
*/

class WallpaperAdapter(private val verticals: ArrayList<Vertical>) :
RecyclerView.Adapter<WallpaperAdapter.ViewHolder>() {

fun addData(data: List<Vertical>) {
verticals.addAll(data)
}

class ViewHolder(itemWallPaperRvBinding: ItemWallpaperRvBinding) :
RecyclerView.ViewHolder(itemWallPaperRvBinding.root) {

var binding: ItemWallpaperRvBinding

init {
binding = itemWallPaperRvBinding
}
}

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int) =
ViewHolder(ItemWallpaperRvBinding.inflate(LayoutInflater.from(parent.context), parent, false))

override fun getItemCount() = verticals.size

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
//加载图片
verticals[position].priview.let {
Glide.with(holder.itemView.context).load(it).int0(holder.binding.ivWallPaper)
}
}
}

这里的代码相对比较简单,就不做说明了,属于适配器的基本操作了。


② 数据渲染


适配器写好之后,我们需要修改一下activity_main.xml中的内容,修改后代码如下所示:


<?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"
tools:context=".ui.MainActivity">


<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_wallpaper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:visibility="gone" />


<ProgressBar
android:id="@+id/pb_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />


<Button
android:id="@+id/btn_get_wallpaper"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="获取壁纸"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>

下面我们进入MainActivity,修改里面的代码如下所示:


package com.llw.mvidemo.ui

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.llw.mvidemo.network.NetworkUtils
import com.llw.mvidemo.databinding.ActivityMainBinding
import com.llw.mvidemo.data.intent.MainIntent
import com.llw.mvidemo.data.state.MainState
import com.llw.mvidemo.ui.adapter.WallpaperAdapter
import com.llw.mvidemo.ui.viewmodel.MainViewModel
import com.llw.mvidemo.ui.viewmodel.ViewModelFactory
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding

private lateinit var mainViewModel: MainViewModel

private var wallPaperAdapter = WallpaperAdapter(arrayListOf())

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//使用ViewBinding
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
//绑定ViewModel
mainViewModel = ViewModelProvider(this, ViewModelFactory(NetworkUtils.apiService))[MainViewModel::class.java]
//初始化
initView()
//观察ViewModel
observeViewModel()
}

/**
* 观察ViewModel
*/

private fun observeViewModel() {
lifecycleScope.launch {
//状态收集
mainViewModel.state.collect {
when(it) {
is MainState.Idle -> {

}
is MainState.Loading -> {
binding.btnGetWallpaper.visibility = View.GONE
binding.pbLoading.visibility = View.VISIBLE
}
is MainState.Wallpapers -> { //数据返回
binding.btnGetWallpaper.visibility = View.GONE
binding.pbLoading.visibility = View.GONE

binding.rvWallpaper.visibility = View.VISIBLE
it.wallpaper.let { paper ->
wallPaperAdapter.addData(paper.res.vertical)
}
wallPaperAdapter.notifyDataSetChanged()
}
is MainState.Error -> {
binding.pbLoading.visibility = View.GONE
binding.btnGetWallpaper.visibility = View.VISIBLE
Log.d("TAG", "observeViewModel: $it.error")
Toast.makeText(this@MainActivity, it.error, Toast.LENGTH_LONG).show()
}
}
}
}
}

/**
* 初始化
*/

private fun initView() {
//RV配置
binding.rvWallpaper.apply {
layoutManager = GridLayoutManager(this@MainActivity, 2)
adapter = wallPaperAdapter
}
//按钮点击
binding.btnGetWallpaper.setOnClickListener {
lifecycleScope.launch{
//发送意图
mainViewModel.mainIntentChannel.send(MainIntent.GetWallpaper)
}
}
}
}

  说明一下,首先声明变量并在onCreate()中进行初始化,这里绑定ViewModel采用的是ViewModelProvider(),而不是ViewModelProviders.of,这是因为这个API已经被移除了,在之前的版本中是过时弃用,在最新的版本中你都找不到这个API了,所以使用ViewModelProvider(),然后通过ViewModelFactory去创建对应的MainViewModel


  initView()函数中是控件的一些配置,比如给RecyclerView添加布局管理器和设置适配器,给按钮添加点击事件,在点击的时候发送意图,发送的意图被MainViewModel中mainIntentChannel收集到,然后执行网络请求操作,此时意图的状态为Loading


  observeViewModel()函数中是对状态的收集,在状态为Loading,隐藏按钮,显示加载条,然后网络请求会有结果,如果是成功,则在UI上隐藏按钮和加载条,显示列表控件,并添加数据到适配器中,然后刷新适配器,数据就会渲染出来;如果是失败则显示按钮,隐藏加载条,打印错误信息并提示一下。这样就完成了通过状态更新UI的环节,MVI的框架就是这样设计的。


页面UI(点击事件发送意图) → ViewModel收集意图(确定内容) →
ViewModel更新状态(修改_state) → 页面观察ViewModel状态(收集state,执行相关的UI)

这是一个环,从UI页面出发,最终回到UI页面中进行数据渲染,我们看看效果。


在这里插入图片描述


六、源码


欢迎Star 或 Fork,山高水长,后会有期~


源码地址:MviDemo


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

OkDownloader,基于 OkHttp的现代化开源下载框架

OkDownloader是一款基于 OkHttp 编写的适用于Kotlin/Java/Android平台的开源下载框架,可以运行在任何JVM 机器上。 简单易用:和 OkHttp 一样简单易用的 API 功能丰富:支持同步/异步下载、网络限制、任务优先级、资...
继续阅读 »

OkDownloader是一款基于 OkHttp 编写的适用于Kotlin/Java/Android平台的开源下载框架,可以运行在任何JVM 机器上。



  • 简单易用:和 OkHttp 一样简单易用的 API

  • 功能丰富:支持同步/异步下载、网络限制、任务优先级、资源校验、多线程下载等

  • 现代化:用 Kotlin 编写的基于 OkHttp 的下载框架

  • 易扩展:支持在代码中注入自定义拦截器以及SPI声明自定义拦截器的方式扩展下载功能

  • 多平台:支持在任何 JVM 机器上运行


使用示例


创建Downloader对象


val downloader = Downloader.Builder().build()

同步下载


val request = Download.Request.Builder()
.url(url)
.int0(file)
.build()
downloader.newCall(request).execute()

异步下载


val request = Download.Request.Builder()
.url(url)
.int0(file)
.build()
downloader.newCall(request).enqueue()

取消下载


call.cancel()

更多的用法可以参考文章最后的官网


设计思路


OkDownloader 整体上模仿 OkHttp 的代码风格和模式编写,拥有和 OkHttp 一样简单易用的 API和拦截器,这种设计非常容易扩展。


代码添加拦截器


val downloader = Downloader.Builder()
.addInterceptor(CustomInterceptor())
.build()

SPI声明拦截器(可以在不同的模块中,通常会在一个扩展模块),即在扩展模块的META-INF/services/com.billbook.lib.Interceptor


com.example.CustomInterceptor1
com.example.CustomInterceptor2
com.example.CustomInterceptor3

Downloader为什么不直接设计成单例?


通常,我们在使用 OkHttp 的时候会将 OkHttpClient 包装成单例。那么为什么OkHttp 不把 OkHttpClient 直接设置成单例呢?


原因是不设计成单例会更加灵活,在需要特殊配置的时候我们调用原有的 OkHttpClient 的 newBuilder 方法重新创建一个 Builder进行特殊的参数配置(如更短的连接超时)后 build一个新的 OkHttpClient 以适应于新的网络请求场景。这样不仅可以进行资源复用(如内部的连接池)还可以特殊定制化以便适应多个网络请求场景。


资源复用


类似地,Downloader对象中有一个 ExecutorService,是内部异步下载任务调度执行的线程池。通常我们需要进行线程池的复用,所以 Downloader 也提供了 newBuilder 方法进行资源的复用。同时 Downloader 对象中会有自己的 DownloadPool,我们称它为下载池,它的职责是管理 Downloader 中的所有下载任务。Downloader 的 DownloadPool 不会进行复用,目的是为了对不同 Downloader 的下载任务隔离。


任务隔离


每个Downloader 实例有自己的DownloadPool,这样方便进行下载任务隔离,做到不同业务的下载任务互不干扰。


当然,如果你需要的是全局的Downloader统一管理App 的所有下载任务,那么你可以将 Downloader 包装成单例对象,并且设置同一个下载池,如


val downloadPool = DownloadPool()

val globalDownloader = Downloader.Builder()
.downloadPool(downloadPool)
.build()

val retry10Downloader = globalDownloader.newBuilder()
.downloadPool(downloadPool)
.defaultMaxRetry(10)
.build()

// cancelAll
globalDownloader.cancelAll()


需要说明的是,当你需要特殊配置一个 Downloader 对象,并且你需要将该 Downloader 中的任务在全局 Downloader调用 cancelAll 时也会取消它的下载任务的时候你才需要设置同一个 DownloadPool。


最后


OkDownloader提供了和 OkHttp 类似的简单易用的 API,很方便使用。同时也提供了拦截器很方便对现有的功能进行扩展,如可扩展免流 Url转换功能,4G或WIFI网络限制功能。



目前下载框架已接入线上 App 中使用,欢迎大佬吐槽点赞,如果您觉得OkDownloader好用或者该文章对你有帮助的话不妨动动你的手指给个Star~感谢您的阅读和支持!


作者:异独行
来源:juejin.cn/post/7261862616095768634
收起阅读 »

再次吐槽鸿蒙

上次吐槽鸿蒙还是是刚刚读完官网文档。 最近尝试利用鸿神的玩安卓开放 API 写一个 WanHarmony,也是遇到了一些设计上的不合理,或者是我没有 get 到设计精髓的地方,记录一下。 没有全局 Style 在安卓中,遇到需要公共的样式,一般会抽取全局 St...
继续阅读 »

上次吐槽鸿蒙还是是刚刚读完官网文档。


最近尝试利用鸿神的玩安卓开放 API 写一个 WanHarmony,也是遇到了一些设计上的不合理,或者是我没有 get 到设计精髓的地方,记录一下。


没有全局 Style


在安卓中,遇到需要公共的样式,一般会抽取全局 Style,鸿蒙也提供了类似的能力 @Style 装饰器。例如宽高都是 100% :


@Styles function matchSize() {
.width('100%')
.height('100%')
}

文档中说是支持 组件内全局 重用。但实际测试,所谓的全局仅仅支持单个文件内的不同组件可以引用到,一旦跨文件就无法引用。


这个还挺不方便的,希望后续得到修复。


费解的 LazyForEach


LazyForEach 从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了 LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。


显而易见,LazyForEach 是 RecyclerView 的替代品,甚至连用法都有一些类似。


LazyForEach(
dataSource: IDataSource, // 需要进行数据迭代的数据源
itemGenerator: (item: any, index?: number) => void, // 子组件生成函数
keyGenerator?: (item: any, index?: number) => string // 键值生成函数
): void

数据源需要实现 IDataSource 接口:


interface IDataSource {
totalCount(): number; // 获得数据总数
getData(index: number): Object; // 获取索引值对应的数据
registerDataChangeListener(listener: DataChangeListener): void; // 注册数据改变的监听器
unregisterDataChangeListener(listener: DataChangeListener): void; // 注销数据改变的监听器
}

这个 Listener 也是一堆接口方法:


interface DataChangeListener {
onDataReloaded(): void; // 重新加载数据完成后调用
onDataAdded(index: number): void; // 添加数据完成后调用
onDataMoved(from: number, to: number): void; // 数据移动起始位置与数据移动目标位置交换完成后调用
onDataDeleted(index: number): void; // 删除数据完成后调用
onDataChanged(index: number): void; // 改变数据完成后调用
onDataAdd(index: number): void; // 添加数据完成后调用
onDataMove(from: number, to: number): void; // 数据移动起始位置与数据移动目标位置交换完成后调用
onDataDelete(index: number): void; // 删除数据完成后调用
onDataChange(index: number): void; // 改变数据完成后调用
}

乍看起来,跟 RecyclerView.Adapter 差不多。等等,ArkUI 不应该是声明式 UI 吗?为什么还要用这种写法来实现列表呢。


其实 ArkUI 也有声明式的 List 组件:


    List({ scroller: this.scroller }) {
ForEach(this.articleList, (item: ArticleEntity) => {
ListItem() {
ArticleView({ article: item })
.onClick(() => {
router.pushUrl({
url: 'pages/WebPage',
params: item
}, router.RouterMode.Single)
})
}
})
}
.height('100%')
.width('100%')

但是呢,默认会加载所有数据,不支持预加载,不支持 item 的回收复用。所以,屏蔽实现细节,直接让 List 支持回收复用会不会更好呢?


费解的 Dialog


期望的声明式 Dialog 写法:


.dialog($isShow) {
// 自定义 dialog 布局
}

鸿蒙需要通过一个神奇的 CustomDialogController 来处理。


先通过 @CustomDialog 定义自定义 Dialog,


@CustomDialog
struct CustomDialogExample {
controller: CustomDialogController = new CustomDialogController({
builder: CustomDialogExample({}),
})

build() {
Column() {
Text('自定义 Dialog')
.fontSize(20)
.margin({ top: 10, bottom: 10 })
}
}
}

然后声明一个 CustomDialogController,调用其 open() 方法来展示弹窗。


@Entry
@Component
struct CustomDialogUser {
dialogController: CustomDialogController = new CustomDialogController({
builder: CustomDialogExample(),
})

build() {
Column() {
Button('click me')
.onClick(() => {
this.dialogController.open()
})
}.width('100%').margin({ top: 5 })
}
}

官网示例中还有一个更加晦涩难懂的 一个 dialog 中弹出另一个 dialog 的场景示例。


能用,但没那么好用。


硬编码


良好的设计应该避免让程序员硬编码,以尽量减少犯错的可能性。


当我第一次看到下面这个代码,有点懵。


Grid() {
...
}
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 2fr 1fr')


这种相比 GridLayoutManager.SpanSizeLookUp 的写法,效率确实得到了很大的提升,但可读性就降低了。


还有宽高的硬编码,


.width('100%')
.height('100%')

我一直期望可以有个类似 fillWidth/fillHeight 的装饰器可以代替一下。


最后


以上吐槽基于 API 10 版本。另外希望早日可以有 API 9 以上版本的虚拟机可以使用。


今天是鸿蒙生态千帆启航仪式,目前已经参与鸿蒙原生开发的 App 数量比我想象的还要多一些,官方也给出了 Q4 正式商用的计划。可以想象,今年肯定是鸿蒙 App 井喷的一年。



作者:路遥写代码
来源:juejin.cn/post/7325338405408555060
收起阅读 »