注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。


小结



在这里插入图片描述


您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力


持续更新中,和我一起步步为营系统、深入学习Android/Kotlin


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

拒绝代码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
收起阅读 »

仿微信列表左滑删除、置顶。。

仿微信消息列表 前言 最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这...
继续阅读 »

仿微信消息列表


前言


最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这种解决方法的,但由于我对自定义view的了解还是比较少,而且之前也没有做过,所以就作罢。上周看了任玉刚老师的《Android开发艺术探索》中的View事件体系章节,提起了兴趣,就想着试一试吧,反正弄不成功也没关系。最后弄成了,但还是有些小瑕疵(在6、问题中),希望大佬能够指教一二。话不多说,放上一张动图演示下:


messlist.gif


1、典型的事件类型


在附上源码之前,想先向大家介绍下事件类型,在手指接触屏幕后所产生的一系列事件中,典型的事件类型有如下几种:



  • ACTION_DOWN ---- 手指刚接触屏幕

  • ACTION_MOVE ---- 手指在屏幕上移动

  • ACTION_UP ---- 手指刚离开屏幕


正常情况下、一次手指触摸屏幕的行为会触发一系列点击事件:



  • 点击屏幕后松开,事件序列为DOWN -> UP

  • 点击屏幕滑动后松开,事件序列为DOWN -> MOVE -> ... -> MOVE -> UP


2、Scroller


Scroller - 弹性滑动对象,用于实现View的弹性滑动。
当使用View的scrollTo/scrollBy方法来实现滑动时,其过程是在瞬间完成的,这个过程没有过渡效果,用户体验感较差,这个时候就可以使用Scroller来实现有过渡效果的滑动,其过程不是瞬间完成的,而是在一定时间间隔内完成的。


3、View的滑动


Android手机由于屏幕较小,为了给用户呈现更多的内容,就需要使用滑动来显示和隐藏一些内容,不管滑动效果多么绚丽,它们都是由不同的滑动外加特效实现的。View的滑动可以通过三种方式实现:



  • scrollTo/scrollBy:操作简单,适合对View内容的滑动。

  • 修改布局参数:操作稍微复杂,适合有交互的View。

  • 动画:操作简单,适合没有交互的View和实现复杂的动画效果。


3.1、scrollTo/scrollBy


为了实现View的滑动,View提供了专门的方法来实现这一功能,也就是scrollTo/scrollBy。是基于所传参数的绝对滑动。


3.2、修改布局参数


即改变LayoutParams,比如想把一个布局向右平移100px,只需要将该布局LayoutParams中的marginLeft参数值增加100px即可。或者在该布局左边放入一个默认宽度为0px的空View,当需要向右平移时,重新设置空View的宽度就OK了。


3.3、动画


动画和Scroller一样具有过渡效果,View动画是对View的影像做操作,并不能真正改变View的位置,单击新位置无法触发onClick事件,在这篇文章中并没有使用到,所以不再赘叙了。


4、布局文件


<?xml version="1.0" encoding="utf-8"?>
### <LinearLayout 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"
xmlns:widget="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<com.example.myapplication.view.ScrollerLinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">

<RelativeLayout
android:id="@+id/friend_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">

<com.makeramen.roundedimageview.RoundedImageView
android:id="@+id/friend_icon"
android:layout_width="45dp"
android:layout_height="45dp"
android:src="@mipmap/touxiang"
app:riv_corner_radius="5dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginLeft="12dp"
android:gravity="center_vertical"
android:orientation="vertical">

<TextView
android:id="@+id/friend_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textColor="@color/black"
android:textSize="15dp"
tools:text="好友名" />

<TextView
android:id="@+id/friend_last_mess"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginEnd="18dp"
android:singleLine="true"
android:textColor="@color/color_dbdbdb"
android:textSize="12dp"
tools:text="最后一条信息内容" />
</LinearLayout>

</LinearLayout>

<TextView
android:id="@+id/last_mess_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="5dp"
android:singleLine="true"
android:textColor="@color/color_dbdbdb"
android:textSize="11dp"
tools:text="时间" />
</RelativeLayout>

<LinearLayout
android:layout_width="240dp"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/unread_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_theme"
android:gravity="center"
android:text="标为未读"
android:textColor="@color/color_FFFFFF" />

<Button
android:id="@+id/top_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_orange"
android:gravity="center"
android:text="置顶"
android:textColor="@color/color_FFFFFF" />

<Button
android:id="@+id/delete_item"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:clickable="true"
android:background="@color/color_red"
android:gravity="center"
android:text="删除"
android:textColor="@color/color_FFFFFF" />
</LinearLayout>

</com.example.myapplication.view.ScrollerLinearLayout>

<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_alignParentBottom="true"
android:layout_marginLeft="60dp"
android:layout_marginRight="3dp"
android:background="@color/color_e7e7e7" />

</LinearLayout>

ScrollerLinearLayout布局最多包含两个子布局(默认是这样,后面可能还会修改成自定义),一个是展示在用户面前充满屏幕宽度的布局,一个是待展开的布局,在该xml布局中,ScrollerLinearLayout布局包含了一个RelativeLayout和一个LinearLayoutLinearLayout中包含了三个按钮,分别是删除、置顶、标为未读。


5、自定义View-ScrollerLinearLayout


/**
* @Copyright : China Telecom Quantum Technology Co.,Ltd
* @ProjectName : My Application
* @Package : com.example.myapplication.view
* @ClassName : ScrollerLinearLayout
* @Description : 文件描述
* @Author : yulu
* @CreateDate : 2023/8/17 17:05
* @UpdateUser : yulu
* @UpdateDate : 2023/8/17 17:05
* @UpdateRemark : 更新说明
*/

class ScrollerLinearLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) :
LinearLayout(context, attrs, defStyleAttr) {

private val mScroller = Scroller(context) // 用于实现View的弹性滑动
private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var mVelocityTracker: VelocityTracker? = null // 速度追踪
private var intercept = false // 拦截状态 初始值为不拦截
private var lastX: Float = 0f
private var lastY: Float = 0f // 用来记录手指按下的初始坐标
var expandWidth = 720 // View待展开的布局宽度 需要手动设置 3*dp
private var expandState = false // View的展开状态
private val displayWidth =
context.applicationContext.resources.displayMetrics.widthPixels // 屏幕宽度
private var state = true


override fun onTouchEvent(event: MotionEvent): Boolean {
Log.e(TAG, "onTouchEvent $event")
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (!expandState) {
state = false
}
}
else -> {
state = true
}
}
return state
}


override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.e(TAG, "onInterceptTouchEvent Result : ${onInterceptTouchEvent(ev)}")
Log.e(TAG, "dispatchTouchEvent : $ev")
mVelocityTracker = VelocityTracker.obtain()
mVelocityTracker!!.addMovement(ev)
return super.dispatchTouchEvent(ev)
}

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
Log.e(TAG, "onInterceptTouchEvent $ev")
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.rawX
lastY = ev.rawY
// 处于展开状态且点击的位置不在扩展布局中 拦截点击事件
intercept = expandState && ev.x < (displayWidth - expandWidth)
}
MotionEvent.ACTION_MOVE -> {
// 当滑动的距离超过10 拦截点击事件
intercept = lastX - ev.x > 10
moveWithFinger(ev)
}
MotionEvent.ACTION_UP -> {
// 判断滑动距离是否超过布局的1/2
chargeToRightPlace(ev)
intercept = false
}
MotionEvent.ACTION_CANCEL -> {
chargeToRightPlace(ev)
intercept = false
}
else -> intercept = false
}
return intercept
}

/**
* 将布局修正到正确的位置
*/

private fun chargeToRightPlace(ev: MotionEvent) {
val eventX = ev.x - lastX

Log.e(TAG, "该事件滑动的水平距离 $eventX")
if (eventX < -(expandWidth / 4)) {
smoothScrollTo(expandWidth, 0)
expandState = true
invalidate()
} else {
expandState = false
smoothScrollTo(0, 0)
invalidate()
}

// 回收内存
mVelocityTracker?.apply {
clear()
recycle()
}
//清除状态
lastX = 0f
invalidate()
}

/**
* 跟随手指移动
*/

private fun moveWithFinger(event: MotionEvent) {
//获得手指在水平方向上的坐标变化
// 需要滑动的像素
val mX = lastX - event.x
if (mX > 0 && mX < expandWidth) {
scrollTo(mX.toInt(), 0)
}
// 获取当前水平方向的滑动速度
mVelocityTracker!!.computeCurrentVelocity(500)
val xVelocity = mVelocityTracker!!.xVelocity.toInt()
invalidate()

}

/**
* 缓慢滚动到指定位置
*/

private fun smoothScrollTo(destX: Int, destY: Int) {
val delta = destX - scrollX
// 在多少ms内滑向destX
mScroller.startScroll(scrollX, 0, delta, 0, 600)
invalidate()
translationY = 0f
}

// 流畅地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.currX, mScroller.currY);
postInvalidate()
}
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
expandWidth = childViewWidth()
invalidate()
super.onLayout(changed, l, t, r, b)
}

/**
* 最多只允许有两个子布局
*/

private fun childViewWidth(): Int {
Log.e(TAG, "childCount ${this.childCount}")
return if (this.childCount > 1) {
val expandChild = this.getChildAt(1) as LinearLayout
if (expandChild.measuredWidth != 0){
expandWidth = expandChild.measuredWidth
}
Log.e(TAG, "expandWidth $expandWidth")
expandWidth
} else
0
}

companion object {
const val TAG = "ScrollerLinearLayout_YOLO"
}
}

思路比较简单,就是在ACTION_DOWN时记录初始的横坐标,在ACTION_MOVE中判断是否需要拦截该事件,
当滑动的距离超过10,拦截该点击事件,防止不必要的点击。并且View跟随手指移动。在ACTION_UPACTION_CANCEL中将布局修正到正确的位置,主要是根据滑动的距离来判断是否要展开并记录展开的状态。在ACTION_DOWN中判断是否处于展开状态,如果在展开状态且点击的位置不在扩展布局中,拦截点击事件,防止不必要的点击。


6、问题


自定义布局中的expandWidth参数在childViewWidth()方法和onLayout()方法中都赋值了一次,在onLayout()方法中查看日志expandWidth是有值的,可是在moveWithFinger()方法中打日志查看得到的expandWidth参数值仍然是0,导致无法正常滑动。去到其他的页面再返回到消息界面就可以正常滑动了,再次查看日志参数也有值了,这个问题不知道如何解决,所以需要手动设置expandWidth的值。


7、小结


初步的和自定义View认识了,小试牛刀,自己还是很满意这个学习成果的。希望在接下来的学习中不要因为没有接触过而放弃学习,勇于迈出第一步。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


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

数据抓取:抓取手机设备各种数据

目录 前言 一、DataCapture 1.通讯录集合数据 2.应用列表集合数据 3.日历事件信息数据 4.电量信息数据 5.sms短信信息数据 6.照片集合信息数据 7.传感器信息数据 8.wifi信息数据...等等数据 二、使用步骤 1.引入库 2....
继续阅读 »

目录


前言


一、DataCapture



  • 1.通讯录集合数据

  • 2.应用列表集合数据

  • 3.日历事件信息数据

  • 4.电量信息数据

  • 5.sms短信信息数据

  • 6.照片集合信息数据

  • 7.传感器信息数据

  • 8.wifi信息数据...等等数据


二、使用步骤



  • 1.引入库

  • 2.获取数据方法,目前因数据量庞大,暂推荐手动在子线程调用

  • 3.关于权限,待更新

  • 总结




前言


基于最近刚完结的外包项目功能——数据抓取,通过调用api和内容提供器来获取手机设备各种数据,诸如SMS短信数据、电量数据、手机应用数据等等,我尝试开发了一个开源库,希望能够帮助到大家来实现这个功能。


习惯性上图展示:


在这里插入图片描述


一、DataCapture


对手机设备的信息数据抓取,目前支持在子线程抓取数据,因为有些数据量过于庞大会阻塞线程,可抓取数据有:


1.通讯录集合数据


字段名详情
contact_display_name联系人名称
last_time_contacted上次通讯时间(毫秒)
number联系人手机号
times_contacted联系次数
up_time编辑时间(毫秒))
type通话类型

2.应用列表集合数据


字段名详情
app_nameAPP名称
app_type是否系统app 0:非系统app 1:系统app
app_versionAPP版本
in_time安装时间(毫秒)
obtain_time数据抓取时间(秒))
package_name包名
up_time更新时间 (毫秒)
version_code版本号

3.日历事件信息数据


字段名详情
description事件描述
end_time事件结束时间(毫秒)
event_id事件ID
event_title事件标题
start_time事件开始时间(毫秒))
reminders提醒列表

4.电量信息数据


字段名详情
battery_level电池电量
battery_max电池容量
battery_pct电池百分比
battery_state电池状态 充电0 不充电1
is_ac_charge是否交流充电(1:yes,0:no)
is_charging是否正在充电
is_usb_charge是否USB充电(1:yes,0:no)

5.sms短信信息数据


字段名详情
content短信消息体
other_phone收件⼈/发件⼈⼿机号
package_name包名
read短信状态 0-未读,1-已读
seen短信是否被用户看到 0-尚未查看,1-已查看
status短信状态:-1表示接收,0-complete,64-pending,128-failed
subject短信主题
time收到短信的时间戳(毫秒),long型
type短信类型:1-接收短信,2-已发出短信

6.照片集合信息数据


字段名详情
addTime添加数据库时间(保存)
author照片作者
createTime照片读取时间(毫秒数时间戳),即当前时间
date拍照时间(毫秒数时间戳)
flash闪光灯
focal_length镜头的实际焦距
gps_altitude海拔高度
gps_processing_method定位的方法名称
height照片高度
latitude照片拍摄时的经度
lens_make镜头制造商
lens_model镜头的序列号
longitude照片拍摄时的纬度
model拍照机型
name照片名称
orientation照片方向
save_time照片修改时间
software生成图像的相机或图像输入设备的软件或固件的名称和版本
take_time创建时间(毫秒数时间戳)
updateTime编辑时间
width照片宽度
x_resolutionX方向上每个分辨率的像素数
y_resolutionY方向上每个分辨率的像素数

7.传感器信息数据


字段名详情
id传感器id,0不支持功能,-1即其类型和名称的组合在系统中唯一标识。-2获取不到
maxRange传感器单元中传感器的最大量程
minDelay两个事件之间允许的最小延迟(以微秒为单位),如果此传感器仅在其测量的数据发生变化时返回值,则为零
name传感器名称
power使用时功率
resolution传感器单元中传感器的分辨率
type该传感器的通用类型
name传感器名称
vendor厂商字符串
version版本

8.wifi信息数据...等等数据


二、使用步骤


1.引入库


在seetings.gradle中引入


repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}

在build.gradle中引入


 implementation 'com.github.Android5730:DataCapture:v0.23'

2.获取数据方法,目前因数据量庞大,暂推荐手动在子线程调用


// 获取通讯录
List<AddressBookBean> addressBookBean = AddressBookUtil.getAddressBookBean(getBaseContext());
// 获取应用列表
List<AppListBean> appListBean = AppListUtil.getAppListBean(this);
// 获取日历事件
List<CalendarListBean> calendarListBean = CalendarListUtil.getCalendarListBean(this);
// 获取电量信息
BatteryStatusBean batteryState = BatteryStatusUtil.getBatteryState(this);
// 获取wifi信息
NetworkBean networkBean = NetworkBeanUtils.getNetworkBean(this);
// 获取sms短信信息
List<SmsBean> smsList = SmsUtil.getSmsList(this);
// 获取照片集合信息
List<PhotoInfosBean> photoInfosBean = PhotoInfosUtil.getPhotoInfosBean(this, LocationUtils.getInstance(this).showLocation());
// 获取传感器集合信息
List<SensorListBean> sensorListBean = SensorListUtil.getSensorListBean(this);


3.关于权限,待更新


注意:因为获取图片时需要外部存储的权限,我这里采取的取消分区存储的做法,所以大家不要忘记在application里添加android:requestLegacyExternalStorage="true"
如果有哪个权限碍眼,或者项目强制不需要,也可以进行删除,如去除读取外部存储的权限:


    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove"/>


    <!-- 定位权限,需动态请求 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 通讯录,需动态请求 -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- 日历信息,需动态请求 -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<!-- wifi信息,不用动态请求 -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- SMS信息,需动态请求 -->
<uses-permission android:name="android.permission.READ_SMS" />
<!-- photo信息,需动态请求-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- 取消分区存储-->
<meta-data
android:name="ScopedStorage"
android:value="true" />


最后附上开源库地址:数据抓取:https://github.com/Android5730/DataCapture
如果有帮助到各位,可以给个star,给我一点信心去完善这个开源库


总结


当然目前该库目前抓取的数据还不到外包项目抓取数据的一半,只是因为最近有点忙,没时间完善所以才匆匆忙忙推出,相信等开学后就有时间完善,现在实习太累了。如果大家有疑问,可以在评论区提出,也可以在issue提出来,如果

作者:人间正四月
来源:juejin.cn/post/7271659608011358264
受到大家欢迎,我会持续完善此库。

收起阅读 »

Android简单的两级评论功能实现

Android简单的两级评论功能实现 前言 在App开发过程中,做了文章页面,那评论的功能自然是必不可少的,怎么做呢,如果只是做一个简单的评论不带回复功能的话,那和之前做毕设时候的我也看不到进步呀,这怎么行呢?于是我打开‘稀土掘金’App,随便看了几个文章,试...
继续阅读 »

Android简单的两级评论功能实现


前言


在App开发过程中,做了文章页面,那评论的功能自然是必不可少的,怎么做呢,如果只是做一个简单的评论不带回复功能的话,那和之前做毕设时候的我也看不到进步呀,这怎么行呢?于是我打开‘稀土掘金’App,随便看了几个文章,试了试评论的功能,于是便开始了我的构思。我想要实现的效果如下图所示,如何实现这样一个页面呢?我使用的方法是RecyclerView中再嵌套一个RecyclerView,一个用来展示一级评论,另一个则用来展示相应的二级评论,思路有了,下面就开始我的实现。


1693188380832.png

一、数据库


1、构建数据库


要想做好一个功能,数据库的构建是重中之重。下图是我构造的评论实体类


image.png

评论表中包含如下字段:



  • id --评论主键(自动生成)

  • newsId -- 主键

  • number -- 评论的用户主键

  • content -- 评论内容

  • time -- 评论时间

  • level -- 评论级别(有两级,当评论的对象是文章作者时,level为1,当评论对象为文章内的评论时,level为2,默认level为1)

  • replyNumber -- 评论回复的用户主键

  • replyId -- 评论回复的评论主键(只有level为2的评论才会用到该字段,所以默认为空)



replyNumber其实这里不应该默认为空的,因为无论是那种类型的回复,都是有对应的用户的,这个疏忽也造成了我在后面构建“我的评论”界面时,无法展示出文章作者的详细信息。



2、封装数据库


数据访问层Dao主要封装了对数据库的访问:


image.png

很平常的SQL语句,只简单说明下:分别是添加评论、根据id删除评论、获取该文章的所有评论、获取该用户的所有评论、通过id获取该评论



(省略了CommentTask接口即实现)


最后仓库层将这些方法都封装起来,方便后续调用,如下图所示:
image.png


二、布局


1、文章详情界面的评论布局


1693191191816.png



就是个RecyclerView哈哈



2、评论的适配器布局


1693191324123.png



可以看到适配器布局中还包含了一个RecyclerView,这里面展示的就是二级评论



3、二级评论的适配器布局


image.png



这个布局很简单,就由几个TextView组件构成



三、代码逻辑


首先,在ViewModel层初始化该文章的所有评论,观察评论数据变化,给评论适配器数据赋值并刷新,在评论适配器中再对level为2的评论数据进行过滤并赋值给回复适配器。


1、获取评论数据


var comments = MutableLiveData<List<CommentInfo>>()
comments.value = commentStoreRepository.getCommentsByNewId(newsId)

通过文章的id获取到评论


2、给评论适配器数据赋值


image.png


3、在评论适配器处理数据



首先,评论适配器中的数据是通过文章的id获取到的所有评论,包含了一级和二级评论,在评论适配器展示的当然不能是所有的评论,而是所有一级的评论,而二级评论的数据需要再进行过滤传递给回复适配器



所以,在绑定ViewHolder以及getItemCount时,需要对传递的数据进行过滤,


image.png
如图所示,allList是通过文章的id获取到的所有评论,list是level为1的所有评论,replyList是level为2的所有评论。getItemCount返回的是一级评论的个数。在绑定ViewHolder时,将一些回调函数和一级评论和二级评论列表传递进去,接着就看ViewHolder中的数据处理逻辑,如下两张图


image.png



这张图只是一些简单的一级数据的赋值和一些回调参数的调用传参



image.png



这里首先对二级评论进行过滤,过滤出与该条一级评论相关联的二级评论,接着对布局进行一些操作,接着是赋值操作和回复适配器中一些函数的实现。



4、在回复适配器处理数据


image.png



在这里就不需要对数据进行处理了,只有简单的赋值和回调了



5、回调函数的实现


image.png


四、实现效果


1、评论功能


7edbc47b85fd05b9c25841161eb4ba8.jpg

2、我的评论展示


dd6d2787fbf6ebf4a397367fc91fd4e.jpg

这里的“@3333333333”就是因为replyNumber为空的导致无法展示出文章作者的详细信息,只有展示用户主键了,后面再进行修改。



五、结语


就这样,一个简单的二级评论功能就完成了。文章若出现错误,欢迎各位批评指正,写文不易,转载<

作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7271991667246694437
/strong>请注明出处谢谢。

收起阅读 »

一文理解贝塞尔曲线

贝塞尔曲线的来源 贝塞尔曲线最早是由贝塞尔在1962年提出来的,目的是获取汽车的外形。贝塞尔曲线看上去非常复杂,其实想法非常简单,(如下图1所示)就是先用折线先绘制出大致的轮廓,然后用曲线来逼近。 图1 这个方式是不是有点熟悉,刚看到的时候,我就想到了计算圆...
继续阅读 »

贝塞尔曲线的来源


贝塞尔曲线最早是由贝塞尔在1962年提出来的,目的是获取汽车的外形。贝塞尔曲线看上去非常复杂,其实想法非常简单,(如下图1所示)就是先用折线先绘制出大致的轮廓,然后用曲线来逼近。




图1


这个方式是不是有点熟悉,刚看到的时候,我就想到了计算圆的面积时,我们会使用多边形来逼近圆的曲线(如图2所示);贝塞尔曲线刚好相反,它是使用曲线来逼近多边形,刚好反着来了😂。




图2


构造贝塞尔曲线


思路虽然简单,但是如何把这个曲线画出来,或者说如何用一个函数来表示这条曲线就很困难了。不过这个不需要我们关心,有大佬已经解决了。我们直接来看看贝塞尔曲线的定义式,如下图3:




图3


先别急着划走,这个公式不用记,因为它太复杂而且计算量大,因此在工程开发中我们不会用它。一般在工程中,我们使用德卡斯特里奥算法(de Casteljau) 来构造贝塞尔曲线。听起来更复杂了,别急让我们举个🌰。下面以2次贝塞尔曲线为例。




图4




图5


看图4,德卡斯特里奥算法(de Casteljau) 的意义就是满足P0Q0P0P1=P1Q1P1P2=Q0BQ0Q1=t  \frac{P_0Q_0}{P_0P_1} = \frac{P_1Q_1}{P_1P_2} = \frac{Q_0B}{Q_0Q_1} = t 的情况下,随着 t 从 0 到 1 逐渐变大,B点经过的点组成的曲线就是我们需要的贝塞尔曲线了。图5是我们常见的动图,之前看的时候一直很懵逼,现在了解了贝塞尔曲线是如何画出来的,是不是清楚多了。


更高阶的贝塞尔曲线绘制方式和上面的一样,只是多了几条边,绘制的动图如下:




3次贝塞尔曲线




4次贝塞尔曲线




5次贝塞尔曲线


贝塞尔曲线的函数表示


看到这里,我们已经对贝塞尔曲线有了一个大概的了解。但是还是一个关键的问题,我们怎么画出贝塞尔曲线呢?或者说有什么函数可以让我们画出这个曲线吗?这个其实更简单,我们高中就学过了。还是以二次贝塞尔曲线为例,它的参数方程如下,其中 P0、P1、P2代表控制点。




我们假设三个控制点的坐标是 P0 (-1, 0)、 P1 (0, 1) 、P2 (1, 0),把值带入上面的参数方程,就可以得到如下结果:


(xy)=(1t)2(10)+2t(1t)(01)+t2(10)\left(\begin{array}{c}x\\ y\end{array}\right) = (1 - t)^{2} \left(\begin{array}{c}-1\\ 0\end{array}\right)
+ 2t(1 - t) \left(\begin{array}{c}0\\ 1\end{array}\right) + t^{2} \left(\begin{array}{c}1\\ 0\end{array}\right)

(xy)=((12t)2+t22t(1t))\left(\begin{array}{c}x\\ y\end{array}\right) = \left(\begin{array}{c}-(1 - 2t)^{2} + t ^ 2\\ 2t(1 - t)\end{array}\right)

{x=2t1y=2t2+2t\begin{cases} x = 2t - 1 \\ y = -2t^2 + 2t\end{cases}

最后化解可得到我们熟悉的 y = f(x) 函数y=12x2+12:y = -\frac{1}{2}x^2 + \frac{1}{2} 效果图如下图。可以看出二次贝塞尔曲线实际上就是我们高中学的抛物线。唯一不同的是,我们高中求的抛物线,会经过 P0、P1、P2三个点,而贝塞尔曲线只会经过 P0、P1两个端点。




类似的:


一次贝塞尔曲线就是一次函数y=a0x+a1:y = a_0x + a_1


三次贝塞尔曲线就是三次函数:y=a0x3+a1x2+a2x+a3y = a_0x^3 + a_1x^2 + a_2x + a_3


四次贝塞尔曲线就是四次函数:y=a0x4+a1x3+a2x2+a3x+a4y = a_0x^4 + a_1x^3 + a_2x^2 + a_3x + a_4


n次贝塞尔曲线就是n次函数:y=a0xn+a1xn1+...+an y = a_0x^n + a_1x^{n-1} + ... + a_{n}


总结


贝塞尔曲线实际上并不复杂,我们可以简单的把n次贝塞尔曲线看成对应的n次函数的曲线。因为贝塞尔曲线的这个特点,也造成了贝塞尔曲线的最大缺陷————不能局部修改,即改变其中一个参数时会改变整条曲线。后面为了解决贝塞尔曲线的这个问题,提出了B样条曲线,下篇文章我们就介绍B样条曲线。


最后这篇文章为了方便读者的理解,省略了很多贝塞尔曲线特性的介绍,如果对贝塞尔曲线感兴趣,可以在B站上看看它的完整课程。


作者:小墙程序员
链接:https://juejin.cn/post/7267417719799857213
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

客户端开发的我,准备认真学前端了

⏰ : 全文字数:2200+ 🥅 : 内容关键字:前端,独立开发者,思考 背景 我呢,一个Android开发工程师,从毕业到现在主要做的是客户端开发,目前在一个手机厂商任职。自己目前知识技能主要在客户端上,其他方面会一点点,会一点点前端知识,会一点点后端知识...
继续阅读 »

⏰ : 全文字数:2200+

🥅 : 内容关键字:前端,独立开发者,思考



背景


我呢,一个Android开发工程师,从毕业到现在主要做的是客户端开发,目前在一个手机厂商任职。自己目前知识技能主要在客户端上,其他方面会一点点,会一点点前端知识,会一点点后端知识,会一点点脚本,用网络的一句话概括起来就是“有点东西,但是不多”😭。


为什么


决定学习前端,并不是心血来潮,一时自嗨,而是经过了比较长时间的思考。对于程序员来说,知识的更新迭代实在是很快,所以保持学习很重要。但是技术防线这么多,到底学什么?我相信这不是一个很容易做出抉择的问题。


对于前端之前有断断续续的学过一些,但是最后没有一直坚持下来。之所以这样,原因很多,比如没有很强的目标、没有足够的时间,前端涉及的知识点太多等。


但是我觉得对自己而言,最重要的一个原因是:**学习完前端,我能用它来干嘛?**如果没有想清楚这个原因,就很难找到目标。做事情没有目标,就无法拆解,也就无法长期坚持下去。直到最近,看了一些文章,碰到了一些事情,才慢慢想清楚这个问题。目前对我而言,开始决定认真学习前端的主要原因有两个:



  • 自己一直想做点什么

  • 工作上有需要


想做点什么


从我接触计算机开始,心底里一直有个梦,就是想利用自己手上技能,做点什么。我也和旁边的朋友同事交流过,大家都有类似的想法,从这看估计很多程序员朋友都会有这样的想法。我从一开始的捣鼓网站,论坛,到后来开发APP等,折腾了好多东西。但是到了最后,都没有折腾出点啥,都无疾而终。


前一段时间,看到一个博主写的一篇文章,文章大概是讲他如何从一个公司的后端开发工程师,走到今天成为一名独立开发者的故事。


其中有一段是说他一直心里念念不忘,想做一款 saas 应用,期间一直在学习和看其他人的产品,学习经验,尝试不同的想法。所谓念念不忘必有回响,终于从别人的产品中产生了一个点子,然后很快写好了后端服务,并自学前端边做边学,完成了这个产品。目前他的这个产品运作的很成功。


这个故事给我很大鼓舞,之前看到过很多这样的故事,有成功的,有失败的。我也去分析看了那些成功的,经过自己的观察,大部分成功的独立开发者,基本上都是多年前成功的那批,那段时间还是处于互联网的红利期,天时地利人和加在一起,造就了他们的成功。


当然这里并不是否认他们能力,觉得是他们运气好。能在当时那么多人中,脱颖而出,依然表明他们是佼佼者。这里只是想表达那个时间段,大环境对开发者来说,是比较友好的,阻力没有那么大。


很少看到最近两年成功的开发者(不排除自己不知道哈),但是从这位博主的经历来看,他确实在成功了,这给了我很大的鼓舞,说明这条路上还是有机会的,只是在现在这种大环境下,成功的难度在增加,阻力变大。如果我们自己始终坚持,寻找机会,不断地尝试,是否有一天可能会成功呢?


那这样的话,我主要关注哪个方向呢?我个人更加偏向于前端全栈方向,包括WebApp,小程序,P C 软件等。


为什么这么认为呢?看下现在的大环境,不提之前上架APP需要各种软件著作权,后来个人无法在各大商店上发布APP,再到现在新出的APP备案制,基本上个人想在Android App上发力,真的很难了。而且,经过自己在ProductHunt上观察,目前大部分独立开发者的作品都是聚焦于WebAppSAAS,或者是PC类软件,剩下就是IOSMAC平台的。


且学习前端技术栈是一个比较好的选择。JavaScript这门语言很强大,整个技术栈不仅可以做前端,也可以做后端开发,还可以做跨平台的 P C 软件开发, 提供的丰富的解决方案,对个人开发者来说极为合适。


当然,我们也可以找合适的人,一起组队合作,不用单打独斗,这样不仅节省期间和精力,也能有好的交流和碰撞。这条路我也经历过,但是说实话执行起来确实有一定的困难。首先就是人难找,要想找到一个三观差不多的伙伴,其实真的挺难的。还有一个就是个人时间和做事方式也很难契合。所以个人认为如果想做点什么,前期一个人自己去实现一个MVP出来,是一个合适的选择。后面如果有必要了,倒是可以考虑慢慢招人。


我们也要认识到技术只是最基础的第一步,要想做成一个产品,还有很多东西要学习。推广、运营,沟通交流无论哪个都是一道坎。但是作为登山者的我们不要关注前面路有多远,而是要确保自己一直在路上。


工作涉及


还有一个原因是,最近工作上和前端打交道有很多。因为项目内部接入了类似 React Native 的框架,有大量的业务场景是基于这个框架开发。这就导致了客户端涉及到大量和前端的交互,流程的优化,工程化等工作。客户端可以不用了解前端和框架的知识,也没什么问题。
但是想着如果后续这一块有什么问题,或者想对这块做一些性能优化、工程提效的事情,如果对前端知识没有一个很好的了解,估计也很难做出彩。


结尾


今天在这里絮絮叨叨这么多,并不是想要告诉大家选择前端技术栈学习就一定咋样,比如第一点说的独立开发者中,有很多的全栈开发者,他们有的已经失败了,有的还在路上,成功的毕竟还是少数。
我想分享的是我个人关于为什么选择前端技术栈作为学习方向,如何做出选择的一些思考。这都是我的一家之言,不一定正确,大家姑且一看。


同时自己心里也还是希望能像文章提到的那位博主一样,在做产品这条路上,也能“念念不忘,必有回响”。正如我一直相信秉持的“日拱一卒,功不唐捐”。

作者:七郎的小院
来源:juejin.cn/post/7271248528999481384

收起阅读 »

论如何在Android中还原设计稿中的阴影

每当设计稿上注明需要添加阴影时,Android上总是显得比较棘手,因为Android的阴影实现方式与Web和iOS有所区别。 一般来说阴影通常格式是有: X: 在X轴的偏移度 Y: 在Y轴偏移度 Blur: 阴影的模糊半径 Color: 阴影的颜色 何为阴影 ...
继续阅读 »

每当设计稿上注明需要添加阴影时,Android上总是显得比较棘手,因为Android的阴影实现方式与Web和iOS有所区别。


一般来说阴影通常格式是有:


X: 在X轴的偏移度


Y: 在Y轴偏移度


Blur: 阴影的模糊半径


Color: 阴影的颜色


何为阴影


但是在Android中却比较单一,只有一个度量单位:Elevation,作为在Android5.0的material2引入的概念,用一个图来形象的描绘一下,其实本质上就是虚拟的Z轴坐标。


image.png


那好,高度差有了,还差个光源,这样才能形成阴影,在material2中,光源不是单一的位于屏幕正上方的,而且有两组光源,分为主光源(Key light)和环境光源(Ambient light)如下图所示:
image.png


最终形成的效果是一种复合光源下更自然的阴影。


image.png


其中环境光源,在屏幕空间中没有实际的位置,但是主光源是有实际的位置的,具体的参数见:


frameworks/base/core/res/res/values/dimens.xml - Android Code Search
image.png


好,既然知道了阴影本身的机制,那下一步现在则是如何自定义控制阴影,这也是本文的目的。


从SDK 21开始,提供了Elevation可以实现类似于阴影的模糊半径的效果,但是毕竟尺度过于单一,往往有时候无法满足所需的效果,所以,还需要控制阴影的颜色。


在SDK 28之后,可以通过outlineSpotShadowColoroutlineAmbientShadowColor来分别设置Key light和Ambient light投射的阴影颜色,但是说实话,这两个属性基本用不到或者说比较鸡肋。


不过这里引入了一个概念:Outline。


四种常见方案


Elevation + Outline


Outline其实是View的边框(轮廓),通过OutlineProvider可以自定义一个View的Outline从而影响View本身在elevation下的投影,比如定义以实现一个圆角ImageView为例:


<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@color/material_dynamic_primary90" />


image.clipToOutline = true
image.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}

}

效果基本没啥问题:
image.png


同样的,既然View的轮廓变化了,阴影自然也会跟着随之变化,所以outline也可以改变阴影:


image.elevation = 32f
image.outlineAmbientShadowColor = Color.RED
image.outlineSpotShadowColor = Color.BLUE
image.clipToOutline = true
image.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}

}

效果如下:(不过outlineAmbientShadowColoroutlineSpotShadowColor仅支持SDK 28及以上)


image.png


通常,到这一步通过调整elevation的数值和outline以及高版本可用的shadowColor大体上可以满足设计师的阴影需求。
而且通常来说shadowColor都是Color.Black以及alpha的区别,所以你也可以这样:


outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
view ?: return
outline?.alpha = 0.5f
outline?.setRoundRect(0, 0, view.width, view.height, 32f)
}
}

但是,还记着前面提到的两个光源吗?其中有一个光源是位于屏幕斜上方的,这就带来了另外一个问题,同一个View设置相同的Elevation在不同的Y轴坐标它的阴影效果是不一样的,如下图所示:



总之,阴影的Blur和Color参数勉强是可以得到满足的。


