注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

深入研究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 第一期】

一、详细步骤导入uikit二、遇到集成报错解决 1. 从github下载的附件我们打开以后 会有两个 一个是ChatRoomService ,另外一个是ChatroomUIKit2.先倒入UIkit的本地库(引导的内容可以参考标题1. 的绿色箭头第二...
继续阅读 »
一、详细步骤导入uikit
二、遇到集成报错解决 

1. 从github下载的附件我们打开以后 会有两个 一个是ChatRoomService ,另外一个是ChatroomUIKit

2.先倒入UIkit的本地库(引导的内容可以参考标题1. 的绿色箭头第二个文件夹)

3.然后在导入ChatRoomservice 选择文件后也点击Finish 注: 一共两个文件 都需要导入

4.填写settings.gradle
include(":ChatroomUIKit")
include(":ChatroomService")
添加:build.gradle(app)
implementation(project(mapOf("path" to ":ChatroomUIKit")))
引导图1


引导图2

遇到报错如下:
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
5.初始化UIkit

(1)appkey管理后台位置

6.客户端登录调用
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")
7.展示进入聊天室逻辑
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
收起阅读 »

Android:面向单Activity开发

记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Ac...
继续阅读 »


记得前一两年很多人都跟风面向单Activity开发,顾名思义,就是整个项目只有一个Activity。一个Activity里面装着N多个Fragment,再给Fragment加上转场动画,效果和多Activity跳转无异。其实想想还比较酷,以前还需要关注多个Acitivity之间的生命周期,现在只需关注一个,但还是需要对Fragment的生命周期进行关注。



其实早在六七年前GitHub上就有单Activity的开源库Fragmentation,后来谷歌也出了一个库Navigation。本来以为官方出品必为经典,当时跟着官方文档一步一步踩坑,最后还是放弃了该方案。理由大概如下:



  1. 需要创建XML文件,配置导航关系和跳转参数等

  2. 页面回退是重新创建,需要配合livedata使用

  3. 貌似还会存在卡顿,一些栈内跳转处理等问题


而Github上Fragmentation库已经停止维护,所幸的是再lssuse中发现了一个基于它继续维护的SFragmentation,于是正是开启了面向单Activity的开发。


提供了可滑动返回的版本


dependencies {
//请使用最新版本
implementation 'com.github.weikaiyun.SFragmentation:fragmentation:latest'
//滑动返回,可选
implementation 'com.github.weikaiyun.SFragmentation:fragmentation_swipeback:latest'
}

由于是Fragment之间的跳转,我们需要将原有的Activity跳转动画在框架初始化时设置到该框架中


Fragmentation.builder() 
//设置 栈视图 模式为 (默认)悬浮球模式 SHAKE: 摇一摇唤出 NONE:隐藏, 仅在Debug环境生效
.stackViewMode(Fragmentation.BUBBLE)
.debug(BuildConfig.DEBUG)
.animation(
R.anim.public_translate_right_to_center, //进入动画
R.anim.public_translate_center_to_left, //隐藏动画
R.anim.public_translate_left_to_center, //重新出现时的动画
R.anim.public_translate_center_to_right //退出动画
)
.install()

因为只有一个Activity,所以需要在这个Activity中装载根Fragment


loadRootFragment(int containerId, SupportFragment toFragment)

但现在的APP几乎都是一个页面多个Tab组成的怎么办呢?


loadMultipleRootFragment(int containerId, int showPosition, SupportFragment... toFragments);

有了多个Fragment的显示,我们需要切换Tab实际也很简单


showHideFragment(ISupportFragment showFragment);

是不是使用起来很简单,首页我们解决了,关于跳转和返回、参数的接受和传递呢?


//启动目标fragment
start(SupportFragment fragment)
//带返回的启动方式
startForResult(SupportFragment fragment,int requestCode)
//接收返回参数
override fun onFragmentResult(requestCode: Int, resultCode: Int, data: Bundle?) {
super.onFragmentResult(requestCode, resultCode, data)
}
//返回到上个页面,和activity的back()类似
pop()

对于单Activity而言,我们其实也可以注册一个全局的Fragment监听,这样就能掌控当前的Fragmnet


supportFragmentManager.registerFragmentLifecycleCallbacks(
object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) {
super.onFragmentAttached(fm, f, context)
}
override fun onFragmentCreated(
fm:
FragmentManager,
f:
Fragment,
savedInstanceState:
Bundle?
)
{
super.onFragmentCreated(fm, f, savedInstanceState)
}
override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
super.onFragmentStarted(fm, f)
}
override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
super.onFragmentResumed(fm, f)
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
super.onFragmentDestroyed(fm, f)
}
},
true
)

接下来我们看看Pad应用。对于手机应用来说,一般不会存在局部页面跳转的情况,但是Pad上是常规操作。


image.png


如图,点击左边列表的单个item,右边需要显示详情,这时候再点左边的其他item,此时的左边页面是保持不动的,但右边的详情页需要跳转对应的页面。使用过Pad的应该经常见到这种页面,比如Pad的系统设置等页面。这时只使用Activty应该是不能实现的,必须配合Fragment,左右分为两个Fragment。


但问题又出现了,这时候点击back怎么区分局部返回和整个页面返回呢?


//整个页面回退,主要是用于当前装载了Fragment的页面回退
_mActivity.pop()
//局部回退,被装载的Fragment之间回退
pop()

如下图,这样的页面我们又应该怎么装载呢?
image.png


可以分析,页面最外面是一个Activty,要实现单Activity其内部必装载了一个根Fragment。接着这个根Fragment中使用ViewPage和tablayout完成主页框架。当前tab页要满足右边详情页的单独跳转,还得将右边页面作为主页面,以此装载子Fragment才能实现。


image.png


总结


单Activity开发在手机和平板上使用都一样,但在平板上注意的地方更多,尤其是平板一个页面可能是多个页面组成,其局部还能单独跳转的功能,其中涉及到参数回传和栈的回退问题。使用下来,我还是觉得某些页面对硬件要求很高的使用单Activity会出现体验不好的情况,有可能是优化不到位。手机应用我还是使用多Activity方式,平板应用则使用该框架实现单Activity方式。



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

Camera2 同时预览多个摄像头,CameraX不行?

本来是想通过CameraX实现同时预览多个摄像头,通过官网文档介绍,在CameraX 1.3 后通过ConcurrentCamera运行多个摄像头,但实际在小米10(Android 13)运行,报错当前设备不支持ConcurrentCamera,代码Camer...
继续阅读 »

本来是想通过CameraX实现同时预览多个摄像头,通过官网文档介绍,在CameraX 1.3 后通过ConcurrentCamera运行多个摄像头,但实际在小米10(Android 13)运行,报错当前设备不支持ConcurrentCamera,代码CameraProvider.availableConcurrentCameraInfos查询也是返回数量0,表示设备不支持。


请教ChatGPT回答,来进行编写,回答可以通过代码创建多个previewrequireLensFacing,但是实际运行时不可行的。程序会报下面代码问题,选择摄像头设备异常。


val cameraSelector =builder
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.requireLensFacing(CameraSelector.LENS_FACING_FRONT)
.build()

因此个人下定义是在cameraX 1.3.0-alpha07前应该是不支持预览多摄像头的。如果有小伙伴验证OK,希望可以告知,多谢。


故采用Camera2来实现多摄像头同时预览。


Camera2 同时预览摄像头


记得先申请权限,以及动态申请!!


    <uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />

记得先申请权限,以及动态申请!!


1、判断设备是否支持摄像头


fun isSupportCamera(): Boolean {
initCameraManager()
return cameraManager!!.cameraIdList.isNotEmpty()
}

initCameraManager主要是初始化CameraManager对象cameraManager。我们通过cameraIdList列表是否空来判断是否有摄像头。


private fun initCameraManager() {
if (cameraManager == null) {
cameraManager = getApplication<Application>().getSystemService(AppCompatActivity.CAMERA_SERVICE) as CameraManager
}
}

2、获取摄像头列表


我们遍历第1步获取到的摄像头ID列表,然后通过getCameraCharacteristics查询该摄像头相关的数据,封装到NCameraInfo对象中。这里我们只查询几个简单的信息。


fun getCameraListInfo() {

initCameraManager()

if (cameraManager.cameraIdList.isNotEmpty()) {
for (cameraId in cameraManager.cameraIdList) {
val cameInfo = NCameraInfo()
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)

cameInfo.id = cameraId
cameInfo.face ="${ getFaceStr(facing)},CameraId:${cameraId}"
cameraMap[cameraId] = cameInfo
}
cameraInfo.value = cameraMap.values.toList()
}
}

3、打开摄像头


打开摄像头非常简单,只需要调用openCamera函数即可,主要是stateCallback函数的实现。其中handler,是用来切换到主线程var handler = Handler(Looper.getMainLooper())


fun openCamera(cameraId: String) {
initCameraManager()
cameraManager?.openCamera(cameraId, stateCallback, handler)
}

我们一起看看stateCallback函数的实现。也就是当我们打开摄像头,摄像头相关状态会通过下面三个函数进行回调,因为这里采用ViewModel方式,所以会多一份回调到Activity。不用着急,最后有完整代码。


   private val stateCallback=object : StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraMap[camera.id]?.apply {
cameraDevice = camera
state = 1
cameraCallback?.onCameraOpen(this)
}

}

override fun onDisconnected(camera: CameraDevice) {
cameraMap[camera.id]?.apply {
cameraDevice = camera
state = 0
cameraCallback?.onCameraClose(this)
}
}

override fun onError(camera: CameraDevice, error: Int) {
Log.e(TAG, "camera ${camera.id} error code:${error}")
cameraMap[camera.id]?.apply {
cameraDevice = camera
state = 3
cameraCallback?.onCameraError(this,error)
}
}
}

我们查看Activity中的实现。onCameraOpen函数主要动态创建TextureView对象,添加到界面中,用于预览摄像头内容。


	 override fun onCameraOpen(camera: NCameraInfo) {
adapter.notifyItemChanged(adapter.items.indexOf(camera))

//创建TextureView
val textureView = TextureView(this)
textureView.id = View.generateViewId()
camera.previewId=textureView.id
val layoutParams = LinearLayout.LayoutParams(previewWidth, LayoutParams.MATCH_PARENT)
viewBinding.llCameraPreview.addView(textureView, layoutParams)

//textureview 与摄像头绑定
textureView.surfaceTextureListener=object:SurfaceTextureListener{
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
//创建Surface并用于摄像头渲染
val surface = Surface(textureView.surfaceTexture)
val builder = camera.cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)!!
builder.addTarget(surface)

camera.cameraDevice?.createCaptureSession(listOf(surface), object : StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
session.setRepeatingRequest(builder.build(),null,model.handler)
}

override fun onConfigureFailed(session: CameraCaptureSession) {

}
}, model.handler)
}

override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
Log.d(TAG,"onSurfaceTextureSizeChanged")
}

override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
Log.d(TAG,"onSurfaceTextureDestroyed")
return true
}

override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
//Log.d(TAG,"onSurfaceTextureUpdated")
}
}


}

override fun onCameraClose(camera: NCameraInfo) {
Log.d(TAG,"onCameraClose:${camera}")
adapter.notifyItemChanged(adapter.items.indexOf(camera))
camera.cameraDevice?.close()
val view=viewBinding.llCameraPreview.findViewById<TextureView>(camera.previewId)
viewBinding.llCameraPreview.removeView(view)
}

override fun onCameraError(camera: NCameraInfo, error: Int) {
Log.e(TAG,"onCameraError:${camera}${error}")
adapter.notifyItemChanged(adapter.items.indexOf(camera))
camera.cameraDevice?.close()
}

4、效果


image-20230615220353385


5、小坑



  • 实测在小米10手机,先开启后摄,再开启前摄,前摄无法打开=》异常。先开前摄,再开后摄正常。

  • 小米11、诺基亚x7实测正常。


项目地址,点我跳战,关键类:Camera2Activity


作者:新小梦
来源:juejin.cn/post/7244783947821236285
收起阅读 »

从framework角度看app保活问题

问题背景 最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论 不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发...
继续阅读 »

问题背景


最近在群里看到群友在讨论app保活的问题,回想之前做应用(运动类)开发时也遇到过类似的需求,于是便又来了兴趣,果断加入其中,和群友展开了激烈的讨论


保活


不少群友的想法和我当初的想法一样,这特么保活不是看系统的心情么,系统想让谁活谁才能活,作为app开发者,根本无能为力,可真的是这样的吗?


保活方案


首先,我整理了从古到今,app开发者所使用过的以及当前还在使用的保活方式,主要思路有两个:保活和复活


保活的方案有:


  • 1像素惨案

  • 后台无声音乐

  • 前台service

  • 心跳机制

  • socket长连接

  • 无障碍服务

  • ......


复活的方案有:


  • 双进程守护(java层和native层)

  • JobScheduler定时任务

  • 推送/相互唤醒

  • ......


不难看出,app开发者为了能让自己的应用多存活一会儿,可谓是绞尽脑汁,但即使这样,随着Android系统升级,尤其是进入8.0之后,系统对应用的限制越来越高,传统的保活方式已经不生效,这让Android开发者手足无措,于是乎,出现了一种比较和谐的保活方式:



  • 引导用户开启手机白名单


这也是目前绝大多数应用所采用的的方式,相对于传统黑科技而言,此方式显得不那么流氓,比较容易被用户所接受。


但跟微信这样的国民级应用比起来,保活效果还是差了一大截,那么微信是怎么实现保活的呢?或者回到我们开头的问题,应用的生死真的只能靠系统调度吗?开发者能否干预控制呢?


进程调度原则


解开这个疑问之前,我们需要了解一下Android系统进程调度原则,主要介绍framework中承载四大组件的进程是如何根据组件状态而动态调节自身状态的。进程有两个比较重要的状态值:



  • oom_adj,定义在frameworks/base/services/core/java/com/android/server/am/ProcessList.java当中

  • procState,定义在frameworks/base/core/java/android/app/ActivityManager.java当中


OOM_ADJ

以Android10的源码为例,oom_adj划分为20级,取值范围[-10000,1001],Android6.0以前的取值范围是[-17,16]



  • oom_adj值越大,优先级越低

  • oom_adj<0的进程都是系统进程。


public final class ProcessList {
static final String TAG = TAG_WITH_CLASS_NAME ? "ProcessList" : TAG_AM;

// The minimum time we allow between crashes, for us to consider this
// application to be bad and stop and its services and reject broadcasts.
static final int MIN_CRASH_INTERVAL = 60 * 1000;

// OOM adjustments for processes in various states:

// Uninitialized value for any major or minor adj fields
static final int INVALID_ADJ = -10000;

// Adjustment used in certain places where we don't know it yet.
// (Generally this is something that is going to be cached, but we
// don't know the exact value in the cached range to assign yet.)
static final int UNKNOWN_ADJ = 1001;

// This is a process only hosting activities that are not visible,
// so it can be killed without any disruption.
static final int CACHED_APP_MAX_ADJ = 999;
static final int CACHED_APP_MIN_ADJ = 900;

// This is the oom_adj level that we allow to die first. This cannot be equal to
// CACHED_APP_MAX_ADJ unless processes are actively being assigned an oom_score_adj of
// CACHED_APP_MAX_ADJ.
static final int CACHED_APP_LMK_FIRST_ADJ = 950;

// Number of levels we have available for different service connection group importance
// levels.
static final int CACHED_APP_IMPORTANCE_LEVELS = 5;

// The B list of SERVICE_ADJ -- these are the old and decrepit
// services that aren't as shiny and interesting as the ones in the A list.
static final int SERVICE_B_ADJ = 800;

// This is the process of the previous application that the user was in.
// This process is kept above other things, because it is very common to
// switch back to the previous app. This is important both for recent
// task switch (toggling between the two top recent apps) as well as normal
// UI flow such as clicking on a URI in the e-mail app to view in the browser,
// and then pressing back to return to e-mail.
static final int PREVIOUS_APP_ADJ = 700;

// This is a process holding the home application -- we want to try
// avoiding killing it, even if it would normally be in the background,
// because the user interacts with it so much.
static final int HOME_APP_ADJ = 600;

// This is a process holding an application service -- killing it will not
// have much of an impact as far as the user is concerned.
static final int SERVICE_ADJ = 500;

// This is a process with a heavy-weight application. It is in the
// background, but we want to try to avoid killing it. Value set in
// system/rootdir/init.rc on startup.
static final int HEAVY_WEIGHT_APP_ADJ = 400;

// This is a process currently hosting a backup operation. Killing it
// is not entirely fatal but is generally a bad idea.
static final int BACKUP_APP_ADJ = 300;

// This is a process bound by the system (or other app) that's more important than services but
// not so perceptible that it affects the user immediately if killed.
static final int PERCEPTIBLE_LOW_APP_ADJ = 250;

// This is a process only hosting components that are perceptible to the
// user, and we really want to avoid killing them, but they are not
// immediately visible. An example is background music playback.
static final int PERCEPTIBLE_APP_ADJ = 200;

// This is a process only hosting activities that are visible to the
// user, so we'd prefer they don't disappear.
static final int VISIBLE_APP_ADJ = 100;
static final int VISIBLE_APP_LAYER_MAX = PERCEPTIBLE_APP_ADJ - VISIBLE_APP_ADJ - 1;

// This is a process that was recently TOP and moved to FGS. Continue to treat it almost
// like a foreground app for a while.
// @see TOP_TO_FGS_GRACE_PERIOD
static final int PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ = 50;

// This is the process running the current foreground app. We'd really
// rather not kill it!
static final int FOREGROUND_APP_ADJ = 0;

// This is a process that the system or a persistent process has bound to,
// and indicated it is important.
static final int PERSISTENT_SERVICE_ADJ = -700;

// This is a system persistent process, such as telephony. Definitely
// don't want to kill it, but doing so is not completely fatal.
static final int PERSISTENT_PROC_ADJ = -800;

// The system process runs at the default adjustment.
static final int SYSTEM_ADJ = -900;

// Special code for native processes that are not being managed by the system (so
// don't have an oom adj assigned by the system).
static final int NATIVE_ADJ = -1000;

// Memory pages are 4K.
static final int PAGE_SIZE = 4 * 1024;

//省略部分代码
}

ADJ级别取值说明(可参考源码注释)
INVALID_ADJ-10000未初始化adj字段时的默认值
UNKNOWN_ADJ1001缓存进程,无法获取具体值
CACHED_APP_MAX_ADJ999不可见activity进程的最大值
CACHED_APP_MIN_ADJ900不可见activity进程的最小值
CACHED_APP_LMK_FIRST_ADJ950lowmemorykiller优先杀死的级别值
SERVICE_B_ADJ800旧的service的
PREVIOUS_APP_ADJ700上一个应用,常见于应用切换场景
HOME_APP_ADJ600home进程
SERVICE_ADJ500创建了service的进程
HEAVY_WEIGHT_APP_ADJ400后台的重量级进程,system/rootdir/init.rc文件中设置
BACKUP_APP_ADJ300备份进程
PERCEPTIBLE_LOW_APP_ADJ250受其他进程约束的进程
PERCEPTIBLE_APP_ADJ200可感知组件的进程,比如背景音乐播放
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_RECENT_FOREGROUND_APP_ADJ50最近运行的后台进程
FOREGROUND_APP_ADJ0前台进程,正在与用户交互
PERSISTENT_SERVICE_ADJ-700系统持久化进程已绑定的进程
PERSISTENT_PROC_ADJ-800系统持久化进程,比如telephony
SYSTEM_ADJ-900系统进程
NATIVE_ADJ-1000native进程,不受系统管理

可以通过cat /proc/进程id/oom_score_adj查看目标进程的oom_adj值,例如我们查看电话的adj


dialer_oom_adj


值为935,处于不可见进程的范围内,当我启动电话app,再次查看


dialer_oom_adj_open


此时adj值为0,也就是正在与用户交互的进程


ProcessState

process_state划分为23类,取值范围为[-1,21]


@SystemService(Context.ACTIVITY_SERVICE)
public class ActivityManager {
//省略部分代码
/** @hide Not a real process state. */
public static final int PROCESS_STATE_UNKNOWN = -1;

/** @hide Process is a persistent system process. */
public static final int PROCESS_STATE_PERSISTENT = 0;

/** @hide Process is a persistent system process and is doing UI. */
public static final int PROCESS_STATE_PERSISTENT_UI = 1;

/** @hide Process is hosting the current top activities. Note that this covers
* all activities that are visible to the user. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_TOP = 2;

/** @hide Process is hosting a foreground service with location type. */
public static final int PROCESS_STATE_FOREGROUND_SERVICE_LOCATION = 3;

/** @hide Process is bound to a TOP app. This is ranked below SERVICE_LOCATION so that
* it doesn't get the capability of location access while-in-use. */

public static final int PROCESS_STATE_BOUND_TOP = 4;

/** @hide Process is hosting a foreground service. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_FOREGROUND_SERVICE = 5;

/** @hide Process is hosting a foreground service due to a system binding. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_BOUND_FOREGROUND_SERVICE = 6;

/** @hide Process is important to the user, and something they are aware of. */
public static final int PROCESS_STATE_IMPORTANT_FOREGROUND = 7;

/** @hide Process is important to the user, but not something they are aware of. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_IMPORTANT_BACKGROUND = 8;

/** @hide Process is in the background transient so we will try to keep running. */
public static final int PROCESS_STATE_TRANSIENT_BACKGROUND = 9;

/** @hide Process is in the background running a backup/restore operation. */
public static final int PROCESS_STATE_BACKUP = 10;

/** @hide Process is in the background running a service. Unlike oom_adj, this level
* is used for both the normal running in background state and the executing
* operations state. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_SERVICE = 11;

/** @hide Process is in the background running a receiver. Note that from the
* perspective of oom_adj, receivers run at a higher foreground level, but for our
* prioritization here that is not necessary and putting them below services means
* many fewer changes in some process states as they receive broadcasts. */

@UnsupportedAppUsage
public static final int PROCESS_STATE_RECEIVER = 12;

/** @hide Same as {@link #PROCESS_STATE_TOP} but while device is sleeping. */
public static final int PROCESS_STATE_TOP_SLEEPING = 13;

/** @hide Process is in the background, but it can't restore its state so we want
* to try to avoid killing it. */

public static final int PROCESS_STATE_HEAVY_WEIGHT = 14;

/** @hide Process is in the background but hosts the home activity. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_HOME = 15;

/** @hide Process is in the background but hosts the last shown activity. */
public static final int PROCESS_STATE_LAST_ACTIVITY = 16;

/** @hide Process is being cached for later use and contains activities. */
@UnsupportedAppUsage
public static final int PROCESS_STATE_CACHED_ACTIVITY = 17;

/** @hide Process is being cached for later use and is a client of another cached
* process that contains activities. */

public static final int PROCESS_STATE_CACHED_ACTIVITY_CLIENT = 18;

/** @hide Process is being cached for later use and has an activity that corresponds
* to an existing recent task. */

public static final int PROCESS_STATE_CACHED_RECENT = 19;

/** @hide Process is being cached for later use and is empty. */
public static final int PROCESS_STATE_CACHED_EMPTY = 20;

/** @hide Process does not exist. */
public static final int PROCESS_STATE_NONEXISTENT = 21;
//省略部分代码
}

state级别取值说明(可参考源码注释)
PROCESS_STATE_UNKNOWN-1不是真正的进程状态
PROCESS_STATE_PERSISTENT0持久化的系统进程
PROCESS_STATE_PERSISTENT_UI1持久化的系统进程,并且正在操作UI
PROCESS_STATE_TOP2处于栈顶Activity的进程
PROCESS_STATE_FOREGROUND_SERVICE_LOCATION3运行前台位置服务的进程
PROCESS_STATE_BOUND_TOP4绑定到top应用的进程
PROCESS_STATE_FOREGROUND_SERVICE5运行前台服务的进程
PROCESS_STATE_BOUND_FOREGROUND_SERVICE6绑定前台服务的进程
PROCESS_STATE_IMPORTANT_FOREGROUND7对用户很重要的前台进程
PROCESS_STATE_IMPORTANT_BACKGROUND8对用户很重要的后台进程
PROCESS_STATE_TRANSIENT_BACKGROUND9临时处于后台运行的进程
PROCESS_STATE_BACKUP10备份进程
PROCESS_STATE_SERVICE11运行后台服务的进程
PROCESS_STATE_RECEIVER12运动广播的后台进程
PROCESS_STATE_TOP_SLEEPING13处于休眠状态的进程
PROCESS_STATE_HEAVY_WEIGHT14后台进程,但不能恢复自身状态
PROCESS_STATE_HOME15后台进程,在运行home activity
PROCESS_STATE_LAST_ACTIVITY16后台进程,在运行最后一次显示的activity
PROCESS_STATE_CACHED_ACTIVITY17缓存进程,包含activity
PROCESS_STATE_CACHED_ACTIVITY_CLIENT18缓存进程,且该进程是另一个包含activity进程的客户端
PROCESS_STATE_CACHED_RECENT19缓存进程,且有一个activity是最近任务里的activity
PROCESS_STATE_CACHED_EMPTY20空的缓存进程,备用
PROCESS_STATE_NONEXISTENT21不存在的进程

进程调度算法

frameworks/base/services/core/java/com/android/server/am/OomAdjuster.java中,有三个核心方法用于计算和更新进程的oom_adj值



  • updateOomAdjLocked():更新adj,当目标进程为空,或者被杀则返回false,否则返回true。

  • computeOomAdjLocked():计算adj,计算成功返回true,否则返回false。

  • applyOomAdjLocked():应用adj,当需要杀掉目标进程则返回false,否则返回true。


adj更新时机

也就是updateOomAdjLocked()被调用的时机。通俗的说,只要四大组件被创建或者状态发生变化,或者当前进程绑定了其他进程,都会触发adj更新,具体可在源码中查看此方法被调用的地方,比较多,这里就不列举了


adj的计算过程

computeOomAdjLocked()计算过程相当复杂,将近1000行代码,这里就不贴了,有兴趣可自行查看,总体思路就是根据当前进程的状态,设置对应的adj值,因为状态值很多,所以会有很多个if来判断每个状态是否符合,最终计算出当前进程属于哪种状态。


adj的应用

计算得出的adj值将发送给lowmemorykiller(简称lmk),由lmk来决定进程的生死,不同的厂商,lmk的算法略有不同,下面是源码中对lmk的介绍


/* drivers/misc/lowmemorykiller.c
*
* The lowmemorykiller driver lets user-space specify a set of memory thresholds
* where processes with a range of oom_score_adj values will get killed. Specify
* the minimum oom_score_adj values in
* /sys/module/lowmemorykiller/parameters/adj and the number of free pages in
* /sys/module/lowmemorykiller/parameters/minfree. Both files take a comma
* separated list of numbers in ascending order.
*
* For example, write "0,8" to /sys/module/lowmemorykiller/parameters/adj and
* "1024,4096" to /sys/module/lowmemorykiller/parameters/minfree to kill
* processes with a oom_score_adj value of 8 or higher when the free memory
* drops below 4096 pages and kill processes with a oom_score_adj value of 0 or
* higher when the free memory drops below 1024 pages.
*
* The driver considers memory used for caches to be free, but if a large
* percentage of the cached memory is locked this can be very inaccurate
* and processes may not get killed until the normal oom killer is triggered.
*
* Copyright (C) 2007-2008 Google, Inc.
*
* This software is licensed under the terms of the GNU General Public
* License version 2, as published by the Free Software Foundation, and
* may be copied, distributed, and modified under those terms.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*/


保活核心思路


根据上面的Android进程调度原则得知,我们需要尽可能降低app进程的adj值,从而减少被lmk杀掉的可能性,而我们传统的保活方式最终目的也是降低adj值。而根据adj等级分类可以看出,通过应用层的方式最多能将adj降到100~200之间,我分别测试了微信、支付宝、酷狗音乐,启动后返回桌面并息屏,测试结果如下


微信测试结果:


weixin_oom_adj


微信创建了两个进程,查看这两个进程的adj值均为100,对应为adj等级表中的VISIBLE_APP_ADJ,此结果为测试机上微信未登录状态测试结果,当换成我的小米8测试后发现,登录状态下的微信有三个进程在运行


weixin_login_oom_adj


后查阅资料得知,进程名为com.tencent.soter.soterserver的进程是微信指纹支付,此进程的adj值居然为-800,上面我们说过,adj小于0的进程为系统进程,那么微信是如何做到创建一个系统进程的,我和我的小伙伴都惊呆了o.o,为此,我对比了一下支付宝的测试结果


支付宝测试结果:


alipay_oom_adj


支付宝创建了六个进程,查看这六个进程的adj值,除了一个为915,其余均为0,怎么肥事,0就意味着正在与用户交互的前台进程啊,我的世界要崩塌了,只有一种可能,支付宝通过未知的黑科技降低了adj值。


酷狗测试结果:


kugou_oom_adj.png


酷狗创建了两个进程,查看这两个进程的adj值分别为700、200,对应为adj等级表中的PREVIOUS_APP_ADJPERCEPTIBLE_APP_ADJ,还好,这个在意料之中。


测试思考


通过上面三个app的测试结果可以看出,微信和支付宝一定是使用了某种保活手段,让自身的adj降到最低,尤其是微信,居然可以创建系统进程,简直太逆天了,这是应用层绝对做不到的,一定是在native层完成的,但具体什么黑科技就不得而知了,毕竟反编译技术不是我的强项。


正当我郁郁寡欢之时,我想起了前两天看过的一篇文章《当 App 有了系统权限,真的可以为所欲为?》,文章讲述了第三方App如何利用CVE漏洞获取到系统权限,然后神不知鬼不觉的干一些匪夷所思的事儿,这让我茅塞顿开,或许这些大厂的app就是利用了系统漏洞来保活的,不然真的就说不通了,既然都能获取到系统权限了,那创建个系统进程不是分分钟的事儿吗,还需要啥厂商白名单。


总结


进程保活是一把双刃剑,增加app存活时间的同时牺牲的是用户手机的电量,内存,cpu等资源,甚至还有用户的忍耐度,作为开发者一定要合理取舍,不要为了保活而保活,即使需要保活,也尽量采用白色保活手段,别让用户手机变板砖,然后再来哭爹骂娘。


参考资料:


探讨Android6.0及以上系统APP常驻内存(保活)实现-争宠篇


探讨Android6.0及以上系统APP常驻内存(保活)实现-复活篇


探讨一种新型的双进程守护应用保活


史上最强Android保活思路:深入剖析腾讯TIM的进程永生技术


当 App 有了系统权限,真的可以为所欲为?


「 深蓝洞察 」2022 年度最“不可赦”漏洞


作者:小迪vs同学
来源:juejin.cn/post/7210375037114138680
收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等

作者:未央歌
来源:juejin.cn/post/7262558218169008188
收起阅读 »

Android 逆向从入门到入“狱”

免责声明 本次技术分享仅用于逆向技术的交流与学习,请勿用于其他非法用途;技术是把双刃剑,请善用它。 逆向是什么、可以做什么、怎么做 简单讲,就是将别人打包好的 apk 进行反编译,得到源码并分析代码逻辑,最终达成自己的目的。 可以做的事: 修改 smali...
继续阅读 »

免责声明


本次技术分享仅用于逆向技术的交流与学习,请勿用于其他非法用途;技术是把双刃剑,请善用它。


逆向是什么、可以做什么、怎么做



  • 简单讲,就是将别人打包好的 apk 进行反编译,得到源码并分析代码逻辑,最终达成自己的目的。

  • 可以做的事:



    • 修改 smali 文件,使程序达到自己想要的效果,重新编译签名安装,如去广告、自动化操作、电商薅羊毛、单机游戏修改数值、破解付费内容、汉化、抓包等

    • 阅读源码,借鉴别人写好的技术实践

    • 破解:小组件盒子:http://www.coolapk.com/apk/io.ifte…



  • 怎么做:



    • 这是门庞杂的技术活,需要知识的广度、经验、深度

    • 需要具体问题,具体分析,有针对性的学习与探索

    • 了解打包原理、ARM、Smali汇编语言

    • 加固、脱壳

    • Xposed、Substrate、Fridad等框架

    • 加解密

    • 使用好工具## 今日分享涉及工具



  • apktool:反编译工具



    • 反编译:apktool d <apkPath> o <outputPath>

    • 重新打包:apktool b <fileDirPath> -o <apkPath>

    • 安装:brew install apktool



  • jadx:支持命令行和图形界面,支持apk、dex、jar、aar等格式的文件查看




  • apksigner:签名工具




  • Charles:抓包工具



    • http://www.charlesproxy.com/

    • Android 7 以上抓包 HTTPS ,需要手机 Root 后将证书安装到系统中

    • Android 7 以下 HTTPS 直接抓




正题




  • 正向编译



    • java -> class -> dex -> apk



  • 反向编译



    • apk -> dex -> smali -> java



  • Smali 是 Android 的 Dalvik 虚拟机所使用的一种 dex 格式的中间语言

  • 官方文档source.android.com/devices/tec…

  • code.flyleft.cn/posts/ac692…

  • 正题开始,以反编译某瓣App为例:



    • jadx 查看 Java 源码,找到想修改的代码

    • 反编译得到 smali 源码:apktool d douban.apk -o doubancode --only-main-classes

    • 修改:找到 debug 界面入口并打开

    • 将修改后的 smali 源码正向编译成 apk:apktool b doubancode -o douban_mock1.apk

    • 重签名:jarsigner -verbose -keystore keys.jks test.apk key0

    • 此时的包不能正常访问接口,因为豆瓣 API 做了签名校验,而我们的新 apk 是用了新的签名,看接口抓包

    • 怎么办呢?

    • 继续分析代码,修改网络请求中的 apikey

    • 来看看新的 apk



  • 也可以做爬虫等


启发与防范



  • 混淆

  • 加固

  • 加密

  • 运行环境监测

  • 不写敏感信息或操作到客户端

  • App 运行签名验证

  • Api 接口签名验证


One More Thing



作者:Sinyu1012
来源:juejin.cn/post/7202573260659163195
收起阅读 »

对于Android开发,Jetpack Compose真的要开始学起来了?

Jetpack Compose 是个啥?为啥要学它? 谷歌对 Jetpack Compose 的定义: Jetpack Compose 是推荐用于构建原生 Android 界面的新工具包。它可简化并加快 Android 上的界面开发,使用更少的代码、强大的工...
继续阅读 »

Jetpack Compose 是个啥?为啥要学它?


谷歌对 Jetpack Compose 的定义:



Jetpack Compose 是推荐用于构建原生 Android 界面的新工具包。它可简化并加快 Android 上的界面开发,使用更少的代码、强大的工具和直观的 Kotlin API,快速打造生动而精彩的应用。



提取关键词:界面开发新工具包、简化并加快界面开发、Kotlin API


对于大部分Android项目来说,如果基础库(如网络库、hybird、图片加载、热修复库等)已经搭好,那么平时大部分时间就是跟 UI界面、需求逻辑 打交道了,而谷歌提供的 Jetpack Compose 正好是加快界面开发的工具包
对比



就跟魂斗罗里的子弹类型似的,使用普通子弹(XML方式)也可以通关,但是相比之下耗时更长;而换成超级子弹(Jetpack Compose)体验就不一样了,耗时更少,而且游戏体验更爽!



命令式UI vs 声明式UI


长期以来,Android 视图层次结构一直可以表示为界面 widget 树。最常见的界面更新方式是使用 findViewById() 等函数遍历树,并通过调用 button.setText(String)、container.addChild(View) 或 img.setImageBitmap(Bitmap) 等方法更改节点。这些方法会改变 widget 的内部状态,这种手动更新UI的方式即是命令式UI


在过去的几年中,整个行业已开始转向声明性界面模型,该模型大大简化了与构建和更新界面关联的工程任务。该技术的工作原理是在概念上从头开始重新生成整个屏幕,然后仅执行必要的更改。此方法可避免手动更新有状态视图层次结构的复杂性。Compose 即是一个声明式UI框架。


Jetpack Compose要学起来了?


很遗憾,Jetpack Compose 确实要学起来了(快起来,你还能学!哈哈...),随着Jetpack Compose 版本的不断迭代,API 逐渐稳定了,性能也越来越好了。


优点



  • 更少的代码:编写代码只需要采用Kotlin,而不用拆分成 Kotlin + XML方式了。

  • 直观:只需描述界面,Compose会负责处理剩余工作。应用状态变化时,界面自动更新。

  • 加速开发View 与 Compose 之间可以相互调用,兼容现有的所有代码。借助AS可以实时预览界面,轻松执行界面检查。

  • 功能强大:直接访问Android API,内置对Material Design、主题、动画等的支持。


Jetpack Compose vs Flutter




  • Jetpack Compose的目的是为了提高 Android 原生的 UI 开发效率!声明式UI已经成为主流的开发方式了,就像当初谷歌将Kotlin定为Android主流语言时我们学习Kotlin一样,未来Jetpack Compose 一定会是Android UI开发的主流方式。

  • Flutter 的定位是多平台 UI 框架,优势在于跨平台。



大家很喜欢把Jetpack Compose 和 Flutter作对比,不知道该学哪一个?的确,某些场景下它们确实挺像的,而且还都是谷歌在推的。


个人理解是:如果你未来的主攻方向还是Android,那么无脑选择Jetpack Compose,虽然Compose目前也能实现跨端,但跨端目前看并不是它的主要工作;而如果你的方向是多平台开发,那么学习Flutter是首选吧


另外,与其一直纠结学哪一个,不如直接上手亲身感受下它们的不同,正所谓 “纸上得来终觉浅,绝知此事要躬行”。


Jetpack Compose入门


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text("Hello World!")
}
}
}

其中,setContent()传入一个@Composable作用域,其作用跟之前的setContentView()一样用来设置界面。Text()用来描述一个UI元素,里面有各种参数,这里我们只把文案填上去,执行结果:


hello world


一个最简单的功能就完成了。


1、@Composable 可组合函数


还是上述展示一个文本的功能,我们换一种写法:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MessageCard("Android")
}
}
}

@Composable
fun MessageCard(name: String) {
Text(text = "Hello $name!")
}

执行效果跟上面一样。唯一的区别就是把文本展示单独抽离到一个方法中了,并且该方法上面加了@Composable 注解