优点:原生的阴影效果


缺点:设置阴影的颜色需要SDK>=28,需要配合使用outline来实现对阴影的轮廓控制


下面我们先来引申一下Android中了解过的阴影实现方式。


LayerDrawable


我相信大家肯定见过这种实现方式,通过绘制一层层渐变色来模拟阴影,其实官方也有通过该方式实现的阴影:MaterialShapeDrawable,示例如下:


val drawable = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setAllCornerSizes(16.dp)
.build()
)
drawable.fillColor = ColorStateList.valueOf(getColor(com.google.android.material.R.color.material_dynamic_primary90))
drawable.setShadowColor(Color.RED)
drawable.shadowVerticalOffset = 8.dp.toInt()
drawable.elevation = 32f
drawable.shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
image.background = drawable

效果如图:
image.png


只能说很一般,毕竟是模拟的阴影模糊效果,而且目前只支持Y轴的offset。


优点:几乎是开箱即用的Drawable且自带圆角


缺点:模拟的阴影效果,展示效果不够精细且效率不高


NinePatchDrawable


说实话想在Android上实现一个简单的阴影太折腾了,什么奇怪的技巧都来了,比如.9图,至于什么是.9图这里便不再过多介绍。
通过这个网站:Android Shadow Generator (inloop.github.io)


image.png
你可以直接生成一个CSS Style的阴影效果,几乎可以完美还原Figma的阴影效果,效果如下:
image.png


其实还是很还原的,但是它有一个致命的缺点,就是圆角,因为是一张图片,所以圆角的单位本质上是px而非Android上的dp,如果你需要一个带圆角弧度的阴影是达不到预期的。


优点:参数完全可控的阴影,可以做到1:1还原设计稿


缺点:因为是图片,所以阴影的圆角无法跟随像素密度缩放(非常致命的缺点)


Paint.setShadowLayer/BlurMaskFilter


这两个我之所以放在一起本质上是因为实现起来都是类似的,
如:


paint.setShadowLayer(radius, offsetX, offsetY, shadowColor)
// 或者使用maskFilter然后通过paint.color以及绘制的区域进行offset来变相控制阴影的Color及offset
paint.maskFilter = BlurMaskFilter(field, BlurMaskFilter.Blur.NORMAL)

相比之下更推荐使用setShadowLayer,最终效果如下,基本上没啥问题:
image.png


但是值得注意的是,其绘制的阴影本质上等价于BlurMaskFilter,是占位的,而且是需要留出空间来展示的,所以必要时需要对父布局设置android:clipChildren="false"或者预留出足够的空间。


优点:


1. 参数完全可控的阴影,可以做到1:1还原设计稿


2. 参数的自定义程度及可控性强


缺点:


1. 阴影占位,需要通过clipChildren=false来或者预留空间规避


2. 需要自定义View或者Drawable,写起来较为麻烦。


总的来说,上面介绍了4种可能常见的阴影实现方式,其中按我的经验来说,较为推荐采用Outline或者setShadowLayer的方式来实现,如果可以的话原生Elevation配合Outline基本可以满足大部分需求场景。


当然还有部分实现方式比如用RenderScriptBlur等等,我没提是因为是前几种方式较为复杂,性价比不高。


Paint.setShadowLayer 扩展内容


下面则重点讲一下Paint.setShadowLayer/BlurMaskFilter这种方式,为什么说这两种方式实现的阴影都是一致的呢?这个就需要深入到C++层。
首先直接跳到paint.setShadowLayer的native实现类:
frameworks/base/libs/hwui/jni/Paint.cpp


Paint.cpp - Android Code Search


    static void setShadowLayer(CRITICAL_JNI_PARAMS_COMMA jlong paintHandle, jfloat radius,
jfloat dx, jfloat dy, jlong colorSpaceHandle,
jlong colorLong)
{
SkColor4f color = GraphicsJNI::convertColorLong(colorLong);
sk_sp<SkColorSpace> cs = GraphicsJNI::getNativeColorSpace(colorSpaceHandle);

Paint* paint = reinterpret_cast<Paint*>(paintHandle);
if (radius <= 0) {
paint->setLooper(nullptr);
}
else {
SkScalar sigma = android::uirenderer::Blur::convertRadiusToSigma(radius);
paint->setLooper(BlurDrawLooper::Make(color, cs.get(), sigma, {dx, dy}));
}
}


里面将我们传入的阴影radius参数转为Sigma并创建了BlurDrawLooper,我们来看看其实现


#include "BlurDrawLooper.h"
#include <SkMaskFilter.h>

namespace android {

BlurDrawLooper::BlurDrawLooper(SkColor4f color, float blurSigma, SkPoint offset)
: mColor(color), mBlurSigma(blurSigma), mOffset(offset) {}

BlurDrawLooper::~BlurDrawLooper() = default;

SkPoint BlurDrawLooper::apply(Paint* paint) const {
paint->setColor(mColor);
if (mBlurSigma > 0) {
paint->setMaskFilter(SkMaskFilter::MakeBlur(kNormal_SkBlurStyle, mBlurSigma, true));
}
return mOffset;
}

sk_sp<BlurDrawLooper> BlurDrawLooper::Make(SkColor4f color, SkColorSpace* cs, float blurSigma,
SkPoint offset)
{
if (cs) {
SkPaint tmp;
tmp.setColor(color, cs); // converts color to sRGB
color = tmp.getColor4f();
}
return sk_sp<BlurDrawLooper>(new BlurDrawLooper(color, blurSigma, offset));
}

} // namespace android

内容不多,可以看到本质上还是利用了setMaskFilter来实现的。


然后还剩下一个点就是通过SkMaskFilter::MakeBlur生成的模糊是占位的,如果能知道模糊具体需要多大的空间,就可以方便的进行预留以免实际展示时阴影被裁剪。
MakeBlur最终返回的是一个SkBlurMaskFilterImpl对象,我们可以先看一下其父类SkMaskFilterBase的虚函数:重点关注computeFastBounds函数


SkMaskFilterBase.h - Android Code Search


    /**
* The fast bounds function is used to enable the paint to be culled early
* in the drawing pipeline. This function accepts the current bounds of the
* paint as its src param and the filter adjust those bounds using its
* current mask and returns the result using the dest param. Callers are
* allowed to provide the same struct for both src and dest so each
* implementation must accommodate that behavior.
*
* The default impl calls filterMask with the src mask having no image,
* but subclasses may override this if they can compute the rect faster.
*/

virtual void computeFastBounds(const SkRect& src, SkRect* dest) const;

可以看到该函数的作用便是计算MaskFiter的bounds,看一下子类的SkBlurMaskFilterImpl的实现


void SkBlurMaskFilterImpl::computeFastBounds(const SkRect& src,
SkRect* dst)
const
{
// TODO: if we're doing kInner blur, should we return a different outset?
// i.e. pad == 0 ?

SkScalar pad = 3.0f * fSigma;

dst->setLTRB(src.fLeft - pad, src.fTop - pad,
src.fRight + pad, src.fBottom + pad);
}

其中fSigme便是最开始通过convertRadiusToSigma(radius)获取到的返回值,其计算方式如下:
SkBlurMask.cpp - Android Code Search


// This constant approximates the scaling done in the software path's
// "high quality" mode, in SkBlurMask::Blur() (1 / sqrt(3)).
// IMHO, it actually should be 1: we blur "less" than we should do
// according to the CSS and canvas specs, simply because Safari does the same.
// Firefox used to do the same too, until 4.0 where they fixed it. So at some
// point we should probably get rid of these scaling constants and rebaseline
// all the blur tests.
static const SkScalar kBLUR_SIGMA_SCALE = 0.57735f;

SkScalar SkBlurMask::ConvertRadiusToSigma(SkScalar radius) {
return radius > 0 ? kBLUR_SIGMA_SCALE * radius + 0.5f : 0.0f;
}

这样,我们可以得到一个模糊的近似Bound,虽然不是一个准确的值但是至少可以保证绘制的阴影不会被裁剪。
当然,如果无法预留Padding也可以通过clipChildren=false来实现。


总结


最后我也是针对setShadowLayer提供了一个自定义View的实现方式:


Lowae/Shadows: A simple and customizable library on Android to implement CSS style shadows (github.com)


感兴趣的可以尝试使用,有任何兼容性问题欢迎提issue~



(我十分清楚会有很多兼容性问题,没办法,这种Api就是这样,不,准确来说,Android就是这样)



所以,想在Android上1:1还原设计稿上的阴影是比较困难的,但是如果不去追求参数的还原只是寻求视觉的略显一致,那还是可以做到的,简单点的通过第一种方式(Elevation + Outline),如果设置到阴影颜色或者offset这种便可以尝试最后一种方式(

作者:Lowae
来源:juejin.cn/post/7270503053358874664
setShadowLayer)。

收起阅读 »

Volatile 关键字

保证内存可见性 Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读...
继续阅读 »

保证内存可见性


Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。 



这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。


这样的情况我们通常称之为「可见性」,而我们加上 volatile 关键字修饰的变量就可以保证对所有线程的可见性。


这里的可见性是什么意思呢?当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。


为什么 volatile 关键字可以有这样的特性?这得益于 Java 语言的先行发生原则(happens-before)。简单地说,就是先执行的事件就应该先得到结果。


但是! volatile 并不能保证并发下的安全。


Java 里面的运算并非原子操作,比如 i++ 这样的代码,实际上,它包含了 3 个独立的操作:读取 i 的值,将值加 1,然后将计算结果返回给 i。这是一个「读取-修改-写入」的操作序列,并且其结果状态依赖于之前的状态,所以在多线程环境下存在问题。



要解决自增操作在多线程下线程不安全的问题,可以选择使用 Java 提供的原子类,如 AtomicInteger 或者使用 synchronized 同步方法。


原子性:在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。(变量之间的相互赋值不是原子操作,比如 y = x,实际上是先读取 x 的值,再把读取到的值赋值给 y 写入工作内存)



禁止指令重排


最开始看到「指令重排」这个词语的时候,我也是一脸懵逼。后面看了相关书籍才知道,处理器为了提高程序效率,可能对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。


指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,却会影响到多线程的执行结果。比如下面的代码:


使用场景


从上面的总结来看,我们非常容易得出 volatile 的使用场景:

  1. 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

比如下面的场景,就很适合使用 volatile 来控制并发,当 shutdown() 方法调用的时候,就能保证所有线程中执行的 work() 立即停下来。

volatile boolean shutdownRequest;
private void shutdown(){
shutdownRequest = true;
}
private void work(){
while (!shutdownRequest){
// do something
}
}

总结


说了这么多,其实对于 volatile 我们只需要知道,它主要特性:保证可见性、禁止指令重排、解决 long 和 double 的 8 字节赋值问题。


还有一个比较重要的是:它并不能保证并发安全,不要和 synchronize 混淆。


作者:PuddingSama
链接:https://juejin.cn/post/7234466978911993916
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

揭秘:Android屏幕中你不知道的刷新机制

前言 之前在整理知识的时候,看到android屏幕刷新机制这一块,以前一直只是知道,Android每16.6ms会去刷新一次屏幕,也就是我们常说的60fpx,那么问题也来了: 16.6ms刷新一次是什么一次,是以这个固定的频率去重新绘制吗?但是请求绘制的代码时...
继续阅读 »

前言


之前在整理知识的时候,看到android屏幕刷新机制这一块,以前一直只是知道,Android每16.6ms会去刷新一次屏幕,也就是我们常说的60fpx,那么问题也来了:


16.6ms刷新一次是什么一次,是以这个固定的频率去重新绘制吗?但是请求绘制的代码时机调用是不同的,如果操作是在16.6ms快结束的时候去绘制的,那么岂不是就是时间少于16.6ms,也会产生丢帧的问题?再者熟悉绘制的朋友都知道请求绘制是一个Message对象,那这个Message是会放进主线程Looper的队列中吗,那怎么能保证在16.6ms之内会执行到这个Message呢?


View ## invalidate()


既然是绘制,那么就从这个方法看起吧


public void invalidate() {
invalidate(true);
}
public void invalidate(boolean invalidateCache) {
invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}
void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
boolean fullInvalidate
) {
......
final AttachInfo ai = mAttachInfo;
final ViewParent p = mParent;
if (p != null && ai != null && l < r && t < b) {
final Rect damage = ai.mTmpInvalRect;
damage.set(l, t, r, b);
p.invalidateChild(this, damage);
}
.....
}
}

主要关注这个p,最终调用的是它的invalidateChild()方法,那么这个p到底是个啥,ViewParent是一个接口,那很明显p是一个实现类,答案是ViewRootImpl,我们知道View树的根节点是DecorView,那DecorView的Parent是不是ViewRootImpl呢


熟悉Activity启动流程的朋友都知道,Activity 的启动是在 ActivityThread 里完成的,handleLaunchActivity() 会依次间接的执行到 Activity 的 onCreate(), onStart(), onResume()。在执行完这些后 ActivityThread 会调用 WindowManager#addView(),而这个 addView()最终其实是调用了 WindowManagerGlobal 的 addView() 方法,我们就从这里开始看,因为是隐藏类,所以这里借助Source Insight查看WindowManagerGlobal


public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow
) {
synchronized (mLock) {
.....
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
}
try {
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
synchronized (mLock) {
final int index = findViewLocked(view, false);
if (index >= 0) {
removeViewLocked(index, true);
}
}
throw e;
}
}
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
synchronized (this) {
....
view.assignParent(this);
...
}
}
void assignParent(ViewParent parent) {
if (mParent == null) {
mParent = parent;
} else if (parent == null) {
mParent = null;
}
}

参数是ViewParent,所以在这里就直接将DecorView和ViewRootImpl给绑定起来了,所以也验证了上述的结论,子 View 里执行 invalidate() 之类的操作,最后都会走到 ViewRootImpl 里来


ViewRootImpl##scheduleTraversals


根据上面的链路最终是会执行到scheduleTraversals方法


void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
复制代码方法不长,首先如果mTraversalScheduled为false,进入判断,同时将此标志位置位true,第二句暂时不管,后续会讲到,主要看postCallback方法,传递进去了一个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");
}
performTraversals();
if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

doTraversal方法里面,又将mTraversalScheduled置位了false,对应上面的scheduleTraversals方法,可以看到一个是postSyncBarrier(),而在这里又是removeSyncBarrier(),这里其实涉及到一个很有意思的东西,叫同步屏障,等会会拉出来单独讲解,然后调用了performTraversals(),这个方法应该都知道了,View 的测量、布局、绘制都是在这个方法里面发起的,代码逻辑太多了,就不贴出来了,暂时只需要知道这个方法是发起测量的开始。


这里我们暂时总结一下,当子View调用invalidate的时候,最终是调用到ViewRootImpl的performTraversals()方法的,performTraversals()方法又是在doTraversal里面调用的,doTraversal又是封装在mTraversalRunnable之中的,那么这个Runnable的执行时机又在哪呢


Choreographer##postCallback


回到上面的scheduleTraversals方法中,mTraversalRunnable是传递进了Choreographer的postCallback方法之中


private void postCallbackDelayedInternal(int callbackType,
Object action, Object token, long delayMillis
) {
if (DEBUG_FRAMES) {
synchronized (mLock) {
final long now = SystemClock.uptimeMillis();
final long dueTime = now + delayMillis;
mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

if (dueTime <= now) {
scheduleFrameLocked(now);
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
msg.arg1 = callbackType;
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, dueTime);
}
}
}

可以看到内部好像有一个类似MessageQueue的东西,将Runnable通过delay时间给存储起来的,因为我们这里传递进来的delay是0,所以执行scheduleFrameLocked(now)方法


private void scheduleFrameLocked(long now) {
if (!mFrameScheduled) {
mFrameScheduled = true;
if (isRunningOnLooperThreadLocked()) {
scheduleVsyncLocked();
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
msg.setAsynchronous(true);
mHandler.sendMessageAtFrontOfQueue(msg);
}
}
}
private boolean isRunningOnLooperThreadLocked() {
return Looper.myLooper() == mLooper;
}

这里有一个判断isRunningOnLooperThreadLocked,看着像是判断当前线程是否是主线程,如果是的话,调用scheduleVsyncLocked()方法,不是的话会发送一个MSG_DO_SCHEDULE_VSYNC消息,但是最终都会调用这个方法


public void scheduleVsync() {
if (mReceiverPtr == 0) {
Log.w(TAG, "Attempted to schedule a vertical sync pulse but the display event "
+ "receiver has already been disposed.");
} else {
nativeScheduleVsync(mReceiverPtr);
}
}

如果mReceiverPtr不等于0的话,会去调用nativeScheduleVsync(mReceiverPtr),这是个native方法,暂不跟踪到C++里面去了,看着英文方法像是一个安排信号的意思 之前是把CallBack存储在一个Queue之中了,那么必然有执行的方法


void doCallbacks(int callbackType, long frameTimeNanos) {
CallbackRecord callbacks;
synchronized (mLock) {
try {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, CALLBACK_TRACE_TITLES[callbackType]);
for (CallbackRecord c = callbacks; c != null; c = c.next) {
if (DEBUG_FRAMES) {
Log.d(TAG, "RunCallback: type=" + callbackType
+ ", action=" + c.action + ", token=" + c.token
+ ", latencyMillis=" + (SystemClock.uptimeMillis() - c.dueTime));
}
c.run(frameTimeNanos);
}
} finally {
synchronized (mLock) {
mCallbacksRunning = false;
do {
final CallbackRecord next = callbacks.next;
recycleCallbackLocked(callbacks);
callbacks = next;
} while (callbacks != null);
}
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}

看一下这个方法在哪里调用的,走到了doFrame方法里面


void doFrame(long frameTimeNanos, int frame) {
final long startNanos;
synchronized (mLock) {
try {
.....
mFrameInfo.markInputHandlingStart();
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

mFrameInfo.markAnimationsStart();
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);

mFrameInfo.markPerformTraversalsStart();
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
} finally {
AnimationUtils.unlockAnimationClock();
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
.....
}

那么这样一来,就找到关键的方法了:doFrame(),这个方法里会根据一个时间戳去队列里取任务出来执行,而这个任务就是 ViewRootImpl 封装起来的 doTraversal() 操作,而 doTraversal() 会去调用 performTraversals() 开始根据需要测量、布局、绘制整颗 View 树。所以剩下的问题就是 doFrame() 这个方法在哪里被调用了。


private final class FrameDisplayEventReceiver extends DisplayEventReceiver
implements
Runnable {
private boolean mHavePendingVsync;
private long mTimestampNanos;
private int mFrame;

public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
super(looper, vsyncSource);
}

@Override
public void onVsync(long timestampNanos, int builtInDisplayId, int frame) {
scheduleVsync();
return;
}
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
mHavePendingVsync = false;
doFrame(mTimestampNanos, mFrame);
}
}

可以看到,是在onVsync回调中,Message msg = Message.obtain(mHandler, this),传入了this,然后会执行到run方法里面,自然也就执行了doFrame方法,所以最终问题也就来了,这个onVsync()这个是什么时候回调来的,这里查询了网上的一些资料,是这么解释的



FrameDisplayEventReceiver继承自DisplayEventReceiver接收底层的VSync信号开始处理UI过程。VSync信号由SurfaceFlinger实现并定时发送。FrameDisplayEventReceiver收到信号后,调用onVsync方法组织消息发送到主线程处理。这个消息主要内容就是run方法里面的doFrame了,这里mTimestampNanos是信号到来的时间参数。



那也就是说,onVsync是底层回调回来的,那也就是每16.6ms,底层会发出一个屏幕刷新的信号,然后会回调到onVsync方法之中,但是有一点很奇怪,底层怎么知道上层是哪个app需要这个信号来刷新呢,结合日常开发,要实现这种一般使用观察者模式,将app本身注册下去,但是好像也没有看到哪里有往底层注册的方法,对了,再次回到上面的那个native方法,nativeScheduleVsync(mReceiverPtr),那么这个方法大体的作用也就是注册监听了,


同步屏障


总结下上面的知识,我们调用了 invalidate(),requestLayout(),等之类刷新界面的操作时,并不是马上就会执行这些刷新的操作,而是通过 ViewRootImpl 的 scheduleTraversals() 先向底层注册监听下一个屏幕刷新信号事件,然后等下一个屏幕刷新信号来的时候,才会去通过 performTraversals() 遍历绘制 View 树来执行这些刷新操作。


那么这样是不是产生一个问题,因为我们知道,平常Handler发送的消息都是同步消息的,也就是Looper会从MessageQueue中不断去取Message对象,一个Message处理完了之后,再去取下一个Message,那么绘制的这个Message如何尽量保证能够在16.6ms之内执行到呢,


这里就使用到了一个同步屏障的东西,再次回到scheduleTraversals代码


void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

mHandler.getLooper().getQueue().postSyncBarrier(),这一句上面没有分析,进入到方法里


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) {
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
if (prev != null) { // invariant: p == prev.next
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
mMessages = msg;
}
return token;
}
}

可以看到,就是生成Message对象,然后进一些赋值,但是细心的朋友可能会发现,这个msg为什么没有设置target,熟悉Handler流程的朋友应该清楚,最终消息是通过msg.target发送出去的,一般是指Handler对象


那我们再次回到MessageQueue的next方法中看看