@Composable注解用于标记一个函数为可组合函数。可组合函数是一种特殊的函数,不需要返回任何UI元素,因为可组合函数描述的是所需的屏幕状态,而不是构造界面widget;而如果按我们以前的XML编程方式,必须在方法中返回UI元素才能使用它(如返回View类型)。



@Composable注解的函数之间可以相互调用,因为这样Compose框架才能正确处理依赖关系。另外,@Composable函数中也可以调用普通函数,而普通函数中却不能直接调用@Composable函数。 这里可以类比下kotlin中suspend挂起函数的用法,其用法是相似的


几个定义:



  • 组合:对 Jetpack Compose 在执行可组合项时所构建界面的描述。

  • 初始组合:通过首次运行可组合项创建组合。

  • 重组在数据发生变化时重新运行可组合项以更新组合


可组合函数的特点:



  • 使用同一参数多次调用此函数时,它的行为方式相同,并且它不使用其他值,如全局变量或对 random() 的调用。

  • 此函数描述界面而没有任何副作用,如修改属性或全局变量、点击事件的处理等。当需要执行附带效应时,应通过回调触发。如:


@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

可见通过回调,点击事件这个附带效应是在调用方触发的。


在编译时,Jetpack Compose会将标记为@Composable的函数编译成字节码,并生成一个专门的ComposeNode类来管理其状态和属性。这个类会自动处理依赖关系,并在需要时计算UI元素。这样,开发者就可以专注于编写UI逻辑,而不用担心状态管理和UI更新的细节


这里引出一个问题,Compose 是如何做 UI 更新的呢?总不能每次有一小部分数据的变化,整个UI都要跟着刷新一次吧,那性能肯定差的要死。其实,当有数据变化时,Compose实现的是增量更新,只会重新绘制数据有改动的UI(该过程称为重组),数据没有改动的则不会重新绘制了


2、布局基础知识


布局
Compose 通过元素组合、布局、绘制之后可以将状态转换为UI元素。


组合

在 Compose 中,可以通过从可组合函数中调用其他可组合函数来构建界面层次结构。
基础布局
如图所示:



  • Column :可以将多个项垂直地放置在屏幕上;

  • Row :可以将多个项水平地放置在屏幕上;

  • Box :可将元素放在其他元素上,还支持为其包含的元素配置特定的对齐方式。


排列及对齐方式:


/**
* @param verticalArrangement 竖直排列方式
* @param horizontalAlignment 水平对齐方式
*/

inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
)
{...}

/**
* @param horizontalArrangement 水平排列方式
* @param verticalAlignment 竖直对齐方式
*/

@Composable
inline fun Row(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
content: @Composable RowScope.() -> Unit
)
{...}

/**
* @param contentAlignment 内容对齐方式
*/

@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
)
{...}

verticalArrangement、horizontalArrangement 排列方式及效果:
排列方式


布局

界面树布局通过单次传递即可完成。父节点会在其子节点之前进行测量,但会在其子节点的尺寸和放置位置确定之后再对自身进行调整
Layout流程
当界面树较深时,Compose 可以通过只测量一次子项来实现高性能。


3、Modifier修饰符


可以通过Modifier修饰符更改可组合项的大小、布局、外观,还可以添加高级互动,例如使元素可点击等。如:


  Image(
painter = painterResource(id = R.mipmap.icon_water_melon),
contentDescription = "",
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.border(width = 2.dp, Color.Red, CircleShape)
)

执行结果:Modifier,原图本身是长方形的,通过Modifier修饰符修饰后,很容易变成圆角图片。想一下如果用XML方式来写,是不是要写好多代码呢。


4、存储状态


可组合函数中可以使用 remember 将本地状态存储在内存中,并跟踪传递给 mutableStateOf 的值的变化。该值更新时,系统会自动重新绘制使用此状态的可组合项(及其子项),这也是上面所说的重组。如:


@Composable
fun MessageCard(msg: Message) {
// We keep track if the message is expanded or not in this variable
var isExpanded by remember { mutableStateOf(false) }
// We toggle the isExpanded variable when we click on this Column
Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) {...}
}

当点击Column 元素时,每次都会重新执行MessageCard()可组合函数进行刷新,而通过remember和mutableStateOf可以保存了上次的isExpanded状态;如果不使用它们,重新执行 MessageCard() 时 isExpanded 也会重新初始化。


除了remember之外,还有rememberSaveable、savedStateHandle.saveable等。。。


总结


这篇文章主要讲了Compose是什么以及我们要开始学习它的必要性。作为Compose 第一篇介绍文章,本文旨在初步感受一下 Compose的能力,后续再详细研究 Compose 的精彩用法!


资料


【1】谷歌Jetpack Compose 教程
https://developer.android.com/jetpack/compose/tutorial?hl=zh-cn


作者:_小马快跑_
来源:juejin.cn/post/7271832299340202036
收起阅读 »

Java切换到Kotlin,Crash率上升了?

前言 最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。 通过本篇文章...
继续阅读 »

前言


最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天通过APM平台查看发现Crash率上升了,查看堆栈定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及规避方式。

通过本篇文章,你将了解到:




  1. NPE(空指针 NullPointerException)的本质

  2. Java 如何预防NPE?

  3. Kotlin NPE检测

  4. Java/Kotlin 混合调用

  5. 常见的Java/Kotlin互调场景



1. NPE(空指针 NullPointerException)的本质


变量的本质


    val name: String = "fish"

name是什么?

对此问题你可能嗤之以鼻:



不就是变量吗?更进一步说如果是在对象里声明,那就是成员变量(属性),如果在方法(函数)里声明,那就是局部变量,如果是静态声明的,那就是全局变量。



回答没问题很稳当。

那再问为什么通过变量就能找到对应的值呢?



答案:变量就是地址,通过该地址即可寻址到内存里真正的值



无法访问的地址



在这里插入图片描述


如上图,若是name="fish",表示的是name所指向的内存地址里存放着"fish"的字符串。

若是name=null,则说明name没有指向任何地址,当然无法通过它访问任何有用的信息了。


无论C/C++亦或是Java/Kotlin,如果一个引用=null,那么这个引用将毫无意义,无法通过它访问任何内存信息,因此这些语言在设计的过程中会将通过null访问变量/方法的行为都会显式(抛出异常)提醒开发者。


2. Java 如何预防NPE?


运行时规避


先看Demo:


public class TestJava {
public static void main(String args[]) {
(new TestJava()).test();
}

void test() {
String str = getString();
System.out.println(str.length());
}

String getString() {
return null;
}
}

执行上述代码将会抛出异常,导致程序Crash:



在这里插入图片描述


我们有两种解决方式:




  1. try...catch

  2. 对象判空



try...catch 方式


public class TestJava {
public static void main(String args[]) {
(new TestJava()).testTryCatch();
}

void testTryCatch() {
try {
String str = getString();
System.out.println(str.length());
} catch (Exception e) {
}
}

String getString() {
return null;
}
}

NPE被捕获,程序没有Crash。


对象判空


public class TestJava {
public static void main(String args[]) {
(new TestJava()).testJudgeNull();
}

void testJudgeNull() {
String str = getString();
if (str != null) {
System.out.println(str.length());
}
}

String getString() {
return null;
}
}

因为提前判空,所以程序没有Crash。


编译时检测


在运行时再去做判断的缺点:



无法提前发现NPE问题,想要覆盖大部分的场景需要随时try...catch或是判空
总有忘记遗漏的时候,发布到线上就是个生产事故



那能否在编译时进行检测呢?

答案是使用注解。


public class TestJava {
public static void main(String args[]) {
(new TestJava()).test();
}

void test() {
String str = getString();
System.out.println(str.length());
}

@Nullable String getString() {
return null;
}
}

在编写getString()方法时发现其可能为空,于是给方法加上一个"可能为空"的注解:@Nullable


当调用getString()方法时,编译器给出如下提示:



在这里插入图片描述


意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。

看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try...catch或是判空)。


当然此处的注解仅仅只是个"弱提示",你即使没有进行针对性的处理也能编译通过,只是问题最后都流转到运行时更难挽回了。


有"可空"的注解,当然也有"非空"的注解:



在这里插入图片描述


@Nonnull 注解修饰了方法后,若是检测到方法返回null,则会提示修改,当然也是"弱提示"。


3. Kotlin NPE检测


编译时检测


Kotlin 核心优势之一:



空安全检测,变量分为可空型/非空型,能够在编译期检测潜在的NPE,并强制开发者确保类型一致,将问题在编译期暴露并解决



先看非空类型的变量声明:


class TestKotlin {

fun test() {
val str = getString()
println("${str.length}")
}

private fun getString():String {
return "fish"
}
}

fun main() {
TestKotlin().test()
}


此种场景下,我们能确保getString()函数的返回一定非空,因此在调用该函数时无需进行判空也无需try...catch。


你可能会说,你这里写死了"fish",那我写成null如何?



在这里插入图片描述


编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。


有非空场景,那也得有空的场景啊:


class TestKotlin {

fun test() {
val str = getString()
println("${str.length}")
}

private fun getString():String? {
return null
}
}

fun main() {
TestKotlin().test()
}

此时将getString()声明为非空,因此可以在函数里返回null。

然而调用之处就无法编译通过了:



在这里插入图片描述


意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:


class TestKotlin {

fun test() {
val str = getString()
println("${str?.length}")
}

private fun getString():String? {
return null
}
}

str?.length 意思是:如果str==null,就不去访问其成员变量/函数,若不为空则可以访问,于是就避免了NPE问题。


由此可以看出:



Kotlin 通过检测声明与实现,确保了函数一定要言行一致(声明与实现),也确保了调用者与被调用者的言行一致



因此,若是用Kotlin编写代码,我们无需花太多时间去预防和排查NPE问题,在编译期都会有强提示。


4. Java/Kotlin 混合调用


回到最初的问题:既然Kotlin都能在编译期避免了NPE,那为啥使用Kotlin重构后的代码反而导致Crash率上升呢?


原因是:项目里同时存在了Java和Kotlin代码,由上可知两者在NPE的检测上有所差异导致了一些兼容问题。


Kotlin 调用 Java


调用无返回值的函数


Kotlin虽然有空安全检测,但是Java并没有,因此对于Java方法来说,不论你传入空还是非空,在编译期我都没法检测出来。


public class TestJava {
void invokeFromKotlin(String str) {
System.out.println(str.length());
}
}

class TestKotlin {

fun test() {
TestJava().invokeFromKotlin(null)
}
}

fun main() {
TestKotlin().test()
}

如上无论是Kotlin调用Java还是Java之间互调,都没法确保空安全,只能由被调用者(Java)自己处理可能的异常情况。


调用有返回值的函数


public class TestJava {
public String getStr() {
return null;
}
}

class TestKotlin {
fun testReturn() {
println(TestJava().str.length)
}
}

fun main() {
TestKotlin().testReturn()
}

如上,Kotlin调用Java的方法获取返回值,由于在编译期Kotlin无法确定返回值,因此默认把它当做非空处理,若是Java返回了null,那么将会Crash。


Java 调用 Kotlin


调用无返回值的函数


先定义Kotlin类:


class TestKotlin {

fun testWithoutNull(str: String) {
println("len:${str.length}")
}

fun testWithNull(str: String?) {
println("len:${str?.length}")
}
}

有两个函数,分别接收可空/非空参数。


在Java里调用,先调用可空函数:


public class TestJava {
public static void main(String args[]) {
(new TestKotlin()).testWithNull(null);
}
}

因为被调用方是Kotlin的可空函数,因此即使Java传入了null,也不会有Crash。


再换个方式,在Java里调用非空函数:


public class TestJava {
public static void main(String args[]) {
(new TestKotlin()).testWithoutNull(null);
}
}

却发现Crash了!



在这里插入图片描述


为什么会Crash呢?反编译查看Kotlin代码:


public final class TestKotlin {
public final void testWithoutNull(@NotNull String str) {
Intrinsics.checkNotNullParameter(str, "str");
String var2 = "len:" + str.length();
System.out.println(var2);
}

public final void testWithNull(@Nullable String str) {
String var2 = "len:" + (str != null ? str.length() : null);
System.out.println(var2);
}
}

对于非空的函数来说,会有检测代码:

Intrinsics.checkNotNullParameter(str, "str"):


    public static void checkNotNullParameter(Object value, String paramName) {
if (value == null) {
throwParameterIsNullNPE(paramName);
}
}
private static void throwParameterIsNullNPE(String paramName) {
throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
}

可以看出:




  1. Kotlin对于非空的函数参数,先判断其是否为空,若是为空则直接抛出NPE

  2. Kotlin对于可空的函数参数,没有强制检测是否为空



调用有返回值的函数


Java 本身就没有空安全,只能在运行时进行处理。


小结


很容看出来:




  1. Java 调用Kotlin的非空函数有Crash的风险,编译器无法检查到传入的参数是否为空

  2. Java 调用Kotlin的可空函数没有Crash风险,Kotlin编译期检查空安全

  3. Kotlin 调用Java的函数有Crash风险,由Java代码规避风险

  4. Kotlin 调用Java有返回值的函数有Crash风险,编译器无法检查到返回值是否为空



回到文章的标题,我们已经大致知道了Java切换到Kotlin,为啥Crash就升上了的原因了,接下来再详细分析。


5. 常见的Java/Kotlin互调场景


Android里的Java代码分布



在这里插入图片描述


在Kotlin出现之前,Java就是Android开发的唯一语言,Android Framework、Androidx很多是Java代码编写的,因此现在依然有很多API是Java编写的。


而不少的第三方SDK因为稳定性、迁移代价的考虑依然使用的是Java代码。


我们自身项目里也因为一些历史原因存在Java代码。


以下讨论的前提是假设现有Java代码我们都无法更改。


Kotlin 调用Java获取返回值


由于编译期无法判定Java返回的值是空还是非空,因此若是确认Java函数可能返回空,则可以通过在Kotlin里使用可空的变量接收Java的返回值。


class TestKotlin {
fun testReturn() {
val str: String? = TestJava().str
println(str?.length)
}
}

fun main() {
TestKotlin().testReturn()
}

Java 调用Kotlin函数


LiveData Crash的原因与预防


之前已经假设过我们无法改动Java代码,那么Java调用Kotlin函数的场景只有一个了:回调。

上面的有返回值场景还是比较容易防备,回调的场景就比较难发现,尤其是层层封装之后的代码。

这也是特别常见的场景,典型的例子如LiveData。


Crash原因


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData: MutableLiveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
println(it.length)
}
}

init {
testLiveData()
}
}

如上,使用Kotlin声明LiveData,其类型是非空的,并监听LiveData的变化。


在另一个地方给LiveData赋值:


TestKotlin(this@MainActivity).liveData.value = null

虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。

发送和接收都是用Kotlin编写的,为啥还会Crash呢?

看看打印:



在这里插入图片描述


意思是接收到的字符串是空值(null),看看编译器提示:



在这里插入图片描述


原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。


再看看调用的地方:



在这里插入图片描述


可以看出,这回调是Java触发的。


Crash 预防


第一种方式:

我们看到LiveData的数据类型是泛型,因此可以考虑在声明数据的时候定为非空:


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
println(it?.length)
}
}

init {
testLiveData()
}
}

如此一来,当访问it.length时编译器就会提示可空调用。


第二种方式:

不修改数据类型,但在接收的地方使用可空类型接收:


class TestKotlin(val lifecycleOwner: LifecycleOwner) {
val liveData = MutableLiveData()
fun testLiveData() {
liveData.observe(lifecycleOwner) {
val dataStr:String? = it
println(dataStr?.length)
}
}

init {
testLiveData()
}
}

第三种方式:

使用Flow替换LiveData。


LiveData 修改建议:




  1. 若是新写的API,建议使用第三种方式

  2. 若是修改老的代码,建议使用第一种方式,因为可能有多个地方监听LiveData值的变化,如果第一种方式的话需要写好几个地方。



其它场景的Crash预防:


与后端交互的数据结构
比如与后端交互声明的类,后端有可能返回null,此时在客户端接收时若是使用了非空类型的字段去接收,那么会发生Crash。

通常来说,我们会使用网络框架(如retrofit)接收数据,数据的转换并不是由我们控制,因此无法使用针对LivedData的第二种方式。

有两种方式解决:




  1. 与后端约定,不能返回null(等于白说)

  2. 客户端声明的类的字段声明为可空(类似针对LivedData的第一种方式)



Json序列化/反序列化

Json字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。


小结



在这里插入图片描述


作者:小鱼人爱编程
来源:juejin.cn/post/7274163003158511616
收起阅读 »

当遇到需要在Activity间传递大量的数据怎么办?

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办? Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大...
继续阅读 »

在Activity间传递的数据一般比较简单,但是有时候实际开发中也会传一些比较复杂的数据,尤其是面试问道当遇到需要在Activity间传递大量的数据怎么办?


Intent 传递数据的大小是有限制的,它大概能传的数据是1M-8K,原因是Binder锁映射的内存大小就是1M-8K.一般activity间传递数据会要使用到binder,因此这个就成为了数据传递的大小的限制。那么当activity间要传递大数据采用什么方式呢?其实方式很多,我们就举几个例子给大家说明一下,但是无非就是使用数据持久化,或者内存共享方案。一般大数据的存储不适宜使用SP, MMKV,DataStore。


Activity之间传递大量数据主要有如下几种方式实现:


  • LruCache

  • 持久化(sqlite、file等)

  • 匿名共享内存


使用LruCache

LruCache是一种缓存策略,可以帮助我们管理缓存,想具体了解的同学可以去Glide章节中具体先了解下。在当前的问题下,我们可以利用LruCache存储我们数据作为一个中转,好比我们需要Activity A向Activity B传递大量数据,我们可以Activity A先向LruCache先写入数据,之后Activity B从LruCache读取。


首先我们定义好写入读出规则:


public interface IOHandler {
   //保存数据
   void put(String key, String value);
   void put(String key, int value);
   void put(String key, double value);
   void put(String key, float value);
   void put(String key, boolean value);
   void put(String key, Object value);

   //读取数据
   String getString(String key);
   double getDouble(String key);
   boolean getBoolean(String key);
   float getFloat(String key);
   int getInt(String key);
   Object getObject(String key);
}

我们可以根据规则也就是接口,写出具体的实现类。实现类中我们保存数据使用到LruCache,这里面我们一定要设置一个大小,因为内存中数据的最大值是确定,我们保存数据的大小最好不要超过最大值的1/8.


LruCache<String, Object> mCache = new LruCache<>( 10 * 1024*1024);

写入数据我们使用比较简单:


@Override
public void put(String key, String value) {
   mCache.put(key, value);
}

好比上面写入String类型的数据,只需要接收到的数据全部put到mCache中去。


读取数据也是比较简单方便:


@Override
public String getString(String key) {
   return String.valueOf(mCache.get(key));
}

持久化数据

那就是sqlite、file等方式。将需要传递的数据写在临时文件或者数据库中,再跳转到另外一个组件的时候再去读取这些数据信息,这种处理方式会由于读写文件较为耗时导致程序运行效率较低。这种方式特点如下:


优势:


(1)应用中全部地方均可以访问


(2)即便应用被强杀也不是问题了


缺点:


(1)操做麻烦


(2)效率低下


匿名共享内存

在跨进程传递大数据的时候,我们一般会采用binder传递数据,但是Binder只能传递1M一下的数据,所以我们需要采用其他方式完成数据的传递,这个方式就是匿名共享内存。


Anonymous Shared Memory 匿名共享内存」是 Android 特有的内存共享机制,它可以将指定的物理内存分别映射到各个进程自己的虚拟地址空间中,从而便捷的实现进程间内存共享。


Android 上层提供了一些内存共享工具类,就是基于 Ashmem 来实现的,比如 MemoryFile、 SharedMemory。



今日分享到此结束,对你有帮助的话,点个赞再走呗,每日一个面试小技巧




关注公众号:Android老皮

解锁  《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版



内容如下



1.Android车载应用开发系统学习指南(附项目实战)

2.Android Framework学习指南,助力成为系统级开发高手

3.2023最新Android中高级面试题汇总+解析,告别零offer

4.企业级Android音视频开发学习路线+项目实战(附源码)

5.Android Jetpack从入门到精通,构建高质量UI界面

6.Flutter技术解析与实战,跨平台首要之选

7.Kotlin从入门到实战,全方面提升架构基础

8.高级Android插件化与组件化(含实战教程和源码)

9.Android 性能优化实战+360°全方面性能调优

10.Android零基础入门到精通,高手进阶之路



作者:派大星不吃蟹
来源:juejin.cn/post/7264503091116965940
收起阅读 »

如何让 Android 网络请求像诗一样优雅

在 Android 应用开发中,网络请求必不可少,如何去封装才能使自己的请求代码显得更加简洁优雅,更加方便于以后的开发呢?这里利用 Kotlin 的函数式编程和 Retrofit 来从零开始封装一个网络请求框架,下面就一起来瞧瞧吧! 首先,引入网络请求框架的依...
继续阅读 »

在 Android 应用开发中,网络请求必不可少,如何去封装才能使自己的请求代码显得更加简洁优雅,更加方便于以后的开发呢?这里利用 Kotlin 的函数式编程和 Retrofit 来从零开始封装一个网络请求框架,下面就一起来瞧瞧吧!


首先,引入网络请求框架的依赖。


implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.okhttp3:logging-interceptor:4.9.1'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

定义拦截器


我们可以先自定义一些拦截器,对一些公共提交的字段做封装,比如 token。在服务器注册成功或者登录成功之后获取 token,过期之后便无法正常请求接口,所以需要在请求接口时判断 token 是否过期,由于接口众多,不可能每个接口都进行判断,所以需要全局设置一个拦截器判断 token。


class TokenInterceptor : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
// 当前拦截器中收到的请求对象
val request = chain.request()
// 执行请求
var response = chain.proceed(request)
if (response.body == null) {
return response
}
val mediaType = response.body!!.contentType() ?: return response
val type = mediaType.toString()
if (!type.contains("application/json")) {
return response
}
val result = response.body!!.string()
var code = ""
try {
val jsonObject = JSONObject(result)
code = jsonObject.getString("code")
} catch (e: Exception) {
e.printStackTrace()
}
// 重新构建 response
response = response.newBuilder().body(result.toResponseBody(null)).code(200).build()
if (isTokenExpired(code)) {
// token 过期,需要获取新的 token
val newToken = getNewToken() ?: return response
// 重新构建新的 token 请求
val builder = request.url.newBuilder().setEncodedQueryParameter("token", newToken)
val newRequest = request.newBuilder().method(request.method, request.body)
.url(builder.build()).build()
return chain.proceed(newRequest)
}
return response
}

// 判断 token 是否过期
private fun isTokenExpired(code: String) =
TextUtils.equals(code, "401") || TextUtils.equals(code, "402")

// 刷新 token
private fun getNewToken() = ServiceManager.instance.refreshToken()

}

这里是 token 过期之后直接重新请求接口获取新的 token,这需要根据具体业务需求来,有些可能是过期之后跳转到登录页面,让用户重新登录等等。


我们还可以再定义一个拦截器,全局添加 token。


class TokenHeaderInterceptor : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val headers = request.headers
var token = headers["token"]
if (TextUtils.isEmpty(token)) {
token = ServiceManager.instance.getToken()
request = request.newBuilder().addHeader("token", token).build()
}
return chain.proceed(request)
}

}

创建 retrofit


class RetrofitUtil {

companion object {

private const val TIME_OUT = 20L

private fun createRetrofit(): Retrofit {

// OkHttp 提供的一个拦截器,用于记录和查看网络请求和响应的日志信息。
val interceptor = HttpLoggingInterceptor()
// 打印请求和响应的所有内容,响应状态码和执行时间等等。
interceptor.level = HttpLoggingInterceptor.Level.BODY

val okHttpClient = OkHttpClient().newBuilder().apply {
addInterceptor(interceptor)
addInterceptor(TokenInterceptor())
addInterceptor(TokenHeaderInterceptor())
retryOnConnectionFailure(true)
connectTimeout(TIME_OUT, TimeUnit.SECONDS)
writeTimeout(TIME_OUT, TimeUnit.SECONDS)
readTimeout(TIME_OUT, TimeUnit.SECONDS)
}.build()

return Retrofit.Builder().apply {
addConverterFactory(GsonConverterFactory.create())
baseUrl(ServiceManager.instance.baseHttpUrl)
client(okHttpClient)
}.build()

}

fun <T> getAPI(clazz: Class<T>): T {
return createRetrofit().create(clazz)
}

}
}

网络请求封装


定义通用基础请求返回的数据结构


private const val SERVER_SUCCESS = "200"

data class BaseResp<T>(val code: String, val message: String, val data: T)

fun <T> BaseResp<T>?.isSuccess() = this?.code == SERVER_SUCCESS

请求状态流程封装,可以根据具体业务流程实现方法。


class RequestAction<T> {

// 开始请求
var start: (() -> Unit)? = null
private set

// 发起请求
var request: (suspend () -> BaseResp<T>)? = null
private set

// 请求成功
var success: ((T?) -> Unit)? = null
private set

// 请求失败
var error: ((String) -> Unit)? = null
private set

// 请求结束
var finish: (() -> Unit)? = null
private set

fun request(block: suspend () -> BaseResp<T>) {
request = block
}

fun start(block: () -> Unit) {
start = block
}

fun success(block: (T?) -> Unit) {
success = block
}

fun error(block: (String) -> Unit) {
error = block
}

fun finish(block: () -> Unit) {
finish = block
}

}

因为网络请求都是在 ViewModel 中进行的,我们可以定义一个 ViewModel 的扩展函数,用来处理网络请求。


fun <T> ViewModel.netRequest(block: RequestAction<T>.() -> Unit) {

val action = RequestAction<T>().apply(block)

viewModelScope.launch {
try {
action.start?.invoke()
val result = action.request?.invoke()
if (result.isSuccess()) {
action.success?.invoke(result!!.data)
} else {
action.error?.invoke(result!!.message)
}
} catch (ex: Exception) {
// 可以做一些定制化的返回错误提示
action.error?.invoke(getErrorTipContent(ex))
} finally {
action.finish?.invoke()
}
}

}

private const val SERVER_ERROR = "HTTP 500 Internal Server Error"
private const val HTTP_ERROR_TIP = "服务器或者网络连接错误"

fun getErrorTipContent(ex: Throwable) = if (ex is ConnectException || ex is UnknownHostException
|| ex is SocketTimeoutException || SERVER_ERROR == ex.message.toString()
) HTTP_ERROR_TIP else ex.message.toString()

使用案例


定义网络请求接口


interface HttpApi {

@GET("/exampleA/exampleP/exampleI/exampleApi/getNetData")
suspend fun getNetData(@QueryMap params: HashMap<String, String>): BaseResp<NetDataBean>

@GET("/exampleA/exampleP/exampleI/exampleApi/getTestData")
suspend fun getTestData(
@Query("param1") param1: String,
@Query("param2") param2: String
)
: BaseResp<NetDataBean>

@GET("/exampleA/exampleP/exampleI/exampleApi/{id}")
fun getNetTask(
@Path("id") id: String,
@QueryMap params: HashMap<String, String>,
)
: Call<BaseResp<TaskBean>>

@FormUrlEncoded
@POST("/exampleA/exampleP/exampleI/exampleApi/confirm")
suspend fun confirm(@Field("id") id: String, @Field("token") token: String): BaseResp<String>

@FormUrlEncoded
@POST("/exampleA/exampleP/exampleI/exampleApi/upload")
suspend fun upload(@FieldMap params: Map<String, String>): BaseResp<String>

}

我们可以写一个网络请求帮助类,用于请求的创建。


class RequestHelper {

private val httpApi = RetrofitUtil.getAPI(HttpApi::class.java)

companion object {
val instance: RequestHelper by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
RequestHelper()
}
}

suspend fun getNetData(params: HashMap<String, String>) = httpApi.getNetData(params)

suspend fun getTestData(branchCode: String, token: String) =
httpApi.getTestData(branchCode, token)

suspend fun getNetTask(id: String, params: HashMap<String, String>) =
httpApi.getNetTask(id, params)

suspend fun confirm(id: String, token: String) = httpApi.confirm(id, token)

suspend fun upload(params: HashMap<String, String>) = httpApi.upload(params)

}

定义用户的意图和 UI 状态


// 定义用户意图
sealed class MainIntent {
object FetchData : MainIntent()
}

// 定义 UI 状态
sealed class MainUIState {
object Loading : MainUIState()
data class NetData(val data: NetDataBean?) : MainUIState()
data class Error(val error: String?) : MainUIState()
}

ViewModel 中做意图的处理和 UI 状态的变更,根据网络请求结果传递不同的状态,使用定义的扩展方法去执行网络请求,封装过后的网络请求就很简洁方便了,下面演示下具体使用。


class MainViewModel : ViewModel() {

val mainIntent = Channel<MainIntent>(Channel.UNLIMITED)

private val _mainUIState = MutableStateFlow<MainUIState>(MainUIState.Loading)
val mainUIState: StateFlow<MainUIState>
get() = _mainUIState

init {
viewModelScope.launch {
mainIntent.consumeAsFlow().collect {
if (it is MainIntent.FetchData) {
getNetDataResult()
}
}
}
}
// 使用
private fun getNetDataResult() = netRequest {
start { _mainUIState.value = MainUIState.Loading }
request {
val paramMap = hashMapOf<String, String>()
paramMap["param1"] = "param1"
paramMap["param2"] = "param2"
RequestHelper.instance.getNetData(paramMap)
}
success { _mainUIState.value = MainUIState.NetData(it) }
error { _mainUIState.value = MainUIState.Error(it) }
}

}

这样是不是看起来很简洁呢?接下来,Activity 负责发送意图和接收 UI 状态进行相关的处理就行啦!


class MainActivity : AppCompatActivity() {

private val viewModel by viewModels<MainViewModel>()
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
initData()
observeViewModel()
}

private fun initData() {
lifecycleScope.launch {
// 发送意图
viewModel.mainIntent.send(MainIntent.FetchData)
}
}

private fun observeViewModel() {
lifecycleScope.launch {
viewModel.mainUIState.collect {
when (it) {
is MainUIState.Loading -> showLoading()
// 这里拿到网络请求返回的数据,根据业务自行操作,这里只做简单的显示。
is MainUIState.NetData -> showText(it.data.toString())
is MainUIState.Error -> showText(it.error)
}
}
}
}

private fun showLoading() {
binding.progressBar.visibility = View.VISIBLE
binding.netText.visibility = View.GONE
}

private fun showText(result: String?) {
binding.progressBar.visibility = View.GONE
binding.netText.visibility = View.VISIBLE
binding.netText.text = result
}

}

文件的上传与下载


如果是文件的上传和下载呢?其实文件还不太一样,这涉及到上传进度,文件的处理等方面,所以,为了方便开发使用,我们可以针对文件单独再做一下封装。


定义文件上传对象


data class UpLoadFileBean(val file: File, val fileKey: String)

自定义 RequestBody,从中获取上传进度。


class ProgressRequestBody(
private var requestBody: RequestBody,
var onProgress: ((Int) -> Unit)?,
) : RequestBody() {

private var bufferedSink: BufferedSink? = null

override fun contentType(): MediaType? = requestBody.contentType()

override fun contentLength(): Long {
return requestBody.contentLength()
}

override fun writeTo(sink: BufferedSink) {
if (bufferedSink == null) bufferedSink = createSink(sink).buffer()
bufferedSink?.let {
requestBody.writeTo(it)
it.flush()
}
}

private fun createSink(sink: Sink): Sink = object : ForwardingSink(sink) {
// 当前写入字节数
var bytesWritten = 0L

// 总字节长度
var contentLength = 0L

override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)

if (contentLength == 0L) {
contentLength = contentLength()
}

// 增加当前写入的字节数
bytesWritten += byteCount

CoroutineScope(Dispatchers.Main).launch {
// 进度回调
onProgress?.invoke((bytesWritten * 100 / contentLength).toInt())
}
}
}

}

创建 MultipartBody.Part


fun <T> createPartList(action: UpLoadFileAction<T>): List<MultipartBody.Part> =
MultipartBody.Builder().apply {
// 公共参数 token
addFormDataPart("token", ServiceManager.instance.getToken())

// 其他基本参数
action.params?.forEach {
if (it.key.isNotBlank() && it.value.isNotBlank()) {
addFormDataPart(it.key, it.value)
}
}

// 文件校验
action.fileData?.let {
addFormDataPart(
it.fileKey, it.file.name, ProgressRequestBody(
requestBody = it.file
.asRequestBody("application/octet-stream".toMediaTypeOrNull()),
onProgress = action.progress
)
)
}
}.build().parts

定义文件上传行为


class UpLoadFileAction<T> {

// 请求体
lateinit var request: (suspend () -> BaseResp<T>)
private set

lateinit var parts: List<MultipartBody.Part>

// 其他普通参数
var params: HashMap<String, String>? = null
private set

// 文件参数
var fileData: UpLoadFileBean? = null
private set

// 初始化参数
fun init(params: HashMap<String, String>?, fileData: UpLoadFileBean?) {
this.params = params
this.fileData = fileData
parts = createPartList(this)
}

var start: (() -> Unit)? = null
private set

var success: (() -> Unit)? = null
private set

var error: ((String) -> Unit)? = null
private set

var progress: ((Int) -> Unit)? = null
private set

var finish: (() -> Unit)? = null
private set

fun start(block: () -> Unit) {
start = block
}

fun success(block: () -> Unit) {
success = block
}

fun error(block: (String) -> Unit) {
error = block
}

fun progress(block: (Int) -> Unit) {
progress = block
}

fun finish(block: () -> Unit) {
finish = block
}

fun request(block: suspend () -> BaseResp<T>) {
request = block
}

}

同样,定义 ViewModel 的扩展函数,用来执行文件上传。


fun <T> ViewModel.upLoadFile(
block: UpLoadFileAction<T>.() -> Unit,
params: HashMap<String, String>?,
fileData: UpLoadFileBean?,
)
= viewModelScope.launch {
val action = UpLoadFileAction<T>().apply(block)
try {
action.init(params, fileData)
action.start?.invoke()
val result = action.request.invoke()
if (result.isSuccess()) {
action.success?.invoke()
} else {
action.error?.invoke(result.message)
}
} catch (ex: Exception) {
action.error?.invoke(getErrorTipContent(ex))
} finally {
action.finish?.invoke()
}
}

定义文件上传接口


interface HttpApi {
//...

@Multipart
@POST("/exampleA/exampleP/exampleI/exampleApi/uploadFile")
suspend fun uploadFile(@Part partLis: List<MultipartBody.Part>): BaseResp<String>

}

在 RequestHelper 中定义上传文件方法


class RequestHelper {

private val httpApi = RetrofitUtil.getAPI(HttpApi::class.java)

companion object {
val instance: RequestHelper by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
RequestHelper()
}
}

//...

suspend fun uploadFile(partList: List<MultipartBody.Part>) = httpApi.uploadFile(partList)

}

封装过后的文件上传就很简洁方便了,下面演示下具体使用。


private fun uploadMyFile() = upLoadFile(
params = hashMapOf("param1" to "param1", "param2" to "param2"),
fileData = UpLoadFileBean(File(absoluteFilePath), "file"),
) {
start {
// TODO: 开始上传,此处可以显示加载动画
}
request { RequestHelper.instance.uploadFile(parts) }
success {
// TODO: 上传成功
}
error {
// TODO: 上传失败
}
finish {
// TODO: 上传结束,此处可以关闭加载动画
}
}

既然上传文件都有了,那怎么少得了下载呢?其实,下载比上传更简单,下面就来写一下,同样利用了 kotlin 的函数式编程,我们添加 ViewModel 的扩展函数,需要注意的是,由于这边是直接使用 OkHttp 的同步请求,所以把这部分代码放在了 IO 线程中。


fun ViewModel.downLoadFile(
downLoadUrl: String,
dirPath: String,
fileName: String,
progress: ((Int) -> Unit)?,
success: (File) -> Unit,
failed: (String) -> Unit,
)
= viewModelScope.launch(Dispatchers.IO) {
try {
val fileDir = File(dirPath)
if (!fileDir.exists()) {
fileDir.mkdirs()
}
val downLoadFile = File(fileDir, fileName)
val request = Request.Builder().url(downLoadUrl).get().build()
val response = OkHttpClient.Builder().build().newCall(request).execute()
if (response.isSuccessful) {
response.body?.let {
val totalLength = it.contentLength().toDouble()
val stream = it.byteStream()
stream.copyTo(downLoadFile.outputStream()) { currentLength ->
// 当前下载进度
val process = currentLength / totalLength * 100
progress?.invoke(process.toInt())
}
success.invoke(downLoadFile)
} ?: failed.invoke("response body is null")
} else failed.invoke("download failed:$response")
} catch (ex: Exception) {
failed.invoke("download failed:${getErrorTipContent(ex)}")
}
}


// InputStream 添加扩展函数,实现字节拷贝。
private fun InputStream.copyTo(
out: OutputStream,
bufferSize: Int = DEFAULT_BUFFER_SIZE,
progress: (Long) -> Unit,
)
: Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
bytes = read(buffer)
progress(bytesCopied)
}
return bytesCopied
}

然后,使用就会变得很简洁了,如下所示:


fun downloadMyFile(downLoadUrl: String, dirPath: String, fileName: String) =
downLoadFile(
downLoadUrl = downLoadUrl,
dirPath = dirPath,
fileName = fileName,
progress = {
// TODO: 这里可以拿到进度
},
success = {
// TODO: 下载成功,拿到下载的文件对象 File
},
failed = {
// TODO: 下载失败,返回原因
}

)

作者:阿健君
来源:juejin.cn/post/7266768708139434045
收起阅读 »

思辨:移动开发的未来在哪?

前段时间在知乎看到关于移动开发未来的问题,就尝试回答了一下,也触发了我对移动开发未来的思考。移动开发未来怎么样? - 知乎 http://www.zhihu.com/question/61…什么是移动开发?我们口中说的移动开发是什么,从广义和狭义的角...
继续阅读 »

前段时间在知乎看到关于移动开发未来的问题,就尝试回答了一下,也触发了我对移动开发未来的思考。

image.png

移动开发未来怎么样? - 知乎 http://www.zhihu.com/question/61…

什么是移动开发?