Message next() {
for (;;) {
....
synchronized (this) {
...
//对,就是这里了,target==null
if (msg != null && msg.target == null) {
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
nextPollTimeoutMillis = -1;
}
}
}

可以看到有一个Message.target==null的判断, do while循环遍历消息链表,跳出循环时,msg指向离表头最近的一个消息,此时返回msg对象


可以看到,当设置了同步屏障之后,next函数将会忽略所有的同步消息,返回异步消息。换句话说就是,设置了同步屏障之后,Handler只会处理异步消息。再换句话说,同步屏障为Handler消息机制增加了一种简单的优先级机制,异步消息的优先级要高于同步消息


这样的话,能够尽快的将绘制的Message给取出来执行,嗯,这里为什么说是尽快呢,因为,同步屏障是在 scheduleTraversals() 被调用时才发送到消息队列里的,也就是说,只有当某个 View 发起了刷新请求时,在这个时刻后面的同步消息才会被拦截掉。如果在 scheduleTraversals() 之前就发送到消息队列里的工作仍然会按顺序依次被取出来执行

作者:花海blog
来源:juejin.cn/post/7267528065809907727

收起阅读 »

高斯模糊

前言 通常,图像处理软件会提供"模糊"(blur)滤镜,使图片产生模糊的效果。 “模糊”的算法不只一种,高斯模糊只是其中一种,甚至它只是其中效率很差的一种。 在Android中使用高斯模糊,需要使用到 JNI 技术,Android Studio开发之 JNI...
继续阅读 »

前言


通常,图像处理软件会提供"模糊"(blur)滤镜,使图片产生模糊的效果。



“模糊”的算法不只一种,高斯模糊只是其中一种,甚至它只是其中效率很差的一种。


在Android中使用高斯模糊,需要使用到 JNI 技术,Android Studio开发之 JNI 篇已具体讨论JNI的用法等。本文主要讲述高斯模糊原理及编码等。


高斯模糊原理


所谓"模糊",可以理解成每一个像素都取周边像素的平均值。



如图所示,2是中间点,周围点都是1。中间点取周围点平均值,就会变成1。在数值上,这是一种"平滑化"。在图形上,就相当于产生"模糊"效果,"中间点"失去细节。


显然,计算平均值时,取值范围越大,"模糊效果"越强烈。


如果使用简单平均,显然不是很合理,因为图像都是连续的,越靠近的点关系越密切,越远离的点关系越疏远。因此,加权平均更合理,距离越近的点权重越大,距离越远的点权重越小。


高斯模糊根据正态分布,决定周围点的权重值。



正态分布是一种钟形曲线,越接近中心,取值越大,越远离中心,取值越小。


计算平均值的时候,我们只需要将"中心点"作为原点,其他点按照其在正态曲线上的位置,分配权重,就可以得到一个加权平均值。


正态分布的一维公式为:



由于每次计算都是以中间点为原点,所以u为标准差,即为0。所以公式进一步进化为:



由于图像是二维的,需要根据二维正态分布函数来计算权重值,它的公式以及曲线如下:



不过为了代码效率问题,不会采用二维正态分布的计算方式,而是分别对 X 轴和 Y 轴进行两次高斯模糊,也能达到效果(即通过一维正态分布计算权重)。


高斯模糊代码


先分别计算正态分布各参数,sigma与高斯模糊半径有关系,2.57既是1除以根号2 PI得来。

float sigma = 1.0 * radius / 2.57;
float deno = 1.0 / sigma * sqrt(2.0 * PI);
float nume = -1.0 / (2.0 * sigma * sigma);

因为对于每一个像素点来说,周围点在正态分布中所占的权重值都是一样的,所以正态分布计算一次即可。

float *gaussMatrix = (float *) malloc(sizeof(float) * (radius + radius + 1));
float gaussSum = 0.0;
for (int i = 0, x = -radius; x <= radius; ++x, ++i) {
float g = deno * exp(1.0 * nume * x * x);
gaussMatrix[i] = g;
gaussSum += g;
}

因为是以中间点自身为原点,所以 x 的取值范围是从 -radius 到 radius,计算结果存储的数组中。请注意周围点权重值与数组的对应关系,x 等于 -radius 时,而 i 等于0,后文会用到。


由于并没有计算所有的周围点,所以权重总合必然不为1,所以需要归一化,设法使权重值为一。

int len = radius + radius + 1;
for (int i = 0; i < len; ++i) {
gaussMatrix[i] /= gaussSum;
}

先进行 x 轴的模糊。

  for (int y = 0; y < h; ++y) {
//取一行像素数据,注意像素总数组的访问方式是 x + y * w
memcpy(rowData, pix + y * w, sizeof(int) * w);
for (int x = 0; x < w; ++x) {
float r = 0, g = 0, b = 0;
gaussSum = 0;
//以当前坐标点 x、y 为中心,查看前后一个模糊半径的周围点,根据正态分布
//重新计算像素点的颜色值
for (int i = -radius; i <= radius; ++i) {
// k 表示周围点的真实坐标
int k = x + i;
// 边界上的像素点,它的周围点只有正常的一半,所以要保证 k 的取值范围
if (k >= 0 && k <= w) {
// 取到周围点的像素,并根据 argb 的排列方式,计算 r、g、b分量
int color = rowData[k];
int cr = (color & 0x00ff0000) >> 16;
int cg = (color & 0x0000ff00) >> 8;
int cb = (color & 0x000000ff);
//真实点坐标为 k,与它对应的权重数组下标是 i + radius
//前文中计算正态分布权重时已经说明相关的对应关系。
//根据正态分布的权重关系,计算中心点的 r g b各分量
int index = i + radius;
r += cr * gaussMatrix[index];
g += cg * gaussMatrix[index];
b += cb * gaussMatrix[index];
gaussSum += gaussMatrix[index];
}
}
//因为边界点的存在,gaussSum值不一定为1,所以需要除以gaussSum,归一化。
int cr = (int) (r / gaussSum);
int cg = (int) (g / gaussSum);
int cb = (int) (b / gaussSum);
//根据权重值与各周围点像素相乘之和,得到新的中间点像素。
pix[y * w + x] = cr << 16 | cg << 8 | cb | 0xff000000;
}
}

y轴的模糊原理和x轴基本一样,这里就不再重复说明了。


JNI图片接口


JNI中处理图片,需要引用 bitmap.h,头文件中主要定义三个方法。

  int AndroidBitmap_getInfo(JNIEnv* env, jobject jbitmap,
AndroidBitmapInfo* info);
int AndroidBitmap_lockPixels(JNIEnv* env, jobject jbitmap, void** addrPtr);
int AndroidBitmap_unlockPixels(JNIEnv* env, jobject jbitmap);

AndroidBitmap_getInfo:获取图片信息,比如宽、高、图片格式等
AndroidBitmap_lockPixels:顾名思义,锁定像素
AndroidBitmap_unlockPixels:解锁。


AndroidBitmap_lockPixels 和 AndroidBitmap_unlockPixels 成对调用,在两个方法之间可对图片像素进行相应处理,解锁像素以后,对图片的调整效果可以立即看到,并不需要再重新生成图片了。


ps:有时并不知道 JNI 有哪些接口可以调用,最好的方式就是看源码,有哪些接口,一目了然。


其它模糊方法


除了高斯模糊之外,还有其它模糊方法,比如说 fastblur,不过这个算法还没看明白,此处不再详述,具体代码本人的github上都有,欢迎访问。


作者:某昆real
链接:https://juejin.cn/post/7225094324527546429
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

当遇到需要在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零基础入门到精通,高手进阶之路


作者:派大星不吃蟹
链接:https://juejin.cn/post/7264503091116965940
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

三分钟教会你微信炸一炸,满屏粑粑也太可爱了!

相信这个特效你和你的朋友(或对象)一定玩过 当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。 不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢的...
继续阅读 »

相信这个特效你和你的朋友(或对象)一定玩过


当你发送一个便便的表情,对方如果扔一个炸弹表情,就会立刻将这个便便炸开,实现满屏粑粑的“酷炫”画面。可谓是“臭味十足”,隔着屏幕都能感受到来自微信爸爸的满满恶意。


不清楚大家对这个互动设计怎么看,反正一恩当时是喜欢的不行,拉着朋友们就开始“炸”得不亦乐乎。


同样被虏获芳心的设计小哥哥在玩到尽兴后,突然灵感大发,连夜绘制出了设计稿,第二天就拉上产品和研发开始脑暴。


“微信炸💩打动我的一点是他满屏的设计,能够将用户强烈的情绪抒发出来;同时他能够与上一条表情进行捆绑,加强双方的互动性。”设计小哥哥声情并茂道。


“所以,让我们的表情也‘互动’起来吧!”


这不,需求文档就来了:



改掉常见的emoji表情发送方式,替换为动态表情交互方式。即,

当用户发送或接收互动表情,表情会在屏幕上随机分布,展示一段时间后会消失。

用户可以频繁点击并不停发送表情,因此屏幕上的表情是可以非常多且重叠的,能够将用户感情强烈抒发。


(暂用微信的聊天界面进行解释说明,图1为原样式,图2是需求样式)



这需求一出,动态表情在屏幕上的分布方案便引起了研发内部热烈讨论:当用户点击表情时,到底应该将表情放置在屏幕哪个位置比较好呢?


最直接的做法就是完全随机方式:取0到屏幕宽度和高度中随机值,放置表情贴纸。但这么做的不确定因素太多,比如存在一定几率所有表情都集中在一个区域,布局边缘化以及最差的重叠问题。因此简单的随机算法对于用户的体验是无法接受的。




我们开始探索新的方案:

因为目前点的选择依赖于较多元素,比如与屏幕已有点的间距,与中心点距离以及屏幕已有点的数目。因此最终决定采用点权随机的方案,根据上述元素决策出屏幕上可用点的优度,选取优度最高的插入表情。


基本思路


维护对应屏幕像素的二维数组,数组元素代指新增图形时,图形中心取该点的优度。

采用懒加载的方式,即每次每次新增图形后,仅记录现有方块的位置,当需要一个点的优度时再计算。


遍历所有方块的位置,将图形内部的点优度全部减去 A ,将图形外部的点按到图形的曼哈顿距离从 0 到 max (W,H),映射,减 0 到 A * K2。

每次决策插入位置时,随机取 K + n * K1 个点,取这些点中优度最高的点为插入中心

A, K, K1, K2 四个常数可调整



一次选择的复杂度是 n * randT,n 是场上方块数, randT 是本次决策需要随机取多少个点。 从效率和 badcase来说,这个方案目前最优。





代码展示


```cpp
#include <iostream>
#include <vector>
using namespace std;

const int screenW = 600;
const int screenH = 800;

const int kInnerCost = 1e5;
const double kOuterCof = .1;

const int kOutterCost = kInnerCost * kOuterCof;

class square
int x1;
int x2;
int y1;
int y2;
};

int lineDist(int x, int y, int p){
if (p < x) {
return x - p;
} else if (p > y) {
return p - y;
} else {
return 0;
}
}

int getVal(const square &elm, int px, int py){
int dx = lineDist(elm.x1, elm.x2, px);
int dy = lineDist(elm.y1, elm.y2, py);
int dist = dx + dy;
constexpr int maxDist = screenW + screenH;
return dist ? ( (maxDist - dist) * kOutterCost / maxDist ) : kInnerCost;
}

int getVal(const vector<square> &elmArr, int px, int py){
int rtn = 0;
for (auto elm:elmArr) {
rtn += getVal(elm, px, py);
}
return rtn;
}

int main(void){

int n;
cin >> n;

vector<square> elmArr;
for (int i=0; i<n; i++) {
square cur;
cin >> cur.x1 >> cur.x2 >> cur.y1 >> cur.y2;
elmArr.push_back(cur);
}


for (;;) {
int px,py;
cin >> px >> py;
cout << getVal(elmArr, px, py) << endl;
}

}

优化点

  1. 该算法最优解偏向边缘。因此随着随机值设置越多,得出来的点越偏向边缘,因此随机值不能设置过多。
  2. 为了解决偏向边缘的问题,每一个点在计算优度getVal需要加上与屏幕中心的距离 * n * k3

效果演示


最后就是给大家演示一下最后的效果啦!


圆满完成任务,收工,下班!


作者:李一恩
链接:https://juejin.cn/post/7257410685118677048
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么App独立开发最好别做日记、记账

我在前几天发了条帖子说(新人)独立开发选择笔(日)记、记账、Todo主流三件套之一等于加速死亡。引发了一点点🤏🏻争议。刚好我在选择 app 方向的时候也深思过这个问题,所以我展开讲讲,分享一下我的想法。 认清自己的定位 在独立开发刚开始做,羽翼未丰的时候,你对...
继续阅读 »

我在前几天发了条帖子说(新人)独立开发选择笔(日)记、记账、Todo主流三件套之一等于加速死亡。引发了一点点🤏🏻争议。刚好我在选择 app 方向的时候也深思过这个问题,所以我展开讲讲,分享一下我的想法。


认清自己的定位


在独立开发刚开始做,羽翼未丰的时候,你对自己的定位就应该是一个游击队队员。这也是独立开发的天然优势,因为小,所以灵活机动。游击战的核心奥义是流动性和速决性。因为你没有一个根据地需要防守,你可以找到一个薄弱的地方进攻。因为这个地方薄弱,所以你集中优势兵力可以很快的决胜。因为你小,所以赚到一笔是一笔。因为你是在边缘薄弱的地方建立的优势,这个小细分市场的利润不足以吸引大部队来,你就有了自己的根据地。你想想红军的根据地都是在什么地方,都是在两省交界的山里。可没听说根据地在上海、北京的。


举个例子,一个小业务,几个月工作量,可以有10万利润。对于一个互联网团队是看不上的。一个完整的互联网团队,一个CEO,一个产品,一个设计,两个研发(前端+后端),一个测试,加上行政,加上公司的运营费用。这一套组合拳摊下来,为了组织的长期利益,短期的一个20万利润的项目他们是看不上的。但是如果你是独立开发,两个人搞两个月,赚10万,你做不做?或者说你能做吗?你能做的,只是赚的不多。但是公司这个事情就没法做。这就是大象踩不死蚂蚁的道理。有人觉得就算两个人两个月赚10万,一个月人均25000也不是什么大钱。确实不是很多的钱,但这也是独立开发普遍心态上的一个问题:你想要的太多。平民独立开发早期最关键的是找到一个持续赚钱的方式,你有实力让自己活下来,后期就可以慢慢发展。而不是一上来就觉得一年要赚100万,然后以一个游击队的姿态去攻打大城市。你要赚大钱,就要有对应的能力。但是很多起步的独立开发者并没有这样的能力,那就是一个不匹配的目标了。


局部创新在笔记领域不是决定性的优势


很多人觉得我做笔记,我有一个想法,我有一个痛点,市面上的笔记没有。我进行了一个局部创新,可能是更好看一点点的设计,或者一个特别的笔记格式。我有一个局部优势,我可以做,我入场。


第一个问题:某些品类的局部优势没有决定性。换一句话说,也许你的确解决了一个其他笔记没有的痛点,但是这个不能转化成你的产品整体优势。不足以转化成足够的收入。


如果你解决了一个普遍痛点,一个高商业价值的痛点,有什么理由现有的成熟笔记app不做呢?请问你作为一个独立开发者开发这个功能要做多久?你有没可能一年里没有任何风声开发一个杀手功能,出来的时候瞬间占领用户心智,用户蜂拥而来?在一个成熟品类里,这样很难的。诺曼底登陆不是游击队能做到的。你要是有这个能力和信心,你就不应该做独立开发,你应该成立公司高举高打。真实的现状是如果你的一个功能受欢迎,成熟的笔记团队花一两个月也就做出来了。


而且笔记类还有一个特点:他有数据积累。用户价值=新体验-旧体验-替换成本。假设你的新体验确实有优势,大于现有产品的旧体验,但是笔记类的替换成本是很高的。我已经在这里积累了好几年的数据,一个新的、个人的、上线没多久的数据积累类,替换成本是很高的。我需要同时愿意抛弃我的旧数据,还需要对你建立信任。 所以除非领头的app犯错误,否则后来的小app是没有机会超过他的。因为他有先发优势,他可以在看到你之后,完善自己,提高自己的旧体验。你不能静态的认为头部的成功的app会停在那里。


成熟品类的高入局门槛


笔记类app自 AppStore 开始有以来,就有人做了。这意味着在你的 app 上线之前,高付费意愿的用户已经付费购买过了。这意味着,如果你要获得存量用户,你需要超越现有品类的成功 app,你需要有更好的整体体验,你还需要有足够全面的运营能力。让这些买了其他 app 的用户愿意选择你。你做笔记 app,一上线,真正的笔记用户心里已经有一个对标的门槛了。本来你在农村盖个楼,是个一层土楼也可以,是个木楼也行,大家都认可是个楼。你在市中心盖一个土楼,大家就会觉得你简陋,功能不全了。用户心智里已经把这个品类的成熟应用当做了一个基线。


再说新的用户。新的用户如果要用一个笔记,上 AppStore 搜,上社交平台搜,肯定看到的大量都是成熟 app 的推荐。他们本来就是历经时代活下来的,自然是得到了用户的认可。既然存在了这么久,自然知道他们的人就多。所以你在自然新增流量上也很劣势。


所以你做一个成熟品类,意味着你要成功,你就必须有一个极具说服力的产品优势,加足够好的产品质量,加足够高效的运营。


我有两个理财项目,一个项目利润年化10%,一个项目利润年后1%,你选哪个?你既然都是在做新产品,为什么要选一个更难的赛道?是你觉得自己命中注定要开发一个笔记app,还是你贫瘠的想象力只想到了做笔记?你确定是在100个产品评估出的最好的方向是笔记?


对比一个新市场,比如最近很火的套壳的 gpt 应用。这是一个全新品类,意味着用户心里是没有对标品的。当用户下载你的 app 时,他不会期待你应该有怎样的功能,他没有对比的对象。这个品类里因为没有头部应用,大家搜索的时候也就看到什么下什么。你会有自然新增流量。因为大家都是差不多时间起步的,其他app不会领先很多,意味着你也有可能建立产品优势,至少你没什么太大的劣势,更有希望建立用户口碑。


长线和短线


投资理财主要有两个流派:长线和短线。长线就关注这个企业未来长期的发展,关注企业的价值。如果这个企业是低估的,就可以持有,因为他们评估出未来这个企业会成长。短期的波动对他们并不构成干扰。大概就是大家口中说的价值投资吧。还有一类是短线,他们不关注未来的情况,买入一只股票只关心这只股票下个月会不会涨,明天会不会涨。因此这个股票的已经100倍PE对他们也没影响,后面有人接盘就可以了。量化交易大多是这样的短线逻辑,于是他们有着高换手率。


长线和短线都是合理的策略。最大的问题是,你不能用抱着长线的心态做短线。这大概就是接盘股民的心态吧,他们持有一只股票的时候觉得这个企业未来会成长。但是短期市场遇冷跌了20%,看到很多人抛了,他们就觉得受不了了,于是割肉离场。买的时候听的是做长线人的意见,卖的时候是跟着做短线的人卖的。


把这个逻辑放到 app 上也是这样的。笔记 app 是一个长线价值,越到后面越值钱,做的越久产品优势越大,用户粘性越高。但是你抱着短线的心态进来做,做了4 个月,收入用户都没起色,你怀疑自己,团队开始有意见,于是你就放弃了。这就是问题所在了,大多数独立开发者没有耐心在一个赛道持续亏损做两年,不具备做长线的心态和财力。


所以我建议独立开发起步的时候多关注短线价值。就是你投入三五个月,能有起色的方向。做三五个月,要不就要赚钱,要不就要有用户口碑。一鸟在手胜过二鸟在林。等你解决了起步的时候生存问题,再考虑农村包围城市的问题。


谈谈番茄钟


也有人觉得番茄钟是独立开发的重灾区。虽然番茄钟似乎是一个红海了,但是我却觉得番茄钟反而是一个可以做的赛道。不知道大家有没有留意一下这个有这个功能的app,似乎万物都可以番茄钟。谜底时钟有番茄钟,滴答清单有番茄钟。番茄钟在设计上可以极简,可以是我的番茄,可以是小鸡,可以是面条,可以是像素。


image.png
IMG\_5978.png
IMG\_5979.png
IMG\_5980.png

那么为什么番茄钟可以呢?


番茄钟替换成本低。因为是及时性的工具类,历史积累的番茄时间统计并不太重要。在功能操作上也很简单,核心交互就是点击一下开始计时,25分钟后提醒你要休息5分钟。很容易可以完成一个基础任何。


设计差异化可以成为付费点,市场容量大。 因为交互很简单,所以做出突出的设计成本可以接受。因为这个品类里,设计可以成为卖点,设计又是一个各有所好的事情,因此天然会有很多不同的需求。某个番茄钟可能做的很好用,很好看,但是他不可能吃掉全部用户。一件短袖设计的再好看,也不可能让所有人都买单。


由此我们可以得出一个结论:番茄钟有短线价值。并且番茄钟引入养成和成就体系以后,有可能变成一个长线产品。如果你非要卷,去卷番茄钟吧。


最后


地上有两张钱,一张100,一张10块,你先捡哪张?我想结果是不言而喻的。独立开发者的一个生态位优势就是可以在一个小领域建立极高的人效比。这个小领域有两个可能:一个是这个领域太小太细分,只能容得下小团队做(高人效);这个领域是新的,还没人知道这里行不行,于是小成本的独立开发先做了(高灵活)。建议各位独立开发者如果要做死亡加速三件套的产品的话三思而后行。


作者:没故事的卓同学
来源:juejin.cn/post/7265967971162898487
收起阅读 »

使用 AndroidX 增强 WebView 的能力

在应用开发过程中,为了在多个平台上保持一致的用户体验和提高开发效率,许多应用程序选择使用 H5 技术。在 Android 平台上,通常使用 WebView 组件来承载 H5 内容以供展示。 WebView 存在的问题 自 Android Lollipop 起,...
继续阅读 »

在应用开发过程中,为了在多个平台上保持一致的用户体验和提高开发效率,许多应用程序选择使用 H5 技术。在 Android 平台上,通常使用 WebView 组件来承载 H5 内容以供展示。


WebView 存在的问题


自 Android Lollipop 起,WebView 组件的升级已经独立于 Android 平台。然而,控制 WebView 的 API(android.webkit) 仍然与平台升级相关。这意味着应用开发者只能使用当前平台所定义的接口,而无法充分利用 WebView 的全部能力。例如: WebView.startSafeBrowsing API 在 Android 8.1 上被添加,该 Feature 由 WebView 提供,即使我们在 Android 7.0 更新 WebView 拥有了该 Feature ,由于 Android 7.0 没有 WebView.startSafeBrowsing API ,我们也没办法使用该功能。


WebView 的实现基于 Chromium 开源项目,而 Android 则基于 AOSP 项目,这两个项目有着不同的发布周期,WebView 往往一个月就可以推出下一个版本,而 Android 则需要一年的时间,对于 WebView 新增的 Feature 我们最迟需要一年才能使用。


AndroidX Webkit 的出现


为了解决上面平台能力和 WebView 不匹配的问题,我们可以独立于平台之外定义一套 WebView API ,并让它随着 WebView 的 Feature 更新 API ,这样解决了现有的问题却导入了另一个问题——如何将新定义的 WebView API 和 WebView 进行衔接。


从应用开发的角度,系统 WebView 难以修改,自己编译定制一个 WebView 并随着 apk 提供是一个很好方案。这时候,我们可以轻松的解决衔接问题,并能够按照需求,任意增改 Feature 而不必等官方更新。同时解决了兼容问题和 WebView 内核碎片化的问题。腾讯 X5 ,UC U4 等都是这个方案。维护一份 WebView 并不是一件容易的事,需要投入更多的人力支持,因为将 WebView 打入包中,还伴随着包体积的急剧增加。


从 Android 官方的角度,可以推动 WebView 上游支持该 WebView API , 而这正是 AndroidX Webkit 的解决方案。Android 官方将定义的 WebView API 放置到 AndroidX Webkit 库,以支持频繁的更新,并在 WebView 上游增加“胶水层”与 AndroidX Webkit 进行衔接,这样在旧版的 Android 平台上,只要安装了拥有"胶水"层代码的 WebView ,也就拥有了新版平台的功能。



“胶水层” 是在某个版本之后才后才支持的,旧版本的 WebView 内核并不支持,这也是为什么在调用之前始终应该检查 isFeatureSupported 的原因。



AndroidX Webkit 的功能


初步了解了 AndroidX Webkit 的产生和实现原理,下面带领大家看一下它都提供了哪些新能力能够增强我们的 WebView 。


向下兼容


如上文分析,AndroidX Webkit 提供了向下的兼容,如下面代码所示,由 WebViewCompat 提供兼容的接口调用。



需要注意的是在调用之前对 WebViewFeature 的检查,对于每个 Feature ,AndroidX Webkit 会取平台和 WebView 所提供 Feature 的并集 ,在调用某个 API 之前必须进行检查,如果平台和 WebView 均不支持该API则将抛出 UnsupportedOperationException 异常。

// Old code:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
   WebView.startSafeBrowsing(appContext, callback);
}

// New code:
if (WebViewFeature.isFeatureSupported(WebViewFeature.START_SAFE_BROWSING)) {
   WebViewCompat.startSafeBrowsing(appContext, callback);
}

如果我们扒开 WebViewCompat 的外衣查看他的源码(如下所示),会发现如果在当前版本 Platform API 提供了接口,就会直接调用 Platform API 的接口,而对于低版本,则由 AndroidX Webkit 和 WebView 的"通道"提供服务。

// WebViewCompat#startSafeBrowsing
public static void startSafeBrowsing(@NonNull Context context,
@Nullable ValueCallback<Boolean> callback) {
ApiFeature.O_MR1 feature = WebViewFeatureInternal.START_SAFE_BROWSING;
if (feature.isSupportedByFramework()) {
ApiHelperForOMR1.startSafeBrowsing(context, callback);
} else if (feature.isSupportedByWebView()) {
getFactory().getStatics().initSafeBrowsing(context, callback);
} else {
throw WebViewFeatureInternal.getUnsupportedOperationException();
}
}

对比上面的代码,使用平台 API(old code)时仅可以支持 90% 的用户,而使用 AndroidX Webkit(new code) 则可以覆盖大约 99% 的用户。


代理功能支持


一直以来WebView 的代理设置异常繁琐,当遇到复杂的代理规则就无能为力了。在 AndroidX Webkit 中增加了 ProxyController API 用于为 WebView 设置代理。ProxyConfig.Builder 类提供了设置代理以及配置代理的绕过方式等方法,通过组合可以满足复杂的代理场景。

if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyConfig proxyConfig = new ProxyConfig.Builder()
.addProxyRule("localhost:7890") //添加要用于所有 URL 的代理
.addProxyRule("localhost:1080") //优先级低于第一个代理,仅在上一个失败时应用
.addDirect() //当前面的代理失败时,不使用代理直连
.addBypassRule("www.baidu.com") //该网址不使用代理,直连服务
.addBypassRule("*.cn") //以.cn结尾的网址不使用代理
.build();
Executor executor = ...
Runnable listener = ...
ProxyController.getInstance().setProxyOverride(proxyConfig, executor, listener);

以上代码定义了一个复杂的代理场景,我们为 WebView 设置了两个代理服务器,localhost:1080 仅当 localhost:7890 失败的情况下启用,addDirect 声明了如果两个服务器都失败则直连服务器,addBypassRule 规定了 http://www.baidu.com 和以 .so 结尾的域名始终不应该使用代理。


白名单代理


如果仅有少量的 URL 需要配置代理,我们可以使用 setReverseBypassEnabled(true) 方法将addBypassRule 添加的 URL 转变为使用代理服务器,而其他的 URL 则直连服务。


安全的 WebView 和 Native 通信支持


建立 WebView 和 Native 的双向通信是使用 Hybrid 混合开发模式的基础,在之前 Android 已经提供了一些机制能够让完成基本的通信,但是已有的接口都存在一些安全和性能问题,在 AndroidX 中增加了一个功能强大的接口 addWebMessageListener 兼顾了安全和性能等问题。


代码示例中将 JavaSript 对象 replyObject 注入到匹配 allowedOriginRules的上下文中,这样只有在可信的网站中才能被使用此对象,也就防止了不明来源的网络攻击者对该对象的利用。

// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    // do something about view, message, sourceOrigin and isMainFrame.
    replyProxy.postMessage("Got it!");
  }
};

HashSet<String> allowedOriginRules = new HashSet<>(Arrays.asList("[https://example.com](https://example.com/)"));
// Add WebMessageListeners.

WebViewCompat.addWebMessageListener(webView, "replyObject", allowedOriginRules,myListener);
调用上述方法之后,在 JavaScript 上下文中我们就可以访问 myObject ,调用 postMessage 就可以回调 Native 端的 onPostMessage 方法并自动切换到主线程执行,当 Native 端需要发送消息给 WebView 时,可以通过 JavaScriptReplyProxy.postMessage 发送到 WebView ,并将消息传递给 onmessage 闭包。
// Web page (in JavaScript)
myObject.onmessage = function(event) {
  // prints "Got it!" when we receive the app's response.
  console.log(event.data);
}
myObject.postMessage("I'm ready!");

文件传递


在以往的通讯机制中,如果我们想传递一个图片只能将其转换为 base64 等进行传输,如果曾经使用过 shouldOverrideUrlLoading 拦截 url 大概率会遇见传输瓶颈,AndroidX Webkit 中很贴心的提供了字节流传递机制。


Native 传递文件给 WebView

// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    // Communication is setup, send file data to web.
    if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
      // Suppose readFileData method is to read content from file.
      byte[] fileData = readFileData("myFile.dat");
      replyProxy.postMessage(fileData);
    }
  }
}
// Web page (in JavaScript)
myObject.onmessage = function(event) {
  if (event.data instanceof ArrayBuffer) {
    const data = event.data;  // Received file content from app.
    const dataView = new DataView(data);
    // Consume file content by using JavaScript DataView to access ArrayBuffer.
  }
}
myObject.postMessage("Setup!");

WebView 传递文件给 Native

// Web page (in JavaScript)
const response = await fetch('example.jpg');
if (response.ok) {
    const imageData = await response.arrayBuffer();
    myObject.postMessage(imageData);
}
// App (in Java)
WebMessageListener myListener = new WebMessageListener() {
  @Override
  public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin,
           boolean isMainFrame, JavaScriptReplyProxy replyProxy) {
    if (message.getType() == WebMessageCompat.TYPE_ARRAY_BUFFER) {
      byte[] imageData = message.getArrayBuffer();
      // do something like draw image on ImageView.
    }
  }
};

深色主题的支持


Android 10 提供了深色主题的支持,但是在 WebView 中显示的网页却不会自动显示深色主题, 这就表现出严重的割裂感,开发者只能通过修改 css 来达到目的,但这往往费时费力还存在兼容性问题,Android 官方为了改善这一用户体验,为 WebView 提供了深色主题的适配。


一个网页如何表现是和prefers-color-scheme and color-scheme 这两个 Web 标准互操作的。 Android官方提供了一张表阐述了他们之间的关系。


上面这张图比较复杂,简单来说如果你想让 WebView 的内容和应用的主题相匹配,你应该始终定义深色主题并实现 prefers-color-scheme ,而对于未定义 prefers-color-scheme 的页面,系统按照不同的策略选择算法生成或者显示默认页面。



以 Android 12 或更低版本为目标平台的应用 API 设计过于复杂,以 Android 13 或更高版本为目标平台的应用精简了 API ,具体变更请参考官方文档



JavaScript and WebAssembly 执行引擎支持


我们有时候我们会在程序中运行 JavaScript 而不显示任何 Web 内容,比如小程序的逻辑层,使用 WebView 本能够满足我们的要求但是浪费了过多的资源,我们都知道在 WebView 中真正负责执行 JavaScript 的引擎是 V8 ,但是我们又无法直接使用,所以我们的安装包中出现了各种各样的引擎:HermesJSCV8等。


Android 发现了这”群雄割据“的局面,推出了AndroidX JavascriptEngine,JavascriptEngine 直接使用了 WebView 的 V8 实现,由于不用分配其他 WebView 资源所以资源分配更低,并可以开启多个独立运行的环境,还针对传递大量数据做了优化。


代码展示了执行 JavaScript 和 WebAssembly 代码的使用:

if(!JavaScriptSandbox.isSupported()){
return;
}
//连接到引擎
ListenableFuture<JavaScriptSandbox> jsSandboxFuture =
JavaScriptSandbox.createConnectedInstanceAsync(context);
//创建上下文 上下文间有简单的数据隔离
JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();
//执行函数 && 获取结果
final String code = "function sum(a, b) { let r = a + b; return r.toString(); }; sum(3, 4)";
ListenableFuture<String> resultFuture = jsIsolate.evaluateJavaScriptAsync(code);
String result = resultFuture.get(5, TimeUnit.SECONDS);
Futures.addCallback(resultFuture,
new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
text.append(result);
}
@Override
public void onFailure(Throwable t) {
text.append(t.getMessage());
}
},
mainThreadExecutor); //Wasm运行
final byte[] hello_world_wasm = {
0x00 ,0x61 ,0x73 ,0x6d ,0x01 ,0x00 ,0x00 ,0x00 ,0x01 ,0x0a ,0x02 ,0x60 ,0x02 ,0x7f ,0x7f ,0x01,
0x7f ,0x60 ,0x00 ,0x00 ,0x03 ,0x03 ,0x02 ,0x00 ,0x01 ,0x04 ,0x04 ,0x01 ,0x70 ,0x00 ,0x01 ,0x05,
0x03 ,0x01 ,0x00 ,0x00 ,0x06 ,0x06 ,0x01 ,0x7f ,0x00 ,0x41 ,0x08 ,0x0b ,0x07 ,0x18 ,0x03 ,0x06,
0x6d ,0x65 ,0x6d ,0x6f ,0x72 ,0x79 ,0x02 ,0x00 ,0x05 ,0x74 ,0x61 ,0x62 ,0x6c ,0x65 ,0x01 ,0x00,
0x03 ,0x61 ,0x64 ,0x64 ,0x00 ,0x00 ,0x09 ,0x07 ,0x01 ,0x00 ,0x41 ,0x00 ,0x0b ,0x01 ,0x01 ,0x0a,
0x0c ,0x02 ,0x07 ,0x00 ,0x20 ,0x00 ,0x20 ,0x01 ,0x6a ,0x0b ,0x02 ,0x00 ,0x0b,
};
final String jsCode = "android.consumeNamedDataAsArrayBuffer('wasm-1').then(" +
"(value) => { return WebAssembly.compile(value).then(" +
"(module) => { return new WebAssembly.Instance(module).exports.add(20, 22).toString(); }" +
")})";
boolean success = js.provideNamedData("wasm-1", hello_world_wasm);
if (success) {
FluentFuture.from(js.evaluateJavaScriptAsync(jsCode))
.transform(this::println, mainThreadExecutor)
.catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
} else {
// the data chunk name has been used before, use a different name
}

更多支持


AndroidX Webkit 是一个功能强大的库,由于篇幅原因上文将开发者比较常用的功能进行了列举,AndroidX 还提供对 WebView 更精细化的控制,对 Cookie 的便捷访问、对 Web 资源的便捷访问,对 WebView 性能的收集,还有对大屏幕的支持等等强大的 API,大家可以查看发布页面查看最新的功能。


写在最后


本文从实际矛盾出发,带领大家思考 AndroidX Webkit 的产生原因和实现原理,对于AndroidX Webkit 的几个功能分别做了简单的介绍,希望大家能在这篇文章获得一点启发和帮助。


作者:简绘Android
链接:https://juejin.cn/post/7259762775365320741
来源:稀土掘金
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。

// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。

<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>
将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。
private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。

val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")
这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。
override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析

先看BaseSkinActivity的源码。

package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}
我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。
package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}
所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。
/**
* 从xml的属性集合中获取皮肤相关的属性。
*/
fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。

package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/
BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/
TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/
SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/
val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dview…] 。

作者:dora
链接:https://juejin.cn/post/7258483700815609916
来源:稀土掘金
收起阅读 »

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频


从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐


网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用

  • 获取定位信息和生物特征识别信息

在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。


如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。

其他权限

其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图


2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图

2.4 应用签名

需要保证签名的真实有效性。

作者:付十一
链接:https://juejin.cn/post/7253610755126476857
来源:稀土掘金
收起阅读 »

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

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

一、整体逻辑


image.png


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



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

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

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


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


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


横向.gif


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


1、跨页吸附


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


(1)Scroll Idle


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


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

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

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

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


(2)Fling


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


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


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

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

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

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

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

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

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

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

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


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

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

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

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

完整操作:


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

2、覆盖效果实现


(1)如果使用PageTransform实现


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


image.png


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


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

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


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


image.png


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


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


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


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

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

三、竖直滑动(Scroll Mode)


垂直.gif


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


1、跨章吸附


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


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


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

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


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

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


2、跨章Fling阻断


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



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

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


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


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

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

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

四、仿真页(Flip Mode)


仿真.gif


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



  1. 确认手指滑动方向

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

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

  4. 绘制仿真页

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


1、确认手指滑动方向


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


image.png


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


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

image.png


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


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

2、遮盖效果


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


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

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


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


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

4、绘制仿真页


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


(1)生成截图


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


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

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

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

(2)绘制仿真页


绘制仿真页参考 gedoor/legadoSimulationPageDelegate



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

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

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


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


5、动画控制


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


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

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


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

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

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


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

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

(以上为所有关键点,只截取了部分

作者:三尺丶
来源:juejin.cn/post/7244819106343829564
代码,提供一个思路)

收起阅读 »

Flutter 仿 Hero 的动画

Flutter 模仿 Hero 动画的效果,实现逻辑比较简单,就是用 Stack 结合 AnimatedBuilder 组件实现类似 Hero 的转场的动画效果。 效果 代码 DEMO class TWAnimationHeroApp extends Sta...
继续阅读 »

Flutter 模仿 Hero 动画的效果,实现逻辑比较简单,就是用 Stack 结合 AnimatedBuilder 组件实现类似 Hero 的转场的动画效果。


效果


Simulator Screen Recording - iPhone 14 Pro - 2023-08-08 at 22.32.59.gif


代码


DEMO


class TWAnimationHeroApp extends StatelessWidget {
final controller = TWAnimationHeroController();
TWAnimationHeroApp({super.key});

@override
Widget build(BuildContext context) {
Widget heroChild = GestureDetector(
onTap: () => controller.executeAnimation(),
child: Image.asset(
Assets.beauty.path,
fit: BoxFit.fitHeight,
),
);

return MaterialApp(
theme: ThemeData(primarySwatch: Colors.grey),
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(),
body: TWAnimationHero(
controller: controller,
heroChild: heroChild,
child: Stack(
children: [
ListView(
children: [
Container(
height: 100,
alignment: Alignment.center,
color: Colors.orange,
child: GestureDetector(
onTap: () => controller.reverseAnimation(),
child: SizedBox(
width: 50,
height: 50,
key: controller.targetKey,
child: Image.asset(
Assets.beauty.path,
),
),
),
),
Container(
height: 100,
color: Colors.black,
),
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.red,
),
Container(
height: 100,
color: Colors.lime,
),
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.yellow,
),
Container(
height: 100,
color: Colors.blueAccent,
),
],
),
],
),
),
),
);
}
}

TWAnimationHeroController


class TWAnimationHeroController extends ChangeNotifier {
GlobalKey targetKey = GlobalKey();
GlobalKey heroKey = GlobalKey();

/// 是否可见
bool get isHeroVisible => _isHeroVisible;

bool _isHeroVisible = true;

set heroVisible(bool value) {
_isHeroVisible = value;
notifyListeners();
}

/// 是否方向状态
bool isReverse = false;
AnimationController? controller;
Animation? animation;

double offTop = 0;
double offBottom = 0;
double offLeft = 0;
double offRight = 0;
TWAnimationHeroController();

/// 执行正向动画
executeAnimation() {
if (isReverse) return;
isReverse = true;
final child1Rect = fetchChildRect(targetKey);
final child2Rect = fetchChildRect(heroKey);
if (child1Rect == null || child2Rect == null) return;
offTop = child1Rect.top - child2Rect.top;
offBottom = child2Rect.bottom - child1Rect.bottom;
offLeft = child1Rect.left - child2Rect.left;
offRight = child2Rect.right - child1Rect.right;
controller?.forward();
}

/// 执行反向动画
reverseAnimation() {
if (!isReverse) return;
heroVisible = true;
isReverse = false;
controller?.reverse();
}

Rect? fetchChildRect(GlobalKey key) {
RenderBox? renderBox = key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return null;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
final childRect = offset & size;
return childRect;
}
}

TWAnimationHero 组件


class TWAnimationHero extends StatefulWidget {
final Widget child;
final Widget? heroChild;

final TWAnimationHeroController controller;
const TWAnimationHero({
super.key,
required this.controller,
required this.child,
this.heroChild,
});

@override
State<TWAnimationHero> createState() => _TWAnimationHeroState();
}

class _TWAnimationHeroState extends State<TWAnimationHero>
with TickerProviderStateMixin
{
@override
void initState() {
super.initState();
createController();
}

/// 创建控制器
createController() {
final controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);

//应用curve
widget.controller.animation = CurvedAnimation(
parent: controller,
curve: Curves.easeIn,
);

controller.addListener(() {
// 注意正向动画才会监听到 isCompleted
if (controller.isCompleted) {
widget.controller.heroVisible = false;
}
});

widget.controller.controller = controller;
}

@override
void didUpdateWidget(covariant TWAnimationHero oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller.controller == null) {
widget.controller.controller?.dispose();
createController();
}
}

@override
void dispose() {
widget.controller.controller?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (widget.heroChild != null &&
widget.controller.controller != null &&
widget.controller.animation != null)
AnimatedBuilder(
animation: widget.controller.controller!,
builder: (BuildContext context, Widget? child) {
return Positioned(
top: widget.controller.animation!.value *
widget.controller.offTop,
bottom: widget.controller.animation!.value *
widget.controller.offBottom,
left: widget.controller.animation!.value *
widget.controller.offLeft,
right: widget.controller.animation!.value *
widget.controller.offRight,
child: child!,
);
},
child: AnimatedBuilder(
animation: widget.controller,
builder: (BuildContext context, Widget? child) {
return Visibility(
visible: widget.controller.isHeroVisible,
child: Container(
color: Colors.transparent,
key: widget.controller.heroKey,
child: widget.heroChild,
),
);
},
),
),
],
);
}
}


作者:zeqinjie
来源:juejin.cn/post/7264921108398604343
收起阅读 »

Flutter:创建和发布一个 Dart Package

在 Dart 生态系统中使用 packages(包) 实现代码的共享,比如一些 library 和工具。本文旨在介绍如何创建和发布一个 package。 通常来讲,我们所说的 package 一般都是指 library package,即可以被其他的 pac...
继续阅读 »

在 Dart 生态系统中使用 packages(包) 实现代码的共享,比如一些 library 和工具。本文旨在介绍如何创建和发布一个 package。


通常来讲,我们所说的 package 一般都是指 library package,即可以被其他的 package 所依赖,同时它自身也可以依赖其他 package。本文中说的 package 也都默认是指 library package


1.package 的组成


下图展示了最简单的 library package 布局:








  • library package 中需要包括 pubspec.yaml 文件lib 目录





  • library 的 pubspec.yaml 文件和应用程序的 pubspec.yaml 没有本质区别。





  • library 的代码需要位于 lib 目录 下,且对于其他 package 是 公开的。你可以根据需要在 lib 下创建任意目录。但是如果你创建的目录名是 src 的话,会被当做 私有目录,其他 package 不能直接使用。目前一般的做法都是把代码放到 lib/src 目录下,然后将需要公开的 API 通过 export 进行导出。




2.创建一个 package


假设我们要开发一个叫做 yance 的 package。


2.1 通过 IDE 创建一个 package








我们来看看创建好的一个 package 工程的结构:





可以看到 lib 目录和 pubspec.yaml 文件已经默认给我们创建好了。


2.2 认识 main library


我们打开 lib 目录,会发现有一个默认和 package 项目名称同名的 dart 文件,我们把这个文件成为 main library。因为我的 package 名称是 yance,因此,我的 main libraryyance.dart





main library 的作用是用来声明所有需要公开的 API。


我们打开 yance.dart 文件:


library yance;

/// A Calculator.
class Calculator {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

第一行使用 library 关键字。这个 library 是用来为当前的 package 声明一个唯一标识。也可以不声明 library,在不声明 library 的情况下,package 会根据当前的路径及文件生成一个唯一标记。


如果你需要为当前的 package 生成 API 文档,那么必须声明 library。


至于 library 下面的 Calculator 代码只是一个例子,可以删除。


前面说了 main library 的作用是用来声明公开的 API,下面我们来演示一下,如何声明。


2.3 在 main library 中公开 API


我们在 lib 目录下新建一个 src 目录,后面所有的 yance package 的实现代码都统一放在 src 目录下,记住,src 下的所有代码都是私有的,其他项目或者 package 不能直接使用。


我们在 src 目录下,创建一个 yance_utils.dart 文件,在里面简单写一点测试代码:


class YanceUtils {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

好了,现在需求来了,我要将 YanceUtils 这个工具类声明为一个公开的 API ,好让其他项目或者 package 可以使用。


那么就需要在 yance.dart 这个 main library 中使用 export 关键字进行声明,格式为:


export 'src/xxx.dart';

输入 src 关键字,然后选择 src/ 这个路径:





然后再输入 yance_utils.dart 即可:


library yance;

export 'src/yance_utils.dart';

这样就完成了 API 的公开,yance_utils.dart 里面所有的内容,都可以被其他项目所引用:


import 'package:yance/yance.dart';

class MyDemo{
  void test() {
    var yanceUtils = YanceUtils();
    var addOne = yanceUtils.addOne(1);
    print('结果:$addOne}');
  }
}

此时,可能大家会有个疑问,使用 export 'src/xxx.dart' 的方式,会将该 dart 文件里所有的内容都完全公开,那假如该文件里的内容,我只想公开一部分,该如何操作呢?


需要使用到 show 关键字:


export 'src/xxx.dart' show 需要公开的类名or方法名or变量名

/// 多个公开的 API 用逗号分隔开

还是以 yance_utils.dart 为例子,我们在 yance_utils.dart 再添加一点代码:


String yanceName = "123";

void yanceMain() {
  print('调用了yanceMain方法');
}

class YanceUtils {
  /// Returns [value] plus 1.
  int addOne(int value) => value + 1;
}

class StringUtils {
  String getStr(String value) => value.replaceAll("/""_");
}

此时,我想公开 yanceName 属性yanceMain() 方法YanceUtils 类,可以这样声明:


library yance;

export 'src/yance_utils.dart' show YanceUtils, yanceName, yanceMain;

使用 show 不仅可以避免导出过多的 API,而且可以为开发者提供公开的 API 的概览。


3.发布一个 package


开发完成自己的 package 后,就可以将其发布到 pub.dev 上了。


发布 package 大致需要 5 个步骤:





下面会一一解答每一个步骤。


3.1 关于 pub.dev 的一些政策说明





  • 发布是永久的


只要你在 pub.dev 上发布了你的 package,那么它就是永久存在,不会允许你删除它。这样做的目的是为了保护依赖了你 package 的项目,因为你的删除操作会给他们的项目带来破坏。





  • 可以随时发布 package 的新版本,而旧版本对未升级的用户仍然可用。





  • 对于那些已经发布,但不再维护的 package,你可以把它标记为终止(discontinued)。




进入到 package 页面上的 Admin 标签栏,可以将 package 标记为终止。








标记为终止(discontinued)的 package,以前发布的版本依然留存在 pub.dev 上,并可以被看到,但是它有一个清楚的 终止 徽章,而且不会出现在搜索结果中。


3.2 发布前的准备


3.2.1 首先需要一个 Google 账户


Google 账户申请地址:传送门




如果之前你登录过任何 Google 产品(例如 Gmail、Google 地图或 YouTube),这就意味着你已拥有 Google 帐号。你可以使用自己创建的同一组用户名和密码登录任何其他 Google 产品。



3.2.2 检查 LICENSE 文件


package 必须包含一个 LICENSE 文件。推荐使用 BSD 3-clause 许可证,也就是 Dart 和 Flutter 团队所使用的开源许可证。


参考:


Copyright 2021 com.yance. All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above
      copyright notice, this list of conditions and the following
      disclaimer in the documentation and/or other materials provided
      with the distribution.
    * Neither the name of Google Inc. nor the names of its
      contributors may be used to endorse or promote products derived
      from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3.2.3 检查包大小


通过 gzip 压缩后,你的 package 必须小于 100 MB


如果它所占空间过大,考虑将它分割为几个小的 package。或者使用 .pubignore 移除不需要的文件,或者减少包含资源或实例的数量。


3.2.4 检查依赖项


package 应该尽量只依赖于托管在 pub.dev 上的库,以避免不必要的风险。


3.3 编写几个重要的文件 🔺


3.3.1 README.md


README.md 的内容在 pub.dev 上会当做一个页面进行展示:





3.3.2 CHANGELOG.md


如果你的 package 中有 CHANGELOG.md 文件,同样会被作为一个页面(Changelog)进行展示:





来看一个例子:


# 1.0.1

Fixed missing exclamation mark in `sayHi()` method.

# 1.0.0

**Breaking change:** Removed deprecated `sayHello()` method.
Initial stable release.

## Upgrading from 0.1.x

Change all calls to `sayHello()` to instead be to `sayHi()`.

# 0.1.1

Deprecated the `sayHello()` method; use `sayHi()` instead.

# 0.1.0

Initial development release.

3.3.3 pubspec.yaml


pubspec.yaml 文件被用于填写关于 package 本身的细节,例如它的描述,主页等等。这些信息将被展现在页面的右侧。





一般来说,需要填写这些信息:





注意:


目前 author 信息已经不需要了,所以大家可以把 author 给删除掉。


3.4 预发布


预发布使用如下命令。执行预发布命令不会真的发布,它可以帮助我们验证填写的发布信息是否符合 pub.dev 的规范,同时展示所有会发布到 pub.dev 的文件。


dart pub publish --dry-run

比如,我运行的结果是:


chenyouyu@chenyouyudeMacBook-Pro-2 yance % dart pub publish --dry-run
Publishing yance 0.0.1 to https://pub.dartlang.org:
|-- CHANGELOG.md
|-- LICENSE
|-- README.md
|-- lib
|   |-- src
|   |   '-- yance_utils.dart
|   '
-- yance.dart
|-- pubspec.yaml
|-- test
|   '-- yance_test.dart
'
-- yance.iml
Package validation found the following potential issue:
* Your pubspec.yaml includes an "author" section which is no longer used and may be removed.

Package has 1 warning.

它提示我们author 信息已经不需要了,可以删除。


删除后,再次运行就没有警告了。





3.5 正式发布


当你已经准备好正式发布你的 package 后,移除 --dry-run 参数:


dart pub publish







点击链接会跳转浏览器验证账户,验证成功后,会有提示:





账户验证通过后,会继续执行上传任务:





此时,去 pub.dev 上就能看到发布成功的 package 了:





pub.dev 会检测 package 支持哪些平台,并呈现到 package 的页面上。


注意:


正式发布可能需要科学上网。


4.参考文章



作者:有余同学
来源:mdnice.com/writing/d5460df39ddd4649be9b102ccb2fb0b2
收起阅读 »

Android协程带你飞越传统异步枷锁

引言 在Android开发中,处理异步任务一直是一项挑战。以往的回调和线程管理方式复杂繁琐,使得代码难以维护和阅读。Jetpack引入的Coroutine(协程)成为了异步编程的新标杆。本文将深入探讨Android Jetpack Coroutine的使用、原...
继续阅读 »

引言


在Android开发中,处理异步任务一直是一项挑战。以往的回调和线程管理方式复杂繁琐,使得代码难以维护和阅读。Jetpack引入的Coroutine(协程)成为了异步编程的新标杆。本文将深入探讨Android Jetpack Coroutine的使用、原理以及高级用法,助您在异步编程的路上游刃有余。


什么是Coroutine?


Coroutine是一种轻量级的并发设计模式,它允许开发者以顺序代码的方式处理异步任务,避免了传统回调和线程管理带来的复杂性。它建立在Kotlin语言的suspend函数上,suspend函数标记的方法能够挂起当前协程的执行,并在异步任务完成后恢复执行。


Coroutine的优势



  • 简洁:通过简洁的代码表达异步逻辑,避免回调地狱。

  • 可读性:顺序的代码结构使得逻辑更加清晰易懂。

  • 卓越的性能:Coroutine能够有效地利用线程,避免过度的线程切换。

  • 取消支持:通过Coroutine的结构,方便地支持任务取消和资源回收。

  • 适用范围广:从简单的后台任务到复杂的并发操作,Coroutine都能应对自如。


Coroutine的原理


挂起与恢复


当遇到挂起函数时,例如delay()或者进行网络请求的suspend函数,协程会将当前状态保存下来,包括局部变量、指令指针等信息,并暂停协程的执行。然后,协程会立即返回给调用者,释放所占用的线程资源。一旦挂起函数的异步操作完成,协程会根据之前保存的状态恢复执行,就好像从挂起的地方继续运行一样,这使得异步编程变得自然、优雅。


线程调度与切换


Coroutine使用调度器(Dispatcher)来管理协程的执行线程。主要的调度器有:



  • Dispatchers.Main:在Android中主线程上执行,用于UI操作。

  • Dispatchers.IO:在IO密集型任务中使用,比如网络请求、文件读写。

  • Dispatchers.Default:在CPU密集型任务中使用,比如复杂的计算。


线程切换通过withContext()函数实现,它智能地在不同的调度器之间切换,避免不必要的线程切换开销,提高性能。


异常处理与取消支持


Coroutine支持异常处理,我们可以在协程内部使用try-catch块来捕获异常,并将异常传播到协程的外部作用域进行处理,这使得我们能够更好地管理和处理异步操作中出现的异常情况。


同时,Coroutine支持任务的取消。当我们不再需要某个协程执行时,可以使用coroutineContext.cancel()或者coroutinecope.cancel()来取消该协程。这样,协程会自动释放资源,避免造成内存泄漏。


基本用法


并发与并行


使用async函数,我们可以实现并发操作,同时执行多个异步任务,并等待它们的结果。而使用launch函数,则可以实现并行操作,多个协程在不同线程上同时执行。


val deferredResult1 = async { performTask1() }
val deferredResult2 = async { performTask2() }

val result1 = deferredResult1.await()
val result2 = deferredResult2.await()

超时与异常处理


通过withTimeout()函数,我们可以设置一个任务的超时时间,当任务执行时间超过指定时间时,会抛出TimeoutCancellationException异常。这使得我们能够灵活地处理超时情况。


try {
withTimeout(5000) {
performLongRunningTask()
}
} catch (e: TimeoutCancellationException) {
// 处理超时情况
}

组合挂起函数


Coroutine提供了一系列的挂起函数,例如delay()withContext()等。我们可以通过asyncawait()函数将这些挂起函数组合在一起,实现复杂的异步操作。


val result1 = async { performTask1() }.await()
val result2 = async { performTask2() }.await()

与jetpack联动


当使用Jetpack组件和Coroutine结合起来时,我们可以在Android应用中更加优雅地处理异步任务。下面通过一个示例演示如何在ViewModel中使用Jetpack组件和Coroutine来处理异步数据加载:


创建一个ViewModel类,例如MyViewModel.kt,并在其中使用Coroutine来加载数据:


import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutine.Dispatchers

class MyViewModel : ViewModel() {

fun loadData() = liveData(Dispatchers.IO) {
emit(Resource.Loading) // 发送加载中状态

try {
// 模拟耗时操作
val data = fetchDataFromRemote()
emit(Resource.Success(data)) // 发送加载成功状态
} catch (e: Exception) {
emit(Resource.Error(e.message)) // 发送加载失败状态
}
}

// 假设这是一个网络请求的方法
private suspend fun fetchDataFromRemote(): String {
// 模拟耗时操作
delay(2000)
return "Data from remote"
}
}

创建一个Resource类用于封装数据状态:


sealed class Resource<out T> {
object Loading : Resource<Nothing>()
data class Success<T>(val data: T) : Resource()
data class Error(val message: String?) : Resource<Nothing>()
}

在Activity或Fragment中使用ViewModel,并观察数据变化:


class MyActivity : AppCompatActivity() {

private val viewModel: MyViewModel by viewModels()

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

viewModel.loadData().observe(this) { resource ->
when (resource) {
is Resource.Loading -> {
// 显示加载中UI
}
is Resource.Success -> {
// 显示加载成功UI,并使用resource.data来更新UI
val data = resource.data
}
is Resource.Error -> {
// 显示加载失败UI,并使用resource.message显示错误信息
val errorMessage = resource.message
}
}
}
}
}

在以上示例中,ViewModel中的loadData()方法使用Coroutine的liveData构建器来执行异步任务。我们通过emit()函数发送不同的数据状态,Activity(或Fragment)通过观察LiveData来处理不同的状态,并相应地更新UI。


结论


Android Jetpack Coroutine是异步编程的高级艺术。通过深入理解Coroutine的原理和高级用法,我们可以写出更加优雅、高效的异步代码。掌握Coroutine的挂起与恢复、线程切换、异常处理和取消支持,使得我们能够更好地处理异步操作,为用户带来更出色的应用体验。

作者:午后一小憩
来源:juejin.cn/post/7264399534474297403

收起阅读 »

如何自动打开你的 App?

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。 那么这种自动打开一个 App 到底是怎么实现的呢? URL Scheme 首先是最原始的方...
继续阅读 »

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。


那么这种自动打开一个 App 到底是怎么实现的呢?


URL Scheme


首先是最原始的方式 URL Scheme。



URL Scheme 是一种特殊的 URL,用于定位到某个应用以及应用的某个功能。



它的格式一般是: [scheme:][//authority][path][?query]


scheme 代表要打开的应用,每个上架应用商店的 App 所注册的 scheme 都是唯一的;后面的参数代表应用下的某个功能及其参数。


在 IOS 上配置 URL Scheme


在 XCode 里可以轻松配置


image.png


在 Android 上配置 URL Scheme


Android 的配置也很简单,在 AndroidManifest.xml 文件下添加以下配置即可


image.png


通过访问链接自动打开 App


配置完成后,只要访问 URL Scheme 链接,系统便会自动打开对应 scheme 的 App。


因此,我们可以实现一个简单的 H5 页面来承载这个跳转逻辑,然后在页面中通过调用 location.href=schemeUrl 或者 <a href='schemeUrl' /> 等方式来触发访问链接,从而自动打开 App


优缺点分析


优点: 这个是最原始的方案,因此最大的优点就是兼容性好


缺点:



  1. 通过 scheme url 这种方式唤起 App,对于 H5 中间页面是无法感知的,并不知道是否已经成功打开 App

  2. 部分浏览器有安全限制,自动跳转会被拦截,必须用户手动触发跳转(即 location.href 行不通,必须 a 标签)

  3. 一些 App 会限制可访问的 scheme,你必须要在白名单内,否则也会被拦截跳转

  4. 通过 scheme url 唤起 App 时,浏览器会提示你是否确定要打开该 App,会影响用户体验


DeepLink


通过上述缺点我们可以看出,传统的 URL Scheme 在用户体验上是存在一定缺陷的。


因此,DeepLink 诞生了。


DeepLink 的宗旨就是通过传统的 HTT P链接就可以唤醒app,而如果用户没有安装APP,则会跳转到该链接对应的页面。


IOS Universal Link


在 IOS 上一般称之为 Universal Link。


【配置你的 Universal Link 域名】


首先要去 Apple 的开发者平台上配置你的 domains,假设是: mysite.com


image.png


【配置 apple-app-site-association 文件】


在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 apple-app-site-association 文件。


文件内容包含 appID 以及 path,path如果配置 /app 则表示访问该域名下的 /app 路径均能唤起App


该文件内容大致如下:


{
"applinks": {
"apps": [],
"details": [
{
"appID": "xxx", // 你的应用的 appID
"paths": [ "/app/*"]
}
]
}
}

【系统获取配置文件】


上面两步配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统都会主动去拉取域名下的配置文件。



即系统会主动去拉取 https://mysite.com/.well-known/apple-app-site-association 这个文件



然后根据返回的 appID 以及 path 判断访问哪些路径是需要唤起哪个App


【自动唤起 App】


当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。


同时,客户端还可以进行一些自定义逻辑处理:


客户端会接收到 NSUserActivity 对象,其 actionType 为 NSUserActivityTypeBrowsingWeb,因此客户端可以在接收到该对象后做一些跳转逻辑处理。


image.png


Android DeepLink


与 IOS Universal Link 原理相似,Android系统也能够直接通过网站地址打开应用程序对应的内容页面,而不需要用户选择使用哪个应用来处理网站地址


【配置 AndroidManifest.xml】
在 AndroidManifest 配置文件中添加对应域名的 intent-filter:


scheme 为 https / http;


host 则是你的域名,假设是: mysite.com


image.png


【生成 assetlinks.json 文件】


首先要去 Google developers.google.com/digital-ass… 生成你的 assetlinks json 文件。


image.png


【配置 assetlinks.json 文件】


生成文件后,同样的需要在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 assetlinks.json 配置文件,文件内容包含应用的package name 和对应签名的sha哈希


【系统获取配置文件】


配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统会进行以下校验:



  1. 如果 intent-filter 的 autoVerify 设置为 true,那么系统会验证其



  • Action 是否为 android.intent.action.VIEW

  • Category 是否为android.intent.category.BROWSABLE 和 android.intent.category.DEFAULT

  • Data scheme 是否为 http 或 https



  1. 如果上述条件都满足,那么系统将会拉取该域名下的 json 配置文件,同时将 App 设置为该域名链接的默认处理App


【自动唤起 App】


当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。


优缺点分析


【优点】



  1. 用户体验好:可以直接打开 App,没有弹窗提示

  2. 唤起App失败则会跳转链接对应的页面


【缺点】



  1. iOS 9 以后才支持 Universal Link,

  2. Android 6.0 以后才支持 DeepLink

  3. DeepLink 需要依赖远程配置文件,无法保证每次都能成功拉取到配置文件


推荐方案: DeepLink + H5 兜底


基于前面两种方案的优缺点,我推荐的解决方案是配置 DeepLink,同时再加上一个 H5 页面作为兜底。


首先按照前面 DeepLink 的教程先配置好 DeepLink,其中访问路径配置为 https://mysite.com/app


接着,我们就可以在 https://mysite.com/app 路径下做文章了。在该路径下放置一个 H5 页面,内容可以是引导用户打开你的 App。


当用户访问 DeepLink 没有自动打开你的 App 时,此时用户会进入浏览器,并访问 https://mysite.com/app 这个 H5 页面。


在 H5 页面中,你可以通过浏览器 ua 获取当前的系统以及版本:



  1. 如果是 Android 6.0 以下,那么可以尝试用 URL Scheme 去唤起 App

  2. 如果是 IOS / Android 6.0 及以上,那么此时可以判断用户未安装 App。这种情况下可以做些额外的逻辑,比如重定向到应用商店引导用户去下载之类的


作者:龙飞_longfe
来源:juejin.cn/post/7201521440612974649
收起阅读 »

Android 记录一次因隐私合规引发的权限hook

背景 一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三...
继续阅读 »

背景


一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三方的技术,询问是否有静默方面的api,结果一番舌战后,对方告诉我他们隐私政策里有添加说明,之后也没有想要改动的打算,但是集团那边说在隐私里说明也不行。


综上,那只能自己动手。


解决的方法:是通过hook系统权限,添加某个业务逻辑点拦截并处理。


涉及到的知识点:java反射、动态代理、一点点耐心。


本文涉及到的敏感权限:


//wifi
android.net.wifi.WifiManager.getScanResults()
android.net.wifi.WifiManager.getConnectionInfo()
//蓝牙
android.bluetooth.le.BluetoothLeScanner.startScan()
//定位
android.location.LocationManager.getLastKnownLocation()

开始


wifi篇


1.首先寻找切入点,以方法WifiManager.getScanResults()为例查看源码


public List<ScanResult> getScanResults() {
  try {
return mService.getScanResults(mContext.getOpPackageName(),
  mContext.getAttributionTag());
  } catch (RemoteException e) {
  throw e.rethrowFromSystemServer();
  }
}

发现目标方法是由mService对象调用,它的定义


@UnsupportedAppUsage
IWifiManager mService;

查看IWifiManager


interface IWifiManager{
...

List<ScanResult> getScanResults(String callingPackage, String callingFeatureId);

WifiInfo getConnectionInfo(String callingPackage, String callingFeatureId);

...
}

可以看到IWifiManager是一个接口类,包含所需方法,可以当成一个切入点。


若以IWifiManager为切入点,进行hook


方法一

private static void hookWifi(Context context) {
try {
//反射获取相关类、字段对象
Class<?> iWifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
Field serviceField = HookUtil.getField("android.net.wifi.WifiManager", "mService");

WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
//获取原始mService对象
Object realIwm = serviceField.get(wifiManager);

//创建IWifiManager代理
Object proxy = Proxy.newProxyInstance(iWifiManagerClass.getClassLoader(),
new Class[]{iWifiManagerClass}, new WifiManagerProxy(realIwm));

//设置新代理
serviceField.set(wifiManager, proxy);
} catch (Exception e) {
e.printStackTrace();
}
}

其中新代理类实现InvocationHandler


public class WifiManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public WifiManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (("getScanResults".equals(methodName) || "getConnectionInfo".equals(methodName))){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}


2.考虑context问题:


获取原始wifiManager需要用到context上下文,不同context获取到的wifiManager不同。若统一使用application上下文可以基本覆盖所需,但是可能会出现遗漏(比如某处使用的是activity#context)。为了保证hook开关唯一,尝试再往上查找新的切入点。


查看获取wifiManager方法,由context调用.getSystemService()


WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);

继续查看context的实现contextImpl


@Override
public Object getSystemService(String name) {
...
return SystemServiceRegistry.getSystemService(this, name);
}

查看SystemServiceRegistry.getSystemService静态方法


public static Object getSystemService(ContextImpl ctx, String name) {
if (name == null) {
return null;
}
final ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
if (fetcher == null) {
...
return null;
}

final Object ret = fetcher.getService(ctx);
if (sEnableServiceNotFoundWtf && ret == null) {
...
return null;
}
return ret;
}

服务由SYSTEM_SERVICE_FETCHERS获取,它是一个静态的HashMap,它的put方法在registerService


private static <T> void registerService(@NonNull String serviceName,
@NonNull Class<T> serviceClass, @NonNull ServiceFetcher<T> serviceFetcher) {
...
SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
...
}

static{
...
//Android 11及以上
WifiFrameworkInitializer.registerServiceWrappers()
...
}

...

@SystemApi
public static <TServiceClass> void registerContextAwareService(
@NonNull String serviceName, @NonNull Class<TServiceClass> serviceWrapperClass,
@NonNull ContextAwareServiceProducerWithoutBinder<TServiceClass> serviceProducer) {
...
registerService(serviceName, serviceWrapperClass,
new CachedServiceFetcher<TServiceClass>() {
@Override
public TServiceClass createService(ContextImpl ctx)
throws ServiceNotFoundException {
return serviceProducer.createService(
ctx.getOuterContext(),
ServiceManager.getServiceOrThrow(serviceName));
}});

}


public static void registerServiceWrappers() {
...
SystemServiceRegistry.registerContextAwareService(
  Context.WIFI_SERVICE,
  WifiManager.class,
  (context, serviceBinder) -> {
  IWifiManager service = IWifiManager.Stub.asInterface(serviceBinder);
  return new WifiManager(context, service, getInstanceLooper());
  }
  );
}

SYSTEM_SERVICE_FETCHERS静态代码块中通过.registerServiceWrappers()注册WIFI_SERVICE服务。


registerService中new了一个CachedServiceFetcher,它返回一个serviceProducer.createService(...)


TServiceClass createService(@NonNull Context context, @NonNull IBinder serviceBinder);

其中第二个参数是一个IBinder对象,它的创建


ServiceManager.getServiceOrThrow(serviceName)

继续


public static IBinder getServiceOrThrow(String name) throws ServiceNotFoundException {
  final IBinder binder = getService(name);
  if (binder != null) {
  return binder;
  } else {
  throw new ServiceNotFoundException(name);
  }
  }
...
@UnsupportedAppUsage
public static IBinder getService(String name) {
  try {
  IBinder service = sCache.get(name);
  if (service != null) {
  return service;
  } else {
  return Binder.allowBlocking(rawGetService(name));
  }
  } catch (RemoteException e) {
  Log.e(TAG, "error in getService", e);
  }
  return null;
  }

最终在getServiceIBinder缓存在sCache中,它是一个静态变量


@UnsupportedAppUsage
private static Map<String, IBinder> sCache = new ArrayMap<String, IBinder>();

综上,如果可以创建新的IBinder,再替换掉sCache中的原始值就可以实现所需。


若以sCache为一个切入点


方法二

private static void hookWifi2() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.WIFI_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//生成代理IBinder,并替换原始值
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new WifiBinderProxy(iBinder));
sCacheMap.put(Context.WIFI_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class WifiBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public WifiBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IWifiManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.net.wifi.IWifiManager$Stub", "asInterface", IBinder.class);
Object iwifiManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成新IWifiManager代理
Class<?> iwifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iwifiManagerClass},
new WifiManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

至此完成无需上下文的全局拦截。


蓝牙篇


BluetoothLeScanner.startScan()为例查找切入点,以下省略非必需源码粘贴


private int startScan(List<ScanFilter> filters, ScanSettings settings,
final WorkSource workSource, final ScanCallback callback,
final PendingIntent callbackIntent,
List<List<ResultStorageDescriptor>> resultStorages) {
...
IBluetoothGatt gatt;
try {
gatt = mBluetoothManager.getBluetoothGatt();
} catch (RemoteException e) {
gatt = null;
}
...

private final IBluetoothManager mBluetoothManager;

...
public BluetoothLeScanner(BluetoothAdapter bluetoothAdapter) {
mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
mBluetoothManager = mBluetoothAdapter.getBluetoothManager();
...
}

向上查找IBluetoothManager,它在BluetoothAdapter中;向下代理getBluetoothGatt方法处理IBluetoothGatt


查看BluetoothAdapter的创建


public static BluetoothAdapter createAdapter(AttributionSource attributionSource) {
IBinder binder = ServiceManager.getService(BLUETOOTH_MANAGER_SERVICE);
if (binder != null) {
return new BluetoothAdapter(IBluetoothManager.Stub.asInterface(binder),
attributionSource);
} else {
Log.e(TAG, "Bluetooth binder is null");
return null;
}
}

ok,他也包含由ServiceManager中获取得到IBinder,然后进行后续操作。


若以IBluetoothManager为切入点


private static void hookBluetooth() {
try {
//反射ServiceManager中的getService(BLUETOOTH_MANAGER_SERVICE = 'bluetooth_manager')方法,获取原始IBinder
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, "bluetooth_manager");

//获取ServiceManager对象sCache
Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new BluetoothBinderProxy(iBinder));
sCacheMap.put("bluetooth_manager", (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

代理IBluetoothManager


public class BluetoothBinderProxy implements InvocationHandler {

private final IBinder mOriginalTarget;

public BluetoothBinderProxy(IBinder originalTarget) {
this.mOriginalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
//拦截
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
//不拦截
return method.invoke(mOriginalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IBluetoothManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.bluetooth.IBluetoothManager$Stub", "asInterface", IBinder.class);
Object iBluetoothManagerObject = asInterfaceMethod.invoke(null, mOriginalTarget);

//生成代理IBluetoothManager
Class<?> iBluetoothManagerClass = HookUtil.getClass("android.bluetooth.IBluetoothManager");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothManagerClass},
new BluetoothManagerProxy(iBluetoothManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

代理IBluetoothGatt


public class BluetoothManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getBluetoothGatt".equals(method.getName())) {
Object object = method.invoke(mOriginalTarget,args);
Object hook = hookGetBluetoothGatt(object);
if (hook != null){
return hook;
}
}
return method.invoke(mOriginalTarget, args);
}

private Object hookGetBluetoothGatt(Object object) {
try {
Class<?> iBluetoothGattClass = HookUtil.getClass("android.bluetooth.IBluetoothGatt");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothGattClass},
new BluetoothGattProxy(object));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

处理业务逻辑


public class BluetoothGattProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothGattProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startScan".equals(method.getName())){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}

定位篇


LocationManager.getLastKnownLocation()为例查找切入点,此处不粘贴源码,直接展示


private static void hookLocation() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.LOCATION_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new LocationBinderProxy(iBinder));
sCacheMap.put(Context.LOCATION_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class LocationBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public LocationBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始ILocationManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.location.ILocationManager$Stub", "asInterface", IBinder.class);
Object iLocationManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成代理ILocationManager
Class<?> iLocationManagerClass = HookUtil.getClass("android.location.ILocationManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iLocationManagerClass},
new LocationManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

总结


作为Android进程间通信机制Binder的守护进程,本次所hook的权限都可追溯到ServiceManagerServiceManager中的sCache缓存了权限相关的IBinder,以此为切入点可以进行统一处理,不需要引入context。


在此记录一下因隐私合规引发的hook处理流程,同时也想吐槽一下国内应用市场App上架审核是真滴难,每个市场的合规扫描标准都不一样。


附录


源码查看网站 aospxref.com/


路径:/frameworks/base/core/java/android/os/ServiceManager.j

作者:秋至
来源:juejin.cn/post/7262243685898960955
ava

收起阅读 »

如何选择 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

收起阅读 »

Flutter路由跳转参数处理小技巧

需求 我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。 实现 那么在Flutter中,我们经常会使用路由跳转到另外...
继续阅读 »

需求


我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。


实现


那么在Flutter中,我们经常会使用路由跳转到另外一个界面,那么如果这个时候需要传参。 代码如下:


/// 路由跳转并带参数
 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
);       
     
     
/// 测试数据模型
class TestArguments {
  String? name;
  String? address;
  TestArguments(this.name, this.address);
}

没错,直接赋值arguments字段就可以了,那么我们如何获取呢?


在第二个页面中


class TwoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 从路由设置中获取传递的参数
    var arguments = ModalRoute.of(context)?.settings.arguments;
    // 其他部分的代码...
  }
}


我们需要通过 ModalRoute.of(context)?.settings.arguments获取数据,那么我们直接在 initState方法中直接通过 ModalRoute.of(context)?.settings.arguments获取,会报错


这里出错原因,可以通过错误并查看源码可知,这里部讲述。


我们有的时候需要在initState方法中获取数据并处理一些事情,我们应该怎么做呢?


下面提供一个小技巧。





  • 路由定义


class RouteConst {
  static const routeNext = "/route_next";
}


class RoutePathConst {
  static var routePaths = <String, Widget Function(BuildContext context)>{
    RouteConst.routeNext: (context) => ArgumentsNextPage(),
  };
}




  • 跳转代码


 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
          );

/// 测试数据模型
class TestArguments {
  String? name;
  String? address;

  TestArguments(this.name, this.address);
}




  • 定义ArgumentsMixin


/// Arguments参数数据
mixin ArgumentsMixin {
  late final Object? arguments;
}

/// 路由拼接的参数数据
mixin RouteQueryMixin {
  final Map<String, String> routeParams = HashMap();
}




  • 重写onGenerateRoute



void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      onGenerateRoute: (settings) {
        var uri = Uri.parse(settings.name ?? "");
        var route = uri.path;
        var params = uri.queryParameters;
        if (!RoutePathConst.routePaths.containsKey(route)) {
          return null;
        }
        return MaterialPageRoute(
          builder: (context) {
            var widgetBuilder = RoutePathConst.routePaths[route];
            var widget = widgetBuilder!(context);
            if (widget is RouteQueryMixin) {
              (widget as RouteQueryMixin).routeParams.addAll(params);
            }
            if (widget is ArgumentsMixin) {
              (widget as ArgumentsMixin).arguments = settings.arguments;
            }
            return widget;
          },
          settings: settings,
        );
      },
    );
  }
}





  • 创建ArgumentsNextPage



///第二页
class ArgumentsNextPage extends StatefulWidget
    with ArgumentsMixin, RouteQueryMixin {
  ArgumentsNextPage({super.key});

  @override
  State<ArgumentsNextPage> createState() => _ArgumentsNextPageState();
}

class _ArgumentsNextPageState extends State<ArgumentsNextPage> {
  /// 传参数据文本
  String get result {
    // Arguments传参数据
    TestArguments? arguments;
    if (widget.arguments != null && widget.arguments is TestArguments) {
      arguments = widget.arguments as TestArguments;
    }

    // 路由拼接的数据
    var params = widget.routeParams;

    // 拼接结果数据
    return "arguments:name=${arguments?.name ?? ""} address=${arguments?.address ?? ""} \nrouteParams=$params";
  }

  @override
  void initState() {
    super.initState();
    print("result=$result}");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: XYAppBar(
        title: "第二页",
        onBack: () {
          Navigator.pop(context);
        },
      ),
      body: Center(
        child: Text(result),
      ),
    );
  }
}


这样就OK了,好像没讲啥,直接看代码吧。


详细代码见:github.com/yixiaolunhui/flutter_xy


作者:移动小样
来源:mdnice.com/writing/3d43c6e3544b45c59773b133a135fb01
收起阅读 »

Flutter-数字切换动画

效果 需求 数字切换时新数字从上往下进入,上个数字从上往下出 新数字进入时下落到位置并带有回弹效果 上个数字及新输入切换时带有透明度和缩放动画 实现 主要采用Animat...
继续阅读 »

效果





需求





  • 数字切换时新数字从上往下进入,上个数字从上往下出



  • 新数字进入时下落到位置并带有回弹效果



  • 上个数字及新输入切换时带有透明度和缩放动画


实现


主要采用AnimatedSwitcher实现需求,代码比较简单,直接撸


import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_xy/widgets/xy_app_bar.dart';

class NumAnimPage extends StatefulWidget {
  const NumAnimPage({super.key});

  @override
  State<NumAnimPage> createState() => _NumAnimPageState();
}

class _NumAnimPageState extends State<NumAnimPage> {
  int _currentNum = 0;

  // 数字文本随机颜色
  Color get _numColor {
    Random random = Random();
    int red = random.nextInt(256);
    int green = random.nextInt(256);
    int blue = random.nextInt(256);
    return Color.fromARGB(255, red, green, blue);
  }

  // 数字累加
  void _addNumber() {
    setState(() {
      _currentNum++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: XYAppBar(
        title: "数字动画",
      ),
      body: Center(
        child: _bodyWidget(),
      ),
    );
  }

  Widget _bodyWidget() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 500),
          transitionBuilder: (Widget child, Animation<double> animation) {
            Offset startOffset = animation.status == AnimationStatus.completed
                ? const Offset(0.0, 1.0)
                : const Offset(0.0, -1.0);
            Offset endOffset = const Offset(0.0, 0.0);
            return SlideTransition(
              position: Tween(begin: startOffset, end: endOffset).animate(
                CurvedAnimation(parent: animation, curve: Curves.bounceOut),
              ),
              child: FadeTransition(
                opacity: Tween(begin: 0.0, end: 1.0).animate(
                  CurvedAnimation(parent: animation, curve: Curves.linear),
                ),
                child: ScaleTransition(
                  scale: Tween(begin: 0.5, end: 1.0).animate(
                    CurvedAnimation(parent: animation, curve: Curves.linear),
                  ),
                  child: child,
                ),
              ),
            );
          },
          child: Text(
            '$_currentNum',
            key: ValueKey<int>(_currentNum),
            style: TextStyle(fontSize: 100, color: _numColor),
          ),
        ),
        const SizedBox(height: 80),
        ElevatedButton(
          onPressed: _addNumber,
          child: const Text(
            '数字动画',
            style: TextStyle(fontSize: 25, color: Colors.white),
          ),
        ),
      ],
    );
  }
}


具体见github:https://github.com/yixiaolunhui/flutter_xy


作者:移动小样
来源:mdnice.com/writing/9645b22a9a54493f9f2e3f74e60d17c7
收起阅读 »

我的又一个神奇的框架——Skins换肤框架

为什么会有换肤的需求 app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。 换肤是什么 换...
继续阅读 »

为什么会有换肤的需求


app的换肤,可以降低app用户的审美疲劳。再好的UI设计,一直不变的话,也会对用户体验大打折扣,即使表面上不说,但心里或多或少会有些难受。所以app的界面要适当的改版啊,要不然可难受死用户了,特别是UI设计还相对较丑的。


换肤是什么


换肤是将app的背景色、文字颜色以及资源图片,一键进行全部切换的过程。这里就包括了图片资源和颜色资源。


Skins怎么使用


Skins就是一个解决这样一种换肤需求的框架。


// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
// skins依赖了dora框架,所以你也要implementation dora
implementation("com.github.dora4:dora:1.1.12")
implementation 'com.github.dora4:dview-skins:1.4'
}

我以更换皮肤颜色为例,打开res/colors.xml。


<!-- 需要换肤的颜色 -->
<color name="skin_theme_color">@color/cyan</color>
<color name="skin_theme_color_red">#d23c3e</color>
<color name="skin_theme_color_orange">#ff8400</color>
<color name="skin_theme_color_black">#161616</color>
<color name="skin_theme_color_green">#009944</color>
<color name="skin_theme_color_blue">#0284e9</color>
<color name="skin_theme_color_cyan">@color/cyan</color>
<color name="skin_theme_color_purple">#8c00d6</color>

将所有需要换肤的颜色,添加skin_前缀和_skinname后缀,不加后缀的就是默认皮肤。
然后在启动页应用预设的皮肤类型。在布局layout文件中使用默认皮肤的资源名称,像这里就是R.color.skin_theme_color,框架会自动帮你替换。要想让框架自动帮你替换,你需要让所有要换肤的Activity继承BaseSkinActivity。


private fun applySkin() {
val manager = PreferencesManager(this)
when (manager.getSkinType()) {
0 -> {
}
1 -> {
SkinManager.changeSkin("cyan")
}
2 -> {
SkinManager.changeSkin("orange")
}
3 -> {
SkinManager.changeSkin("black")
}
4 -> {
SkinManager.changeSkin("green")
}
5 -> {
SkinManager.changeSkin("red")
}
6 -> {
SkinManager.changeSkin("blue")
}
7 -> {
SkinManager.changeSkin("purple")
}
}
}

另外还有一个情况是在代码中使用换肤,那么跟布局文件中定义是有一些区别的。


val skinThemeColor = SkinManager.getLoader().getColor("skin_theme_color")

这个skinThemeColor拿到的就是当前皮肤下的真正的skin_theme_color颜色,比如R.color.skin_theme_color_orange的颜色值“#ff8400”或R.id.skin_theme_color_blue的颜色值“#0284e9”。
SkinLoader还提供了更简洁设置View颜色的方法。


override fun setImageDrawable(imageView: ImageView, resName: String) {
val drawable = getDrawable(resName) ?: return
imageView.setImageDrawable(drawable)
}

override fun setBackgroundDrawable(view: View, resName: String) {
val drawable = getDrawable(resName) ?: return
view.background = drawable
}

override fun setBackgroundColor(view: View, resName: String) {
val color = getColor(resName)
view.setBackgroundColor(color)
}

框架原理解析


先看BaseSkinActivity的源码。


package dora.skin.base

import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.InflateException
import android.view.LayoutInflater
import android.view.View
import androidx.collection.ArrayMap
import androidx.core.view.LayoutInflaterCompat
import androidx.core.view.LayoutInflaterFactory
import androidx.databinding.ViewDataBinding
import dora.BaseActivity
import dora.skin.SkinManager
import dora.skin.attr.SkinAttr
import dora.skin.attr.SkinAttrSupport
import dora.skin.attr.SkinView
import dora.skin.listener.ISkinChangeListener
import dora.util.LogUtils
import dora.util.ReflectionUtils
import java.lang.reflect.Constructor
import java.lang.reflect.Method
import java.util.*

abstract class BaseSkinActivity<T : ViewDataBinding> : BaseActivity<T>(),
ISkinChangeListener, LayoutInflaterFactory {

private val constructorArgs = arrayOfNulls<Any>(2)

override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
if (createViewMethod == null) {
val methodOnCreateView = ReflectionUtils.findMethod(delegate.javaClass, false,
"createView", *createViewSignature)
createViewMethod = methodOnCreateView
}
var view: View? = ReflectionUtils.invokeMethod(delegate, createViewMethod, parent, name,
context, attrs) as View?
if (view == null) {
view = createViewFromTag(context, name, attrs)
}
val skinAttrList = SkinAttrSupport.getSkinAttrs(attrs, context)
if (skinAttrList.isEmpty()) {
return view
}
injectSkin(view, skinAttrList)
return view
}

private fun injectSkin(view: View?, skinAttrList: MutableList<SkinAttr>) {
if (skinAttrList.isNotEmpty()) {
var skinViews = SkinManager.getSkinViews(this)
if (skinViews == null) {
skinViews = arrayListOf()
}
skinViews.add(SkinView(view, skinAttrList))
SkinManager.addSkinView(this, skinViews)
if (SkinManager.needChangeSkin()) {
SkinManager.apply(this)
}
}
}

private fun createViewFromTag(context: Context, viewName: String, attrs: AttributeSet): View? {
var name = viewName
if (name == "view") {
name = attrs.getAttributeValue(null, "class")
}
return try {
constructorArgs[0] = context
constructorArgs[1] = attrs
if (-1 == name.indexOf('.')) {
// try the android.widget prefix first...
createView(context, name, "android.widget.")
} else {
createView(context, name, null)
}
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
} finally {
// Don't retain references on context.
constructorArgs[0] = null
constructorArgs[1] = null
}
}

@Throws(InflateException::class)
private fun createView(context: Context, name: String, prefix: String?): View? {
var constructor = constructorMap[name]
return try {
if (constructor == null) {
// Class not found in the cache, see if it's real, and try to add it
val clazz = context.classLoader.loadClass(
if (prefix != null) prefix + name else name).asSubclass(View::class.java)
constructor = clazz.getConstructor(*constructorSignature)
constructorMap[name] = constructor
}
constructor!!.isAccessible = true
constructor.newInstance(*constructorArgs)
} catch (e: Exception) {
// We do not want to catch these, lets return null and let the actual LayoutInflater
null
}
}

override fun onCreate(savedInstanceState: Bundle?) {
val layoutInflater = LayoutInflater.from(this)
LayoutInflaterCompat.setFactory(layoutInflater, this)
super.onCreate(savedInstanceState)
SkinManager.addListener(this)
}

override fun onDestroy() {
super.onDestroy()
SkinManager.removeListener(this)
}

override fun onSkinChanged(suffix: String) {
SkinManager.apply(this)
}

companion object {
val constructorSignature = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap: MutableMap<String, Constructor<out View>> = ArrayMap()
private var createViewMethod: Method? = null
val createViewSignature = arrayOf(View::class.java, String::class.java,
Context::class.java, AttributeSet::class.java)
}
}

我们可以看到BaseSkinActivity继承自dora.BaseActivity,所以dora框架是必须要依赖的。有人说,那我不用dora框架的功能,可不可以不依赖dora框架?我的回答是,不建议。Skins对Dora生命周期注入特性采用的是,依赖即配置。


package dora.lifecycle.application

import android.app.Application
import android.content.Context
import dora.skin.SkinManager

class SkinsAppLifecycle : ApplicationLifecycleCallbacks {

override fun attachBaseContext(base: Context) {
}

override fun onCreate(application: Application) {
SkinManager.init(application)
}

override fun onTerminate(application: Application) {
}
}

所以你无需手动配置<meta-data android:name="dora.lifecycle.config.SkinsGlobalConfig" android:value="GlobalConfig"/>,Skins已经自动帮你配置好了。那么我顺便问个问题,BaseSkinActivity中最关键的一行代码是哪行?LayoutInflaterCompat.setFactory(layoutInflater, this)这行代码是整个换肤流程最关键的一行代码。我们来干预一下所有Activity onCreateView时的布局加载过程。我们在SkinAttrSupport.getSkinAttrs中自己解析了AttributeSet。


    /**
* 从xml的属性集合中获取皮肤相关的属性。
*/

fun getSkinAttrs(attrs: AttributeSet, context: Context): MutableList<SkinAttr> {
val skinAttrs: MutableList<SkinAttr> = ArrayList()
var skinAttr: SkinAttr
for (i in 0 until attrs.attributeCount) {
val attrName = attrs.getAttributeName(i)
val attrValue = attrs.getAttributeValue(i)
val attrType = getSupportAttrType(attrName) ?: continue
if (attrValue.startsWith("@")) {
val ref = attrValue.substring(1)
if (TextUtils.isEqualTo(ref, "null")) {
// 跳过@null
continue
}
val id = ref.toInt()
// 获取资源id的实体名称
val entryName = context.resources.getResourceEntryName(id)
if (entryName.startsWith(SkinConfig.ATTR_PREFIX)) {
skinAttr = SkinAttr(attrType, entryName)
skinAttrs.add(skinAttr)
}
}
}
return skinAttrs
}

我们只干预skin_开头的资源的加载过程,所以解析得到我们需要的属性,最后得到SkinAttr的列表返回。


package dora.skin.attr

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import dora.skin.SkinLoader
import dora.skin.SkinManager

enum class SkinAttrType(var attrType: String) {

/**
* 背景属性。
*/

BACKGROUND("background") {
override fun apply(view: View, resName: String) {
val drawable = loader.getDrawable(resName)
if (drawable != null) {
view.setBackgroundDrawable(drawable)
} else {
val color = loader.getColor(resName)
view.setBackgroundColor(color)
}
}
},

/**
* 字体颜色。
*/

TEXT_COLOR("textColor") {
override fun apply(view: View, resName: String) {
val colorStateList = loader.getColorStateList(resName) ?: return
(view as TextView).setTextColor(colorStateList)
}
},

/**
* 图片资源。
*/

SRC("src") {
override fun apply(view: View, resName: String) {
if (view is ImageView) {
val drawable = loader.getDrawable(resName) ?: return
view.setImageDrawable(drawable)
}
}
};

abstract fun apply(view: View, resName: String)

/**
* 获取资源管理器。
*/

val loader: SkinLoader
get() = SkinManager.getLoader()
}

当前skins框架只定义了几种主要的换肤属性,你理解原理后,也可以自己进行扩展,比如RadioButton的button属性等。


开源项目传送门


如果你要深入理解完整的换肤流程,请阅读skins的源代码,[github.com/dora4/dvi

ew…] 。

收起阅读 »

一名(陷入Android无法自拔的)大二狗的年中总结

前言 大家好,我是入行没多久,不会写代码的RQ。一名来自双非学校的大二狗。2023时间已经过去了一半,我的大学生活也过去了一半。借着这个2023年中总结的话题我也想给我的前半段大学生活做一个总结和记录(希望大家原谅理工男的表达能力,已经在学着写博客了🙃)。 大...
继续阅读 »

前言


大家好,我是入行没多久,不会写代码的RQ。一名来自双非学校的大二狗。2023时间已经过去了一半,我的大学生活也过去了一半。借着这个2023年中总结的话题我也想给我的前半段大学生活做一个总结和记录(希望大家原谅理工男的表达能力,已经在学着写博客了🙃)。


大学之前


大学之前,我的高中初中都是在一个小乡镇度过,每天都是过着教室、食堂、厕所三点一线的生活。可能偶尔会和几个好兄弟打打球,开开黑。那时候一心只读圣贤书,从未碰过电脑(也只有偶尔去网吧玩玩电脑游戏),也未曾了解过任何跟代码相关的东西。只有在高三快毕业了,学校进行志愿填报培训的时候,我才在想我想干什么。


qq_pic_merged_1689681281489.jpg


我想学编程,我想搞钱,我要成为编程高手!!哈哈哈,当时的确是这么想的,因为我一直都觉得会电脑,会编程的“大黑客”很酷!。然而结果是


qq_pic_merged_1689681309027.jpg


当时我还一时兴起,在京东上买了本0基础学python的书:


IMG_20230718_152056.jpg


奈何高三学业繁忙没时间看,而且也没有电脑实操,看了十几页压根不知道在讲什么,之后这本书也就放着吃灰了。现在看来当时确实挺傻×的。后来上了大学,自学了python,这本书也就送给了室友。


高三的时候大家都想着我要上某某985!我要上某某211!当然我也不例外。然而等到高考出分,才知道现实是多么残酷。我的高考成绩也只够报一个末流的211,报不了什么985。思虑最终我报了一个专业性比较强的双非计算机。因为我觉得一个专业不对口的末流211不如一个专业好的双非。


上大学前我是保持怀疑的,我没有任何相关编程经验,甚至是接触电脑的机会都少。不过幸运的是家里人都支持我,给我买了一台不错的笔记本。那个暑假我加入了我们学校的新生群,我发现原来大家都是卷王。有初中就开始接触编程的,有高中就学完的java的,有暑假已经快把c语言学完了的。为了不落后,高考完的那个后半个暑假我也在偷偷学c语言,能力有限,到开学也才学到指针多一点点(指针这个东西对于当时的我简直就是噩梦)。


大一


大一开学后,我同大多数人一样,满怀期待地踏进了向往的大学生活。在第一次年级集中会上,我收到了一份宣传单。那是一份我们学校的一个互联网组织的宣传单。分有产品、视觉、后端、移动、前端、运维几个部门。听说里面全是编程大牛,学校里顶尖技术人员的集聚地。这不就是我想成为的人吗?于是我下定决心我要加入他们。


大一的时候大部分课余时间都花在了这个叫做红岩网校工作站的课程上面。大一上半个学期学会了javase,下半个学期开始学写APP,会写几个简单的Activity页面,当时我还写了个整蛊APP(只是简单将声音放到最大然后播放整蛊音乐lost-rivers。哈哈哈这个不提倡,小心被打)。当然学校课程我也没有忘记,我记得c语言期末大作业自己写了个贪吃蛇和俄罗斯方块:


image.png


image.png


一行一行敲了八九百行,对于当时还是编程小白的我是个不小的成就了。


后来的一整个寒假都在写我们移动开发部的寒假考核,也是我人生中的第一个项目--彩云天气app(地址就不贴了,现在看来写的代码就是💩)。


大一的下学期,开学自学了Kotlin语言,从此再也不想用java了😭。之后也是按照网校的课程学了jetpack、rxjava、retrofit、MvvM等等。到了五一,写了自己的第二个项目--星球app(时间管理类app),也是网校的期中考核(当然也顺利通过啦~)。后面自学了python,简单写了一个抢课的脚本(以后再也不怕抢不到课了😭)。之后自己租了个服务器用python搭了个QQ机器人,后面搞到网校招新群里去玩了。不得不说Bot社区真的不错,文档什么的都很完善,对QQ机器人感兴趣的可以试试(概览 | Bot (baka.icu))。


大一的暑假,我留在了学校参加了网校的暑期培训。培训期间简单研究了一下Android性能优化跟LeakCanary,然后也是写了自己的第三个项目:开眼APP(RQ527/KaiYan,图片可能寄掉了。)


最终呢也是没有辜负自己的努力通过了最终的考核成为了网校的干事:


mmexport1689672953594.jpg

总的来说,大一学年算是踏入了编程的门吧,没有在荒废中度过。同时也要感谢网校给了我这个机会😁。


大二


大二的课余时间主要都花在了给移动开发部门培养新血液的事情上面。因为我的上一届也就是带我们的学长他们大三了,准备考研的考研,就业的就业,自然教学的任务就落到了我们头上。期间上了三节课,我发现给他们上课的同时也是给我自己上课。学习一个东西最有效的方式就是给别人讲懂。


这是大二刚开学的宣讲会😁:


IMG_20221003_185411.jpg


1664878518099.jpeg


大二期间我还了解了一下ktor和compose,嗯~,不算深入吧,简单写了几个demo。
同时自己也接手了一个多人项目,跟我们部门的另外一个人写一个类似于微博投票表决的项目,不过还没上线。


下半个学期自己用hexo+butterfly搭了个个人博客网站:rq527.github.io (还没钱买域名,暂时先用github吧😭),页面大概长这样:


image-20230719144318074


image-20230719144342289


个人思考


我认识到了什么



  • 接受自己的平庸,接受任何方面的平庸。

  • 永远不要斤斤计较

  • 杜绝一分钟热度,永远保持一颗热忱的心

  • 打铁还需自身硬

    从入行Android 开发以来,网上很多人都说 “Android 开发早就凉了,现在就是死路一条”,“现在学Android就是49年入国军!”等等。但是我身边同行的人还不是能找到实习,找到工作。我的意思是,什么事情都是需要自己有实力。





说实话,上了大学我最痛惜的是那些曾经交好的朋友也逐渐不联系了,一张通知书撕裂了一群人,以后再见也不知道是什么时候了。


未来的事情



  • 管理移动开发部

  • 找实习(目标是进大厂)


盘点一下要做的事情,发现太多了,主要的方向是这两个。人外有人,天外有天,比你牛逼的人还有很多,一直保持学习吧🤕!


最后


最后我想说很感谢家里人的支持,他们没有说反对我,强制要求我当老师,当警察等等,而是支持我所做的一切。同时也很感谢那个她,陪我一起成长,学习,愿意和我分享快乐,听我诉说(世上最幸运的事情莫过于此了吧😁)。也很感谢网校给我这么一个平台,让我认识了很多志

作者:RQ527
来源:juejin.cn/post/7257056512610517048
同道合的兄弟和伙伴。

收起阅读 »

工信部又出新规!爬坑指南

一、背景 工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。 二、整改 2.1 个...
继续阅读 »

一、背景


工信部最近发布了新的入网要求,明确了app进网检测要求的具体变化,主要涉及到一些app权限调用,个人信息保护,软件升级以及敏感行为。为了不影响app的正常运行,依据工信部的文件进行相关整改,下文将从5个方向来阐述具体的解决思路。


二、整改


2.1 个人信息保护


2.1.1 基本模式(无权限、无个人信息获取模式)


这次整改涉及到最大的一个点就是基本模式,基本模式指的是在用户选择隐私协议弹窗时,不能点击“不同意”即退出应用,而是需要给用户提供一个除联网功能外,无任何权限,无任何个人信息获取的模式且用户能正常使用。


这个说法有点抽象,我们来看下友商已经做好的案例。


腾讯视频



从腾讯视频的策略来看,用户第一次使用app,依旧会弹出一个“用户隐私协议”弹窗供用户选择,但是和以往不同的是,“不同意”按钮替换为了“不同意并进入基本功能模式”,用户点击“不同意并进入基本功能模式”则进入到一个简洁版的页面,只提供一些基本功能,当用户点击“进入全功能模式”,则再次弹出隐私协议弹窗。当杀死进程后,再次进入则直接进入基本模式。


网易云音乐



网易云音乐和腾讯视频的产品策略略有不同,在用户在一级授权弹窗点击“不同意”,会跳转至二级授权弹窗,当用户在二级弹窗上点击“不同意,进入基本功能模式”,才会进入基本功能页面,在此页面上点击“进入完整功能模式”后就又回到了二级授权页。当用户杀死进程,重新进入app时,还是会回到一级授权页。


网易云音乐比腾讯视频多了一个弹窗,也只是为了提升用户进入完整模式的概率,并不涉及到新规。


另外,B站、酷狗音乐等都已经接入了基本模式,有兴趣的伙伴可以自行下载体验。


2.1.2 隐私政策内容


如果app存在读取并传送用户个人信息的行为,需要检查其是否具备用户个人信息收集、使用规则,并明确告知读取和传送个人信息的目的、方式和范围。


判断权限是否有读取、修改、传送行为,如果有,需要在隐私协议中明文告知。


举个例子,app有获取手机号码并且保存在服务器,需要在协议中明确声明:读取并传送用户手机号码。


2.2 app权限调用


2.2.1 应用内权限调用



  1. 获取定位信息和生物特征识别信息


在获取定位信息以及生物特征识别信息时需要在调用权限前,单独向用户明示调用权限的目的,不能用系统权限弹窗替代。



如上图,申请位置权限,需要在申请之前,弹出弹窗供用户选择,用户同意调用后才可以申请位置权限。



  1. 其他权限


其他权限如上面隐私政策一样,需要在调用时,声明是读取、修改、还是传送行为,如下图



2.3 应用软件升级


2.3.1 更新


应用软件或插件更新应在用户授权的情况下进行,不能直接更新,另外要明确告知用户此行为包含下载和安装。


简单来说,就是在app进行更新操作时,需要用弹窗告知用户,是否更新应用,更新的确认权交给用户,并且弹窗上需要声明此次更新有下载和安装两个操作。如下图



2.4 应用签名


需要保

作者:付十一
来源:juejin.cn/post/7253610755126476857
证签名的真实有效性。

收起阅读 »

Android常见问题

1.1.Demo为啥手机号验证无法登录? 首先我们将demo跑起来是UI是这个样式的点击4.0.3版本号两下,会出现一个提示 我们点击ok2.切换到这个页面我们点击 服务器配置将在管理后台的appkey填写以后 点击下面的保存这样我们在页面正常按照在环信管理后...
继续阅读 »

1.1.Demo为啥手机号验证无法登录?

首先我们将demo跑起来是UI是这个样式的

点击4.0.3版本号两下,会出现一个提示 我们点击ok
2.
切换到这个页面我们点击 服务器配置

将在管理后台的appkey填写以后 点击下面的保存
这样我们在页面正常按照在环信管理后台申请的 环信id 登录就可以了 (登录方式是账号密码登录)
2.修改会话条目的尺寸宽高 他是属于EaseBaseLayout ,相比EaseChatLayout 他是ChatLayout的父类 关于尺寸大小的设计是存在基本都在父类中


3.集成后环信后,App被其他应用平台下架,厂商反馈是自启动的原因

将此服务去除


4.如何将百度地图切换到高德地图


1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度。
2..点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写。
5.播放语音消息语音消息的声音小(不是语音通话)
(1)首先要打开扬声器 如果觉得声音还是比较小

(2)将ui库中调用的原声音量模式修改为媒体音量模式




收起阅读 »

Flutter如何实现IOC与AOP

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。 IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应...
继续阅读 »

在Flutter中实现IOC(Inversion of Control,控制反转)与AOP(Aspect-Oriented Programming,面向切面编程)之前,让我们先来了解一下这两个概念。


IOC(控制反转) 是一种设计原则,它将应用程序的控制权从应用程序本身转移到外部框架或容器。传统上,应用程序会自己创建和管理对象之间的依赖关系。而在IOC中,对象的创建和管理被委托给一个专门的框架或容器。框架负责创建和注入对象,以实现松耦合和可扩展的架构。通过IOC,我们可以将应用程序的控制流程反转,从而实现更灵活、可测试和可维护的代码。


AOP(面向切面编程) 是一种编程范式,用于将横切关注点(如日志记录、事务管理、性能监控等)从应用程序的主要业务逻辑中分离出来。AOP通过在特定的切入点上织入额外的代码(称为切面),从而实现对这些关注点的统一管理。这种分离和集中的方式使得我们可以在不修改核心业务逻辑的情况下添加、移除或修改横切关注点的行为。


对于Java开发者来说,IOC和AOP可能已经很熟悉了,因为在Java开发中有许多成熟的框架,如Spring,提供了强大的IOC和AOP支持。


在Flutter中,尽管没有专门的IOC和AOP框架,但我们可以利用语言本身和一些设计模式来实现类似的功能。


接下来,我们可以探讨在Flutter中如何实现IOC和AOP的一些常见模式和技术。无论是依赖注入还是横切关注点的管理,我们可以使用一些设计模式和第三方库来实现类似的效果,以满足我们的开发需求


1. 控制反转(IOC):


依赖注入(Dependency Injection):依赖注入是一种将依赖关系从组件中解耦的方式,通过将依赖项注入到组件中,实现控制反转的效果。在Flutter中,你可以使用get_it库来实现依赖注入。下面是一个示例:


import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

class UserService {
String getUser() => 'John Doe';
}

class GreetingService {
final UserService userService;

GreetingService(this.userService);

String greet() {
final user = userService.getUser();
return 'Hello, $user!';
}
}

void main() {
// 注册依赖关系
GetIt.instance.registerSingleton<UserService>(UserService());
GetIt.instance.registerSingleton<GreetingService>(
GreetingService(GetIt.instance<UserService>()),
);

runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final greetingService = GetIt.instance<GreetingService>();

return MaterialApp(
title: 'IOC Demo',
home: Scaffold(
appBar: AppBar(title: Text('IOC Demo')),
body: Center(child: Text(greetingService.greet())),
),
);
}
}


在上述示例中,我们定义了UserServiceGreetingService两个类。GreetingService依赖于UserService,我们通过依赖注入的方式将UserService注入到GreetingService中,并通过get_it库进行管理。


2. 面向切面编程(AOP):


在Flutter中,可以使用Dart语言提供的一些特性,如Mixin和装饰器(Decorator)来实现AOP。


Mixin:Mixin是一种通过将一组方法和属性混入到类中来实现代码复用的方式。下面是一个示例:


import 'package:flutter/material.dart';

mixin LogMixin<T extends StatefulWidget> on State<T> {
void log(String message) {
print('[LOG]: $message');
}
}

class LogButton extends StatefulWidget {
final VoidCallback onPressed;

const LogButton({required this.onPressed});

@override
_LogButtonState createState() => _LogButtonState();
}

class _LogButtonState extends State<LogButton> with LogMixin {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
log('Button clicked');
widget.onPressed();
},
child: Text('Click Me'),
);
}
}

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AOP Demo',
home: Scaffold(
appBar: AppBar(title: Text('AOP Demo')),
body: Center(child: LogButton(onPressed: () => print('Button pressed'))),
),
);
}
}



在上面的示例中,我们定义了一个LogMixin,其中包含了一个log方法,用于记录日志。然后我们在_LogButtonState中使用with LogMixin将日志记录功能混入到_LogButtonState中。每次按钮被点击时,会先打印日志,然后调用传入的回调函数。


装饰器:装饰器是一种将额外行为添加到方法或类上的方式。下面是一个示例:


void logDecorator(Function function) {
print('[LOG]: Method called');
function();
}

@logDecorator
void greet() {
print('Hello, world!');
}

void main() {
greet();
}

在Flutter中,虽然没有专门的IOC(控制反转)和AOP(面向切面编程)框架,但我们可以利用一些设计模式和技术来实现类似的效果。


对于IOC,我们可以使用依赖注入(Dependency Injection)的方式实现。依赖注入通过将依赖项注入到组件中,实现了控制反转的效果。在Flutter中,可以借助第三方库如get_itkiwi来管理依赖关系,将对象的创建和管理交由依赖注入框架处理。


在AOP方面,我们可以使用Dart语言提供的Mixin和装饰器(Decorator)来实现类似的功能。Mixin是一种通过将一组方法和属性混入到类中的方式实现代码复用,而装饰器则可以在不修改被装饰对象的情况下,添加额外的行为或改变对象的行为。


通过使用Mixin和装饰器,我们可以在Flutter中实现横切关注点的管理,例如日志记录、性能监测和权限控制等。通过将装饰器应用于关键的方法或类,我们可以在应用程序中注入额外的功能,而无需直接修改原始代码。


需要注意的是,以上仅为一些示例,具体实现方式可能因项目需求和个人偏好而有所不同。在Flutter中,我们可以灵活运用设计模式、第三方库和语言特性,以实现IOC和AOP的效果,从而提升代码的可维护性、可扩展性和重用性。


总结而言,尽管Flutter没有专门的IOC和AOP框架,但我们可以借助依赖注入和装饰器等技术,结合常见的设计模式,构建灵活、可测试和可维护的应用程序。这些技术和模式为开发者提供了良好的开发体验和代码结构。


希望对您有所帮助谢谢!!

作者:北漂十三载
来源:juejin.cn/post/7251032736692600869

收起阅读 »

Android 内存治理之线程

1、 前言   当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。 java.lang.OutOfMemoryError: pthread_create (1040KB stack) fa...
继续阅读 »

1、 前言


  当我们在应用程序中启动一个线程的时候,也是有可能发生OOM错误的。当我们看到以下log的时候,就说明系统分配线程栈失败了。


java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory


这种情况可能是两种原因导致的。



  • 第一个就是系统的内存不足的时候,我们去启动一个线程。

  • 第二种就是进程内运行的线程总数超过了系统的限制。



  如果是内存不足的情况,需按照堆内存治理的方式来进行解决,检查应用内存泄漏问题并优化,此情况不作为本次讨论的重点。

  本次主要讨论进程内运行的线程总数超过了系统的限制所导致的情况。出现此情况时,我们就需要通过控制并发的线程总数来解决这个问题。


  想要控制并发的线程数。最直接的一种方式就是利用回收的思路,也就是让我们的线程通过串行的方式来执行;一个线程执行完毕之后,再启动下一个线程。这样就能够让并发的线程总数达到一个可控的状态。

  另外一种方式就是通过复用来解决,让同一个线程的实例可以被反复的利用,只创建较少的线程实例,就能完成大量的异步操作。


2、异步任务的方式对比


  对比一下,在安卓平台我们比较常用的开启异步任务的方式中,有哪些是更加有利于我们进行线程总数的控制的。


开启异步任务的方式特点
Thread.start()并行,难以管理
HandlerThread带消息循环的线程,线程内部串行任务(线程复用)
AsyncTask轻量级,串行(3.0以上),可以结合线程池使用
线程池可管理并发数,池化复用线程
Kotlin协程简化异步编程代码,复用线程,提高并发效率
##### 2.1 Thread

  从最简单的直接创建Thread的实例的方式来说起。在Java中这种方式虽然是最简单的去开启一个线程的方式,但是在实际开发中,一旦我们通过这种方式去自己创建 Thread 类的实例,并且调用 start 来开启一个线程的话,所开启的线程会非常的难以调度和管理。这种线程也就是我们平时所说的野线程。所以我们最好不要直接的创建thread类的实例。


2.2 HandlerThread

public class HandlerThread extends Thread { }

  HandlerThread是Thread类的子类,对Thread做了很多便利的封装。它有自己的Loop,它能够进行消息循环,所以就能够做到通过Handler执行异步任务,也能够做到在不同的线程之间,通过Handler进行现成的通讯。我们可以利用Handler的post操作,让我们在一个线程内部串行的执行多个异步任务。从内存的角度来说,也就相当于对线程进行了复用。


2.3 AsyncTask

  AsyncTask是一个相对更加轻量级,专门为了完成执行异步任务,然后返回UI线程更新UI的操作而设计的。对于我们来说,AsyncTask更像是一个任务的概念,而不是一个线程的概念。我们不需要把它当做一个线程去理解。 AsyncTask的本质,其实也是对线程和Handler的封装。



  • Android 1.6前,串行执行,原理:一个子线程进行任务的串行执行;

  • Android 1.6到2.3,并行执行,原理:一个线程数为5的线程池并行执行,但如果前五个任务执行时间过长,会堵塞后续任务执行,故不适合大量任务并发执行;

  • Android 3.0后,串行执行,原理:全局线程池进行串行处理任务;


到了Android 3.0以上版本,默认是串行执行的,但是可以结合线程值来实现有限制的并行。也可以达到一个限制线程总数的目的。


2.4 线程池

  Java语言本身也为我们提供了线程池。线程池的作用就是可以管理并发数,并且能够持续的去复用线程。如果在一个应用内部的全部异步操作,全部都采用线程池的方式来开启的话,那么我们就能够管理我们所有的异步任务了。这样一来,能够大大的降低线程治理的成本。


2.5 Kotlin协程

  在Kotlin中还引入了协程的概念。协程给传统的Java的异步编程带来最大的改变,就是能够让我们更加优雅的去实现异步任务。我们前面所说的这几种异步任务的执行方式,都需要我们额外的去写大量的样本代码。而Kotlin协程就能够做到让我们用写同步代码的方式去写异步代码。


  在语法的层面上,协程的另一个优势就是性能方面。协程能够帮助我们用更少的线程去执行更多的并发任务。同样也降低了我们治理内存的成本。从治理内存的角度来说,用线程池接管线程或者采用协程都是很好的方式。

作者:大神仙
来源:juejin.cn/post/7250357906712854589

收起阅读 »

Vue3 如何开发原生(安卓,ios)

Vue3 有没有一款好用的开发原生的工具 1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uni...
继续阅读 »

Vue3 有没有一款好用的开发原生的工具


1.uniapp 我个人认为uniapp 适合开发小程序之类的,用这个去开发原生应用会存在一些问题




  • 性能限制:由于 Uniapp 是通过中间层实现跨平台,应用在访问底层功能时可能存在性能损失。与原生开发相比,Uniapp 在处理大规模数据、复杂动画和高性能要求的应用场景下可能表现较差。




  • 平台限制:不同平台有着各自的设计规范和特性,Uniapp 在跨平台时可能受到一些平台限制。有些平台特有的功能或界面设计可能无法完全实现,需要使用特定平台的原生开发方式来解决。




  • 生态系统成熟度: 相比于原生开发,Uniapp 的生态系统相对较新,支持和资源相对有限。在遇到问题时,可能难以找到完善的解决方案,开发者可能需要花费更多的时间和精力来解决问题。




  • 用户体验差异: 由于不同平台的设计规范和用户习惯不同,使用 Uniapp 开发的应用在不同平台上的用户体验可能存在差异。开发者需要针对每个平台进行特定的适配和调优,以提供更好的用户体验。




  • 功能支持限制: Uniapp 尽可能提供了跨平台的组件和 API,但某些特定平台的功能和接口可能无法完全支持。在需要使用特定平台功能的情况下,可能需要使用原生开发或自定义插件来解决。




  • uni 文档 uniapp.dcloud.net.cn/




2.react 拥有react native 开发原生应用 Vue无法使用 http://www.reactnative.cn/


3.Cordova cordova.apache.org/ 支持原生html js css 打包成 ios android exe dmg


4.ionic 我发现这个框架支持Vue3 angular react ts 构建Android iOS 桌面程序 这不正合我意 ionicframework.com/docs


前置条件


1.安装 java 环境 和 安卓编辑器sdk



安装完成检查环境变量


image.png


image.png


image.png


检查安卓编辑器的sdk 如果没安装就装一下


image.png


image.png


image.png


ionic


npm install -g @ionic/cli

初始化Vue3项目


安装完成后会有ionic 命令


ionic start [name] [template] [options]
# 名称 模板 类型为vue项目
ionic start app tabs --type vue

image.png


npm install #安装依赖

npm run dev 启动测试

image.png


启动完成后自带一个tabs demo


image.png


运行至android 编辑器 调试


npm run build
ionic capacitor copy android

注意检查


image.png


如果没有这个文件 删除android目录 重新执行下面命令


ionic capacitor copy android

预览


ionic capacitor open android

他会帮你打开安卓编辑器


如果报错说丢失sdk 注意检查sdk目录


image.png.


等待编译


image.png


点击上面绿色箭头运行


image.png


热更新


如果要热更新预览App 需要一个安卓设备


一直点击你的版本号就可以开启开发者模式


bd36c9f72990ae5cf2275e7690c7f354.jpg


开启usb调试 连接电脑


8f1085f12207c5107d39dd8d193dadfb.jpg


ionic capacitor run android -l --external

选择刚才的安卓设备


image.png


成功热更新


image.png


20c29c088e7f4f152fe1af0adbc4035f.jpg


作者:小满zs
来源:juejin.cn/post/7251113487317106745
收起阅读 »

gradle 实用技巧

前言 总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。 实现 以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。 输出打包后 apk 文件路径及 ...
继续阅读 »

前言


总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。


实现


以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。


输出打包后 apk 文件路径及 apk 大小。


Android Studio 最新版本 Run 之后,每次输出的 apk 并没有在这 app/build/outputs 文件夹下(不知道 Android 官方是出于什么考虑要更改这个路径),而是移动到了 build\intermediates\apk\{flavor}\debug\ 目录下。为了方便后续快速找到每次运行完成的 apk ,可以在每次打包后输出 apk 文件路径及大小,从而可以关注一下日常开发过程中自己
的 apk 体积大概是一个什么样的范围。


static def getFileHumanSize(length) {
def oneMB = 1024f * 1024f
def size = String.valueOf((length / oneMB))
def value = new BigDecimal(size)
return value.setScale(2, BigDecimal.ROUND_HALF_UP)
}
/**
* 打包完成后输出 apk 大小*/
android {
applicationVariants.all { variant ->
variant.assembleProvider.configure() {
it.doLast {
variant.outputs.forEach {
logger.error("apk fileName ==> ${it.outputFile.name}")
logger.error("apk filePath ==> ${it.outputFile}")
logger.error("apk fileSize ==> ${it.outputFile.length()} , ${getFileHumanSize(it.outputFile.length())} MB")
}
}
}
}
}

apk fileName ==> app-huawei-global-debug.apk
apk filePath ==> D:\workspace\MinApp\app\build\intermediates\apk\huaweiGlobal\debug\app-huawei-global-debug.apk
apk fileSize ==> 11987818 , 11.43 MB

可以看到 apk 的路径在 build/intermediates 目录下。当然,我们可以通过下面的方法修改这个路径,定义成我们习惯的路径。


gradle 自定义功能的模块化


日常开发中,会有很多关于 build.gradle 的修改和更新。日积月累,build.gradle 的内容越来越多,代码几乎要爆炸了。其实,可以用模块化的思路将每一个小功能单独抽取出来,这样不仅可以减少 build.gradle 的规模,同时小功能可以更加容易的复用。


比如上面定义的输出打包后 apk 文件路径及 apk 大小的功能,我们就可以把他定义在 report_apk_size_after_package.gradle 这样一个文件中,然后在要使用的 build.gradle 中导入即可。


比如我们要在 app module 中使用这个功能,那么就可以直接在其 build.gradle 文件中按照相对路径引入即可。


gradle_dep.png


apply from: file("../custom-gradle/report_apk_size_after_package.gradle") // 打包完成后输出 apk 大小


修改 release 包的输出路径及文件名


输出 apk 后改名的需求,应该已经很普遍了。在最终输出的 apk 文件中,我们可以追加一些和代码相关的信息,方便通过 apk 文件名迅速确定一些内容。


def getCommit() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--short", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def getBranch() {
def stdout = new ByteArrayOutputStream()
exec {
commandLine "git"
args "rev-parse", "--abbrev-ref", "HEAD"
standardOutput = stdout
}
return stdout.toString().trim()
}

def gitLastCommitAuthorName() {
return "git log -1 --pretty=format:'%an'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}

def gitLastCommitAuthorEmail() {
return "git log -1 --pretty=format:'%ae'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}


android {
def i = 0
applicationVariants.all { variant ->
if (variant.assembleProvider.name.contains("Debug")) {
// 只对 release 包生效
return
}

// 打包完成后复制到的目录
def outputFileDir = "${rootDir.absolutePath}/build/${variant.buildType.name}/${variant.versionName}"
//确定输出文件名
def today = new Date()
def path = ((project.name != "app") ? project.name : rootProject.name.replace(" ", "")) + "_" + variant.flavorName + "_" + variant.buildType.name + "_" + variant.versionName + "_" + today.format('yyyy_MM_dd_HH_mm') + "_" + getBranch() + "_" + getCommit() + "_" + gitLastCommitAuthorName() + ".apk"
println("path is $path")
variant.outputs.forEach {
it.outputFileName = path
}
// 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}
}
}

打 release 包后的日志


let me do something after assembleHuaweiGlobalRelease
apk fileName ==> MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk filePath ==> D:\workspace\MinApp\app\build\outputs\apk\huaweiGlobal\release\MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk fileSize ==> 4959230 , 4.73 MB

通过上面的日志,可以看到 MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk 包含了 ProjectName、flavor、debug/release、打包时间、分支、commitId 即最后一个 commitor 邮箱这些信息。通过这样的信息,可以更加方便快速的定位问题和解决问题。


妙用 flavor 实现不同的功能


使用 flavor 可以定制代码的不同功能及组合。不用把所有内容一锅乱炖似的放在一起搞。比如 MiniApp 随着演示代码的增多,已经逐渐丧失了 Mini 的定位,Apk 大小已经来到了 22 MB之多。究其原因,就是把所有代码验证和功能都放在一起导致的,音视频、compose、C++ 代码全都混在一起。部分功能不常用,但是每次为了验证一部分小功能,却要连带编译这些所有功能,同时打出的 apk 包体积也变大了,从编译到安装,无形中浪费了很多时间。


因此,可以通过 flavor 将一些不常用的功能,定义到不同的 flavor 中,真正需要的时候,编译相应 flavor 的包即可。


首先我们可以从 type 维度定义两个 flavor


    flavorDimensions "channel", "type"
productFlavors {
xiaomi {
dimension "channel"
}
oppo {
dimension "channel"
}
huawei {
dimension "channel"
}

global {
dimension "type"
}
local {
dimension "type"
}
}

在 type 维度,我们可以认为 global 是功能完整的 flavor,而 local 是部分功能缺失的 flavor 。那么具体缺失哪些功能呢?这就要从实际情况出发了,比如产品定义,代码架构及模块组合之类的。回到 Mini App 中,我们使用不同 flavor 的目标就是通过减少非常用功能模块,获得一个体积相对较小的 apk. 因此,可以做如下配置。


    if (source_code.toBoolean()) {
globalImplementation project(path: ':thirdlib')
} else {
globalImplementation 'com.engineer.third:thirdlib:1.0.0'
}
globalImplementation project(path: ':compose')
globalImplementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer:v8.1.8-release-jitpack'

如上我们只在 global 这个 flavor 依赖 thirdlib, compose, GSYVideoPlayer 这些组件。这样 local flavor 就不会引入这些组件,那么就会带来一个问题,local flavor 编译的时候没有这些组件的类,会出现找不到类的情况。


class_missing.png


对于这种情况,我们可以在项目 src 和 main 同级的目录下,创建 local 文件夹,然后在其内部按照具体 Class 文件的路径创建相应的类即可。


package com.engineer.compose.ui

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class MainComposeActivity : BasePlaceHolderActivity()



package com.engineer.third

import com.engineer.BasePlaceHolderActivity

/**
* Created on 2022/7/31.
* @author rookie
*/

class CppActivity : BasePlaceHolderActivity()

package com.engineer

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.engineer.android.mini.ext.toast

/**
* Created on 2022/8/1.
* @author rookie
*/

open class BasePlaceHolderActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
"please use global flavor ".toast()
finish()
}
}

local_flavor.png


这里的思路其实很简单,类似于 leanCanary ,就是在不需要这个功能的 flavor 提供空实现,保证编译可以正常通过即可。缺什么类,建按照类的完整路径创建相应的 Class 文件,这样既保证了编译可以通过,同时在不需要次功能的 flavor 又减少无冗余的代码。


flavor 扩展


其实顺着上面的思路,基于不同 flavor 我们可以做更多的事情。基于 Java 虚拟机的类加载机制限制,相同的类只能有一个,因此我们无法做的事情是,通过 flavor 创建同名的类,去覆盖或重写其他 flavor 的逻辑,这种在编译阶段(其实是在创建同名类的阶段)就会报错。


但是,有些功能是可以被覆盖和定制的。比如包名、App 的名称、icon 之类。这些配置可以通过 AndroidManifest.xml/gradle 进行配置。可以在 local 目录下创建这个 flavor 特有的一些资源文件,这样就可以实现基于 flavor 的产品功能定制了。


比如最简单的修改 applicationId


        global {
dimension "type"
}
local {
dimension "type"
applicationId "com.engineer.android.mini.x"
}

这样,local 和 global 就有了各自不同的 applicationId, 这两种不同 flavor 的包就可以安装在同一台设备了。当然,现在这两个包的 label 和 icon 都是一样的,完全看不出区别。这里就可以利用 flavor 各自的文件夹,来定制各类资源和命名了。


flavor 过滤


不同维度的 flavor 会导致最终的 variant 变多。比如定义 product、channel、type 这些几个 dimension 的之后,后续新增的 flavor 会以乘积的形式增长,但是有些 flavor 又是我们不需要的,这个时候我们就可以过滤掉某些不需要的 flavor 。


比如以上面定义的 channel,type 这两个维度为例,在这两个维度下分别又扩展了 xiaomi/opop/huawei,global/local 这些 flavor 。按照规则会有 2x3x2=12 种 flavor,但实际情况可能不需要这么多,为了减少编译的压力,提升代码的可维护性,我们可以对 flavor 进行过滤。


    variantFilter { variant ->
println "variant is ${variant.flavors*.name}"
def dimens = variant.flavors*.name
def type = dimens[1]
def channel = dimens[0]
switch (type) {
case "global":
if (channel == "xiaomi") {
setIgnore(true)
}
break
case "local":
if (channel == "oppo") {
setIgnore(true)
}
break
}
}

这样我们就成功的过滤掉了 xiaomiGlobal 和 oppoLocal 的 flavor ,一下子就去掉了 4 个 flavor 。


基于现有 task 定制任务


再回顾一下上面的 修改 release 包的输出路径及文件名 的代码实现,我们是在打包完成之后进行了 apk 文件的重命名。


        // 打包完成后做的一些事,复制apk到指定文件夹
variant.assembleProvider.configure() {
it.doLast {
File out = new File(outputFileDir)
copy {
variant.outputs.forEach { file ->
copy {
from file.outputFile
into out
}
}
}
}
}

这里的 doLast 就是说,无论是否需要,每次都会在 assemble 这个 task 完成之后做一件事。这样在某些情况下显得非常的不灵活,尤其是当 doLast 闭包中要做的事情非常繁重的时候。这里的 copy 操作显然是比较轻量的,但是换做是其他操作,比如 apk 安全加固等操作,并不是每次必然需要的操作。这种情况下,就需要我们换一种方式去实现相应的逻辑了。


我们就以加固为例,一般情况下,我们需要对各个版本的 release 包进行加固。因此,我们可以基于现有的 assembleXXXRelease 这个 task 展开。


android {
applicationVariants.all { variant ->
if (variant.assemble.name.contains("Debug")) {
// 只对 release 包生效
return
}

def taskPrefix = "jiagu"
def groupName = "jiagu"
def assembleTask = variant.assembleProvider.name
def taskName = assembleTask.replace("assemble", taskPrefix)
tasks.create(taskName) {
it.group groupName
it.dependsOn assembleTask
variant.assembleProvider.configure() {
it.doLast {
logger.error("let me do something after $assembleTask")
}
}
}
}
}

添加上面的代码后,再执行一下 gradle sync ,我们就可以看到新添加的 jiagu 这个 group 和其中的 task 了。


jiagu.png


这里使用创建 task 的一种方式,使用 createdependsOn ,动态创建 task,并指定其依赖的 task 。


这样当我们执行 ./gradlew jiaguHuaweiLocalRelease 时就可以看到结果了。


> Task :app:assembleHuaweiLocalRelease
let me do something after assembleHuaweiLocalRelease

>
Task :app:jiaguHuaweiLocalRelease

BUILD SUCCESSFUL in 56s
82 actionable tasks: 12 executed, 70 up-to-date

可以看到我们自定义的 task 已经生效了,会在 assembleXXXRelease 这个 task 完成之后执行。


关于 gradle 的使用,可以说是孰能生巧,只要逐渐熟悉了 groovy 的语法和 Java 语法之间的差异,那么就可以逐渐摸索出更多有意思的用法了。


本文源码可以参考 Github MiniApp


小结


可以看到基于 gradle 构建流程,我们仅仅通过编写一些脚本,可以做的事情还是很多的。但是由于 groovy 语法过于灵活,不像 Java 那样有语法提示,因此尝试一些新的语法时难免不知所措。面对这种情况,去看他的源码就好。通过源码,我们就可以知道某个类有哪

作者:IAM四十二
来源:juejin.cn/post/7250071693543145529
些属性,有哪些方法。

收起阅读 »

Android 冷启动优化的3个小案例

背景 为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机...
继续阅读 »

背景


为了提高App的冷启动耗时,除了在常规的业务侧进行耗时代码优化之外,为了进一步缩短启动耗时,需要在纯技术测做一些优化探索,本期我们从类预加载、Retrofit 、ARouter方面进行了进一步的优化。从测试数据上来看,这些优化手段的收益有限,可能在中端机上加起来也不超过50ms的收益,但为了冷启动场景的极致优化,给用户带来更好的体验,任何有收益的优化手段都是值得尝试的。


类预加载


一个类的完整加载流程至少包括 加载、链接、初始化,而类的加载在一个进程中只会触发一次,因此对于冷启动场景,我们可以异步加载原本在启动阶段会在主线程触发类加载过程的类,这样当原流程在主线程访问到该类时就不会触发类加载流程。


Hook ClassLoader 实现


在Android系统中,类的加载都是通过PathClassLoader 实现的,基于类加载的父类委托机制,我们可以通过Hook PathClassLoader 修改其默认的parent 来实现。


首先我们创建一个MonitorClassLoader 继承自PathClassLoader,并在其内部记录类加载耗时


class MonitorClassLoader(
dexPath: String,
parent: ClassLoader, private val onlyMainThread: Boolean = false,
) : PathClassLoader(dexPath, parent) {

val TAG = "MonitorClassLoader"

override fun loadClass(name: String?, resolve: Boolean): Class<*> {
val begin = SystemClock.elapsedRealtimeNanos()
if (onlyMainThread && Looper.getMainLooper().thread!=Thread.currentThread()){
return super.loadClass(name, resolve)
}
val clazz = super.loadClass(name, resolve)
val end = SystemClock.elapsedRealtimeNanos()
val cost = end - begin
if (cost > 1000_000){
Log.e(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
} else {
Log.d(TAG, "加载 ${clazz} 耗时 ${(end - begin) / 1000} 微秒 ,线程ID ${Thread.currentThread().id}")
}
return clazz;

}
}

之后,我们可以在Application attach阶段 反射替换 application实例的classLoader 对应的parent指向。


核心代码如下:


    companion object {
@JvmStatic
fun hook(application: Application, onlyMainThread: Boolean = false) {
val pathClassLoader = application.classLoader
try {
val monitorClassLoader = MonitorClassLoader("", pathClassLoader.parent, onlyMainThread)
val pathListField = BaseDexClassLoader::class.java.getDeclaredField("pathList")
pathListField.isAccessible = true
val pathList = pathListField.get(pathClassLoader)
pathListField.set(monitorClassLoader, pathList)

val parentField = ClassLoader::class.java.getDeclaredField("parent")
parentField.isAccessible = true
parentField.set(pathClassLoader, monitorClassLoader)
} catch (throwable: Throwable) {
Log.e("hook", throwable.stackTraceToString())
}
}
}

主要逻辑为



  • 反射获取原始 pathClassLoader 的 pathList

  • 创建MonitorClassLoader,并反射设置 正确的 pathList

  • 反射替换 原始pathClassLoader的 parent指向 MonitorClassLoader实例


这样,我们就获取启动阶段的加载类了



基于JVMTI 实现


除了通过 Hook ClassLoader的方案实现,我们也可以通过JVMTI 来实现类加载监控。关于JVMTI 可参考之前的文章 juejin.cn/post/694278…


通过注册ClassPrepare Callback, 可以在每个类Prepare阶段触发回调。




当然这种方案,相比 Hook ClassLoader 还是要繁琐很多,不过基于JVMTI 还可以做很多其他更强大的事。


类预加载实现


目前应用通常都是多模块的,因此我们可以设计一个抽象接口,不同的业务模块可以继承该抽象接口,定义不同业务模块需要进行预加载的类。


/**
* 资源预加载接口
*/

public interface PreloadDemander {
/**
* 配置所有需要预加载的类
* @return
*/

Class[] getPreloadClasses();
}

之后在启动阶段收集所有的 Demander实例,并触发预加载


/**
* 类预加载执行器
*/

object ClassPreloadExecutor {


private val demanders = mutableListOf<PreloadDemander>()

fun addDemander(classPreloadDemander: PreloadDemander) {
demanders.add(classPreloadDemander)
}

/**
* this method shouldn't run on main thread
*/

@WorkerThread fun doPreload() {
for (demander in localDemanders) {
val classes = demander.preloadClasses
classes.forEach {
val classLoader = ClassPreloadExecutor::class.java.classLoader
Class.forName(it.name, true, classLoader)
}
}
}

}

收益


第一个版本配置了大概90个类,在终端机型测试数据显示 这些类的加载需要消耗30ms左右的cpu时间,不同类加载的消耗时间差异主要来自于类的复杂度 比如继承体系、字段属性数量等, 以及类初始化阶段的耗时,比如静态成员变量的立即初始化、静态代码块的执行等。


方案优化思考


我们目前的方案 配置的具体类列表来源于手动配置,这种方案的弊端在于,类的列表需要开发维护,在版本快速迭代变更的情况下 维护成本较大, 并且对于一些大型App,存在着非常多的AB实验条件,这也可能导致不同的用户在类加载上是会有区别的。


在前面的小节中,我们介绍了使用自定义的 ClassLoader可以手动收集 启动阶段主线程的类列表,那么 我们是否可以在端上 每次启动时 自动收集加载的类,如果发现这个类不在现有 的名单中 则加入到名单,在下次启动时进行预加载。 当然 具体的策略还需要做详细设计,比如 控制预加载名单的列表大小, 被加入预加载名单的类最低耗时阈值, 淘汰策略等等。


Retrofit ServiceMethod 预解析注入


背景


Retrofit 是目前最常用的网络库框架,其基于注解配置的网络请求方式及Adapter的设计模式大大简化了网络请求的调用方式。 不过其并没有采用类似APT的方式在编译时生成请求代码,而是采用运行时解析的方式。


当我们调用Retrofit.create(final Class service) 函数时,会生成一个该抽象接口的动态代理实例。



接口的所有函数调用都会被转发到该动态代理对象的invoke函数,最终调用loadServiceMethod(method).invoke 调用。



在loadServiceMethod函数中,需要解析原函数上的各种元信息,包括函数注解、参数注解、参数类型、返回值类型等信息,并最终生成ServiceMethod 实例,对原接口函数的调用其实最终触发的是 这个生成的ServiceMethod invoke函数的调用。


从源码实现上可以看出,对ServiceMethod的实例做了缓存处理,每个Method 对应一个ServiceMethod。


耗时测试


这里我模拟了一个简单的 Service Method, 并调用archiveStat 观察首次调用及其后续调用的耗时,注意这里的调用还未触发网络请求,其返回的是一个Call对象。




从测试结果上看,首次调用需要触发需要消耗1.7ms,而后续的调用 只需要消耗50微妙左右。



优化方案


由于首次调用接口函数需要触发ServiceMethod实例的生成,这个过程比较耗时,因此优化思路也比较简单,收集启动阶段会调用的 函数,提前生成ServiceMethod实例并写入到缓存中。


serviceMethodCache 的类型本身是ConcurrentHashMap,所以它是并发安全的。



但是源码中 进行ServiceMethod缓存判断的时候 还是以 serviceMethodCache为Lock Object 进行了加锁,这导致 多线程触发同时首次触发不同Method的调用时,存在锁等待问题



这里首先需要理解为什么这里需要加锁,其目的也是因为parseAnnotations 是一个好事操作,这里是为了实现类似 putIfAbsent的完全原子性操作。 但实际上这里加锁可以以 对应的Method类型为锁对象,因为本身不同Method 对应的ServiceMethod实例就是不同的。 我们可以修改其源码的实现来避免这种场景的锁竞争问题。




当然针对我们的优化场景,其实不修改源码也是可以实现的,因为 ServiceMethod.parseAnnotations 是无锁的,毕竟它是一个纯函数。 因此我们可以在异步线程调用parseAnnotations 生成ServiceMethod 实例,之后通过反射 写入 Retrofit实例的 serviceMethodCache 中。这样存在的问题是 不同线程可能同时触发了一个Method的解析注入,但 由于serviceMethodCache 本身就是线程安全的,所以 它只是多做了一次解析,对最终结果并无影响。


ServiceMethod.parseAnnotations是包级私有的,我们可以在当前工程创建一个一样的包,这样就可以直接调用该函数了。 核心实现代码如下


package retrofit2

import android.os.Build
import timber.log.Timber
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier

object RetrofitPreloadUtil {
private var loadServiceMethod: Method? = null
var initSuccess: Boolean = false
// private var serviceMethodCacheField:Map<Method,ServiceMethod<Any>>?=null
private var serviceMethodCacheField: Field? = null

init {
try {
serviceMethodCacheField = Retrofit::class.java.getDeclaredField("serviceMethodCache")
serviceMethodCacheField?.isAccessible = true
if (serviceMethodCacheField == null) {
for (declaredField in Retrofit::class.java.declaredFields) {
if (Map::class.java.isAssignableFrom(declaredField.type)) {
declaredField.isAccessible =true
serviceMethodCacheField = declaredField
break
}
}
}
loadServiceMethod = Retrofit::class.java.getDeclaredMethod("loadServiceMethod", Method::class.java)
loadServiceMethod?.isAccessible = true
} catch (e: Exception) {
initSuccess = false
}
}

/**
* 预加载 目标service 的 相关函数,并注入到对应retrofit实例中
*/

fun preloadClassMethods(retrofit: Retrofit, service: Class<*>, methodNames: Array<String>) {
val field = serviceMethodCacheField ?: return
val map = field.get(retrofit) as MutableMap<Method,ServiceMethod<Any>>

for (declaredMethod in service.declaredMethods) {
if (!isDefaultMethod(declaredMethod) && !Modifier.isStatic(declaredMethod.modifiers)
&& methodNames.contains(declaredMethod.name)) {
try {
val parsedMethod = ServiceMethod.parseAnnotations<Any>(retrofit, declaredMethod) as ServiceMethod<Any>
map[declaredMethod] =parsedMethod
} catch (e: Exception) {
Timber.e(e, "load method $declaredMethod for class $service failed")
}
}
}

}

private fun isDefaultMethod(method: Method): Boolean {
return Build.VERSION.SDK_INT >= 24 && method.isDefault;
}

}

预加载名单收集


有了优化方案后,还需要收集原本在启动阶段会在主线程进行Retrofit ServiceMethod调用的列表, 这里采取的是字节码插桩的方式,使用的LancetX 框架进行修改。



目前名单的配置是预先收集好,在配置中心进行配置,运行时根据配置中写的配置 进行预加载。 这里还可以提供其他的配置方案,比如 提供一个注解用于标注该Retrofit函数需要进行预解析,



之后,在编译期间收集所有需要预加载的Service及函数,生成对应的名单,不过这个方案需要一定开发成本,并且需要去修改业务模块的代码,目前的阶段还处于验证收益阶段,所以暂未实施。


收益


App收集了启动阶段20个左右的Method 进行预加载,预计提升10~20ms。


ARouter


背景


ARouter框架提供了路由注册跳转 及 SPI 能力。为了优化冷启动速度,对于某些服务实例可以在启动阶段进行预加载生成对应的实例对象。


ARouter的注册信息是在预编译阶段(基于APT) 生成的,在编译阶段又通过ASM 生成对应映射关系的注入代码。



而在运行时以获取Service实例为例,当调用navigation函数获取实例最终会调用到 completion函数。



当首次调用时,其对应的RouteMeta 实例尚未生成,会继续调用 addRouteGroupDynamic函数进行注册。



addRouteGroupDynamic 会创建对应预编译阶段生成的服务注册类并调用loadInto函数进行注册。而某些业务模块如何服务注册信息比较多,这里的loadInto就会比较耗时。



整体来看,对于获取Service实例的流程, completion的整个流程 涉及到 loadInto信息注册、Service实例反射生成、及init函数的调用。 而completion函数是synchronized的,因此无法利用多线程进行注册来缩短启动耗时。


优化方案


这里的优化其实和Retroift Service 的注册机制类似,不同的Service注册时,其对应的元信息类(IRouteGroup)其实是不同的,因此只需要对对应的IRouteGroup加锁即可。


在completion的后半部分流程中,针对Provider实例生产的流程也需要进行单独加锁,避免多次调用init函数。



收益


根据线下收集的数据 配置了20+预加载的Service Method, 预期收益 10~20ms (中端机) 。


其他


后续将继续结合自身业务现状以及其他一线大厂分享的样例,在 x2c、class verify、禁用JIT、 disableDex2AOT等方面继续尝试优化。


如果通过本文对你有所收获,可以来个点赞、收藏、关注三连,后续将分享更多性能监控与优化相关的文章。


也可以关注个人公众号:编程物语


image.png


本文相关测试代码已分享至github: github.com/Knight-ZXW/…


APM性能监控与优化专栏


性能优化专栏历史文章:


作者:卓修武K
来源:juejin.cn/post/7249228528573513789
tbody>
文章地址
Android平台下的cpu利用率优化实现juejin.cn/post/724324…
抖音消息调度优化启动速度方案实践juejin.cn/post/721766…
扒一扒抖音是如何做线程优化的juejin.cn/post/721244…
监控Android Looper Message调度的另一种姿势juejin.cn/post/713974…
Android 高版本采集系统CPU使用率的方式juejin.cn/post/713503…
Android 平台下的 Method Trace 实现及应用juejin.cn/post/710713…
Android 如何解决使用SharedPreferences 造成的卡顿、ANR问题juejin.cn/post/705476…
基于JVMTI 实现性能监控juejin.cn/post/694278…
收起阅读 »

Flutter卡片分享功能实现:将你的内容分享给世界

前言 在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片...
继续阅读 »

前言



在app中,在实现分享功能的时候,通常会有一种以卡片形式展示和分享内容的分享方式。这种功能可以将信息以整洁、易读的方式呈现给用户,使他们能够快速了解内容的关键信息,并将其分享给其他人。那么在这篇文章中,就一起来探索下,如何使用Flutter来实现这卡片分享功能吧~


源代码:http://www.aliyundrive.com/s/FH7Xc2vyL…


效果图:



实现方案


为了卡片的样式的灵活性和可定制性,本文采用对组件进行截图的方式来实现卡片保存分享的功能,选择这个方案还有一点好处就是充分利用了flutter跨平台的优势。当然也会有一定的缺点,例如对于性能的考虑,当对复杂的嵌套卡片组件截图时,渲染和图像转换的计算量是需要考虑的,当然也可以选择忽略不计~


创建弹窗&卡片布局


在生成分享卡片的同时还会有其他的操作选项,例如保存图片、复制链接、浏览器打开等等,所以通常分享卡片的形式为弹窗形式,中间为分享卡片主体,剩余空间为操作项。



操作项组件封装:


class ImageDialog extends StatelessWidget {
const ImageDialog({
Key? key,
required this.items,
...
}) : super(key: key);
final List<ItemLittleView> items;
...

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
...
child: Row(
children: items
.map((e) => itemLittleView(
label: e.label,
icon: e.icon,
onTap: () {
Navigator.pop(context);
e.onTap?.call();
}))
.toList()),
),
],
);
}

Widget itemLittleView({
required String label,
required String icon,
Function()? onTap,
}) =>
InkWell(
onTap: onTap,
child: Container(
margin: EdgeInsets.only(right: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
//图标
),
Container(
//文字
),
],
),
),
);
}
}

class ItemLittleView {
final String label;
final String icon;
final Function()? onTap;

ItemLittleView({required this.label, required this.icon, this.onTap});
}

需要加入新的操作项时,只需要简单的添加一个ItemLittleView即可。


ImageDialog(
items: [
ItemLittleView(
label: "生成图片 ",
icon: "assets/images/icon/ic_down.png",
onTap: () => doSaveImage(),
),
...
],
),

卡片的布局则根据业务的需求自定义即可,本文也只是一个简单的例子。


渲染并截取组件截图


在flutter中可以使用RepaintBoundary将将组件渲染为图像。



  • 第一步:定义全局的GlobalKey,用于获取卡片布局组件的引用


var repaintKey = GlobalKey();

RepaintBoundary(
key: repaintKey,
//分享卡片
child: shareImage(),
),


  • 第二步:使用RenderRepaintBoundary的toImage方法将其转换为图像


Future<Uint8List> getImageData() async {
BuildContext buildContext = repaintKey.currentContext!;
//用于存储截取的图片数据
var imageBytes;
//通过 buildContext 获取到 RenderRepaintBoundary 对象,表示要截取的组件边界
RenderRepaintBoundary boundary =
buildContext.findRenderObject() as RenderRepaintBoundary;

//这行代码获取设备的像素密度,用于设置截取图片的像素密度
double dpr = ui.window.devicePixelRatio;
//将边界对象 boundary 转换为图像,使用指定的像素密度。
ui.Image image = await boundary.toImage(pixelRatio: dpr);
// image.width
//将图像转换为ByteData数据,指定了数据格式为 PNG 格式。
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
//将ByteData数据转换为Uint8List 类型的图片数据。
imageBytes = byteData!.buffer.asUint8List();
return imageBytes;
}


  • 第三步:获取权限&保存截图


//获取权限
_requestPermission() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.storage,
].request();

final info = statuses[Permission.storage].toString();
}

Future<String> saveImage(Uint8List imageByte) async {
//将回调拿到的Uint8List格式的图片转换为File格式
//获取临时目录
var tempDir = await getTemporaryDirectory();
//生成file文件格式
var file =
await File('${tempDir.path}/image_${DateTime.now().millisecond}.png')
.create();
//转成file文件
file.writeAsBytesSync(imageByte);
print("${file.path}");
String path = file.path;
return path;
}

//最后通过image_gallery_saver来保存图片
/// 执行存储图片到本地相册
void doSaveImage() async {
await _requestPermission();
Uint8List data = await getImageData();
String path = await saveImage(data);
final result = await ImageGallerySaver.saveFile(path);
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("保存成功!"),
);
});
}

到这里,分享卡片的功能就实现啦~


总结


在本文中,我们探索了使用Flutter实现卡片分享功能的过程。在开发app时,卡片分享功能可以为用户提供更好的交互和共享体验,我猜大家在开发的过程中也会有很大的概率碰上这样的需求。通过设计精美的卡片样式,可以帮助更快速的推广APP。


关于我


Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 ,也可以通过掘金的新的私信功能联系到我。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章~万

作者:编程的平行世界
来源:juejin.cn/post/7249347871564300345
一哪天我进步了呢?😝

收起阅读 »

Kotlin1.8新增特性,进来了解一下

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。 其中Kotlin1.8.0提供的...
继续阅读 »

大家好,之前我已经写过了分析kotlin1.5、1.6、1.7、1.9插件版本新增的一些特性,唯独kotlin1.8的特性还没好好讲讲,本篇文章就带大家好好分析下kotlin1.8新增了那些特性,能对我们日常开发带来哪些帮助。


其中Kotlin1.8.0提供的特性有限,本篇文章主要是分析Kotlin1.8.20提供的一些新特性。下面是支持该插件的IDE对应版本:



一. 提供性能更好的Enum.entries替代Enum.values()



在之前,如果我们想遍历枚举内部元素,我们通常会写出以下代码:


enum class Color(val colorName: String, val rgb: String) {
RED("Red", "#FF0000"),
ORANGE("Orange", "#FF7F00"),
YELLOW("Yellow", "#FFFF00")
}

fun main() {
Color.values().forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

但是不知道大家是否清楚,Color.values() 其实存在性能问题,换句话说,每调用一次该方法,就会触发重新分配一块内存,如果调用的频率过高,就很可能引发内存抖动


我们可以反编译下枚举类简单看下原因:



Color.values()每次都会调用Object.clone()方法重新创建一个新的数组,这就是上面说的潜在的性能问题,github上也有相关的问题链接,感兴趣的可以看下:HttpStatus.resolve allocates HttpStatus.values() once per invocation


同时Color.values()返回的是一个数组,而在我们大多开发场景中,可能集合使用的频率更高,这就可能涉及到一个数组转集合的操作。


基于以上考虑,Kotlin1.8.20官方提供了一个新的属性:Color.entries这个方法会预分配一块内存并返回一个不可变集合,多次调用也不会产生潜在的性能问题


我们简单看下使用:


fun main() {
Color.entries.forEach {
println("${it.rgb}--${it.colorName}--${it.name}")
}
}

输出:



同时我们也可以从反编译的代码中看出区别:



不会每次调用都重新分配一块内存并返回。


如果想要使用这个特性,可以加上下面配置:


compileKotlin.kotlinOptions {
languageVersion = "1.9"
}

另外多说一下,IntelliJ IDEA 2023.1版本也会检测代码中是否存在Enum.values()的使用,存在就提示使用Enum.entries代替。


二. 允许内联类声明次级构造函数



内联类在Kotlin1.8.20之前是不允许带body的次级构造函数存在的,也就是说下面的代码运行会报错:


@JvmInline
value class Person( val fullName: String) {
constructor(name: String, lastName: String) : this("$name $lastName") {
check(lastName.isNotBlank()) {
"Last name shouldn't be empty"
}
}
}

fun main() {
println(Person("a", "b").fullName)
}

运行看下结果:



如果没有次级构造函数body,下面这样写是没问题的:


    constructor(name: String, lastName: String) : this("$name $lastName") 

如果想要支持带body的次级构造函数,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "1.9"配置即可。


然后上面的代码块运行就没问题了,我们看下输出:


fun main() {
println(Person("a", "").fullName)
}


准确的执行了次级构造函数body内的逻辑。


三. 支持java synthethic属性引用



这个特性用文字不好解释,我们直接通过代码去学习下该特性。


当前存在一个类Person1


public class Person1 {
private String name;
private int age;

public Person1(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

在Kotlin1.8.20之前,以下这种写法是会报错的:



而是必须改成sortedBy(Person1::getAge)才能运行通过。


和上面特性一样,如果想要支持Person1::age这种引用方式,只需要在kotlin1.8.20插件版本上和上一个特性一样增加languageVersion = "2.1"配置即可。



PS:请注意,Kotlin官方网站提示配置languageVersion = "1.9" 就能使用上面的实验特性,但是编译器还是提示报错,然后你找报错提示信息改成了languageVersion = "2.1" 就正常了。




四. 新Kotlin K2编译器的更新



就是说目前Kotlin K2编译器还是一个实验阶段,不过Kotlin官方在其stable的路上又增加了一些更新:



  1. 序列化插件的预览版本;

  2. JS IR编译器的alpha支持;

  3. Kotlin2.0版本特性的引入;


如果大家想要体验下最新版的Kotlin K2编译器,增加配置:languageVersion ="2.0"即可。


五. Kotlin标准库支持AutoCloseable



这个AutoCloseable 接口就是用来支持资源关闭的,搭配提供的use扩展函数,就能帮助我们在资源流使用完毕后自动关闭。


Kotlin之所以在标准库中支持,应该是想要支持多平台吧。


六. Kotlin标准库支持Base64编解码


这里不做太多介绍,看下面的使用例子即可:



七. Kotlin标准库@Volatile支持Kotlin/Native


@Volatile注解在Kotlin/JVM就是保证线程之间可见性以及有序性的,kotlin官方在Kotlin/Native中也支持了该注解使用,有兴趣的可以实战试下效果。


总结


本篇文章主要是介绍了Kotlin1.8版本新增的一些特性,主要挑了一些我能理解的、常用的一些特性拉出来介绍,希望能对你有所帮助。


历史文章


两个Kotlin优化小技巧,你绝对用的上


浅析一下:kotlin委托背后的实现机制


Kotlin1.9.0-Beta,它来了!!


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


kotlin密封sealed class/interface的迭代之旅


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧

,了解一下~

收起阅读 »

Flutter 初探原生混合开发

转载请注明出处:juejin.cn/post/724677… 本文出自 容华谢后的博客 0.写在前面 现如今跨平台技术被越来越多的开发者提起和应用,从最早的Java到后来的RN、Weex,到现在的Compose、Flutter,大前端已经成为了趋势,很多公司...
继续阅读 »

转载请注明出处:juejin.cn/post/724677…


本文出自 容华谢后的博客



0.写在前面


现如今跨平台技术被越来越多的开发者提起和应用,从最早的Java到后来的RN、Weex,到现在的Compose、Flutter,大前端已经成为了趋势,很多公司为了节省成本,包括一些大厂已经在Android和iOS平台上使用了Flutter技术,效果还可以,贴近原生但是还会有一些卡顿的问题,好在Flutter目前还在不断的优化更新,希望越来越好吧。


Flutter从2017年发布到现在已经历经了6年,如果你现在创建一个Flutter项目,会发现已经支持了Android、iOS、Linux、MacOS、Web、Windows六大主流的操作系统平台,我以前经常会写一些在Windows上运行的小工具,使用java写的不仅复杂界面也不好看,用Flutter试了试,好像发现了新大陆,在PC上运行十分流畅,还直接支持在其他平台上运行,感觉十分不错,这也让我对未来Flutter的发展抱有期待。


Flutter开发有两种方式,一种是纯Flutter开发,一种是Flutter+原生的开发方式,正如上面所说的,Flutter在PC上运行十分流畅,可能是PC配置比较高的原因,但是在客户端上的运行效果却不如人意,启动有点慢,一些复杂列表有点卡,一些底层功能的API不支持,这就需要原生开发的介入,小部分原生+大部分Flutter开发可能是后续比较主流的一种开发方式。


本文主要讲的是在Android平台上,与Flutter混合开发的一些步骤,一起来看下吧。


1.准备


1.1 先贴下我用的开发环境:




  • 操作系统:Windows 10




  • IDE:Android Studio Flamingo




  • Android SDK:33




  • Gradle:8.0.2




  • JDK:17.0.7




  • Flutter:3.10.4




1.2 下载Flutter SDK


下载地址:docs.flutter.dev/get-started…


是个压缩包,解压到你存放开发环境的目录,然后在AS中打开 File->Settings->Languages&Frameworks,在里面配置一下SDK的路径就可以了。


1.3 配置环境变量


和Jdk一样,为了使用方便,还需要配置下环境变量,设置->关于->高级系统设置->环境变量,找到用户变量,在Path里面新增一个路径 flutter SDK的路径\bin,前面如果有值的话,别忘了在前面加个英文分号进行分割。


1.4 检测flutter状态


为了验证Flutter是否安装成功,打开cmd命令行,输入 flutter doctor 进行检测:


flutter doctor


如果出现上面的提示,是因为Android证书的问题,再输入 flutter doctor --android-licenses 进行修复:


不支持Jdk 1.8版本


可能会出现这样的错误,这个是因为JDK版本有点低,现在大部分还是用的1.8版本,安装配置下JDK 17就可以,再运行下flutter doctor,已经可以了:


flutter doctor通过


1.5 安装Flutter插件


在AS中打开 File->Settings->Plugins,安装下面两个插件:


插件


到这里,所有的准备工作就完成了,接下来去创建项目。


2.创建项目


首先创建一个标准的Android项目,在此基础上,打开 File->New->New Flutter Project 创建一个Flutter Module:


创建Flutter Module


注意Project location要选择你当前的工程目录,Project types选择Module,然后CREATE,看下创建好的目录结构:


目录结构


3.项目Flutter配置


打开项目根目录的settings.gradle配置文件,增加下面的配置:


// Flutter配置
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir,
'flutter_lib/.android/include_flutter.groovy'
))
include ':flutter_lib'


然后再修改下dependencyResolutionManagement,把FAIL_ON_PROJECT_REPOS 改成 PREFER_SETTINGS,增加flutter的maven仓库地址:


dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
maven {
allowInsecureProtocol = true
url "http://download.flutter.io"
}
}
}

找到flutter_lib->.android->Flutter->build.gradle,在android属性增加namespace,这个是Gradle 8.0新增的特性:


android {
namespace 'com.example.flutter_lib'
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
...
}

找到主app的build.gradle,在dependencies中引用flutter模块,注意模块名称是flutter,无论你创建的Moudle是什么名字,这里的名字都是flutter:


dependencies {
...
implementation project(':flutter')
}

4.开始使用


在清单文件中,增加下面的activity标签,注意这个Activity是SDK中自带的,不需要自己手动创建:


<application>
...

<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" />
</application>

在MainActivity写个跳转方法进行测试:


val intent = FlutterActivity
.withNewEngine()
.initialRoute("home")
.build(this)
startActivity(intent)

看下效果:


跳转效果


可以看到在点击跳转按钮后,有一个明显的停顿,这是因为初始化Flutter引擎比较慢导致的,那就提前初始化试试,在Application中初始化引擎:


class App : Application() {

override fun onCreate() {
super.onCreate()
// 创建 Flutter 引擎
val flutterEngine = FlutterEngine(this)
// 指定要跳转的flutter页面
flutterEngine.navigationChannel.setInitialRoute("main")
flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
// 这里做一个缓存,可以在适当的时候执行它,例如app里,在跳转前执行预加载
val flutterEngineCache = FlutterEngineCache.getInstance()
flutterEngineCache.put("default_engine_id", flutterEngine)
}
}

然后使用已经提前创建后的引擎再次跳转:


val intent = FlutterActivity
.withCachedEngine("default_engine_id")
.build(this)
startActivity(intent)

看下效果,已经非常丝滑了:


优化后跳转效果


5.写在最后


GitHub地址:github.com/alidili/Flu…


到这里,Flutter与原生混合开发的基本步骤就介绍完了,如有问题可以给我留言评论或者在GitHub中提交

作者:容华谢后
来源:juejin.cn/post/7246778558248058938
Issues,谢谢!

收起阅读 »

像支付宝那样“致敬”第三方开源代码

前言 通常我们在App中会使用第三方的开源代码,按照许可协议,我们应该在App中公开使用的开源代码并且附上对应的开源协议。当然,实际上只有少部分注重合规性的大厂才会这么干,比如下图是支付宝的关于界面的第三方信息。当然,对于小企业,基本上都不会放使用的第三方开源...
继续阅读 »

前言


通常我们在App中会使用第三方的开源代码,按照许可协议,我们应该在App中公开使用的开源代码并且附上对应的开源协议。当然,实际上只有少部分注重合规性的大厂才会这么干,比如下图是支付宝的关于界面的第三方信息。当然,对于小企业,基本上都不会放使用的第三方开源代码的任何信息。
image.png


不过,作为一个有“追求”的码农,我们还是想对开源软件致敬一下的,毕竟,没有他们我都不知道怎么写代码。然而,我们的 App 里用了那么多第三方开源插件,总不能一个个找出来一一致敬吧?怎么办?其实,Flutter 早就为我们准备好了一个组件,那就是本篇要介绍的 AboutDialog


AboutDialog 简介


AboutDialog 是一个对话框,它可以提供 App 的基本信息,如 Icon、版本、App 名称、版权信息等。
image.png


同时,AboutDialog还提供了一个查看授权信息(View Licenses)的按钮,点击就可以查看 App 里所有用到的第三方开源插件,并且会自动收集他们的 License 信息展示。所以,使用 AboutDialog 可以让我们轻松表达敬意。怎么使用呢?非常简单,我们点击一个按钮的时候,调用 showAboutDialog 就搞定了,比如下面的代码:


IconButton(
onPressed: () {
showAboutDialog(
context: context,
applicationName: '岛上码农',
applicationVersion: '1.0.0',
applicationIcon: Image.asset('images/logo.png'),
applicationLegalese: '2023 岛上码农版权所有'
);
},
icon: const Icon(
Icons.info_outline,
color: Colors.white,
),
),

参数其实一目了然,具体如下:



  • context:当前的 context

  • applicationName:应用名称;

  • applicationVersion:应用版本,如果要自动获取版本号也可以使用 package_info_plus 插件。

  • applicationIcon:应用图标,可以是任意的 Widget,通常会是一个App 图标图片。

  • applicationLegalese:其他信息,通常会放置应用的版权信息。


点击按钮,就可以看到相应的授权信息了,点击一项就可以查看具体的 License。我看了一下使用的开源插件非常多,要是自己处理还真的很麻烦。
image.png


可以说非常简单,当然,如果你直接运行还有两个小问题。


按钮本地化


AboutDialog 默认提供了两个按钮,一个是查看授权信息,一个是关闭,可是两个按钮 的标题默认是英文的(分别是VIEW LICENSES和 CLOSE)。
image.png


如果要改成本地话的,还需要做一个自定义配置。我们扒一下 AboutDialog 的源码,会发现两个按钮在DefaultMaterialLocalizations中定义,分别是viewLicensesButtonLabelcloseButtonLabel。这个时候我们自定义一个类集成DefaultMaterialLocalizations就可以了。


class MyMaterialLocalizationsDelegate
extends LocalizationsDelegate<MaterialLocalizations>
{
const MyMaterialLocalizationsDelegate();

@override
bool isSupported(Locale locale) => true;

@override
Future<MaterialLocalizations> load(Locale locale) async {
final myTranslations = MyMaterialLocalizations(); // 自定义的本地化资源类
return Future.value(myTranslations);
}

@override
bool shouldReload(
covariant LocalizationsDelegate<MaterialLocalizations> old) =>
false;
}

class MyMaterialLocalizations extends DefaultMaterialLocalizations {
@override
String get viewLicensesButtonLabel => '查看版权信息';

@override
String get closeButtonLabel => '关闭';

}

然后在 MaterialApp 里指定本地化localizationsDelegates参数使用自定义的委托类对象就能完成AboutDialog两个按钮文字的替换。


return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const AboutDialogDemo(),
localizationsDelegates: const [MyMaterialLocalizationsDelegate()],
);

添加自定义的授权信息


虽然 Flutter 会自动收集第三方插件,但是如果我们自己使用了其他第三方的插件的话,比如没有在 pub.yaml 里引入,而是直接使用了源码。那么还是需要手动添加一些授权信息的,这个时候我们需要自己手动添加了。添加的方式也不麻烦,Flutter 提供了一个LicenseRegistry的工具类,可以调用其 addLicense 方法来帮我们添加授权信息。具体使用如下:


LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'我是岛上码农,微信公众号同名。\f如有问题可以加本人微信交流,微信号:island-coder。',
);
});

这个方法可以在main方法里调用。其中第一个参数是一个数组,是因为可以允许多个开源代码共用一份授权信息。同时,如果一份开源插件有多个授权信息,可以多次添加,只要名称一致,Flutter就会自动合并,并且会显示该插件的授权信息条数,点击查看时,会将多条授权信息使用分割线分开,代码如下所示:


void main() {
runApp(const MyApp());
LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'我是岛上码农,微信公众号同名。如有问题可以加本人微信交流,微信号:island-coder。',
);
});

LicenseRegistry.addLicense(() async* {
yield const LicenseEntryWithLineBreaks(
['关于岛上码农'],
'使用时请注明来自岛上码农、。',
);
});
}

image.png


总结


本篇介绍了在 Flutter 中快速展示授权信息的方法,通过 AboutDialog 就可以轻松搞定,各位“抄代码”的码农们,赶紧用起来向大牛们致敬吧!



我是岛上码农,微信公众号同名。如有问题可以加本人微信交流,微信号:island-coder


👍🏻:觉得有收获请点个赞鼓励一下!


🌟:收藏文章,方便回看哦!


💬:评论交流,互相进步!


作者:岛上码农
来源:juejin.cn/post/7246328828837871677

收起阅读 »

浅析一下:kotlin委托背后的实现机制

大家好,kotlin的属性委托、类委托、lazy等委托在日常的开发中,给我们提供了很大的帮助,我之前的文章也是有实战过几种委托。不过对比委托实现的背后机制一直都没有分析过,所以本篇文章主要是带领大家分析下委托的实现原理,加深对kotlin的理解。 一. laz...
继续阅读 »

大家好,kotlin的属性委托、类委托、lazy等委托在日常的开发中,给我们提供了很大的帮助,我之前的文章也是有实战过几种委托。不过对比委托实现的背后机制一直都没有分析过,所以本篇文章主要是带领大家分析下委托的实现原理,加深对kotlin的理解。


一. lazy委托


这里我们不说用法,直接说背后的实现原理。


先看一段代码:


val content: String by lazy {
"oiuytrewq"
}

fun main() {
println(content.length)
}

我们看下反编译后的java代码:




  1. 首先会通过DelegateDemoKt静态代码块饿汉式的方式创建一个Lazy类型的变量content$delegate,命名的规则即代码中定义的原始变量值拼接上$delegate,我们原始定义的content变量就会从属性定义上消失,但会生成对应的get方法,即getContent()



  1. 当我们在main方法中调用content.length时,其实就是调用getContent().length(),而getContent()最终是调用了content$delegate.getValue方法;



  1. 这个lazy类型的变量是调用了LazyKt.lazy()方法创建,而真正的核心逻辑——该方法具体参数的传入,在反编译的java代码中并没有体现;


java代码既然看不到,我们退一步看下字节码:



上面是DelegateDemoKt类构造器对应的字节码,其中就是获取了DelegateDemoKt$content$2作为参数传入了LazyKt.lazy()方法。


我们看下DelegateDemoKt$content$2类的实现字节码:



DelegateDemoKt$content$2类实现了Function0接口,所以上面lazy的真正实现逻辑就是DelegateDemoKt$content$2类的invoke方法中,上图的字节码红框圈出的地方就很直观的看出来了。


二. 属性委托


属性委托的委托类就是指实现了ReadWritePropertyReadOnlyProperty接口的类,像官方提供的Delegates.observable()Delegates.vetoable()这两个api也是借助前面两个接口实现的。这里我们就以支持读写的ReadWriteProperty委托接口进行举例分析。


先看一段例子代码:


var age: Int by object : ReadWriteProperty<Any?, Int> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
return 10
}

override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
val v = value * value
println("setValue: $v")
}
}

fun main() {
age = 4
println(age)
}

我们看下反编译的java代码:


public final class DelegateDemoKt {
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{Reflection.mutableProperty0(new MutablePropertyReference0Impl(DelegateDemoKt.class, "age", "getAge()I", 1))};
@NotNull
private static final <undefinedtype> age$delegate = new ReadWriteProperty() {
@NotNull
public Integer getValue(@Nullable Object thisRef, @NotNull KProperty property) {
Intrinsics.checkNotNullParameter(property, "property");
return 10;
}

public void setValue(@Nullable Object thisRef, @NotNull KProperty property, int value) {
Intrinsics.checkNotNullParameter(property, "property");
int v = value * value;
String var5 = "setValue: " + v;
System.out.println(var5);
}
};

public static final int getAge() {
return age$delegate.getValue((Object)null, $$delegatedProperties[0]);
}

public static final void setAge(int var0) {
age$delegate.setValue((Object)null, $$delegatedProperties[0], var0);
}

public static final void main() {
setAge(4);
int var0 = getAge();
System.out.println(var0);
}
}


  1. 和lazy有些类似,会生成一个实现了ReadWriteProperty接口的匿名类变量age$delegate,命名规则和lazy相同,通过还帮助我们生成了对应的getAgesetAge方法;



  1. 当我们在代码中执行age = 4就会调用setAge(4)方法,最终会调用age$delegate.setValue()方法;类似的调用age就会调用getAge(),最终调用到age$delegate.getValue()方法;



  1. 编译器还通过反射帮助我们生成了一个KProperty类型的$$delegatedProperties变量,主要是ReadWritePropertysetValuegetValue方法都需要传入这样一个类型的对象,通过$$delegatedProperties变量我们可以访问到具体的变量名等信息;




类似的还有一种属性委托,我们看下代码:


val map = mutableMapOf<String, Int>()

val name: Int by map

上面代码的意思是:当访问name时,就会从map这个散列表中获取key为"name"的value值并返回,不存在就直接抛异常,接下来我们看下反编译后的java代码:


public final class DelegateDemoKt {
// $FF: synthetic field
static final KProperty[] $$delegatedProperties = new KProperty[]{Reflection.property0(new PropertyReference0Impl(DelegateDemoKt.class, "name", "getName()I", 1))};
@NotNull
private static final Map map = (Map)(new LinkedHashMap());
@NotNull
private static final Map name$delegate;

static {
name$delegate = map;
}

public static final int getName() {
Map var0 = name$delegate;
Object var1 = null;
KProperty var2 = $$delegatedProperties[0];
return ((Number)MapsKt.getOrImplicitDefaultNullable(var0, var2.getName())).intValue();
}
}


  1. 生成一个Map类型的name$delegate变量,这个变量其实就是我们定义的map散列表;



  1. 通过反射生成了一个KProperty类型对象变量$$delegatedProperties,通过这个对象的getName()我们就能拿到变量名称,比如这里的"name"变量名;



  1. 最终调用了MapsKt.getOrImplicitDefaultNullable方法,去map散列表去查找"name"这个key对应的value;



PS:记得kotlin1.6还是1.7的插件版本对应委托进行了优化,这个后续的文章会再进行讲解。



三. 类委托


类委托实现就比较简单了,这里我们看下样例代码:


fun interface Fruit {
fun type(): Int
}

class FruitProxy(private val model: Fruit) : Fruit by model

fun main() {
val proxy: FruitProxy = FruitProxy {
-1
}
println(proxy.type())
}

反编译成java代码看下:





首先我们看下FruitProxy这个类,其实现了Fruit接口,借助属性委托特性,编译器会自动帮助我们生成type() 接口方法的实现,并再其中调用构造方法传入的委托类对象modeltype()方法,类委托的核心逻辑就这些。


再main()方法中构造FruitProxy时,我们也无法知晓具体的构造参数对象是啥,和上面的lazy一样,我们看下字节码:



其实FruitProxy方法就传入了一个DelegateDemoKt$main$proxy$1类型的对象,并实现了Fruit接口重写了type方法。


总结


本篇文章主要是讲解了三种委托背后的实现原理,有时候反编译字节码看不出来原理的,可以从字节码中寻找答案,希望本篇文章能对你有所帮助。


历史文章


这里是我整理的过往kotlin特性介绍的历史文章,大家感兴趣可以阅读下:


Kotlin1.9.0-Beta,它来了!!


聊聊Kotlin1.7.0版本提供的一些特性


聊聊kotlin1.5和1.6版本提供的一些新特性


kotlin密封sealed class/interface的迭代之旅


优化@BuilderInference注解,Kotlin高版本下了这些“毒手”!


@JvmDefaultWithCompatibility优化小技巧

,了解一下~

收起阅读 »

父母在家千万注意别打开“共享屏幕”,银行卡里的钱一秒被转走......

打开屏幕共享,差点直接被转账 今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻: 在辽宁大连务...
继续阅读 »

打开屏幕共享,差点直接被转账


今天和爸妈聊天端午回家的事情,突然说到最近AI诈骗的事情,千叮咛万嘱咐说要对方说方言才行,让他们充分了解一下现在骗子诈骗的手段,顺便也找了一下骗子还有什么其他的手段,打算一起和他们科普一下,结果就发现下面这一则新闻:


在辽宁大连务工的耿女士接到一名自称“大连市公安局民警”的电话,称其涉嫌广州一起诈骗案件,让她跟广州警方对接。耿女士在加上所谓的“广州警官”的微信后,这位“警官”便给耿女士发了“通缉令”,并要求耿女士配合调查,否则将给予“强制措施”。随后,对方与耿女士视频,称因办案需要,要求耿女士提供“保证金”,并将所有存款都集中到一张银行卡上,再把钱转到“安全账户”。


图片


期间,通过 “屏幕共享”,对方掌握了耿女士银行卡的账号和密码。耿女士先后跑到多家银行,取出现金,将钱全部存到了一张银行卡上。正当她打算按照对方指示,进行下一步转账时,被民警及时赶到劝阻。在得知耿女士泄露了银行卡号和密码后,银行工作人员立即帮助耿女士修改了密码,幸运的是,银行卡的近6万元钱没有受到损失。


就这手段,我家里的老人根本无法预防,除非把手机从他们手里拿掉,与世隔绝还差不多,所以还是做APP的各大厂商努力一下吧!


希望各大厂商都能看看下面这个防劫持SDK,让出门在外打工的我们安心一点。


防劫持SDK


一、简介


防劫持SDK是具备防劫持兼防截屏功能的SDK,可有效防范恶意程序对应用进行界面劫持与截屏的恶意行为。


二、iOS版本


2.1 环境要求


条目说明
兼容平台iOS 8.0+
开发环境XCode 4.0 +
CPU架构armv7, arm64, i386, x86_64
SDK依赖libz, libresolv, libc++

2.2 SDK接入


2.2.1 DxAntiHijack获取

官网下载SDK获取,下面是SDK的目录结构


1.png


DXhijack_xxx_xxx_xxx_debug.zip 防劫持debug 授权集成库 DXhijack_xxx_xxx_xxx_release.zip 防劫持release 授权集成库




  • 解压DXhijack_xxx_xxx_xxx_xxx.zip 文件,得到以下文件




    • DXhijack 文件夹



      • DXhijack.a 已授权静态库

      • Header/DXhijack.h 头文件

      • dx_auth_license.description 授权描述文件

      • DXhijackiOS.framework 已授权framework 集成库






2.2.2 将SDK接入XCode

2.2.2.1 导入静态库及头文件

将SDK目录(包含静态库及其头文件)直接拖入工程目录中,或者右击总文件夹添加文件。 或者 将DXhijackiOS.framework 拖进framework存放目录


2.2.2.2 添加其他依赖库

在项目中添加 libc++.tbd 库,选择Target -> Build Phases,在Link Binary With Libraries里点击加号,添加libc++.tbd


2.2.2.3 添加Linking配置

在项目中添加Linking配置,选择Target -> Build Settings,在Other Linker Flags里添加-ObjC配置


2.3 DxAntiHijack使用


2.3.1 方法及参数说明

@interface DXhijack : NSObject

+(void)addFuzzy; //后台模糊效果
+(void)removeFuzzy;//后台移除模糊效果
@end

2.3.2 使用示例

在对应的AppDelegate.m 文件中头部插入


#import "DXhijack.h"

//在AppDelegate.m 文件中applicationWillResignActive 方法调用增加
- (void)applicationWillResignActive:(UIApplication *)application {
[DXhijack addFuzzy];
}

//在AppDelegate.m 文件中applicationDidBecomeActive 方法调用移除
- (void)applicationDidBecomeActive:(UIApplication *)application {
[DXhijack removeFuzzy];
}


三、Android版本


3.1 环境要求


条目说明
开发目标Android 4.0+
开发环境Android Studio 3.0.1 或者 Eclipse + ADT
CPU架构ARM 或者 x86
SDK三方依赖

3.2 SDK接入


3.2.1 SDK获取


  1. 访问官网,注册账号

  2. 登录控制台,访问“全流程端防控->安全键盘SDK”模块

  3. 新增App,填写相关信息

  4. 下载对应平台SDK


3.2.2 SDK文件结构



  • SDK目录结构 android-dx-hijack-sdk.png



    • dx-anti-hijack-${version}.jar Android jar包

    • armeabiarmeabi-v7aarm64-v8ax86 4个abi平台的动态库文件




3.2.3 Android Studio 集成

点击下载Demo


3.2.3.1 Android Studio导入jar, so

把dx-anti-hijack-x.x.x.jar, so文件放到相应模块的libs目录下


android-dx-hijack-as.png



  • 在该Module的build.gradle中如下配置:


 android{
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

repositories{
flatDir{
dirs 'libs'
}
}
}


dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
}



3.2.3.2 权限声明

Android 5.0(不包含5.0)以下需要在项目AndroidManifest.xml文件中添加下列权限配置:


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

3.2.3.3 混淆配置

-dontwarn *.com.dingxiang.mobile.**
-dontwarn *.com.mobile.strenc.**
-keep class com.security.inner.**{*;}
-keep class *.com.dingxiang.mobile.**{*;}
-keep class *.com.mobile.strenc.**{*;}
-keep class com.dingxiang.mobile.antihijack.** {*;}

3.3 DxAntiHijack 类使用


3.3.1 方法及参数说明

3.3.1.1 初始化


建议在Application的onCreate下調用


/**
* 使用API前必須先初始化
* @param context
*/

public static void init(Context context);

3.3.1.2 反截屏功能


/**
* 反截屏功能
* @param activity
*/

public static void DGCAntiHijack.antiScreen(Activity activity);

/**
* 反截屏功能
* @param dialog
*/

public static void DGCAntiHijack.antiScreen(Dialog dialog);

3.3.1.3 反劫持检测


/**
* 调用防劫持检测,通常现在activity的onPause和onStop调用
* @return 是否存在被劫持风险
*/

public static boolean DGCAntiHijack.antiHijacking();

3.3.2 使用示例

//使用反劫持方法
@Override
protected void onPause() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onPause();
}

@Override
protected void onStop() {
boolean safe = DXAntiHijack.antiHijacking();
if(!safe){
Toast.makeText(getApplicationContext(), "App has entered the background", Toast.LENGTH_LONG).show();
}
super.onStop();
}



//使用反截屏方法
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
DXAntiHijack.antiScreen(MainActivity.this);
}

以上。


结语


这种事情层出不穷,真的不是吾等普通民众能解决的,最好有从上至下的政策让相应的厂商(尤其是银行和会议类的APP)统一做处理,这样我们在外

作者:昀和
来源:juejin.cn/post/7242145254057312311
打工的人才能安心呀。

收起阅读 »

末日终极坐标安卓辅助工具

前言 本文档只介绍工具的使用方法,有时间再写一篇介绍一下实现细节。 整体的话就是借助这个工具方便记录当前坐标,可以实现游戏资源不浪费。 阅读本文档前提是大家是《末日血战》游戏玩家。 工具下载安装 download.csdn.net/download/u0… 安...
继续阅读 »

前言


本文档只介绍工具的使用方法,有时间再写一篇介绍一下实现细节。
整体的话就是借助这个工具方便记录当前坐标,可以实现游戏资源不浪费。

阅读本文档前提是大家是《末日血战》游戏玩家。


工具下载安装


download.csdn.net/download/u0…


安装工具


工具安装后,桌面会有这个图标。

在打开工具前需进入应用设置页打开这个应用的显示悬浮窗权限图片说明


填入初始坐标


打开工具大致显示是这个样子,坐标初始都是0,那么填入相应的坐标保存就会是这个样子。
14ae0a88098b04a18b0a0c36b400e3c.jpg
可以看到左上角有个小图,这是一个直接坐标系的缩略图,拖拽可以移动位置。
小图中会显示3个红点,一个绿点。绿点表示当前坐标,红点表示终极坐标。当前点的坐标是固定显示在左上角的


此时可以按返回键退出app,但是不要杀掉应用。


建立坐标系


初次使用,建议可以打开小程序截一张生存之路的全屏图,然后我们打开这张图并横屏显示图片开始操作。当已经熟悉工具如何使用后可以在游戏中进行操作了


建立坐标系

打开图片或者游戏,进入到这个界面,点击左上角悬浮窗上的开始按钮,会看到这样一个界面(没有中间两条直线)
47b446b703476c931a27667de16d706.jpg
我们的目标就是为了建立中间两条直线。


按图片指示的顺序操作。尽可能点击在轴线的中心位置
d6bdd46daf0a94960c54bf8918af5f5.png
以上操作只需要执行一次,后续就不需要操作了。
完成以上操作,就得到有两条线的图了。这个时候就完成了建立坐标系


开始寻找终极坐标


注意观察小地图,找一个我们没有到达的离的近的终极坐标为目标。可以看到x和y的差距。

举个例子,我们当前坐标49,52,刚刚已经在48,52这里取得了一个宝箱,那么下一个目的地选237,29。因为是x坐标相差较大,我们x太小,而y坐标我们的大一点。所以主要的方向是加x,少量的减y。
05c5394993b5a8877db3d81c8ce6425.png
所以我们应该按x轴正方向和y轴负方向这里走,因为x相差较大,所以如果可以的话(有障碍物就走不了)就直接沿着x轴正方向走就好了。


我想游戏玩家应该还是知道要往哪走的,但是容易算错坐标或者根本懒得记,凭运气。那么我们指定往哪走之后,接下来怎么使用这个工具。



  • 1、点击一个位置(我们要让小车开到的位置),这个时候小车不会走,因为我们工具盖住了游戏

  • 2、app回退一下(不是杀掉应用),这时可以发现小车在抖动了,其实就是小车可以走了,再点一下刚才那个位置,小车就会走到那个位置。这样我们就完成了一次移动和坐标记录。小地图当前坐标就会变化。绿点也会移动。

  • 3、小车走完之后,我们再点开始,然后重复1,2 步骤。


补充



  • 1、本工具存在误差,一般每次执行在小车x,y <=|2| 基本100%准确。x,y <= |3| 100个汽油可能会有|2|以内坐标的误差(仅本人测试数据)

  • 1、点击位置尽可能点在地图块的中间,这样可以减少误差。遇到坐标点在路径上,可以进入其中对当前坐标进行校准,当然一般是不需要的。

  • 1、如果遇到了事件,我们就处理完事件后再点开始按钮

  • 2、回退怎么用:右下角回退用途是当我们不想走这一步,可以回退一步。重新再点一个点。确认这个点没问题我们就回退app,如果回退还是后悔不想走这一步(这个回退是指回退我们的记录,游戏中的步骤我们肯定是做不到回退的),再打开开始点击回退

  • 3、本次汽油用完后,就可以杀掉辅助工具app了。下次有汽油可以继续直接使用,记住使用过程中的退出都是回退而不是杀掉app


最后


希望大家先熟悉工具流程,可以截一张图去操作,然后再在游戏中操作避免浪费资源。坐标可以随时矫正。

希望大家游戏愉快,也希望本工具对大家有所帮助。

如有建议或问题可在文章评论

作者:流光无影
来源:juejin.cn/post/7243081126826491941
中反馈或者群里找我。

收起阅读 »

10 秒看懂 Android 动画的实现原理

介绍 动画是 Android 应用程序中重要的交互特性。Android 提供了多种动画效果,包括平移、缩放、旋转和透明度等,它们可以通过代码或 XML 来实现。本文将介绍 Android 动画的原理和实现方法,并提供一些示例。 原理 Android 动画的实现...
继续阅读 »

介绍


动画是 Android 应用程序中重要的交互特性。Android 提供了多种动画效果,包括平移、缩放、旋转和透明度等,它们可以通过代码或 XML 来实现。本文将介绍 Android 动画的原理和实现方法,并提供一些示例。


原理


Android 动画的实现原理是通过改变视图的属性来实现的。当我们在代码中设置视图的属性值时,Android 会通过平滑过渡的方式来将视图从一个状态过渡到另一个状态。这种平滑过渡的效果就是动画效果。


属性


Android 中有许多属性可以用来实现动画效果,以下是一些常用的属性:



  • translationX:视图在 X 轴上的平移距离。

  • translationY:视图在 Y 轴上的平移距离。

  • scaleX:视图在 X 轴上的缩放比例。

  • scaleY:视图在 Y 轴上的缩放比例。

  • rotation:视图的旋转角度。

  • alpha:视图的透明度。


类型


Android 中有多种不同类型的动画,每种类型都有其自身的特点和用途:


View 动画


View 动画是一种在应用程序中实现动画效果的简单方法。它可以通过 XML 或代码来实现。View 动画可以应用于任何 View 对象,包括按钮、文本框、图像等等。常见的 View 动画包括平移、缩放、旋转和透明度等效果。以下是一个 View 动画的 XML 示例:


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%"
android:toXDelta="50%"
android:duration="500"
android:repeatCount="infinite"
android:repeatMode="reverse" />

</set>

帧动画


帧动画是一种将一系列图像逐帧播放来实现动画效果的方法。它可以通过 XML 或代码来实现。帧动画常用于播放一系列连续的图像,例如动态图像、电影等等。以下是一个帧动画的 XML 示例:


<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
android:oneshot="false">

<item android:drawable="@drawable/animation_frame1" android:duration="50" />
<item android:drawable="@drawable/animation_frame2" android:duration="50" />
<item android:drawable="@drawable/animation_frame3" android:duration="50" />
...
</animation-list>

属性动画


属性动画是一种可以改变视图属性值的动画效果。它可以通过 XML 或代码来实现。属性动画可以应用于任何属性,包括大小、颜色、位置、透明度等等。它可以在运行时动态地更改属性值,从而实现平滑的动画效果。以下是一个属性动画的 Java 代码的示例:


ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 300f);
animator.setDuration(1000);
animator.start();

过渡动画


过渡动画是一种在应用程序中实现平滑过渡效果的方法。它可以通过 XML 或代码来实现。过渡动画常用于实现屏幕之间的切换效果,例如滑动、淡入淡出等等。以下是一个过渡动画的 XML 示例:


<transition xmlns:android="http://schemas.android.com/apk/res/android">
<fade android:duration="500" />
</transition>

Lottie 动画


Lottie 是 Airbnb 开源的一种动画库,它可以将 Adobe After Effects 中制作的动画直接导出为 JSON 格式,并在 Android 应用程序中使用。Lottie 动画可以实现非常复杂的动画效果,例如骨骼动画、粒子效果等等。


实现


要实现 Android 动画,我们需要按照以下步骤:



  1. 创建动画资源文件。

  2. 在代码中加载动画资源文件。

  3. 将动画应用到相应的视图中。


我们可以通过 XML 或代码来创建动画资源文件。以下是一个简单的平移动画的 XML 示例:


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromXDelta="0%p"
android:toXDelta="50%p"
android:duration="500"
android:repeatCount="infinite"
android:repeatMode="reverse" />

</set>

在代码中加载动画资源文件的方法如下:


Animation animation = AnimationUtils.loadAnimation(this, R.anim.translate);

最后,我们需要将动画应用到相应的视图中:


imageView.startAnimation(animation);

下面是一个实现平移动画效果的 Java 代码示例:


View view = findViewById(R.id.view);
ObjectAnimator animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 300f);
animator.setDuration(1000);
animator.start();

结论


无论是在应用程序设计中还是在用户体验中,动画都是一个非常重要的因素。如果你想要在你的应用程序中实现动画效果,本文提供了 Android 动画的基本原理和实现方法。你可以根据自己的需要使用不同类型的动画来实现不同的效果。


推荐


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/7242596746180739128
,欢迎加入一起共勉。

收起阅读 »

天马行空使用适配器模式

1. 前言 因为最近没有什么比较好的技术想要分享,所以来水一下文章,啊不对,是分享一下一些思路,和大家交流一下想法,没准能产生一些新的想法。这篇文章不具备权威,不一定正确,简单来说全都是我瞎吹。 开发这么久,多多少少也有了解过适配器模式。我还是一如既往的建议学...
继续阅读 »

1. 前言


因为最近没有什么比较好的技术想要分享,所以来水一下文章,啊不对,是分享一下一些思路,和大家交流一下想法,没准能产生一些新的想法。这篇文章不具备权威,不一定正确,简单来说全都是我瞎吹。


开发这么久,多多少少也有了解过适配器模式。我还是一如既往的建议学习设计模式,不能光靠看别人的文章,得靠自己的积累和理解源码的做法来得更切实际。


2. 浅谈适配器


我们都知道,适配器模式是一种结构型模式,结构型模式我的理解都有一个特点,简单来说就是封装了一些操作,就是隐藏细节嘛,比如说代理就是隐藏了通过代理调用实体的细节。


我还是觉得我们要重头去思考它是怎样的一个思路。如果你去查“适配器模式”,相信你看到很多关于插头的说法,而且你会觉得这东西很简单,但好像看完之后又感觉学了个寂寞。


还是得从源码中去理解。先看看源码中最经典的适配器模式的使用地方,没错,RecyclerView的Adapter。但是它又不仅仅只使用Adapter模式,如果拿它的源码来做分析反而会有些绕。但是我们可以进行抽象。想想Adapter其实主要的流程就是输入数据然后输出View给RecyclerView


然后又可以大胆的去思考一下,如果不定义一个Adapter,没有ViewHolder,要怎么实现这个效果?写一个循环,然后在循环里面做一些逻辑判断,然后创建对应的子View,添加到父View中 ,大概会这样做吧。那其实Adapter就帮我们做了这一步,并且还做了很多比较经典的优化操作,其实大概就是这样。


然后从这样的模型中,我大概是能看出了一些东西,比如使用Adapter是为了输入一些东西,也可以说是为了让它封装一些逻辑,然后达到某些目的,如果用抽象的眼光去看,我不care你封装的什么东西,我只要你达到我的目的(输出我的东西),RecyclerView的adapter就是输出View。这是其一,另外,在这个模型中我有一个使用者去使用这个Adaper,这个Apdater在这里不是一个概念,而是一个对象,我这个使用者通过Adaper这个对象给它一些输入,以此来实现我的某些目标


ok,结合Adapter模式的结构图看看(随便从网上找一张图)


image.png


可以看到这个模型中有个Client,有个Tagrget,有个Adapter。拿RecyclerView的Adapter来说,Client是RecyclerView (当然具体的addView操作是在LayoutManager中,这里是抽象去看),Adapter就是RecyclerView.Adapter,而Tagrget抽象去看就是对view的操作。


3. 天马行空的使用


首先一般使用官方的RecyclerView啊这些,都会提供自己的Adapter,但是会让人会容易觉得Adapter就是在复用View的情况下使用。而我的理解是,RecyclerView的复用是ViewHolder的思想,不是Adapter的思想,比如早期的ListView也有Adapter啊,当时出RecyclerView之前也没有说用到ViewHolder的这种做法(这是很多年前的事),我说这个是想要表达不要把Adapter和RecyclerView绑一起,这样会让思维受到局限。


对我而言,Adapter在RecyclerView中的作用是 “Data To View” ,适配数据而产出对应的View。


那我可以把Apdater的作用理解成“Object To Object” ,对于我来说,Object它可以是Data,可以是View,甚至可以是业务逻辑。


所以当我跳出RecyclerView这种传统的 “Data To View” 的思维模式之后,Adapter适配器模式可以做到的场景就很多,正如上面我理解的,Adapter简单来说就是 “Data To View” ,那我可以用适配器模式去做 “Data To Data” ,可以去做 “View To Data” ,可以去做 “Business To Business” , “Business To View” 等等,实现多种效果。


假设我这里做个Data To Data的场景 (强行举的例子可能不是很好)


我请求后台拿到个人的基本信息数据


data class PeopleInfo(
var name: String?,
var sex: Int?,
......
)

然后通过个人的ID,再请求服务端另外一个接口拿到成绩数据


data class ScoreInfo(
var language : Int,
var math : Int,
......
)

然后我有个数据竞赛的报名表对象。


data class MathCompetition(
var math : Int,
var name : String
)

然后一般我们使用到的时候就会这样赋值,假设这段代码在一个Competition类中进行


val people = getPeopleInfo()
val score = getScoreInfo()
val mathTable = MathCompetition(
score.math?,
people.name?,
......
)

就是深拷贝的一种赋值,我相信很多人的代码里面肯定会有


两个对象 AB
A.1 = B.1
A.2 = B.2
A.3 = B.3
......

这样的代码,然后对象的熟悉多的话这个代码就会写得很长。当然这不会造成什么大问题,但是你看着看着,总觉得缺少一些美感,但是好像这玩意没封装不起来这种感觉。


如果用适配器来做的话,首先明确要做的流程是从Competition这个类中,将所有数据整合成MathCompetition对象,目标就是输出MathCompetition对象。那从适配器模式的模型上去看,client就是Competition,是它的需求,Taget就是getMathCompetition,输出mathTable,然后我们可以写一个Adapter。


class McAdapter {

var mathCompetition : MathCompetition? = null

init {
// 给默认值
mathCompetition = MathCompetition(0, "name", ......)
}

fun setData(people : PeopleInfo? = null, score : ScoreInfo? = null){
people?.let {
mathCompetition?.name = it.name
......
}

score?.let {
mathCompetition?.math = it.math
......
}
}

fun getData() : MathCompetition?{
return mathCompetition
}

}

然后在Competition中就不需要直接引用MathCompetition,而是设置个setAdapter方法,然后需要拿数据时再调用adapter的getData()方法,这样就恰到好处,不会把这些深拷贝方式的赋值代码搞得到处都是。 这个Demo看着好像没什么,但是真碰到了N合1这样的数据场景的时候,使用Adapter显然会更安全。


我再简单举一个Business To View的例子吧。假设你的app中有很几套EmptyView,比如你有嵌入到页面的EmptyView,也有做弹窗类型的EmptyView,我们一般的做法就是对应的页面的xml文件中直接写EmptyView,那这些EmptyView的代码就会很分散是吧。OK,你也想整合起来,所以你会写个EmptyHelper,大概是这个意思,用单例写一个管理EmptyView的类,然后里面统一封装对EmptyView的操作,一般都会这样写。 其实如果你让我来做,我可能就会用适配器模式去实现。 ,当然也有其他办法能很好的管理,这具体的得看心情。


写一个Adapter,我这里写一段伪代码,应该比较容易能看懂


class EmptyAdapter() {
// 这样语法要伴生,懒得写了,看得懂就行
const val STATUS_NORMAL = 0
const val STATUS_LOADING = 1
const val STATUS_ERROR = 2

private var type: Int = 0
var parentView: ViewGroup? = null
private var mEmptyView: BaseEmptyView? = null
private var emptyStatus = 0 // 有个状态,0是不显示,1是显示加载中,2是加载失败......

init {
createEmptyView()
}

private fun createEmptyView() {
// 也可以判断是否有parentView决定使用哪个EmptyView等逻辑
mEmptyView = when (type) {
0 -> AEmptyView
1 -> BEmptyView
2 -> CEmptyView
else -> {
AEmptyView
}
}
}

fun setData(status: Int) {
when (status) {
0 -> parentView?.removeView(mEmptyView)
1 -> mEmptyView?.showLoading()
2 -> mEmptyView?.showError()
}
}

fun updateType(type: Int) {
setData(0)
this.type = type
createEmptyView()
}
}

然后在具体的Activity调用的时候,可以


val emptyAdapter = EmptyAdapter(getContentView())
// 然后在每次要loading的时候去设置adapter的的状态
emptyAdapter.setData(EmptyAdapter.STATUS_LOADING)
emptyAdapter.setData(EmptyAdapter.STATUS_NORMAL)
emptyAdapter.setData(EmptyAdapter.STATUS_ERROR)

可以看出这样做就有几个好处,其一就是不用每个xml都写EmptyView,然后也能做到统一的管理,和一些人写的Helper这种的效果类似,最后调用的方法也很简单,你只需要创建一个Adaper,然后调用它的setData就行,我们的RecyclerView也是这样的,在外层去创建然后调用Adapter就行。


4. 总结


写这篇文章主要是为了水,啊不对,是为了想说明几个问题:

(1)开发时要善于跳出一些限制去思考,比如RecyclerView你可能觉得适配器模式就和它绑定了,就和View绑定了,有View的地方才能使用适配器模式,至少我觉得不是这样。

(2)学习设计模式,只去看一些介绍是很难理解的,当然要知道它的一个大致的思想,然后要灵活运用到开发中,这样学它才有用。

(3)我对适配器模式的理解就是 Object To Object,我可以去写ViewAdapter,可以去写DataAdapter,也可以去写BusinessAadpter,可以用这个模式去适配不同的场景,利用这个思想来使代码更加合理。


当然最后还是要强调一下,我不敢保证我说的就是对的,我肯定不是权威的。但至少我使用这招之后的的新代码效果要比一些旧代码更容

作者:流浪汉kylin
来源:juejin.cn/post/7242623772301459517
易维护,更容易扩展。

收起阅读 »

App高级感营造之 高斯模糊

效果 类似毛玻璃,或者马赛克的效果。我们可以用它来提升app背景的整体质感,或者给关键信息打码。 源代码 import 'dart:ui'; import 'package:flutter/material.dart'; void main() { ...
继续阅读 »

效果


类似毛玻璃,或者马赛克的效果。我们可以用它来提升app背景的整体质感,或者给关键信息打码。


高斯模糊1.gif


高斯模糊2.gif


源代码


import 'dart:ui';

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
// 高斯模糊的第一种写法 ImageFiltered 包裹要模糊的组件

/// 将子组件进行高斯模糊
/// [child] 要模糊的子组件
Widget _imageFilteredWidget1({required Widget child, double sigmaValue = 1}) {
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: sigmaValue, sigmaY: sigmaValue),
child: child,
);
}

/// 使用第一种模糊方式的案例
Widget _demo1() {
return Container(
padding: const EdgeInsets.all(50),
color: Colors.blue.shade100,
width: double.infinity,
child: Column(
children: [
_imageFilteredWidget1(
child: SizedBox(
width: 150,
child: Image.asset(
"assets/images/bz1.jpg",
fit: BoxFit.fitHeight,
),
),
),
const SizedBox(height: 100),
_imageFilteredWidget1(
child: const Text(
"测试高斯模糊",
style: TextStyle(fontSize: 30, color: Colors.blueAccent),
),
sigmaValue: 2)
],
),
);
}

/// 利用 BackdropFilter 做高斯模糊
_backdropFilterWidget2({
required Widget child,
double sigmaValueX = 1,
double sigmaValueY = 1,
}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigmaValueX, sigmaY: sigmaValueY),
child: child,
),
);
}

///
Widget _demo2() {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
"assets/images/bz1.jpg",
fit: BoxFit.fill,
),
),
Positioned(
child: _backdropFilterWidget2(
sigmaValueX: _sigmaValueX,
sigmaValueY: _sigmaValueY,
child: Container(
width: MediaQuery.of(context).size.width - 100,
height: MediaQuery.of(context).size.height / 2,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x90ffffff),
),
child: const Text(
"高斯模糊",
style: TextStyle(fontSize: 30, color: Colors.white),
),
)),
top: 20,
),
_slider(
bottomMargin: 200,
themeColors: Colors.yellow,
title: '横向模糊度',
valueAttr: _sigmaValueX,
onChange: (double value) {
setState(() {
_sigmaValueX = value;
});
},
),
_slider(
bottomMargin: 160,
themeColors: Colors.blue,
title: '纵向模糊度',
valueAttr: _sigmaValueY,
onChange: (double value) {
setState(() {
_sigmaValueY = value;
});
},
),
_slider(
bottomMargin: 120,
themeColors: Colors.green,
title: '同时调整:',
valueAttr: _sigmaValue,
onChange: (double value) {
setState(() {
_sigmaValue = value;
_sigmaValueX = value;
_sigmaValueY = value;
});
},
),
],
),
);
}

Widget _slider({
required String title,
required double bottomMargin,
required Color themeColors,
required double valueAttr,
required ValueChanged<double>? onChange,
}) {
return Positioned(
bottom: bottomMargin,
child: Row(
children: [
Text(title, style: TextStyle(color: themeColors, fontSize: 18)),
SliderTheme(
data: SliderThemeData(
trackHeight: 20,
activeTrackColor: themeColors.withOpacity(.7),
thumbColor: themeColors,
inactiveTrackColor: themeColors.withOpacity(.4)
),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Slider(
value: valueAttr,
min: 0,
max: 10,
onChanged: onChange,
),
),
),
SizedBox(
width: 50,
child: Text('${valueAttr.round()}',
style: TextStyle(color: themeColors, fontSize: 18)),
),
],
),
);
}

double _sigmaValueX = 10;
double _sigmaValueY = 10;

double _sigmaValue = 10;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: _demo2(),
);
}
}

实现原理


实现高斯模糊,在flutter中有两种方式:


ImageFiltered


它可以对其包裹的子组件施加高斯模糊,需要传入 ImageFilter 控制模糊程度,分为X Y两个方向的模糊,实际上就是对图片进行拉伸,数字越大,模糊效果越大。


/// 将子组件进行高斯模糊
/// [child] 要模糊的子组件
Widget _imageFilteredWidget1({required Widget child, double sigmaValue = 1}) {
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: sigmaValue, sigmaY: sigmaValue),
child: child,
);
}

BackDropFilter


同样需要一个 ImageFilter参数控制模糊度,与 ImageFilter的区别是,它会对它覆盖的组件整体模糊。
所以如果我们需要对指定的子组件进行模糊的话,需要再包裹一个ClipRect裁切。


/// 利用  BackdropFilter 做高斯模糊
_backdropFilterWidget2({required Widget child, double sigmaValue = 1}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaY: sigmaValue, sigmaX: sigmaValue),
child: child,
),
);
}

由于 BackdropFilter 会对其子组件进行图形处理,所以其子组件可能会变得更加消耗性能。因此,需要谨慎使用 BackdropFilter 组件。


作者:拳布离手
来源:juejin.cn/post/7239631010429108280
收起阅读 »

Android 14 新增权限

原文: medium.com/proandroidd… 译者:程序员 DHL 本文已收录于仓库 Technical-Article-Translation 这篇文章,主要分享在 Android 14 以上新增的权限 READ_MEDIA_VISUAL_US...
继续阅读 »



这篇文章,主要分享在 Android 14 以上新增的权限 READ_MEDIA_VISUAL_USER_SELECTED,该权限允许用户仅授予对选定媒体的访问权限(Photos / Videos)),而不是访问整个媒体库。


新的权限弹窗


当你的 App 运行在 Andrid 14 以上的设备时,如果请求访问照片,会出现以下对话框,你将看到新的选项。



受影响的行为


当我们在项目中声明新的权限 READ_MEDIA_VISUAL_USER_SELECTED ,并且用户选择 Select photos and videos(Select photos or Select videos)




  • READ_MEDIA_IMAGESREAD_MEDIA_VIDEO 权限都会被拒绝




  • READ_MEDIA_VISUAL_USER_SELECTED 权限被授予时,将会被允许临时访问用户的照片和视频




  • 如果我们需要访问其他照片和视频,我们需要同时申请 READ_MEDIA_IMAGES 或者 READ_MEDIA_VIDEO 权限




如何在项目中使用新的权限



  • AndroidManifest.xml 文件中添加下面的权限


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

// new permisison
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />


  • 使用 ActivityResultContract 请求新的权限


val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { mapResults ->
mapResults.forEach {
Log.d(TAG, "Permission: ${it.key} Status: ${it.value}")
}
// check if any of the requested permissions is granted or not
if (mapResults.values.any { it }) {
// query the content resolver
queryContentResolver(context) { listOfImages ->
imageDataModelList = listOfImages
}
}
}

为什么要使用 RequestMultiplePermissions,因为我们需要同时请求 READ_MEDIA_IMAGES , READ_MEDIA_VIDEO 权限



  • 启动权限申请流程


OutlinedButton(onClick = {
permissionLauncher.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VISUAL_USER_SELECTED))
}) {
Text("Allow to read all or select images")
}

关于 Android 12 、 Android 13 、Android 14 功能和权限的变更,点击下方链接前往查看:



最后我们看一下运行效果





全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!




我开了一个云同步编译工具(SyncKit),主要用于本地写代码,同步到远程设备,在远程设备上进行编译,最后将编译的结果同步到本地,代码已经上传到 Github,欢迎前往仓库 hi-dhl/SyncKit 查看。





Hi 大家好,我是 DHL,就职于美团、快手、小米。公众号:ByteCode ,分享有用、有趣的硬核原创内容,Kotlin、Jetpack、性能优化、系统源码、算法及数据结构、动画、大厂面经,真诚推荐你关注我。





最新文章



开源新项目




  • 云同步编译工具(SyncKit),本地写代码,远程编译,欢迎前去查看 SyncKit




  • KtKit 小巧而实用,用 Kotlin 语言编写的工具库,欢迎前去查看 KtKit




  • 最全、最新的 AndroidX Jetpack 相关组件的实战项目以及相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,欢迎前去查看 AndroidX-Jetpack-Practice




  • LeetCode / 剑指 offer,包含多种解题思路、时间复杂度、空间复杂度分析,在线阅读




作者:程序员DHL
来源:juejin.cn/post/7238762963908689957
收起阅读 »

你真的了解Systrace吗?

欢迎关 Android茶话会 在技术学习、个人成长的道路上,让我们一起前进! 一、什么是SysTrace? 在日常开发中有时候遇到棘手的性能问题就需要使用这个工具,Systrace 是 「Android 4.1」 中新增的性能数据采样和分析工具。它可帮助开发...
继续阅读 »

欢迎关 Android茶话会 在技术学习、个人成长的道路上,让我们一起前进!



一、什么是SysTrace?


在日常开发中有时候遇到棘手的性能问题就需要使用这个工具,Systrace 是 「Android 4.1」 中新增的性能数据采样和分析工具。它可帮助开发者收集 Android 关键子系统
(如 SurfaceFlinger/SystemServer/Kernel/Input/Display 等 Framework 部分关键模块、服务,View」系统等 的运行信息,从而帮助开发者更直观的「分析系统瓶颈,改进性能」image.png


二、如何使用SysTrace?


2.1 采集trace


首先我们需要了解Trace的采集主要涉及几部分:采集方式、自定义trace阶段、Release包抓取Trace和系统Trace类同异步调用的差异;


命令行采集:



  • 设备要求**「Android 4.3 (API level 18)及以上」**

  • 命令如下:python systrace.py [options][「categories」],示例如下:


python /Users/yangzhiyong/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o trace.html gfx input view sched freq wm am hwui workq res dalvik sync disk load perf hal rs idle mmc -a com.ss.android.lark.debug

# -o : 指示输出文件的路径和名字
# -t : 抓取时间(最新版本可以不用指定, 按 Enter 即可结束),单位为秒
# -b : 指定 buffer 大小 (一般情况下,默认的 Buffer 是够用的,如果你要抓很长的 Trae , 那么建议调大 Buffer )
# -a : 指定 app 包名 (如果要 Debug 自定义的 Trace 点, 记得要加这个)



  • 查看支持的categories



    • adb shell atrace --list_categories

    • python systrace.py -l




// 粗体部分是常用的categories;
         gfx - Graphics
       input - Input
        view - View System
     webview - WebView
          wm - Window Manager
          am - Activity Manager
          sm - Sync Manager
       audio - Audio
       video - Video
      camera - Camera
         hal - Hardware Modules
         res - Resource Loading
      dalvik - Dalvik VM
          rs - RenderScript
      bionic - Bionic C Library
       power - Power Management
          pm - Package Manager
          ss - System Server
    database - Database
     network - Network
         adb - ADB
    vibrator - Vibrator
        aidl - AIDL calls
         pdx - PDX services
       sched - CPU Scheduling
        freq - CPU Frequency
        idle - CPU Idle
        disk - Disk I/O
        sync - Synchronization
  memreclaim - Kernel Memory Reclaim
  binder_driver - Binder Kernel driver
  binder_lock - Binder global lock trace

系统自带工具:




  • 设备要求**「Android 9(API level 28)及其以上」**




  • 开发者模式->系统跟踪




  • trace导出:



    • 通知栏分享;

    • adb pull /data/local/traces/ .

    • systrace --from-file .ctrace .perfetto-trace 转换成html




网页版采集分析工具:💻



  • 设备要求**「Android 10(API level 29)及其以上;」**

  • Perfetto UI




除此之外还有一些常用的技巧





  1. 自定义TAG


image.png 具体使用可参考:developer.android.com/topic/perfo…




  • 「定义」



    • 在事件开始调用Trace.beginSection(event);

    • 在事件结束调用Trace.endSection(),需成对调用;




  • 「使用」 添加了自定义TAG后,需要-a指定包名参数,才可以采集到自定义的trace信息;





  1. Release包抓取trace


反射调用setAppTracingAllowed即可:


try {
    Class threadClazz = Class.forName("android.os.Trace");
    Method setAppTracingAllowed = threadClazz.getDeclaredMethod("setAppTracingAllowed"boolean.class);
    setAppTracingAllowed.invoke(nulltrue);
catch (Exception e) {
    e.printStackTrace();
}

3. 同异步trace的差异


「同步」「异步」「区别」
beginSection(@NonNull String sectionName)beginAsyncSection(@NonNull String methodName, int cookie)异步调用需要传methodName及cookie,开始结束匹配更准确,且不用B-A-A-B类似嵌套,不过该接口为隐藏接口,需反射调用,可使用SystemTracer类;
endSection()endAsyncSection(@NonNull String methodName, int cookie)

2.2 分析


拿到这些trace 我们如何分析也是一个重要的部分


2.2.1 Trace文件打开


首先是打开这个文件



  1. chrome://tracing:推荐:不过近期版本该工具对trace中线程、进程名解析不出来,不利于查看;

  2. ui.perfetto.dev/#!/: 推荐,不过新版分析工具不支持VSync的高亮;

  3. perfetto.bytedance.net/#!/viewer: 不推荐,仅支持单进程查看,对于涉及系统调用的分析不方便;


较早版本sysTrace生成的html文件浏览器即可打开,但最近版本已无法正常打开,需要通过chrome工具手动加载;


2.2.2 面板区域说明


image.png


image.png



  1. 用户屏幕交互

  2. CPU 使用率

  3. CPU各核心的运行情况

  4. 进程信息

  5. 进程变量/进程

  6. 选中区段的详细信息

  7. 鼠标操作选项,可通过1-4快速切换

  8. 进程过滤

  9. VSync高亮配置


2.2.3 常用快捷键


比较常用的快捷键是W S A D M V;



  • W : 放大 Systrace,放大可以更好地看清局部细节

  • S : 缩小 Systrace,缩小以查看整体

  • A : 左移

  • D : 右移

  • M : 高亮选中当前鼠标点击的段(这个比较常用,可以快速标识出这个方法的左右边界和执行时间,方便上下查看)

  • V : 高亮VSync的时机,方便分析掉帧的原因;


image.png


image.png


2.2.4 线程状态


a) 线程状态

「线程状态」「Systrace中的显示」「说明」
绿色 : 运行中(Running)image.png我们经常会查看 Running 状态的线程,查看其运行的时间,与竞品做对比,分析快或者慢的原因
蓝色 : 可运行(Runnable)image.pngRunnable 状态的线程状态持续时间越长,则表示 cpu 的调度越忙,没有及时处理到这个任务
白色 : 休眠中(Sleep)image.png一般是在等事件驱动
橘色 : 不可中断的睡眠态 IO Block(Uninterruptible Sleep WakeKill)image.png一般是标示 IO 操作慢,如果有大量的橘色不可中断的睡眠态出现,那么一般是由于进入了低内存状态
紫色 : 不可中断的睡眠态(Uninterruptible Sleep)image.png一般是陷入了内核态,有些情况下是正常的,有些情况下是不正常的,需要按照具体的情况去分析

b) 线程唤醒

一个任务在进入 Running 状态之前,会先进入 Runnable 状态进行等待,而 Systrace 会把这个状态也标示在 Systrace 上; image.png image.png stateWhenDescheduled定义:chromium.googlesource.com/external/tr…


c) 线程参数说明


  • Wall Duration:函数执行的总耗时

  • CPU Duration:在 CPU 上执行的耗时

  • Self Time:自身执行的总耗时,不包括子方法

  • CPU Self Time:自身在 CPU 上执行的耗时,不包括子方法


d) 函数调用虚实区别

如下图,我们看到标识setUpHWComposer调用的紫色条并不是实心的,实心的部分代表CPU Duration相对于Wall Duration的占比: image.png


2.2.5 CPU信息


image.png


image.png



  1. C-State


为了在CPU空闲的时候降低功耗,CPU可以被命令进入low-power模式。每个CPU都有几种power模式,这些模式被统称为C-states或者C-modes; C-States从C0开始,C0是CPU的正常工作模式,CPU处于100%运行状态。C后的数越高,CPU睡眠得越深,CPU的功耗被降低得越多,同时需要更多的时间回到C0模式。


「C-State」「描述」
C-0RUN MODE,运行模式。
C-1STANDBY,就位模式,随时准备投入运行
C-2DORMANT,休眠状态,被唤醒投入运行时有一定的延迟
C-3SHUTDOWN,关闭状态,需要有较长的延迟才能进入运行状态,减少耗电


  1. Clock Frequency:CPU当前运行频率;

  2. Clock Frequency Limits:CPU最大最小频率,通过该参数可以判断CPU核心差异,如大中小核


2.2.6 常见系统进程、线程




  • 进程




    • system_server



      • AMS

      • WMS

      • SurfaceFlinger






  • 线程



    • UI Thread //主线程

    • Render Thread //渲染线程

    • Binder Thread //跨进程调用线程




2.2.7 常见问题


a) 「锁等待」

image.png 「monitor contention」 with owner 「caton_dump_stack (11056)」 waiters=0 blocking from 「boolean android.os.MessageQueue.enqueueMessage(android.os.Message, long)(MessageQueue.java:544)」 image.png 结合代码看,这段信息的意思是,「caton_dump_stack」线程(线程ID是11056)作为「owner」持有了主线程消息队列对象锁,「waiters」表示等待在该对象锁上的其他线程数(不包括当前线程),所以总的有1个线程等待对象锁释放,当前线程等待的位置是「enqueueMessage调用处」


b) 「SurfaceFlinger绘制」

SurfaceFlinger主要是收集各个UI渲染层的数据合成发送给Hardware Composer;一般应用的渲染层包括状态栏、应用页面、导航栏,每个部分都是单独渲染生成Buffer的,基本步骤如下:



  1. 应用收到VSYNC-app信号后,在**「UI Thread」完成数据计算;准备好后,将数据发送到「RenderThread」**;

  2. 应用在**「RenderThread」完成数据渲染后,将数据填充到「SurfaceFlinger」**的对应页面BufferQueue中;

  3. 在**「SurfaceFlinger」收到VSYNC-sf信号后,「SurfaceFlinger」** 会遍历它的层列表的BufferQueue,以寻找新的缓冲区。如果找到新的缓冲区,它会获取该缓冲区;否则,它会继续使用以前获取的缓冲区。「SurfaceFlinger」 必须始终显示内容,因此它会保留一个缓冲区。如果在某个层上没有提交缓冲区,则该层会被忽略;

  4. **「SurfaceFlinger」**向 「HWC」 提供一个完整的层列表,并询问“您希望如何处理这些层?”

  5. 「HWC」 的响应方式是将每个层标记为叠加层或 GLES 合成;

  6. 「SurfaceFlinger」 会处理所有 GLES 合成,将输出缓冲区传送到 「HWC」,并让 「HWC」 处理其余部分;


image.png


image.png


c) 「掉帧」

上一部分描述了SurfaceFlinger合成每帧数据的过程,在上述过程中,如果**「UI Thread」计算不及时,或者「RenderThread」渲染不及时,或者「BufferQueue」**中可用Buffer不足导致在下一次VSYNC信号来临之前,没有准备好需要显示的帧的数据,就会出现丢帧,Systrace 报告列出了渲染界面帧的每个进程,并指明了沿时间轴渲染的每个帧。在 16.6 毫秒内渲染的必须保持每秒 60 帧稳定帧速率的帧会以绿色圆圈表示。渲染时间超过 16.6 毫秒的帧会以黄色或红色帧圆圈表示:



  1. 绿色:未丢帧;

  2. 棕色:轻微丢帧,丢1帧 ;

  3. 红色:严重丢帧,丢大于1帧;


image.png 通过Systrace右上角的View Options > Highlight VSync,我们可以高亮VSYNC信号到来的时刻,需要注意的是,高亮的VSYNC主要是VSYNC-app信号(可在SurfaceFlinger进程中查看),且灰白相间的位置是VSYNC信号到来的时刻。 如下图,显示了丢1帧的情况,第二个VSYNC到来时,主线程仍未完成显示帧数据的计算,所以出现丢帧的问题。 image.png


三、常见trace工具对比


其实我们分析trace不光只有系统这一种方法,下图做个简单的总结


「工具名」「类型」「原理」「优缺点」「使用场景」「使用说明」
Traceviewinstrument利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中。「优点:」 全函数调用分析;「缺点:」 工具本身带来的性能开销过大,有时无法反映真实的情况。比如一个函数本身的耗时是 1 秒,开启 Traceview 后可能会变成 5 秒,而且这些函数的耗时变化并不是成比例放大;- 线下- 整个程序执行流程的耗时已废弃,之前DDMS有相关工具入口
Nanoscopeinstrument直接修改 Android 虚拟机源码,在ArtMethod执行入口和执行结束位置增加埋点代码,将所有的信息先写到内存,等到 trace 结束后才统一生成结果文件。「优点:」- 全函数调用分析;- 性能开销小;- 可以支持分析任意一个应用,可用于做竞品分析;「缺点:」- 需要自己刷 ROM,并且当前只支持 Nexus 6P,或者采用其提供的 x86 架构的模拟器;- 默认只支持主线程采集,其他线程需要代码手动设置;- 线下- 整个程序执行流程的耗时github.com/uber/nanosc…
systracesample实际是其他工具的封装,Systrace使用atrace开启追踪,然后读取ftrace的缓存,并且把它重新转换成HTML格式。「优点:」- 可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用,例如渲染耗时、线程锁,GC 耗时等;- 性能损耗可以接受;「缺点:」- 不支持应用程序代码的耗时分析;需手动添加或者编译期插装;- 线下- 分析系统调用developer.android.com/topic/perfo…
Simpleperfsample利用 CPU 的性能监控单元(PMU)提供的硬件 perf 事件。「优点:」- 支持Native分析;- 性能开销非常低;「缺点:」- Java分析对Android版本要求比较高;- 线下- 分析 Native 代码的耗时android.googlesource.com/platform/sy… 在 Android M 和以前,Simpleperf 不支持 Java 代码分析。- 在 Android O 和以前,需要手动指定编译 OAT 文件。- 在 Android P 和以后,无需做任何事情,Simpleperf 就可以支持 Java 代码分析;
Profiler(CPU Profiler)混合- Sample Java Methods 的功能类似于 Traceview 的 sample 类型;- Trace Java Methods 的功能类似于 Traceview 的 instrument 类型;- Trace System Calls 的功能类似于 systrace;- Sample Native (API Level 26+) 的功能类似于 Simpleperf;「优点:」- 集成在IDE中,操作简单;「缺点:」- 性能开销大,应用明显卡顿;- 无法用于自动化测试等场景;- 线下developer.android.com/studio/prof…

「instrument」:获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点。


「sample」:有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析。


作者:Android茶话会
来源:juejin.cn/post/7238172236185354297
收起阅读 »

初入Android TV/机顶盒应用开发小记1

1.前期 去年公司开展了一个新的项目,是一个运行在机顶盒上的App。项目经理把整个部门的几个重要成员叫到会议室开了场研讨,讨论了关于整个项目的详细情况,但是公司现有做安卓哥们都没有开发过Tv上的项目,所以当时都没有人主动想要负责这个项目的开发。可能在会上我问的...
继续阅读 »

1.前期


去年公司开展了一个新的项目,是一个运行在机顶盒上的App。项目经理把整个部门的几个重要成员叫到会议室开了场研讨,讨论了关于整个项目的详细情况,但是公司现有做安卓哥们都没有开发过Tv上的项目,所以当时都没有人主动想要负责这个项目的开发。可能在会上我问的问题比较多引起了注意。然后就毫无意外的把这个项目客户端开发硬塞了给我负责。我也是醉了。。。


2.准备


在美工(设计师)正画高保真图的这段时间我也开始了研究关于在机顶盒上的一些相关技术储备,也试的写了一些Demo出来。感觉还是阔以拿捏的。但是当前我等到高保真出来的时侯大家一起探讨机顶盒上的一些交互时,我发现我的的相关技术储备还是有点欠缺,没办法,只能先跟着图开始做着。


3.开干


没过这方面开发的哥们可能不知道。开发一个几个按钮外加一个列表的页面如果是手机端的我不用半小时就写完了,但是我在开发TV上的类似的页面时我足足做一了一个多星期。而且产经理看了还是不满意,说这不行,说那焦点有问题。还经常用几个主流的Tv应用在跟我展示说别人也是这么做,也是那么做。


4.遇到问题


就拿一个控件获取焦点时的问题来说吧,别人主流的TV应用里的控件获取焦点显示焦框时,控件里的内容是不是被挤压的。而且有的焦框带有阴影,阴影还会复盖在别的控件之上,也就是说焦点框不占据控件的大小,如果有传统的方式给控件设置src或是background属性来显示焦点框的话是会占据控件原本的大小的。


如图下面三个正方形的控件的宽高都是100dp的,第1个和第2个是可以获取焦点的,第3个是用来作为对比大小的。给第1,2个控件添加了一个selector类型的drawable,作为当控件获取焦点时的的焦点框,很明显可以看到,当获取焦点时第2个控件显示了一个红色的焦点框,但是焦点框却挤压了控件的内容,也就是说焦点框显示在控件100dp之内。像这种方式是有问题的。我们要的是焦点框要显示的控件之外的区域这样就会不占用控件的大小。


7.焦点知识入门-焦点框问题[00_08_02][20230518-190621-4].JPG


5寻找解决方案


顺着这个问题在各大社区寻找解决方案,找到了一种可以把焦点框显示在控件之外的方式。核心就是默认情况下Android的组件可以绘制超出它原本大小之外的区域,但是默认只会显示控件大小之内的区域,如果我把给这个控件的父控件的两个属性设置为false那么就可以进显示控件之外的内容了。而这两个属性就是:


android:clipChildren="false"
android:clipToPadding="false"

接着就要自定义一个控件,这里以ImageView为例,首页要拿到获取焦点框图片的边框大小:


int resourceId = ta.getResourceId(R.styleable.EBoxImageView_ebox_iv_focused_border,
-1);
Rect mFocusedDrawable.getPadding(mFocusDrawableRect);


然后计算出焦点框加点组件之后的整个显示的区域的大小,


private void mergeRect(Rect layoutRect, Rect drawablePaddingRect) {
layoutRect.left -= drawablePaddingRect.left;
layoutRect.right += drawablePaddingRect.right;
layoutRect.top -= drawablePaddingRect.top;
layoutRect.bottom += drawablePaddingRect.bottom;
}

最后再根据这个大小区域绘制焦点框,而绘制内容这一块直接调用super.onDraw(canvas);就可以了。


下面是一个完整的代码:


public class MyImageView extends AppCompatImageView {
private static final String TAG = "EBoxImageView";

private static final Rect mLayoutRect = new Rect();
private static final Rect mFocusDrawableRect = new Rect();

private final static Drawable DEFAULT_FOCUSED_DRAWABLE = ResourceUtils.getDrawable(R.drawable.drawable_focused_border);
private Drawable mFocusedDrawable = DEFAULT_FOCUSED_DRAWABLE;

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

public MyImageView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public MyImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}

private void init(AttributeSet attributeSet) {
setScaleType(ScaleType.FIT_XY);

TypedArray ta = getContext().obtainStyledAttributes(attributeSet, R.styleable.MyImageView);
boolean selectorMode = ta.getBoolean(R.styleable.MyImageView_ebox_iv_selected_mode,false);
int resourceId = ta.getResourceId(R.styleable.MyImageView_ebox_iv_focused_border,
-1);
ta.recycle();

if(selectorMode){
setFocusable(false);
}else {
setFocusable(true);
}

if (resourceId != -1) {
mFocusedDrawable = ResourceUtils.getDrawable(resourceId);
}
mFocusedDrawable.getPadding(mFocusDrawableRect);
}


@Override
public void invalidateDrawable(@NonNull Drawable dr) {
super.invalidateDrawable(dr);
invalidate();
}

@CallSuper
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isFocused()||isSelected()) {
getDrawingRect(mLayoutRect);

mergeRect(mLayoutRect, mFocusDrawableRect);
mFocusedDrawable.setBounds(mLayoutRect);
canvas.save();
mFocusedDrawable.draw(canvas);
canvas.restore();
}

}

/**
* 合并drawable的padding到borderRect里去
*
*
@param layoutRect 当前布局的Rect
*
@param drawablePaddingRect borderDrawable的Rect
*/

private void mergeRect(Rect layoutRect, Rect drawablePaddingRect) {
layoutRect.left -= drawablePaddingRect.left;
layoutRect.right += drawablePaddingRect.right;
layoutRect.top -= drawablePaddingRect.top;
layoutRect.bottom += drawablePaddingRect.bottom;
}

/**
* 指定一个焦点框图片资源,如果不调用此方法默认用,R.drawable.drawable_recommend_focused
*
*
@param focusDrawableRes
*/

public void setFocusDrawable(@DrawableRes int focusDrawableRes) {
mFocusedDrawable = ResourceUtils.getDrawable(focusDrawableRes);
mFocusedDrawable.getPadding(mFocusDrawableRect);
}


总结



以上就是在开发AndroidTV、机顶盒中遇到的焦点框问题的解决方案,后来在CS某N社区中找到一套关于AndroidTV项目开发实战的视频教程看了一下还不错,在其它地方也找不更好的资源。再加上项目实现太赶没有那么多的试错时间成本,然后就报名买了那教程。后来边看边开发,用着这套视频的作者提供的一个UI库,来开发我的项目确定方便快速了很多。现在整套视频教程还没看完,一边学习一边写公司的项目和写博客。


作者:本拉茶
来源:juejin.cn/post/7234447275861475365
收起阅读 »

基于人脸识别算法的考勤系统

​ 作为一个基于人脸识别算法的考勤系统的设计与实现教程,以下内容将提供详细的步骤和代码示例。本教程将使用 Python 语言和 OpenCV 库进行实现。 一、环境配置 安装 Python 请确保您已经安装了 Python 3.x。可以在Python 官网...
继续阅读 »


作为一个基于人脸识别算法的考勤系统的设计与实现教程,以下内容将提供详细的步骤和代码示例。本教程将使用 Python 语言和 OpenCV 库进行实现。


一、环境配置



  1. 安装 Python


请确保您已经安装了 Python 3.x。可以在Python 官网下载并安装。



  1. 安装所需库


在命令提示符或终端中运行以下命令来安装所需的库:


pip install opencv-python
pip install opencv-contrib-python
pip install numpy
pip install face-recognition


二、创建数据集



  1. 创建文件夹结构


在项目目录下创建如下文件夹结构:


attendance-system/
├── dataset/
│ ├── person1/
│ ├── person2/
│ └── ...
└── src/


将每个人的照片放入对应的文件夹中,例如:


attendance-system/
├── dataset/
│ ├── person1/
│ │ ├── 01.jpg
│ │ ├── 02.jpg
│ │ └── ...
│ ├── person2/
│ │ ├── 01.jpg
│ │ ├── 02.jpg
│ │ └── ...
│ └── ...
└── src/


三、实现人脸识别算法


src 文件夹下创建一个名为 face_recognition.py 的文件,并添加以下代码:


import os
import cv2
import face_recognition
import numpy as np

def load_images_from_folder(folder):
images = []
for filename in os.listdir(folder):
img = cv2.imread(os.path.join(folder, filename))
if img is not :
images.append(img)
return images

def create_known_face_encodings(root_folder):
known_face_encodings = []
known_face_names = []
for person_name in os.listdir(root_folder):
person_folder = os.path.join(root_folder, person_name)
images = load_images_from_folder(person_folder)
for image in images:
face_encoding = face_recognition.face_encodings(image)[0]
known_face_encodings.append(face_encoding)
known_face_names.append(person_name)
return known_face_encodings, known_face_names

def recognize_faces_in_video(known_face_encodings, known_face_names):
video_capture = cv2.VideoCapture(0)
face_locations = []
face_encodings = []
face_names = []
process_this_frame = True

while True:
ret, frame = video_capture.read()
small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25)
rgb_small_frame = small_frame[:, :, ::-1]

if process_this_frame:
face_locations = face_recognition.face_locations(rgb_small_frame)
face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations)

face_names = []
for face_encoding in face_encodings:
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
name = "Unknown"

face_distances = face_recognition.face_distance(known_face_encodings, face_encoding)
best_match_index = np.argmin(face_distances)
if matches[best_match_index]:
name = known_face_names[best_match_index]

face_names.append(name)

process_this_frame = not process_this_frame

for (top, right, bottom, left), name in zip(face_locations, face_names):
top *= 4
right *= 4
bottom *= 4
left *= 4

cv2.rectangle(frame, (left, top), (right, bottom), (0, 0, 255), 2)
cv2.rectangle(frame, (left, bottom - 35), (right, bottom), (0, 0, 255), cv2.FILLED)
font = cv2.FONT_HERSHEY_DUPLEX
cv2.putText(frame, name, (left + 6, bottom - 6), font, 0.8, (255, 255, 255), 1)

cv2.imshow('Video', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

video_capture.release()
cv2.destroyAllWindows()

if __name__ == "__main__":
dataset_folder = "../dataset/"
known_face_encodings, known_face_names = create_known_face_encodings(dataset_folder)
recognize_faces_in_video(known_face_encodings, known_face_names)


四、实现考勤系统


src 文件夹下创建一个名为 attendance.py 的文件,并添加以下代码:


import os
import datetime
import csv
from face_recognition import create_known_face_encodings, recognize_faces_in_video

def save_attendance(name):
attendance_file = "../attendance/attendance.csv"
now = datetime.datetime.now()
date_string = now.strftime("%Y-%m-%d")
time_string = now.strftime("%H:%M:%S")
if not os.path.exists(attendance_file):
with open(attendance_file, "w", newline="") as csvfile:
csv_writer = csv.writer(csvfile)
csv_writer.writerow(["Name", "Date", "Time"])
with open(attendance_file, "r+", newline="") as csvfile:
csv_reader = csv.reader(csvfile)
rows = [row for row in csv_reader]
for row in rows:
if row[0] == name and row[1] == date_string:
return
csv_writer = csv.writer(csvfile)
csv_writer.writerow([name, date_string, time_string])

def custom_recognize_faces_in_video(known_face_encodings, known_face_names):
video_capture = cv2.VideoCapture(0)
face_locations = []
face_encodings = []
face_names = []
process_this_frame = True
while True:
ret, frame = video_capture.read()
small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25)
rgb_small_frame = small_frame[:, :, ::-1]

if process_this_frame:
face_locations = face_recognition.face_locations(rgb_small_frame)
face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations)
face_names = []
for face_encoding in face_encodings:
matches = face_recognition.compare_faces(known_face_encodings, face_encoding)
name = "Unknown"
face_distances = face_recognition.face_distance(known_face_encodings, face_encoding)
best_match_index = np.argmin(face_distances)
if matches[best_match_index]:
name = known_face_names[best_match_index]
save_attendance(name)
face_names.append(name)
process_this_frame = not process_this_frame
for (top, right, bottom, left), name in zip(face_locations, face_names):
top *= 4
right *= 4
bottom *= 4
left *= 4
cv2.rectangle(frame, (left, top), (right, bottom), (0, 0, 255), 2)
cv2.rectangle(frame, (left, bottom - 35), (right, bottom), (0, 0, 255), cv2.FILLED)
font = cv2.FONT_HERSHEY_DUPLEX
cv2.putText(frame, name, (left + 6, bottom - 6), font, 0.8, (255, 255, 255), 1)

cv2.imshow('Video', frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

video_capture.release()
cv2.destroyAllWindows()

if __name__ == "__main__":
dataset_folder = "../dataset/"
known_face_encodings, known_face_names = create_known_face_encodings(dataset_folder)
custom_recognize_faces_in_video(known_face_encodings, known_face_names)


五、运行考勤系统


运行 attendance.py 文件,系统将开始识别并记录考勤信息。考勤记录将保存在 attendance.csv 文件中。


python src/attendance.py


现在,您的基于人脸识别的考勤系统已经实现。请注意,这是一个基本示例,您可能需要根据实际需求对其进行优化和扩展。例如,您可以考虑添加更多的人脸识别算法、考勤规则等

作者:A等天晴
来源:juejin.cn/post/7235458133505867837


收起阅读 »

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

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

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


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



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


今天的内容大致如下:


1、分析UI,如何布局


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


3、部分源码剖析


4、开源地址及实用总结


一、分析UI,如何布局


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


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



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


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


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


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


设置属性


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

定义方法


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

三、关键源码剖析


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


定义身份简称数组


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

遍历省份简称


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


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

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

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

addRectView(textView, layout, 0.1f)
}

追加最后一个View


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


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

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

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

endView.layoutParams = params

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


}

四、开源地址及使用总结


开源地址:github.com/AbnerMing88…


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


Maven具体调用


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


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

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


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

代码使用


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


总结


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


作者:二流小码农
来源:juejin.cn/post/7235484890019659834
收起阅读 »