我们口中说的移动开发是什么,从广义和狭义的角度分别来看下:

从广义角度来看,移动开发是指为移动设备(如智能手机、平板电脑等)创建软件、应用程序和服务的过程。这包括了为各种移动操作系统(如 iOS、Android 和 Windows Phone)设计、开发、测试和发布应用程序。移动开发旨在为用户提供高质量的、功能丰富的移动体验,以满足其日常需求和娱乐需求。广义上的移动开发可以包括原生应用程序开发、跨平台应用程序开发、移动网页应用程序开发,以及相关的后端服务和API开发等。

从狭义角度来看,移动开发通常指开发针对特定移动操作系统的应用程序,如 iOS 和 Android。这些应用程序通常使用特定于平台的编程语言(如 Swift 或 Kotlin)开发,并利用该平台的特性和功能。狭义的移动开发关注于为特定平台提供最佳的性能、用户体验和原生功能集成。这种开发方法需要对目标平台的技术细节和设计原则有深入了解,以便充分发挥其潜力。

这段内容是我问GPT4的生成的,针对移动开发的定义基本准确。移动开发涉及的的细分领域有非常多,比如:

  • 混合开发和跨平台框架
  • Framework和Kernel
  • 逆向安全
  • 音视频
  • 移动Web
  • 嵌入式

大家可以对照着自己的岗位要求,给自己所涉及的技术领域归个类,分析下市场的需求如何。

简单回顾一下

移动开发辉煌的十年也是移动互联网快速发展的十年,我还记得2015当年o2o百“团”大战的时候,各种创业公司,各行各业,只要你懂点移动开发就能找到不错的开发工作,那时候移动开发的培训机构也如雨后春笋一般诞生,培训个几个月可能就能获得offer。现在的滴滴、美团都是当年烧钱大户,通过庞大的资本,持续打补贴战,最后才活下来,也是寥寥无几的几家独角兽创业公司。通过烧钱的方式毕竟是不可持续的,经营一家公司必须有足够竞争力的产品和可持续的商业模式,当年的泡沫被刺破之后,你才知道什么公司在裸奔,回过头想想现在还有多少家公司能幸存至今呢。

回到今年2023年,疫情三年让整个中国经济都是千疮百孔,不知道大家是否发现这些年基本没有什么新的独角兽出现了,基本上10年前的成立的公司,跑出来成为新的大厂的我们手指头能数得过来,比如我们熟知字节跳动,因为抖音短视频,直接在短视频领域突围成为了打破了老牌大厂腾讯在社交垄断下的新的巨头,成为新的BAT中的B。

另外附上一张2022年中国互联网综合实力企业排名:

image.png

大家是否发现自己手机上常用的App基本集中在这些我们耳熟能详的企业里面。其他的App要么访问量很少,要么永远消失在你的应用列表当中,可叹可惜。所以App的消亡带来的就是移动端的夕阳西下,除了大厂和中厂还有移动客户端的需求,但也是一坑难求,对求职者的要求基本上是要中高级别的,初级的刚毕业的基本上很难拿到offer。

这里我从自己的理解分析了移动开发目前的情况,从历史进程和供需关系,我们可以看到移动开发的求职环境已经大不如前,所以如果还想进入互联网从事移动开发就要结合自身情况去考虑,或许你需要积累得更多才能在残酷的求职环境中脱颖而出拿到心仪的offer。

个人的一些思考

先说说我个人的情况,自从14年毕业之后一直从事移动开发,岗位是Android工程师,基本也算是赶上了移动互联网发展的快车道,求职路上基本上也没遇到什么坎坷,当时也算是比较幸运毕业一年半左右,以社招的身份面试进入到了腾讯,然后就一直待到现在。期间做过研发工具,比如Bugly Crash上报,应用更新和热更新;做过教育产品,比如腾讯课堂;目前投身于金融科技领域,做创新硬件上层应用相关的开发。主要的技术栈还是Android、Java/Kotlin,目前因为业务的需要,技术栈就开始涉足Linux嵌入式和C/C++。其实我个人也一直求变,不管是业务方向还是技术,危机感也在驱使着我去在专业领域获得更多的成长。作为技术人只能保持饥饿感,不停的更新自己的知识体系。

针对移动开发的未来,我个人还是保持谨慎乐观的态度的,虽然当下的求职环境发生了变化,但存量市场需求依然有很多机会,以下是我认为值得我们去关注的技术方向,但不作为任何求职建议:

  1. AIGC+移动端

2023年的AIGC的火热空前绝后,它带来的影响是非常深远的,甚至能够变革整个互联网行业,很多产品可能将会以新的思路去重构和延伸,这里面就会产生相应的在移动端和AIGC结合相关产品和业务,公司层面也会有相应的投入意愿,这也许会给我们带来新的机会。

  1. 元宇宙:VR/AR/XR

元宇宙虽然被炒概念,一直不温不火的,但这里面涉及的技术是比较前沿的,在游戏领域跟元宇宙的结合,如果能找到愿意投入企业,未尝不是一个不错的方向。

  1. IoT物联网

万物互联方向,比如智能家居,智能创新硬件产品,类似小米IoT相关的产品,智能手环、扫地机器人等等。这里面也有庞大的市场需求,另外软硬件结合对开发人员要求更高,更接近底层。

  1. 新能源车载系统

新能源车的其中一个核心就是智能中控,比如特斯拉的中控系统是Linux,比亚迪还有蔚小理和大多数造车新势力用的是Android系统,这里面也有很多车载系统应用的需求,也是很多人都求职热门方向。

  1. 音视频技术领域

当下流行的短视频,涉及到的核心就是音视频技术,有这方面的技术积累的同学应该也能获得不错的发展机会,而且这方面的人才相对而言比较稀缺。

  1. 跨平台技术

从企业降本的角度,未来可能会更倾向招聘懂跨平台开发的,希望能统一技术栈能够实现多端发布的能力。比如Flutter、React Native、UniApp等。

  1. 鸿蒙OS应用开发

国产替代是个很深远的话题,卡脖子问题现在越演越烈,从软件产业我们跟漂亮国还存在很多差距,我们能够正视这些差距并且迎头突围是一个非常值得敬佩和骄傲的事情。鸿蒙OS有望成为第一个完全去Android化的操作系统,Mate60系列手机产品我认为是一个标志性里程碑,我们不谈什么遥遥领先,我相信华为一定会越来越好,鸿蒙OS应用开发也是我觉得有较好前景的方向。

当然还有很多其他技术方向无法一一列举,我个人觉得一专多能可能是未来我们更应追求的目标,仅靠写几个UI页面就能打天下的时代已经不再适用了,想让自己有足够的竞争力,就必须要多涉猎各种技术,打通任督二脉,很多时候单一视角很难获得创新,只有多维度思考才有可能让自己突围。

最后

作为互联网从业人员,保持一定的危机感是必要的,另外多扩展自己的视野,除了专注于本身的专业领域,也要多关注技术趋势的变化,很多时候技术的价值是需要匹配业务的。移动开发有没有未来这个问题可以转化为:我们自己当前要做哪些选择,才能让自己拥有更多的未来。最后跟大家分享一句话作为结尾:

个人努力固然重要,也要考虑历史进程。


作者:巫山老妖
来源:juejin.cn/post/7292347319431790607
收起阅读 »

ThreadLocal:你不知道的优化技巧,Android开发者都在用

引言 在Android开发中,多线程是一个常见的话题。为了有效地处理多线程的并发问题,Android提供了一些工具和机制。其中,ThreadLocal是一个强大的工具,它可以使得每个线程都拥有自己独立的变量副本,从而避免了线程安全问题。 本文将深入探讨Andr...
继续阅读 »

引言


Android开发中,多线程是一个常见的话题。为了有效地处理多线程的并发问题,Android提供了一些工具和机制。其中,ThreadLocal是一个强大的工具,它可以使得每个线程都拥有自己独立的变量副本,从而避免了线程安全问题。


本文将深入探讨Android中的ThreadLocal原理及其使用技巧, 帮助你更好的理解和使用ThreadLocal


ThreadLocal的原理


public class Thread implements Runnable {

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */

ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocal的原理是基于每个线程都有一个独立的ThreadLocalMap对象。ThreadLocalMap对象是一个Map,它的键是ThreadLocal对象,值是ThreadLocal对象保存的值。


public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

当我们调用ThreadLocalset()方法时,会将值存储到当前线程的ThreadLocalMap对象中。当我们调用ThreadLocalget()方法时,会从当前线程的ThreadLocalMap对象中获取值。


ThreadLocal的使用


使用ThreadLocal非常简单,首先需要创建一个ThreadLocal对象,然后通过setget方法来设置和获取线程的局部变量。以下是一个简单的例子:


val threadLocal = ThreadLocal<String>()

fun setThreadName(name: String) {
threadLocal.set(name)
}

fun getThreadName(): String {
return threadLocal.get() ?: "DefaultThreadName"
}

Android开发中,ThreadLocal的使用场景非常多,比如:



  • Activity中存储Fragment的状态

  • Handler中存储消息的上下文

  • RecyclerView中存储滚动位置


实际应用场景


// 在 Activity 中存储 Fragment 的状态
class MyActivity : AppCompatActivity() {

private val mFragmentState = ThreadLocal<FragmentState>()

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

// 获取 Fragment 的状态
val fragmentState = mFragmentState.get()
if (fragmentState == null) {
// 初始化 Fragment 的状态
fragmentState = FragmentState()
}

// 设置 Fragment 的状态
mFragmentState.set(fragmentState)

// 创建 Fragment
val fragment = MyFragment()
fragment.arguments = fragmentState.toBundle()
supportFragmentManager.beginTransaction().add(R.id.container, fragment).commit()
}

}

class FragmentState {

var name: String? = null
var age: Int? = null

fun toBundle(): Bundle {
val bundle = Bundle()
bundle.putString("name", name)
bundle.putInt("age", age)
return bundle
}

}

这段代码在Activity中使用ThreadLocal来存储Fragment的状态。当Activity第一次启动时,会初始化Fragment的状态。当Activity重新启动时,会从ThreadLocal中获取Fragment的状态,并将其传递给Fragment


注意事项



  • 内存泄漏风险:


ThreadLocal变量的生命周期与线程的生命周期是一致的。这意味着,如果一个线程一直不结束,那么它所持有的ThreadLocal变量也不会被释放。这可能会导致内存泄漏。


为了避免内存泄漏,我们应该在不再需要ThreadLocal变量时,显式地将其移除。


threadLocal.remove()


  • 不适合全局变量: ThreadLocal适用于需要在线程间传递的局部变量,但不适合作为全局变量的替代品。


优化技巧



  • 合理使用默认值: 在获取ThreadLocal值时,可以通过提供默认值来避免返回null,确保代码的健壮性。


fun getThreadName(): String {
return threadLocal.get() ?: "DefaultThreadName"
}


  • 懒加载初始化: 避免在声明ThreadLocal时就初始化,可以使用initialValue方法进行懒加载,提高性能。


val threadLocal = object : ThreadLocal<String>() {
override fun initialValue(): String {
return "DefaultValue"
}
}


  • 尽量避免在ThreadLocal中保存大对象


结论


在本文中,我们介绍了ThreadLocal的原理和使用技巧,希望这些知识能够帮助你更好地理解和使用它。


推荐


android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。


AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。


flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。


android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。


daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


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

Android 通知文本颜色获取

前言 Android Notification 几乎每个版本都有改动,因此有很多兼容性问题,摆在开发者面前的难题是每个版本的展示效果不同,再加app保活能力的日渐式微和google大力推进WorkManager、JobScheduler、前台进程的行为,即便A...
继续阅读 »

前言


Android Notification 几乎每个版本都有改动,因此有很多兼容性问题,摆在开发者面前的难题是每个版本的展示效果不同,再加app保活能力的日渐式微和google大力推进WorkManager、JobScheduler、前台进程的行为,即便AlarmManager#setAlarmClock这种可以解除Dozen模式的超级工具,也无法对抗进程死亡的问题,通知到达率和及时性的效果已经大幅减弱。


Screenshot_20190403-131551.png


自定义通知是否仍有必要?

实际上,目前大多推送通知都被系统厂商代理展示了,导致实现效果雷同且没有新意。众多一样的效果,站在用户角度也产生了很多厌恶情绪,对用户的吸引点也是逐渐减弱,这其实和自定义通知的初衷是相背离的,因为自定义通知首要解决的是特色功能的展示,而通用通知却很难做到这一点。因此,在一些app中,自定义通知仍然是有必要的,但必要性没有那么强了。


当前的使用场景:



  • 前台进程常驻类型app,比如直播、音乐类等

  • 类似QQ的app浮动弹窗提醒 (这类不算通知,但是可以使用统一的方法适配)

  • 系统白名单中的app


现状


通知首要解决的是功能问题,其次是主题问题。当前,大部分app已经习惯使用系统通知栏而不使用自定义的通知,主要原因是适配难度问题。


对于自定义通知的适配,目前有两条路线:



  • 统一样式:

    是定义一套深色模式和浅色模式都能通用的色彩搭配,一些音视频app也是这么做的,巧妙的避免了因系统主题不一致造成的现实效果不同的问题,但仍然在部分手机上展示的比较突兀。

  • 读取系统通知颜色进行适配:

    遗憾的是,在Android 7.0之后,正常的通知是拿不到notification.contentView,但似乎没有看到相关的文章来解决这个问题。


两种方案可以搭配使用,但方案二目前存在无法提取颜色的问题,关键是怎么解决contentView拿不到的问题呢?接下来我们重点解决方案二的这个问题。


问题点


我们在无论使用NotificationBuilder或者NotificationCompatBuilder,其内部的build方法存在targetSdkVersion的判断,而在大于Android 7.0 的版本中,不会立即创建ContentView


protected Notification buildInternal() {
if (Build.VERSION.SDK_INT >= 26) {
return mBuilder.build();
} else if (Build.VERSION.SDK_INT >= 24) {
Notification notification = mBuilder.build();

if (mGr0upAlertBehavior != GR0UP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) != 0
&& mGr0upAlertBehavior == GR0UP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) == 0
&& mGr0upAlertBehavior == GR0UP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}

return notification;
} else if (Build.VERSION.SDK_INT >= 21) {
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
if (mHeadsUpContentView != null) {
notification.headsUpContentView = mHeadsUpContentView;
}

if (mGr0upAlertBehavior != GR0UP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) != 0
&& mGr0upAlertBehavior == GR0UP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) == 0
&& mGr0upAlertBehavior == GR0UP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}
return notification;
} else if (Build.VERSION.SDK_INT >= 20) {
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}

if (mGr0upAlertBehavior != GR0UP_ALERT_ALL) {
// if is summary and only children should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) != 0
&& mGr0upAlertBehavior == GR0UP_ALERT_CHILDREN) {
removeSoundAndVibration(notification);
}
// if is group child and only summary should alert
if (notification.getGr0up() != null
&& (notification.flags & FLAG_GR0UP_SUMMARY) == 0
&& mGr0upAlertBehavior == GR0UP_ALERT_SUMMARY) {
removeSoundAndVibration(notification);
}
}

return notification;
} else if (Build.VERSION.SDK_INT >= 19) {
SparseArray<Bundle> actionExtrasMap =
NotificationCompatJellybean.buildActionExtrasMap(mActionExtrasList);
if (actionExtrasMap != null) {
// Add the action extras sparse array if any action was added with extras.
mExtras.putSparseParcelableArray(
NotificationCompatExtras.EXTRA_ACTION_EXTRAS, actionExtrasMap);
}
mBuilder.setExtras(mExtras);
Notification notification = mBuilder.build();
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
return notification;
} else if (Build.VERSION.SDK_INT >= 16) {
Notification notification = mBuilder.build();
// Merge in developer provided extras, but let the values already set
// for keys take precedence.
Bundle extras = NotificationCompat.getExtras(notification);
Bundle mergeBundle = new Bundle(mExtras);
for (String key : mExtras.keySet()) {
if (extras.containsKey(key)) {
mergeBundle.remove(key);
}
}
extras.putAll(mergeBundle);
SparseArray<Bundle> actionExtrasMap =
NotificationCompatJellybean.buildActionExtrasMap(mActionExtrasList);
if (actionExtrasMap != null) {
// Add the action extras sparse array if any action was added with extras.
NotificationCompat.getExtras(notification).putSparseParcelableArray(
NotificationCompatExtras.EXTRA_ACTION_EXTRAS, actionExtrasMap);
}
if (mContentView != null) {
notification.contentView = mContentView;
}
if (mBigContentView != null) {
notification.bigContentView = mBigContentView;
}
return notification;
} else {
return mBuilder.getNotification();
}
}

那么我们怎么解决这个问题呢?


Context Wrapper


在App 开发中,Context Wrapper是常见的事情,比如用在预加载Layout、模拟Service运行、插件加载等方面有大量使用。


本文思路是要hack targetSdkVersion,但targetSdkVersion是保存在ApplicationInfo中的,不过没关系,它是通过Context获取的,因此我们在它获取前将其修改为android 5.0的不就行了?


为什么可以修改ApplicationInfo,因为其事Parcelable的子类,看到Parcleable的子类你就能明白,该类的修改是不会触发系统服务的调度,但会影响部分功能,安全起见,我们可以拷贝一下。


public class NotificationContext extends ContextWrapper {
private Context mContextBase;
private ApplicationInfo mApplicationInfo;
private NotificationContext(Context base) {
super(base);
this.mContextBase = base;
}

@Override
public ApplicationInfo getApplicationInfo() {
if(mApplicationInfo!=null) return mApplicationInfo;
ApplicationInfo applicationInfo = super.getApplicationInfo();
mApplicationInfo = new ApplicationInfo(applicationInfo);
return mApplicationInfo;
}

public static NotificationContext from(Context context) {
return new NotificationContext(context);
}
}

targetSdkVersion hack


下一步,修改targetSdkVersion 为android 5.0版本


NotificationContext notificationContext = NotificationContext.from(context);
ApplicationInfo applicationInfo = notificationContext.getApplicationInfo();
int targetSdkVersion = applicationInfo.targetSdkVersion;

applicationInfo.targetSdkVersion = Math.min(21, targetSdkVersion);

完整的代码


要获取的属性


class NotificationResourceInfo {
String titleResourceName;
int titleColor;
float titleTextSize;
ViewGr0up.LayoutParams titleLayoutParams;
String descResourceName;
int descColor;
float descTextSize;
ViewGr0up.LayoutParams descLayoutParams;
long updateTime;

}

获取颜色,用于判断是不是深色模式,这里其实利用的是标记查找方法,先给标题和内容设置Text,然后查找具备此Text的TextView


private static String TITLE_TEXT = "APP_TITLE_TEXT";
private static String CONTENT_TEXT = "APP_CONTENT_TEXT";

下面是核心查找逻辑


  //遍历布局找到字体最大的两个textView,视其为主副标题
private <T extends View> T findView(ViewGr0up viewGr0upSource, CharSequence locatorTextId) {

Queue<ViewGr0up> queue = new ArrayDeque<>();
queue.add(viewGr0upSource);
while (!queue.isEmpty()) {
ViewGr0up parentGr0up = queue.poll();
if (parentGr0up == null) {
continue;
}
int childViewCount = parentGr0up.getChildCount();
for (int num = 0; num < childViewCount; ++num) {
View childView = parentGr0up.getChildAt(num);
String resourceIdName = getResourceIdName(childView.getContext(), childView.getId());
Log.d("NotificationManager", "--" + resourceIdName);
if (TextUtils.equals(resourceIdName, locatorTextId)) {
Log.d("NotificationManager", "findView");
return (T) childView;
}
if (childView instanceof ViewGr0up) {
queue.add((ViewGr0up) childView);
}

}
}
return null;

}

NotificationThemeHelper 实现


public class NotificationThemeHelper {
private static String TITLE_TEXT = "APP_TITLE_TEXT";
private static String CONTENT_TEXT = "APP_CONTENT_TEXT";

final static String TAG = "NotificationThemeHelper";
static SoftReference<NotificationResourceInfo> notificationInfoReference = null;
private static final String CHANNEL_NOTIFICATION_ID = "CHANNEL_NOTIFICATION_ID";

public NotificationResourceInfo parseNotificationInfo(Context context) {
String channelId = createNotificationChannel(context, CHANNEL_NOTIFICATION_ID, CHANNEL_NOTIFICATION_ID);
NotificationResourceInfo notificationInfo = null;
NotificationContext notificationContext = NotificationContext.from(context);
ApplicationInfo applicationInfo = notificationContext.getApplicationInfo();
int targetSdkVersion = applicationInfo.targetSdkVersion;

try {
applicationInfo.targetSdkVersion = Math.min(21, targetSdkVersion);
//更改版本号,这样可以让builder自行创建contentview
NotificationCompat.Builder builder = new NotificationCompat.Builder(notificationContext, channelId);
builder.setContentTitle(TITLE_TEXT);
builder.setContentText(CONTENT_TEXT);
int icon = context.getApplicationInfo().icon;
builder.setSmallIcon(icon);
Notification notification = builder.build();
if (notification.contentView == null) {
return null;
}
int layoutId = notification.contentView.getLayoutId();
ViewGr0up root = (ViewGr0up) LayoutInflater.from(context).inflate(layoutId, null);
notificationInfo = getNotificationInfo(notificationContext, root);

} catch (Exception e) {
Log.d(TAG, "更新失败");
} finally {
applicationInfo.targetSdkVersion = targetSdkVersion;
}
return notificationInfo;
}

private NotificationResourceInfo getNotificationInfo(Context Context, ViewGr0up root) {
NotificationResourceInfo resourceInfo = new NotificationResourceInfo();

root.measure(0,0);
root.layout(0,0,root.getMeasuredWidth(),root.getMeasuredHeight());

Log.i(TAG,"bitmap ok");

TextView titleTextView = (TextView) root.findViewById(android.R.id.title);
if (titleTextView == null) {
titleTextView = findView(root, "android:id/title");
}
if (titleTextView != null) {
resourceInfo.titleColor = titleTextView.getCurrentTextColor();
resourceInfo.titleResourceName = getResourceIdName(Context, titleTextView.getId());
resourceInfo.titleTextSize = titleTextView.getTextSize();
resourceInfo.titleLayoutParams = titleTextView.getLayoutParams();
}

TextView contentTextView = findView(root, "android:id/text");
if (contentTextView != null) {
resourceInfo.descColor = contentTextView.getCurrentTextColor();
resourceInfo.descResourceName = getResourceIdName(Context, contentTextView.getId());
resourceInfo.descTextSize = contentTextView.getTextSize();
resourceInfo.descLayoutParams = contentTextView.getLayoutParams();
}
return resourceInfo;
}

//遍历布局找到字体最大的两个textView,视其为主副标题
private <T extends View> T findView(ViewGr0up viewGr0upSource, CharSequence locatorTextId) {

Queue<ViewGr0up> queue = new ArrayDeque<>();
queue.add(viewGr0upSource);
while (!queue.isEmpty()) {
ViewGr0up parentGr0up = queue.poll();
if (parentGr0up == null) {
continue;
}
int childViewCount = parentGr0up.getChildCount();
for (int num = 0; num < childViewCount; ++num) {
View childView = parentGr0up.getChildAt(num);
String resourceIdName = getResourceIdName(childView.getContext(), childView.getId());
Log.d("NotificationManager", "--" + resourceIdName);
if (TextUtils.equals(resourceIdName, locatorTextId)) {
Log.d("NotificationManager", "findView");
return (T) childView;
}
if (childView instanceof ViewGr0up) {
queue.add((ViewGr0up) childView);
}

}
}
return null;

}

public boolean isDarkNotificationTheme(Context context) {
NotificationResourceInfo notificationInfo = getNotificationInfoFromReference();
if (notificationInfo == null) {
notificationInfo = parseNotificationInfo(context);
saveNotificationInfoToReference(notificationInfo);
}
if (notificationInfo == null) {
return isLightColor(Color.TRANSPARENT);
}
return !isLightColor(notificationInfo.titleColor);
}

private void saveNotificationInfoToReference(NotificationResourceInfo notificationInfo) {
if (notificationInfoReference != null) {
notificationInfoReference.clear();
}

if (notificationInfo == null) return;
notificationInfo.updateTime = SystemClock.elapsedRealtime();
notificationInfoReference = new SoftReference<NotificationResourceInfo>(notificationInfo);
}

private boolean isLightColor(int color) {
int simpleColor = color | 0xff000000;
int baseRed = Color.red(simpleColor);
int baseGreen = Color.green(simpleColor);
int baseBlue = Color.blue(simpleColor);
double value = (baseRed * 0.299 + baseGreen * 0.587 + baseBlue * 0.114);
if (value < 192.0) {
Log.d("ColorInfo", "亮色");
return true;
}
Log.d("ColorInfo", "深色");
return false;
}

public NotificationResourceInfo getNotificationInfoFromReference() {
if (notificationInfoReference == null) {
return null;
}
NotificationResourceInfo resourceInfo = notificationInfoReference.get();
if (resourceInfo == null) {
return null;
}
long dx = SystemClock.elapsedRealtime() - resourceInfo.updateTime;
if (dx > 10 * 1000) {
return null;
}
return resourceInfo;
}

public static String getResourceIdName(Context context, int id) {

Resources r = context.getResources();
StringBuilder out = new StringBuilder();
if (id > 0 && resourceHasPackage(id) && r != null) {
try {
String pkgName;
switch (id & 0xff000000) {
case 0x7f000000:
pkgName = "app";
break;
case 0x01000000:
pkgName = "android";
break;
default:
pkgName = r.getResourcePackageName(id);
break;
}
String typeName = r.getResourceTypeName(id);
String entryName = r.getResourceEntryName(id);
out.append(pkgName);
out.append(":");
out.append(typeName);
out.append("/");
out.append(entryName);
} catch (Resources.NotFoundException e) {
}
}
return out.toString();
}

private String createNotificationChannel (Context context,String channelID, String channelNAME){
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
NotificationManager manager = (NotificationManager)context. getSystemService(NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(channelID, channelNAME, NotificationManager.IMPORTANCE_LOW);
manager.createNotificationChannel(channel);
return channelID;
} else {
return null;
}
}
public static boolean resourceHasPackage(int resid) {
return (resid >>> 24) != 0;
}
}

深浅色判断其实有两种方法,第一种是305911公式,第二种是相似度。


下面是305911公式的,其实就是利用视频亮度算法YUV中的Y分量计算,Y分量表示明亮度。


private boolean isLightColor(int color) {
int simpleColor = color | 0xff000000;
int baseRed = Color.red(simpleColor);
int baseGreen = Color.green(simpleColor);
int baseBlue = Color.blue(simpleColor);
double value = (baseRed * 0.299 + baseGreen * 0.587 + baseBlue * 0.114);
if (value < 192.0) {
Log.d("ColorInfo", "亮色");
return true;
}
Log.d("ColorInfo", "深色");
return false;
}

第二种是相似度算法,一般用于检索相似照片,一般用于优化汉明距离算法,不过这里可以用来判断是否接近黑色。
blog.csdn.net/zz_dd_yy/ar…


private boolean isSimilarColor(int colorL, int colorR) {
int red = Color.red(colorL);
int green = Color.green(colorL);
int blue = Color.blue(colorL);

int red2 = Color.red(colorR);
int green2 = Color.green(colorR);
int blue2 = Color.blue(colorR);

float vertor = red * red2 + green * green2 + blue * blue2;
// 向量1的模
double vectorMold1 = Math.sqrt(Math.pow(red, 2) + Math.pow(green, 2) + Math.pow(blue, 2));
// 向量2的模
double vectorMold2 = Math.sqrt(Math.pow(red2, 2) + Math.pow(green2, 2) + Math.pow(blue2, 2));

// 向量的夹角[0, PI],当夹角为锐角时,cosθ>0;当夹角为钝角时,cosθ<0
float cosAngle = (float) (vertor / (vectorMold1 * vectorMold2));
float radian = (float) Math.acos(cosAngle);

float degrees = (float) Math.toDegrees(radian);
if(degrees>= 0 && degrees < 30) {
return true;
}
return false;
}


用法


这种适配其实无法拿到背景色,只能拿到文字的颜色,如果文字偏亮则背景必须的是深色,反之区亮色,那么核心方法是下面的实现


public boolean isDarkNotificationTheme(Context context) {
NotificationResourceInfo notificationInfo = getNotificationInfoFromReference();
if (notificationInfo == null) {
notificationInfo = parseNotificationInfo(context);
saveNotificationInfoToReference(notificationInfo);
}
if (notificationInfo == null) {
return isLightColor(Color.TRANSPARENT);
}
return !isLightColor(notificationInfo.titleColor);
}

遗留问题


正常情况下,只能取深色和暗色,但是如果存在系统UI Mode的变化时,已经展示出来的通知,显然适配颜色无法动态变化,这也是无法避免的,解决办法是删除通知后重新发送。


总结


本篇到这里就结束了,说实在的,Android的通知的重要性大不如从前,但是必要的适配还是需要的。


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

Android跳转系统界面_总结

1、跳转Setting应用列表(所有应用) Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_APPLICATIONS_SETTINGS); this.startActivity(intent); ...
继续阅读 »

1、跳转Setting应用列表(所有应用)


Intent intent =  new Intent(Settings.ACTION_MANAGE_ALL_APPLICATIONS_SETTINGS);
this.startActivity(intent);


2、跳转Setting应用列表(安装应用)


Intent intent =  new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS);


3、跳转Setting应用列表


Intent intent =  new Intent(Settings.ACTION_APPLICATION_SETTINGS);


4、开发者选项


Intent intent =  new Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS);


5、允许在其它应用上层显示的应用


Intent intent =  new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);


6、无障碍设置


Intent intent =  new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);


7、添加账户


Intent intent =  new Intent(Settings.ACTION_ADD_ACCOUNT);


8、WIFI设置


Intent intent =  new Intent(Settings.ACTION_WIFI_SETTINGS);


9、蓝牙设置


Intent intent =  new Intent(Settings.ACTION_BLUETOOTH_SETTINGS);


10、移动网络设置


Intent intent =  new Intent(Settings.ACTION_DATA_ROAMING_SETTINGS);


11、日期时间设置


Intent intent =  new Intent(Settings.ACTION_DATE_SETTINGS);


12、关于手机界面


Intent intent =  new Intent(Settings.ACTION_DEVICE_INFO_SETTINGS);


13、显示设置界面


Intent intent =  new Intent(Settings.ACTION_DISPLAY_SETTINGS);


14、声音设置


Intent intent =  new Intent(Settings.ACTION_SOUND_SETTINGS);


15、互动屏保


Intent intent =  new Intent(Settings.ACTION_DREAM_SETTINGS);


16、输入法


Intent intent =  new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS);


17、输入法_SubType


Intent intent =  new Intent(Settings.ACTION_INPUT_METHOD_SUBTYPE_SETTINGS);


18、内部存储设置界面


Intent intent =  new Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS);


19、存储卡设置界面


Intent intent =  new Intent(Settings.ACTION_MEMORY_CARD_SETTINGS);


20、语言选择界面


Intent intent =  new Intent(Settings.ACTION_LOCALE_SETTINGS);


21、位置服务界面


Intent intent =  new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS);


22、运营商


Intent intent =  new Intent(Settings.ACTION_NETWORK_OPERATOR_SETTINGS);


23、NFC共享界面


Intent intent =  new Intent(Settings.ACTION_NFCSHARING_SETTINGS);


24、NFC设置


Intent intent =  new Intent(Settings.ACTION_NFC_SETTINGS);


25、备份和重置


<Intent intent =  new Intent(Settings.ACTION_PRIVACY_SETTINGS);


26、快速启动


Intent intent =  new Intent(Settings.ACTION_QUICK_LAUNCH_SETTINGS);


27、搜索设置


Intent intent =  new Intent(Settings.ACTION_SEARCH_SETTINGS);


28、安全设置


Intent intent =  new Intent(Settings.ACTION_SECURITY_SETTINGS);


29、设置的主页


Intent intent =  new Intent(Settings.ACTION_SETTINGS);


30、用户同步界面


Intent intent =  new Intent(Settings.ACTION_SYNC_SETTINGS);


31、用户字典


Intent intent =  new Intent(Settings.ACTION_USER_DICTIONARY_SETTINGS);


32、IP设置


Intent intent =  new Intent(Settings.ACTION_WIFI_IP_SETTINGS);


33、App设置详情界面


public void startAppSettingDetail() {
String packageName = getPackageName();
Uri packageURI = Uri.parse("package:" + packageName);
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(packageURI);
startActivity(intent);
}


34、跳转应用市场


public void startMarket() {
Intent intent = new Intent(Intent.ACTION_VIEW);
// intent.setData(Uri.parse("market://details?id=" + "com.xxx.xxx"));
intent.setData(Uri.parse("market://search?q=App Name"));
startActivity(intent);
}


35、获取Launcherbaoming


public void getLauncherPackageName() {
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_HOME);
final ResolveInfo res = this.getPackageManager().resolveActivity(intent, 0);
if (res.activityInfo == null) {
Log.e("TAG", "没有获取到");
return;
}

if (res.activityInfo.packageName.equals("android")) {
Log.e("TAG", "有多个Launcher,且未指定默认");
} else {
Log.e("TAG", res.activityInfo.packageName);
}
}


36、跳转图库获取图片


public void startGallery() {
Intent intent = new Intent(Intent.ACTION_PICK,
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
intent.setType("image/*");
this.startActivityForResult(intent, 1);
}


37、跳转相机,拍照并保存


public void startCamera() {
String dir = Environment.getExternalStorageDirectory().getAbsolutePath() + "/test.jpg";
Uri headCacheUri = Uri.fromFile(new File(dir));
Intent takePicIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
takePicIntent.putExtra(MediaStore.EXTRA_OUTPUT, headCacheUri);
startActivityForResult(takePicIntent, 2);
}


38、跳转文件管理器


public void startFileManager() {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT,
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
intent.setType("file/*");
this.startActivityForResult(intent, 3);
}


39、直接拨打电话


 public void startCall() {
Intent callIntent = new Intent(Intent.ACTION_CALL);
callIntent.setData(Uri.parse("tel:" + "13843894038"));
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
return;
}
startActivity(callIntent);
}


40、跳转电话应用


public void startPhone() {
Intent intent = new Intent(Intent.ACTION_DIAL,Uri.parse("tel:" + "13843894038"));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}


41、发送短信


public void startSMS() {
Uri smsToUri = Uri.parse("smsto://10086");
Intent mIntent = new Intent( android.content.Intent.ACTION_SENDTO, smsToUri );
startActivity(mIntent);
}


42、发送彩信


public void startMMS() {
Uri uri = Uri.parse("content://media/external/images/media/11");
Intent it = new Intent(Intent.ACTION_SEND);
it.putExtra("sms_body", "some text");
it.putExtra(Intent.EXTRA_STREAM, uri);
it.setType("image/png");
startActivity(it);
}


43、发送邮件


public void startEmail() {
Uri uri = Uri.parse("mailto:6666666@qq.com");
String[] email = {"12345678@qq.com"};
Intent intent = new Intent(Intent.ACTION_SENDTO, uri);
intent.putExtra(Intent.EXTRA_CC, email); // 抄送人
intent.putExtra(Intent.EXTRA_SUBJECT, "这是邮件的主题部分"); // 主题
intent.putExtra(Intent.EXTRA_TEXT, "这是邮件的正文部分"); // 正文
startActivity(Intent.createChooser(intent, "请选择邮件类应用"));
}


44、跳转联系人


public void startContact() {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setData(Contacts.People.CONTENT_URI);
startActivity(intent);

/*Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
intent.setData(Uri.parse("content://contacts/people"));
startActivityForResult(intent, 5);*/

}


45、插入联系人


public void insertContact() {
Intent intent = new Intent(Intent.ACTION_INSERT);
intent.setData(ContactsContract.Contacts.CONTENT_URI);
intent.putExtra(ContactsContract.Intents.Insert.PHONE, "18688888888");
startActivityForResult(intent, 1);
}


46、插入日历事件


public void startCalender() {
Intent intent = new Intent(Intent.ACTION_INSERT);
intent.setData(CalendarContract.Events.CONTENT_URI);
intent.putExtra(CalendarContract.Events.TITLE, "开会");
startActivityForResult(intent, 1);
}


47、跳转浏览器


public void startBrowser() {
Uri uri = Uri.parse("http://www.baidu.com");
Intent intent = new Intent(Intent.ACTION_VIEW,uri);
startActivity(intent);
}


48、安装应用


public void startInstall() {
String filePath="/xx/xx/abc.apk";
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse("file://" + filePath),
"application/vnd.android.package-archive");
startActivity(intent);
}>



49、卸载应用


public void startUnInstall() {
String packageName="cn.memedai.mas.debug";
Uri packageUri = Uri.parse("package:"+packageName);//包名,指定该应用
Intent uninstallIntent = new Intent(Intent.ACTION_DELETE, packageUri);
startActivity(uninstallIntent);
}


50、回到桌面


public void startLauncherHome() {
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_HOME);
startActivity(intent);
}


51、打开任意文件(根据其MIME TYPE自动选择打开的应用)


  private void openFile(File f) {
Intent intent = new Intent();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(android.content.Intent.ACTION_VIEW);
String type = getMIMEType(f);
intent.setDataAndType(Uri.fromFile(f), type);
startActivity(intent);
}

private String getMIMEType(File f) {
String end = f.getName().substring(f.getName().lastIndexOf(".") + 1,
f.getName().length()).toLowerCase();
String type = "";
if (end.equalsIgnoreCase("mp3")
|| end.equalsIgnoreCase("aac")
|| end.equalsIgnoreCase("amr")
|| end.equalsIgnoreCase("mpeg")
|| end.equalsIgnoreCase("mp4")) {
type = "audio";
} else if(end.equalsIgnoreCase("mp4")
|| end.equalsIgnoreCase("3gp")
|| end.equalsIgnoreCase("mpeg4")
|| end.equalsIgnoreCase("3gpp")
|| end.equalsIgnoreCase("3gpp2")
|| end.equalsIgnoreCase("flv")
|| end.equalsIgnoreCase("avi")) {
type = "video";
} else if (end.equalsIgnoreCase("jpg")
|| end.equalsIgnoreCase("gif")
|| end.equalsIgnoreCase("bmp")
|| end.equalsIgnoreCase("png")
|| end.equalsIgnoreCase("jpeg")) {
type = "image";
} else {
type = "*";
}
type += "/*";
return type;
}


52、跳转录音


public void startRecord() {
Intent intent = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION);
startActivity(intent);
}



👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀



作者:派大星不吃蟹
来源:juejin.cn/post/7321551188092403764
收起阅读 »

一个优雅解决多个弹窗顺序显示方案

不是因为看到希望才坚持,而是因为坚持了才会有希望!场景  在做直播软件的时候,需要在用户打开App后,先后弹出签到,活动,提示等一系列弹窗。每个弹窗都要在前一个弹窗消失后弹出。于是就面临一个弹窗顺序问题,那时候对设计模式很陌生,不知道怎么更好的解决弹窗顺序问题...
继续阅读 »

不是因为看到希望才坚持,而是因为坚持了才会有希望!

场景

  在做直播软件的时候,需要在用户打开App后,先后弹出签到,活动,提示等一系列弹窗。每个弹窗都要在前一个弹窗消失后弹出。于是就面临一个弹窗顺序问题,那时候对设计模式很陌生,不知道怎么更好的解决弹窗顺序问题,都在下前一个弹窗取消或关闭时去加载后面一个弹窗。这样做虽然也能解决问题,但是实现并不优雅,如果在弹窗中间再添加一个其他类型的弹窗改动代价就变得很大,特别是当你是后来接手代码的新人,稍有不慎,就要背锅。怎么能简单而又优雅的解决这个问题呢?

思路

  开发者必读的23种设计模式,对于日常开发问题的解决提供了很好的思路,可以说几乎所有的优秀架构都离不开设计模式,这也是面试必问问题之一。23种设计模式中有一个责任链模式,为弹窗问题提供了解决方案,这也是我从okhttp源码中学习到的,读过okhttp的同学都知道,okhttp网络请求中的五大拦截器基于链式请求,使用起来简单高效。本篇文章同样也是基于责任链的思路来解决弹窗顺序问题。

代码

  1. 首页我们定义一个接口DialogIntercept,同时提供两个方法 intercept和show。
interface  DialogIntercept {
fun intercept(dialogIntercept: DialogChain)
fun show():Boolean
}

  所有的弹窗都需要实现DialogIntercept中的这两个方法。

  1. 自定义弹窗实现DailogIntercept接口。

● 弹窗


class FirstDialog(val context: Context) :DialogIntercept{

override fun intercept(dialogIntercept: DialogChain) {

}

override fun show():Boolean{
return true
}
}

  这里默认show()方法默认返回true,可根据业务逻辑决定弹窗是否显示。

  1. 提供一个弹窗管理类DialogChain,通过建造者模式创建管理类。根据弹窗添加的顺序弹出。
class DialogChain(private val builder: Builder) {
private var index = 0
fun proceed(){
............
...省略部分代码.....
.............
}
class Builder(){
var chainList:MutableList = mutableListOf()
fun addIntercept(dialogIntercept: DialogIntercept):Builder{
.....省略部分代码.....
return this
}
fun build():DialogChain{
return DialogChain(this)
}
}

}

效果

  为了测试效果,分别定义三个弹窗,FirstDialog,SecondDialog,ThirdDialog。按照显示顺序依次添加到DialogChain弹窗管理类中。

  1. 定义弹窗。

  由于三个弹窗代码基本相同,下面只提供FirstDialog代码。

class FirstDialog(val context: Context) :DialogIntercept{


override fun intercept(dialogIntercept: DialogChain) {
show(dialogIntercept)
}

override fun show():Boolean{
return true
}

private fun show(dialogIntercept: DialogChain){
AlertDialog.Builder(context).setTitle("FirstDialog")
.setPositiveButton("确定"
) { _, _ ->
dialogIntercept.proceed()
}.setNegativeButton("取消"
) { _, _ ->
dialogIntercept.proceed()
}.create().show()
}
}

2 . 分别将三个弹窗按照显示顺序添加到管理器中。

 DialogChain.Builder()
.addIntercept(FirstDialog(this))
.addIntercept(SecondDialog(this))
.addIntercept(ThirdDialog(this))
.build().proceed()
  1. 实现效果如下:

总结

  再优秀的架构,都离不开设计模式和设计原则。很多时候我们觉得架构师遥不可及,其实更多的时候是我们缺少一个想要进步的心。新的一年,新的起点,新的开始。


作者:IT小码哥
来源:juejin.cn/post/7319652739083108402
收起阅读 »

Android 0,1,2 个启动图标

最近改了个隐式app 拉起,启动图标也有不同的玩法 0 个启动图标 <intent-filter> <action android:name="android.intent.action.MAIN" /> <category an...
继续阅读 »

最近改了个隐式app 拉起,启动图标也有不同的玩法


0 个启动图标


<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="app"
android:path="/"
android:port="8080"
android:scheme="lb">
</data>
</intent-filter>

这里是对接受所有隐式拉起,这个是告诉系统app 启动不需要用户手动拉起,是为了被代码或者其他中转站调用,所以不需要用户手动拉起,自然就不用再显示图标


1个启动图标


<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

LAUNCHER 是决定是不是要显示在程序列表里,默认为主动唤起,也是Android 标准启动模式,会正常在手机的界面显示


2 个启动图标


<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme">

<activity android:name="com.camera.demo.1Activity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity android:name="com.camera.demo.2Activity"
android:icon="@mipmap/ic_launcher" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

</application>

上面说了android.intent.category.LAUNCHER 为是否显示在应用列表内,所以我们配置多个LAUNCHER,就会有多个图标显示在手机列表内


intent-filter 相关说明


接受页面隐式跳转的过滤器,


action


必须的包含,定义一些操作.android.intent.action.MAIN/android.intent.action.WEB_SEARCH


image.png


category


一个字符串, 包含了处理该Intent的组件的种类信息, 起着对action的补充说明作用


image.png


data


要指定接受的 Intent 数据,Intent 过滤器既可以不声明任何 <data> 元素,也可以声明多个此类元素,如下例所示:


<intent-filter>
    <data android:mimeType="video/mpeg" android:scheme="http" ... />
    <data android:mimeType="audio/mpeg" android:scheme="http" ... />
    ...
</intent-filter>

每个 <data> 元素均可指定 URI 结构和数据类型(MIME 媒体类型)。URI 的每个部分都是一个单独的属性:schemehostport 和 path


<scheme>://<host>:<port>/<path>


下例所示为这些属性的可能值:


content://com.example.project:200/folder/subfolder/etc


在此 URI 中,架构是 content,主机是 com.example.project,端口是 200,路径是 folder/subfolder/etc
在 <data> 元素中,上述每个属性均为可选,但存在线性依赖关系:



  • 如果未指定架构,则会忽略主机。

  • 如果未指定主机,则会忽略端口。

  • 如果未指定架构和主机,则会忽略路径。


将 Intent 中的 URI 与过滤器中的 URI 规范进行比较时,它仅与过滤器中包含的部分 URI 进行比较。例如:



  • 如果过滤器仅指定架构,则具有该架构的所有 URI 均与该过滤器匹配。

  • 如果过滤器指定架构和权限,但未指定路径,则具有相同架构和权限的所有 URI 都会通过过滤器,无论其路径如何均是如此。

  • 如果过滤器指定架构、权限和路径,则仅具有相同架构、权限和路径的 URI 才会通过过滤器。


最后贴张LAUNCHER 的原理图


image.png


作者:libokaifa
来源:juejin.cn/post/7307471540715126795
收起阅读 »

Android 粒子漩涡动画

前言 粒子动画经常用于大画幅的渲染效果,实际上难度并不高,但是在使用粒子动画时,必须要遵循的一些要素,主要是: 起点 矢量速度 符合运动学公式 在之前的文章中,如《烟花效果》和《粒子喷泉》是典型的粒子动画,起点之所以重要是因为其实位置决定粒子出现的位置,矢...
继续阅读 »

前言


粒子动画经常用于大画幅的渲染效果,实际上难度并不高,但是在使用粒子动画时,必须要遵循的一些要素,主要是:



  • 起点

  • 矢量速度

  • 符合运动学公式


在之前的文章中,如《烟花效果》和《粒子喷泉》是典型的粒子动画,起点之所以重要是因为其实位置决定粒子出现的位置,矢量速度则决定了快慢和方向,运动学公式属于粒子动画的一部分,当然不是物理性的,毕竟平面尺寸也就那么长,这里的物理学公式使得画面更加丝滑而无跳动感觉。


本篇将实现下面的效果


fire_90.gif
注意:gif图有些卡,实际上流畅很多


本篇效果实现


本篇效果是无数圆随机产生然后渐渐变大并外旋,另外也有雨滴,这里的雨滴相对简单一些。


首先定义粒子对象


定义粒子对象是非常重要的,绝大部分倾下粒子本身就是需要单独控制的,因为每个粒子的轨迹都是有所差别的。


定义圆圈粒子


private static class Circle {
float x;
float y;
int color;

float radius;

Circle(float x, float y, float radius) {
reset(x, y, radius);
}

private void reset(float x, float y, float radius) {
this.x = x;
this.y = y;
this.radius = radius;
this.color = Color.rgb((int) (Math.random() * 256), (int) (Math.random() * 256), (int) (Math.random() * 256));
}
}

定义雨滴


private static class RainDrop {
float x;
float y;

RainDrop(float x, float y) {
this.x = x;
this.y = y;
}
}

定义粒子管理集合


private ArrayList mParticles;
private ArrayList mRainDrops;
private long mLastUpdateTime; //记录执行时间

生成粒子对象



  • 生成雨滴是从顶部屏幕意外开始,而y = -50f值是雨滴的高度决定。

  • 圆圈是随机产生,在中心位置圆圈内。


// 创建新的雨滴
if (mRainDrops.size() < 80) {
int num = getWidth() / padding;
double nth = num * Math.random() * padding;
double x = nth + padding / 2f * Math.random();
RainDrop drop = new RainDrop((float) x, -50f);
mRainDrops.add(drop);
}

// 创建新的粒子
if (mParticles.size() < 100) {
float x = (float) (getWidth() / 2f - radius + 2*radius * Math.random());
float y = (float) (getHeight()/2f - radius + 2*radius * Math.random() );

Circle particle = new Circle(x, y,5);
mParticles.add(particle);
}

绘制雨滴


雨滴的绘制非常简单,调用相应的canvas方法即可


// 绘制雨滴
mPaint.setColor(Color.WHITE);
for (RainDrop drop : mRainDrops) {
canvas.drawLine(drop.x, drop.y, drop.x, drop.y + 20, mPaint);
}

// 绘制粒子
for (Circle particle : mParticles) {
mPaint.setColor(particle.color);
canvas.drawCircle(particle.x, particle.y, particle.radius, mPaint);
}

更新粒子位置


雨滴的更新相对简单,但是圆圈的旋转是一个难点,一个重要的问题是如何旋转粒子的,其实有很多方法,其中最笨的方法是旋转Canvas坐标系,底层有很多矩阵计算,但是这个似乎使用Math.atan2(y,x)显然更加方便,我们只需要在当前角度加上偏移量就能旋转。


float angle = (float) Math.atan2(dy, dx) + deltaTime * 0.65f;

下面是完整的更新逻辑


// 更新雨滴位置
Iterator rainIterator = mRainDrops.iterator();
while (rainIterator.hasNext()) {
RainDrop drop = rainIterator.next();
if (drop.y > getHeight() + 50) {
int num = getWidth() / padding;
double nth = num * Math.random() * padding;
double x = nth + padding * Math.random();

drop.x = (float) (x);
drop.y = -50;
} else {
drop.y += 20;
}

}

// 更新粒子位置
long currentTime = System.currentTimeMillis();
float deltaTime = (currentTime - mLastUpdateTime) / 1000f;
mLastUpdateTime = currentTime;

float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;

Iterator iterator = mParticles.iterator();
while (iterator.hasNext()) {
Circle particle = iterator.next();
float dx = particle.x - centerX;
float dy = particle.y - centerY;
float distance = (float) Math.sqrt(dx * dx + dy * dy) + 4.5f;// 增加偏移
float angle = (float) Math.atan2(dy, dx) + deltaTime * 0.5f;
particle.radius += 1f;

particle.x = centerX + (float) Math.cos(angle) * distance;
particle.y = centerY + (float) Math.sin(angle) * distance;

if (particle.radius > 10) {
int maxRadius = 100;
float fraction = (particle.radius - 10) / (maxRadius - 10);
if (fraction >= 1) {
fraction = 1;
}
particle.color = argb((int) (255 * (1 - fraction)), Color.red(particle.color), Color.green(particle.color), Color.blue(particle.color));
}
if (Color.alpha(particle.color) == 0) {

float x = (float) (getWidth() / 2f - radius + 2* radius * Math.random());
float y = (float) (getHeight()/2f - radius + 2*radius * Math.random() );
particle.reset(x,y, 5);
}

}

粒子刷新


其实刷新机制我们以前经常使用,调用postInvalidate即可,本身就是View自身的方法。


总结


本篇主要内容总体上就是这些,下面是全部代码逻辑


public class VortexView extends View {

private Paint mPaint;
private ArrayList mParticles;
private ArrayList mRainDrops;
private long mLastUpdateTime;
private int padding = 20;

public VortexView(Context context) {
super(context);
mPaint = new Paint();
mParticles = new ArrayList<>();
mRainDrops = new ArrayList<>();
mLastUpdateTime = System.currentTimeMillis();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float radius = Math.min(getWidth(), getHeight()) / 3f;

// 创建新的雨滴
if (mRainDrops.size() < 80) {
int num = getWidth() / padding;
double nth = num * Math.random() * padding;
double x = nth + padding / 2f * Math.random();
RainDrop drop = new RainDrop((float) x, -50f);
mRainDrops.add(drop);
}

// 创建新的粒子
if (mParticles.size() < 100) {
float x = (float) (getWidth() / 2f - radius + 2*radius * Math.random());
float y = (float) (getHeight()/2f - radius + 2*radius * Math.random() );

Circle particle = new Circle(x, y,5);
mParticles.add(particle);
}

// 绘制雨滴
mPaint.setColor(Color.WHITE);
for (RainDrop drop : mRainDrops) {
canvas.drawLine(drop.x, drop.y, drop.x, drop.y + 20, mPaint);
}

// 绘制粒子
for (Circle particle : mParticles) {
mPaint.setColor(particle.color);
canvas.drawCircle(particle.x, particle.y, particle.radius, mPaint);
}

// 更新雨滴位置
Iterator rainIterator = mRainDrops.iterator();
while (rainIterator.hasNext()) {
RainDrop drop = rainIterator.next();
if (drop.y > getHeight() + 50) {
int num = getWidth() / padding;
double nth = num * Math.random() * padding;
double x = nth + padding * Math.random();

drop.x = (float) (x);
drop.y = -50;
} else {
drop.y += 20;
}

}

// 更新粒子位置
long currentTime = System.currentTimeMillis();
float deltaTime = (currentTime - mLastUpdateTime) / 1000f;
mLastUpdateTime = currentTime;

float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;

Iterator iterator = mParticles.iterator();
while (iterator.hasNext()) {
Circle particle = iterator.next();
float dx = particle.x - centerX;
float dy = particle.y - centerY;
float distance = (float) Math.sqrt(dx * dx + dy * dy) + 3.5f;// 增加偏移
float angle = (float) Math.atan2(dy, dx) + deltaTime * 0.65f;
particle.radius += 1f;

particle.x = centerX + (float) Math.cos(angle) * distance;
particle.y = centerY + (float) Math.sin(angle) * distance;

if (particle.radius > 10) {
int maxRadius = 100;
float fraction = (particle.radius - 10) / (maxRadius - 10);
if (fraction >= 1) {
fraction = 1;
}
particle.color = argb((int) (255 * (1 - fraction)), Color.red(particle.color), Color.green(particle.color), Color.blue(particle.color));
}
if (Color.alpha(particle.color) == 0) {

float x = (float) (getWidth() / 2f - radius + 2* radius * Math.random());
float y = (float) (getHeight()/2f - radius + 2*radius * Math.random() );
particle.reset(x,y, 5);
}

}

Collections.sort(mParticles, comparator);

// 使view无效从而重新绘制,实现动画效果
invalidate();
}
Comparator comparator = new Comparator() {
@Override
public int compare(Circle left, Circle right) {
return (int) (left.radius - right.radius);
}
};

public static int argb(
int alpha,
int red,
int green,
int blue)
{
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}

private static class Circle {
float x;
float y;
int color;

float radius;

Circle(float x, float y, float radius) {
reset(x, y, radius);
}

private void reset(float x, float y, float radius) {
this.x = x;
this.y = y;
this.radius = radius;
this.color = Color.rgb((int) (Math.random() * 256), (int) (Math.random() * 256), (int) (Math.random() * 256));
}
}

private static class RainDrop {
float x;
float y;

RainDrop(float x, float y) {
this.x = x;
this.y = y;
}
}
}

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

为啥TextureView比SurfaceView表现还差呢?

从原理上面讲,我们大众的认知就是TextureView比SurfaceView的性能要好。硬的比软的好。但是其实这种是片面的。最近就遇到一个奇怪的现象:在3399上面通过ffmpeg拉rtsp流,然后通过mediacodec解码后渲染。渲染到TextureVi...
继续阅读 »

从原理上面讲,我们大众的认知就是TextureView比SurfaceView的性能要好。硬的比软的好。但是其实这种是片面的。最近就遇到一个奇怪的现象:在3399上面通过ffmpeg拉rtsp流,然后通过mediacodec解码后渲染。渲染到TextureView上会比较频繁的出现马赛克的现象。但是换用SurfaceView立马就变好了。


TextureView 和 SurfaceView 都有各自的优势和局限性,所以它们的性能表现也会因应用的具体需求和使用场景而异。
在某些情况下,TextureView 的性能可能会比 SurfaceView 差,原因可能有以下几点:



  1. 渲染管道的差异:TextureView 是基于 OpenGL ES 的,它使用图形渲染管道来渲染内容。而 SurfaceView 则使用传统的 Android 渲染管道,这与 Android 的视图系统更加紧密集成。在某些情况下,这可能会导致 SurfaceView 的性能更好。

  2. 线程管理:SurfaceView 使用一个独立的线程来渲染内容,这可以提供更平滑的渲染性能,尤其是在处理复杂动画或游戏时。而 TextureView 则在主线程上渲染内容,这可能会导致性能下降,尤其是在处理大量数据或复杂渲染时。

  3. 硬件加速:虽然 TextureView 支持硬件加速,但在某些情况下,硬件加速可能会导致性能问题,尤其是在低端设备上。SurfaceView 则更多地依赖于软件渲染,这可能在某些情况下会提供更稳定的性能。


需要注意的是,性能差异可能会因设备和应用而异,因此在实际开发中应该根据具体需求和性能测试结果来选择合适的视图。无论选择哪种视图,都应该优化代码以提高性能,并确保在不同设备上进行充分的测试。


于是,我针对上面的3点的结论做了一个实验,在3399上面ffmpeg硬解码居然比软解码帧率要低。看来3399的CPU性能比其他硬件确实要抢。这就证明了标题中的疑惑了。


下面贴出一段出马赛克的代码,换上SurfaceView就好了。


public class IPCameraPreviewFragment extends Fragment implements TextureView.SurfaceTextureListener{

public static final String TAG = "IPCameraPreviewFragment";
public static final boolean DEBUG = true;

private TextureView mPreview;
private SurfaceTexture mSurfaceTexture;
private Handler mUiHandler = new Handler();
private Runnable mRunnable = new Runnable() {

@Override
public void run() {
if(mPreview == null || mSurfaceTexture == null) return;
Play.getInstances().startPreivew(new Surface(mSurfaceTexture));
}
};
private IErrorCallback mErrorCallback = new IErrorCallback() {

@Override
public void onError(int error) {
Log.d(TAG, "onError = " + error);
if(null == mUiHandler || null == mRunnable) return;
mUiHandler.removeCallbacks(mRunnable);
mUiHandler.postDelayed(mRunnable, 5000);
}
};

public void setDataSource(String source){
Play.getInstances().setErrorCallback(mErrorCallback);
Play.getInstances().setDataSource(source);
}

@Override
public View onCreateView(LayoutInflater inflater, ViewGr0up container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.preview_fragment, container,false);
mPreview = (TextureView)view.findViewById(R.id.preview);
mPreview.setSurfaceTextureListener(this);
return view;
}

@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width,
int height)
{
mSurfaceTexture = surface;
Play.getInstances().startPreivew(new Surface(surface));
}

@Override
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width,
int height)
{

}

@Override
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
mSurfaceTexture = null;
Play.getInstances().releaseMediaPlay();
if(null == mUiHandler || null == mRunnable) return false;
mUiHandler.removeCallbacks(mRunnable);
mUiHandler = null;
mRunnable = null;
return false;
}

@Override
public void onSurfaceTextureUpdated(SurfaceTexture surface) {

}

}

作者:逗比先生
来源:juejin.cn/post/7316592817341218866
收起阅读 »

App防止恶意截屏功能的方法:iOS、Android和鸿蒙系统的实现方案

防止应用被截图是一个比较常见的需求,主要是出于安全考虑。下面将分别为iOS(苹果系统)、Android(安卓系统)及HarmonyOS(鸿蒙系统)提供防止截屏的方法和示例代码。在企业内部使用的应用中,防止员工恶意截屏是一个重要的安全需求。本文将详细介绍iOS、...
继续阅读 »

防止应用被截图是一个比较常见的需求,主要是出于安全考虑。下面将分别为iOS(苹果系统)、Android(安卓系统)及HarmonyOS(鸿蒙系统)提供防止截屏的方法和示例代码。

123456 (161).png

在企业内部使用的应用中,防止员工恶意截屏是一个重要的安全需求。本文将详细介绍iOS、Android和鸿蒙系统的防止截屏的方法,并提供相应的代码示例,以帮助代码初学者理解和实现该功能。

iOS系统防止截屏方法:

在iOS系统中,可以通过设置UIWindow的windowLevel为UIWindowLevelNormal + 1,使应用窗口覆盖在截屏窗口之上,从而阻止截屏。以下是Objective-C和Swift两种语言的代码示例:

  1. iOS系统防止截屏

在iOS中,可以使用UIScreen的isCaptured属性来检测屏幕是否被录制或截图。为了防止截屏,你可以监听UIScreenCapturedDidChange通知,当屏幕开始被捕获时,你可以做一些操作,比如模糊视图或显示一个全屏的安全警告。

swift

// 注册屏幕捕获变化通知
NotificationCenter.default.addObserver(
    self,
    selector: #selector(screenCaptureChanged),
    name: UIScreen.capturedDidChangeNotification,
    object: nil
)
@objc func screenCaptureChanged(notificationNSNotification) {
    if UIScreen.main.isCaptured {
        // 屏幕正在被捕获,可以在这里做一些隐藏内容的操作,比如
        // 显示一个覆盖所有内容的视图
    } else {
        // 屏幕没有被捕获,可以移除那个覆盖的视图
    }
}

但需要注意的是,iOS不允许应用程序完全禁止截屏。因为截图功能是系统级别的,而不是应用级别的,上述代码只能做到在截图时采取一定的响应措施,不能完全防止。

  1. Android系统防止截屏

在Android中,可以通过设置Window的属性来防止用户截图或录屏。这通过禁用FLAG_SECURE来实现。

java

// 在Activity中设置禁止截屏
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // 在setContentView之前调用
    getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE,
                         WindowManager.LayoutParams.FLAG_SECURE);
    setContentView(R.layout.activity_main);
}

这样设置后,当前的Activity将无法被截屏或录屏。

  1. HarmonyOS(鸿蒙系统)防止截屏

HarmonyOS是华为开发的一个分布式操作系统,目前它在应用开发中有着与Android类似的API。因此可以使用与Android相同的方法进行禁止截屏。

java

// 在Ability(Activity)中设置禁止截屏
@Override
protected void onStart(Intent intent) {
    super.onStart(intent);
    // 在setUIContent之前调用
    getWindow().addFlags(WindowManager.LayoutConfig.FLAG_SECURE);
    setUIContent(ResourceTable.Layout_ability_main);
}

在HarmonyOS中,Ability相当于Android中的Activity。

请注意尽管上述方法能够有效地防止绝大多数截屏和录屏行为,但技术上并不是100%无法绕过的(例如某些root设备或具有特殊权限的应用可能可以绕过这些限制)。因此,在处理非常敏感的信息时,请综合其他安全措施一起使用,比如数据加密、用户行为分析等。


作者:咕噜分发企业签名梦奇
来源:juejin.cn/post/7317095140040376346
收起阅读 »

Android自定义锁屏实践总结

1. 背景 在我们的业务场景中,用户在完成下单后大部分的概率不再需要进入App做其他的操作,只需要知道当前的订单状态,为了方便用户在不解锁的情况下也能实时查看当前订单的状态,货拉拉用户 iOS端上线了灵动岛功能,用户的接受度较高,由于Android暂不支持灵动...
继续阅读 »

1. 背景


在我们的业务场景中,用户在完成下单后大部分的概率不再需要进入App做其他的操作,只需要知道当前的订单状态,为了方便用户在不解锁的情况下也能实时查看当前订单的状态,货拉拉用户 iOS端上线了灵动岛功能,用户的接受度较高,由于Android暂不支持灵动岛,所以我们自定义了一个锁屏页面。


2. 实践


  2.1 方案选择


  实现锁屏的方式有多种(锁屏应用、悬浮窗、普通Activity伪造锁屏等等),由于我们的业务场景简单只展示我们的订单状态,且不需要很强的保活干扰用户的操作,采用了普通的Activity伪造锁屏。


  2.2 方案原理


  锁屏的大概实现原理都很简单,监听系统的亮屏广播,在亮屏的时候展示自己的锁屏界面,自定义的锁屏界面会覆盖在系统的锁屏界面上,用户在自定义锁屏界面上进行一系列的动作后进入系统的解锁界面。



  2.3 代码实现


    2.3.1 锁屏页面


    锁屏页Activity在普通的Activity需要加上一些配置


      1. 在onCreate中设置添加Flags,让当前Activity可以在锁屏时显示



  • FLAG_SHOW_WHEN_LOCKED:使Activity在锁屏时仍然能够显示

  • FLAG_DISMISS_KEYGUARD:去掉系统锁屏页,设置了系统锁屏密码是没有办法去掉的,现在手机一般都会设置锁屏密码,该配置可基本忽略。


    this.window.addFlags(
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
)

      2. 在AndroidManifest.xml中进行对锁屏页Activity进行配置



  • 主题配置,


      主要是配置锁屏Activity的背景为透明和去除过度动画,让锁屏Activity过渡到系统锁屏更自然






  • 启动模式配置



    • BroadcastReceiver中启动锁屏页Activity,需要添加Intent.FLAG_ACTIVITY_NEW_TASKflag,造成锁屏Activity单独创建一个history stack,会在最近任务中显示出来,通过配置excludeFromRecentsnoHistorytaskAffinity来规避这个问题。


      name=".lockscreen.LockScreenActivity"
    android:configChanges="uiMode"
    android:excludeFromRecents="true"
    android:exported="false"
    android:launchMode="singleInstance"
    android:noHistory="true"
    android:screenOrientation="portrait"
    android:taskAffinity="com.xxx.lockscreen"
    android:theme="@style/LockScreenTheme">


    ```



      3. Home键,Back键和Menu键事件的处理



  • Home键,由于不是用来替代系统锁屏的锁屏软件,不需要处理Home键事件.

  • Back/Menu键,重写onKeyDown让锁屏页不处理这两个事件


      override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
    return when (event?.keyCode) {
    KeyEvent.KEYCODE_BACK -> true

    KeyEvent.KEYCODE_MENU -> true

    else -> super.onKeyDown(keyCode, event)
    }
    }
    ```



    2.3.2 广播


    LockScreenBroadcastReceiver是普通的BroadcastReceiver,不做其他的配置,需要注意两点:



  1. 动态注册/注销

  2. 在广播中启动Activity,需要添加FLAG_ACTIVITY_NEW_TASK,否则会出现“Calling startActivity() from outside of an Activity”的运行时异常



class LockScreenBroadcastReceiver : BroadcastReceiver() {

override fun onReceive(context: Context?, intent: Intent?) {
intent?.let { handleCommandIntent(context, it) }
}

private fun handleCommandIntent(context: Context?, intent: Intent) {
when (intent.action) {
Intent.ACTION_SCREEN_OFF -> {
val lockScreen = Intent(this, LockScreenActivity::class.java)
lockScreen.setPackage("com.xxx.xxx")
lockScreen.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK )
context?.startActivity(lockScreen)
}
Intent.ACTION_USER_PRESENT -> {
// 处理解锁后才显示自定义锁屏Activity
}
}
}
}

    2.3.3 实现效果





3. 注意点


以下是在实践过程中的一些问题小结,供大家参考。


  3.1 权限相关


  不同手机系统上权限的名称,大体分为5种:



  • 后台弹窗

  • 悬浮窗

  • 显示在其他应用的上层

  • 锁屏展示

  • 后台弹出界面


    以及不同的组合效果也不同,以下是已测试过的手机,


品牌型号系统系统版本相关权限权限截图权限截图
华为P50HarmonyOSHarmonyOS 4.0.01. 悬浮窗 2.后台弹窗
oppoOPPO K9 5GColorOS 13Android 131. 悬浮窗 2. 锁屏显示
vivoY52sFuntouch OS 10.5Android 101. 悬浮窗 2. 锁屏显示 3. 后台弹出界面
一加OnePlus Ace ProColorOS 13Android 131. 悬浮窗 2. 锁屏显示
荣耀honor 60magic ui 6.1Android 121. 显示在其他应用的上层
iQOONeo3Origin OSAndroid 121. 悬浮窗 2. 锁屏显示 3. 后台弹出界面
Hi novaHi nova 9Emui 12Android 121. 后台弹窗 2. 悬浮窗 3. 显示在其他应用的上层

  OPPO/一加 手机特殊说明:在默认状态下在系统设置下找不到“锁屏显示”的入口,需要先授权“悬浮窗”权限再次启动应用会在应用启动时弹窗提示授权在锁屏上显示,然后在系统设置中会出现“锁屏显示”的入口。


  3.2 有些手机在未授权时,应用在前台时锁屏可以展示,但是应用退到后台不展示。


  Android 10 (API 级别 29) 及更高版本对后台应用可启动Activity的时间施加限制。这些限制有助于最大限度地减少对用户造成的中断,并且可以让用户更好地控制其屏幕上显示的内容。具体见官方文档


  3.3 在部分手机上,点亮屏幕后不会立即展示自定义的锁屏界面,在解锁系统锁屏后才会展示自定义的锁屏。1. 监听解锁事件主动finish自定义的锁屏页面


    Intent.ACTION_USER_PRESENT->{
ActivityUtils.getActivityList()?.forEach {
if ("com.xxx.lockscreen.LockScreenActivity" == it.componentName.className) {
it.finish()
}
}
}

2. 在自定义锁屏ActivityonResume中监听设备是否已解锁并finish锁屏页


override fun onResume() {
super.onResume()
val isInteractive = (getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive
val isKeyguardLocked = (getSystemService(KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked
if (isInteractive && !isKeyguardLocked) {
finish()
}
}

3.4 当在自定义锁屏页触发Home键事件后,锁屏页Activity不再显示


提示用户根据自己的系统去授予对应的权限,不同系统所需的权限参考上面第1点


3.5 Android 8.0 透明主题造成闪退


  在Android 8.0系统上Activity满足了以下条件:



  1. targetSdkVersion > 26

  2. 透明主题

  3. 固定屏幕方向


会出现java.lang.IllegalStateException: Only fullscreen activities can request orientation


    // ActivityRecord.java
void setRequestedOrientation(int requestedOrientation) {
if (ActivityInfo.isFixedOrientation(requestedOrientation) && !fullscreen
&& appInfo.targetSdkVersion > O) {
throw new IllegalStateException("Only fullscreen activities can request orientation");
}
....
}

  建议针对Android 8.0以外的系统才固定屏幕方向,可参考Android 8.0系统透明主题适配解决办法


4. 总结


从线上最新的数据来看,接近60%的订单在锁屏后可以通过自定义锁屏查看到订单状态。


功能上线后发现比较少用户会主动选择关闭,从最开始的出发点就是为用户提供一个便捷的状态查看的入口,用户下完单等待司机接单以及接单后司机的状态都是用户会重点关注的,同时我们会过滤掉一些不太重要的状态的显示避免对用户带来不必要的干扰。


从实现的角度上来说整体较简单,较麻烦的是国内的ROM对权限的管控越来越严,且不同的系统同一权限的命名和授予方式差异较大,需要用更吸引用户的体验去引导用户授权。


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

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:实现一个全屏拖拽、自动贴边半隐藏的自定义View

大家好,我是似曾相识2022。不喜欢唱跳篮球,但对杰伦的Rap却情有独钟。 今天给大家带来一个可全屏拖拽,手指离开屏幕后自动贴边,隔一定时间后自动半隐藏的这么一个效果。话不多说直接上效果图: 看到这个效果是不是感觉很熟悉?没错,很多商业APP首页都带一个小...
继续阅读 »

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



今天给大家带来一个可全屏拖拽,手指离开屏幕后自动贴边,隔一定时间后自动半隐藏的这么一个效果。话不多说直接上效果图:


1.gif


看到这个效果是不是感觉很熟悉?没错,很多商业APP首页都带一个小助手的图标,使用的时候点击它就自动弹出,不使用的时候自动贴边隐藏,当然也是可以随意全屏拖拽,为的是防止遮挡一些关键位置的信息,影响用户体验。接下来咱们就来一步步实现它!


要实现上图效果咱们得罗列所有的功能点:



  • 自定义View,这里要显示图片所以继承自ImageView或其子类即可

  • 监听屏幕滑动事件,记录和计算当前视图的位置信息

  • 动画效果,很明显使用平移动画

  • 圆角图片和描边,使用第三方ImageView即可


为了解决小圆球这个图标的问题咱们自定View时直接继承自第三方RoundedImageView,一举两得直接解决了第一和第四步。咱们把焦点聚焦到第二三部分,这也是最为复杂的部分。


之前文章 Android:自定义View实现图片缩放及坐标的计算(上) 中有写到监听界面各类手势可以使用GestureDetector,这里咱们就不采用重写onTouchEvent方法然后再里面监听各类ACTION_UP、ACTION_DOWN、ACTION_MOVE事件的模式来写了。但还是需要重写onTouchEvent方法将GestureDetector的处理结果返回给它即可:


override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}

接下来只需在GestureDetector入参的GestureDetector.SimpleOnGestureListener监听中执行对应的操作:


首先需要在onDown方法中记录最后点击屏幕的位置信息lastXlastY,这里备份一份点击时的位置信息moveXmoveY,用于后续逻辑判断。


override fun onDown(e: MotionEvent): Boolean {
lastX = e.rawX.toInt()
lastY = e.rawY.toInt()
moveX = lastX
moveY = lastY
return true
}

onScroll中需要不停修改自定义视图的位置,所以我们需要计算出需要移动位置的信息。通过当前实时滑动点的信息和最后记录的点信息计算出滑动距离,再重新计算当前视图的上下左右位置,最后咱们采取layout() 方式进行位置设置。


override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
//获取当前实时点信息
val rawX = e2.rawX.toInt()
val rawY = e2.rawY.toInt()

//变化量
dX = rawX - lastX
dY = rawY - lastY

//获取最新的视图位置
var left = left + dX
var right = right + dX
var top = top + dY
var bottom = bottom + dY

//添加限制范围,上下左右不能超出屏幕范围
if (left < 0) {
left = 0
right = left + width
}

if (right > windowWith) {
right = windowWith
left = right - width
}

if (top < 40) {
top = 40
bottom = top + height
}

if (bottom > windowHight) {
bottom = windowHight
top = bottom - height
}

//更新当前视图位置
layout(left, top, right, bottom)

//更新最后屏幕点信息
lastX = rawX
lastY = rawY

return true
}

到此,咱们已经实现了可全屏拖拽的效果了:


2.gif


现在只差最后一步,通过位置信息判断图标该往哪边贴边,以及移动距离的计算。


由于GestureDetector没有抬起监听,所以逻辑咱们还是得在onTouchEvent方法中通过监听ACTION_UP的动作进行操作。判断该往哪边贴边很简单,如果最后松开的位置X坐标的超过屏幕一半就往右贴,反之往左。动画咱们还是使用ValueAnimator,因为我们移动也是用layout() 方法进行操作。


override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_UP -> {
val x = event.rawX
val y = event.rawY
//抬起点和最后一次按下点x、y距离大于视图宽的一半才执行
if (abs(x - moveX) > width / 2 || abs(y - moveY) > width / 2) {
val isRight = x > windowWith / 2

//贴边
startAnimator(isRight, windowWith - width, 0)

//隔1.5秒收边
postDelayed({
startAnimator(isRight, windowWith - width * 2 / 3, -width / 3)
}, 1500)
}
return true
}
}
return gestureDetector.onTouchEvent(event)
}

//属性动画执行
private fun startAnimator(isRight: Boolean, rightValue: Int, leftValue: Int) {
ValueAnimator.ofInt(
left,
if (isRight) rightValue else leftValue
).apply {
addUpdateListener { animation ->
val value = animation.animatedValue as Int
//根据监听值不断改变当前视图位置
layout(value, top, value + width, bottom)
}
//插值器 先快后慢
interpolator = AccelerateDecelerateInterpolator()
duration = 600
start()
}
}

这里使用了两次动画,第一次根据计算得出的方向进行贴边平移,隔了1.5秒后再进行隐藏的操作。到此我们的所有功能全部都实现了接下来总结几点:



  • 自定义View时尽量选择最接近目标功能的View进行继承

  • 屏幕事件监听除了重写onTouchEvent进行动作监听的方式还有GestureDetectorScaleGestureDetector等方式

  • 重写了onTouchEvent方法后需要注意其返回值,如果都返回false的情况该视图的点击事件有可能会被父View或其他设有监听事件控件所消费,导致滑动监听不被触发。


以上便是实现一个全屏拖拽、自动贴边半隐藏的自定义View的所有内容,希望能给大家带来帮助!


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