注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Dialog 按照顺序弹窗

背景: 产品需求,在同一个页面弹窗需要按照顺序实现: 利用PriorityQueue现实,支持相同优先级,按插入时间排序,目前仅支持Activity,不支持Fragment代码: DialogPriorityUtil 实现优先级弹窗/** ...
继续阅读 »

背景: 产品需求,在同一个页面弹窗需要按照顺序

实现: 利用PriorityQueue现实,支持相同优先级,按插入时间排序,目前仅支持Activity,不支持Fragment

代码: DialogPriorityUtil 实现优先级弹窗

/**
* ClassName: DialogPriorityUtil
* Description: show dialog by priority
* author Neo
* since 2021-09-15 20:15
* version 1.0
*/
object DialogPriorityUtil : LifecycleObserver {

private val dialogPriorityQueue = PriorityQueue<PriorityDialogWrapper>()

private var hasDialogShowing = false

@MainThread
fun bindLifeCycle(appCompatActivity: AppCompatActivity) {
appCompatActivity.lifecycle.addObserver(this)
}

@MainThread
fun showDialogByPriority(dialogWrapper: PriorityDialogWrapper? = null) {
if (dialogWrapper != null) {
dialogPriorityQueue.offer(dialogWrapper)
}
if (hasDialogShowing) return
val maxPriority: PriorityDialogWrapper = dialogPriorityQueue.poll() ?: return
if (!maxPriority.isShowing()) {
hasDialogShowing = true
maxPriority.showDialog()
}
maxPriority.setDismissListener {
hasDialogShowing = false
showDialogByPriority()
}
}

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
dialogPriorityQueue.clear()
}
}
/**
* 定义dialog优先级
* @property priority Int
* @constructor
*/
sealed class DialogPriority(open val priority: Int) {
sealed class HomeMapFragment(override val priority: Int) : DialogPriority(priority) {
/**
* App更新
*/
object UpdateDialog : HomeMapFragment(0)

/**
* 等级提升
*/
object LevelUpDialog : HomeMapFragment(1)

/**
* 金币打卡
*/
object CoinClockInDialog : HomeMapFragment(2)
}
}

/**
* ClassName: PriorityDialogWrapper
* Description: 优先级弹窗包装类
* author Neo
* since 2021-09-15 20:20
* version 1.0
*/
class PriorityDialogWrapper(private val dialog: Dialog, private val dialogPriority: DialogPriority) : Comparable<PriorityDialogWrapper> {

private var dismissCallback: (() -> Unit)? = null

private val timestamp = SystemClock.elapsedRealtimeNanos()

init {
dialog.setOnDismissListener {
dismissCallback?.invoke()
}
}

fun isShowing(): Boolean = dialog.isShowing

fun setDismissListener(callback: () -> Unit) {
this.dismissCallback = callback
}

fun showDialog() {
dialog.show()
}

override fun compareTo(other: PriorityDialogWrapper): Int {
return when {
dialogPriority.priority > other.dialogPriority.priority -> {
// 当前对象比目标对象大,则返回 1
1
}
dialogPriority.priority < other.dialogPriority.priority -> {
// 当前对象比目标对象小,则返回 -1
-1
}
else -> {
// 若是两个对象相等,则返回 0
when {
timestamp > other.timestamp -> {
1
}
timestamp < other.timestamp -> {
-1
}
else -> {
0
}
}
}
}
}
}

使用:

AppCompatActivity

DialogPriorityUtil.bindLifeCycle(this)
DialogPriorityUtil.showDialogByPriority(...)
收起阅读 »

kotlin的协程异步,并发(同步)

一:协程的异步任务private fun task(){ println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start"...
继续阅读 »

一:协程的异步

任务

private fun task(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
Thread.sleep(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}

下面使用协程异步的方式,让任务task()在子线程中处理。

方式1:launch()+Dispatchers.IO

launch创建协程;

Dispatchers.IO调度,在子线程处理网络耗时

fun testNotSync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
// 重复执行3次,模拟点击3次
repeat(3) {
CoroutineScope(Dispatchers.IO).launch {
task()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631949431058, 方法start
currentThread:main, time:1631949431166, 方法end

currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631949431176, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631949431182, start
currentThread:DefaultDispatcher-worker-2 @coroutine#1, time:1631949431182, start

currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631949432176, end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631949432182, end
currentThread:DefaultDispatcher-worker-2 @coroutine#1, time:1631949432183, end

显示:主线程内容先执行,然后会在3个子线程异步的执行

方式2:async()+Dispatchers.IO

fun testByCoroutineAsync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).async {
task()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")
// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631957324981, 方法start
currentThread:main @coroutine#1, time:1631957325007, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631957325007, start
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631957325007, start
currentThread:DefaultDispatcher-worker-4 @coroutine#4, time:1631957325007, start
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631957326007, end
currentThread:DefaultDispatcher-worker-4 @coroutine#4, time:1631957326007, end
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631957326007, end

显示:主线程内容先执行,然后会在3个子线程异步的执行

看源码发现CoroutineScope.async 等同 CoroutineScope.launch,不同是返回值。

方式3:withContext+Dispatchers.IO

/**
* 单个withContext的异步任务
*/
fun testByWithContext() = runBlocking {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")

withContext(Dispatchers.IO) {
task()
}

println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10 *1000)
}

结果:

currentThread:main @coroutine#1, time:1631958195591, 方法start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958195669, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958196669, end
currentThread:main @coroutine#1, time:1631958196671, 方法end

发现:withContext的task是在子线程中执行,但是也阻塞了main线程,最后执行了"方法end"

因为withContext切io线程后,还挂起了外部的协程(可以理解线程),需要等withCotext执行完成,才会回到原来的协程,也直接可以理解为阻塞了当前的线程。

上面是单个withCotext的异步执行,看多个withContext是怎么样的

fun testByWithContext() = runBlocking {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
// 重复3次,模拟点击3次
repeat(3) {
println("repeat it = $it")
withContext(Dispatchers.IO) {
task()
}
}

println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10 *1000)
}

结果:

currentThread:main @coroutine#1, time:1631958027834, 方法start
repeat it = 0
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958027870, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958028870, end
repeat it = 1
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958028873, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958029873, end
repeat it = 2
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958029874, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958030874, end
currentThread:main @coroutine#1, time:1631958030874, 方法end

发现:先main线程执行,然后一个withcontext异步执行完成,才能执行下一个withcontext的异步

实现了多个异步任务的同步,当我们有多个接口请求,需要按顺序执行时,可以使用

二:协程的并发(同步)

Java中并发concurrent的处理,基本使用同步synchronized,Lock,join等来处理。下面我们看看协程怎麽处理的。

1:@Synchronized 注解

我们将上面的任务task修改一下,方法上面加个注解@Synchronized,然后执行launch的异步看能不能同步任务?

使用

/**
* @Synchronized 修改普通函数ok,可以同步
*/
@Synchronized
private fun taskSynchronize(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
Thread.sleep(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}

测试:aunch异步同时访问taskSynchronize()任务

fun testCoroutineWithSync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
taskSynchronize()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631959341585, 方法start
currentThread:main, time:1631959341657, 方法end
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631959341657, start
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631959342658, end
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631959342658, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631959343658, end
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631959343658, start
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631959344658, end

发现:先main先执行完成,然后每个线程任务,同步执行完成了

问题

当@Synchronized 注解的方法中,有挂起函数且是阻塞的,就不行了

修改一下任务,其中的Thread.sleep(1000)改为delay(1000),看看如何?

/**
* 和方法taskSynchronize(), 不同的是内部使用了delay的挂起函数,而其它会阻塞,需要等它完成后面的才能开始
*
* @Synchronized 关键字不要修饰方法中有suspend挂起函数,因为内部又挂起了,就不会同步了
*/
@Synchronized
suspend fun taskSynchronizeByDelay(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
delay(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}
/**
* 执行体是taskSynchronizeByDelay(), 内部会使用delay函数,导致他外部的线程挂起,其他线程可以访问执行体,
*
* 所以:@Synchronized 同步注解,尽量不用修饰suspend的函数
*/
fun testCoroutineWithSync2() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
taskSynchronizeByDelay()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631961179390, 方法start
currentThread:main, time:1631961179451, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631961179456, start
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631961179464, start
currentThread:DefaultDispatcher-worker-3 @coroutine#2, time:1631961179464, start
currentThread:DefaultDispatcher-worker-3 @coroutine#1, time:1631961180462, end
currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631961180464, end
currentThread:DefaultDispatcher-worker-4 @coroutine#2, time:1631961180464, end

发现:加了@Synchronized注解,还是异步的执行,因为task中有delay这个挂起函数,它会挂起外部协程,直到执行完成才会执行其他的。

2:Mutex()

使用:

var mutex = Mutex()
mutex.withLock {
// TODO
}

测试:

fun testSyncByMutex() = runBlocking {
var mutex = Mutex()
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
mutex.withLock {
task()
}
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631951230155, 方法start
currentThread:main @coroutine#1, time:1631951230178, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631951230178, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631951231178, end
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631951231183, start
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631951232183, end
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631951232183, start
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631951233184, end

发现:多个异步任务同步完成了。

3:Job.join()

Job创建协程返回的句柄,它支持join()操作,类是java线程的join功能,可以等待任务执行完成,实现同步

测试:

fun testSyncByJob() = runBlocking{
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
var job = CoroutineScope(Dispatchers.IO).launch {
task()
}
job.start()
job.join()
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631959997427, 方法start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631959997507, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631959998507, end
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631959998509, start
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631959999509, end
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631959999510, start
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631960000510, end
currentThread:main @coroutine#1, time:1631960000510, 方法end

发现:多个任务可以同步一个个完成,并且阻塞了main线程,和withContext的效果一样哦。

4:ReentrantLock

使用:

val lock = ReentrantLock()
lock.lock()
task()
lock.unlock()

测试:

fun testReentrantLock2() = runBlocking {

val lock = ReentrantLock()
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
lock.lock()
task()
lock.unlock()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631960884403, 方法start
currentThread:main @coroutine#1, time:1631960884445, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631960884445, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631960885446, end
currentThread:DefaultDispatcher-worker-5 @coroutine#4, time:1631960885446, start
currentThread:DefaultDispatcher-worker-5 @coroutine#4, time:1631960886446, end
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631960886446, start
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631960887447, end

发现:同步完成。

收起阅读 »

Kotlin中的高阶函数,匿名函数、Lambda表达式

高阶函数、匿名函数与lambda 表达式 Kotlin 函数都是头等的,这意味着它们可以存储在变量与数据结构中、作为参数传递给其他高阶函数以及从其他高阶函数返回。可以像操作任何其他非函数值一样操作函数。 头等函数:头等函数(first-class functi...
继续阅读 »

高阶函数、匿名函数与lambda 表达式

 Kotlin 函数都是头等的,这意味着它们可以存储在变量与数据结构中、作为参数传递给其他高阶函数以及从其他高阶函数返回。可以像操作任何其他非函数值一样操作函数。

 头等函数:头等函数(first-class function)是指在程序设计语言中,函数被当作头等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中

高阶函数

高阶函数是将函数用作参数或返回值的函数。

 //learnHighFun是一个高阶函数,因为他有一个函数类型的参数funParam,注意这里有一个新的名词,函数类型,函数在kotlin中也是一种类型。那他是什么类型的函数呢?注意(Int)->Int,这里表示这个函数是一个,接收一个Int,并返回一个Int类型的参数。
 fun learnHighFun(funParam:(Int)->Int,param:Int){}

 以上就是一个最简单的高阶函数了。了解高阶函数之前,显然,我们有必要去了解一下上面的新名词,函数类型

函数类型

如何声明一个函数类型的参数

 在kotlin中,声明一个函数类型的格式很简单,在kotlin中我们是通过->符号来组织参数类型和返回值类型,左右是函数的参数,右边是函数的返回值,函数的参数,必须在()中,多个参数的时候,用,将参数分开。如下:

 //表示该函数类型,接收一个Int类型的参数,并且返回值为Int类型
 (Int)->Int
 
 //表示该函数类型,接收两个参数,一个Int类型的参数,一个String类型的参数,并且返回值为Int类型
 (Int,Stirng)->Int

 那没有函数参数,和无返回值函数怎么声明?如下:

 //声明一个没有参数,返回值是Int的函数类型,函数类型中,函数没有参数的时候,()不可以省略
 ()->Int
 
 //明一个没有参数,没有返回值的函数类型,函数类型中,函数没有返回值的时候,Unit不可以省略
 ()->Unit
 

 以上就是简单的函数类型的声明了。那么如果是一个高阶函数,它的参数类型也是一个高阶函数,那要怎么声明?比如以下的式子表示什么含义:

 private fun learnHigh(funParams:((Int)->Int)->Int){}
 //这里表示的是一个高阶函数learnHigh,他有一个函数类型的参数funParams。而这个funParams的类型也是一个高阶函数的类型。funParams这个函数类型表示,它接受一个普通函数类(Int)->Int的参数,并返回一个Int类型。这段话读起来确实很绕,但是你明白了这个复杂的例子之后,基本所有的高阶函数你都能看懂什么意思了。
 
 //这里这个highParam的类型,就符合上面learnHigh函数所要接收的函数类型
 fun highParam(param: (Int)->Int):Int{
     return  1
 }

 讲了参数为函数类型的高阶函数,返回值类型为函数的高阶函数也基本参照上面的这些看就可以了。那么下一个问题来了,我是讲了这么多高阶函数,这么多函数类型的知识点。那么这些函数类型的参数要怎么传?换句话说,应该怎么样把这些函数类型的参数,传给的高阶函数?直接使用函数名可以吗?显然是不行的,因为函数名并不是一个表达式,不具备类型信息。那么我们这时候就需要一个单纯的方法引用表达式

函数引用

 在kotlin中,使用两个冒号的来实现对某个类的方法进行引用。 这句话包含了哪些信息呢?第一,既然是引用,那么说明是对象。也就是使用双冒号实现的引用也是一个对象。 它是一个函数类型的对象。第二,既然对象,那么他就需要被创建,也就是说,这里创建了一个函数类型的对象,这个对象是具有和这个函数功能相同的对象。还是举例子来说明一下上面两句话是什么意思:

 fun testFunReference(){
     funReference(1)  //普通函数,直接通过函数名然后附带参数来调用。
     val funObject = ::funReference //函数的引用,他本质上已经是一个对象了
     testHighFun(funObject) //通过一个函数引用,将这个函数类型的对象,传递给高阶函数。所以高阶函数里面接收的参数本质上还是对象。
 
     funObject.invoke(1) //等同于funReference(1)
     funObject(1) //等同于funReference(1),等同于funObject.invoke(1)
 }
 
 fun funReference(param:Int){
     //doSomeThing
 }
 
 fun testHighFun(funParam:(Int)->Unit){
     //doSomeThing
 }
 //这是反编译出来的java代码
 public final void testFunReference() {
     this.funReference(1);
     //val funObject = ::funReference 这句代码反编译出来就是这样的,可以看出这里是新创建了一个对象
     KFunction funObject = new Function1((TestFun)this) {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke(Object var1) {
             this.invoke(((Number)var1).intValue());
             return Unit.INSTANCE;
        }
 
         public final void invoke(int p1) {
            ((TestFun)this.receiver).funReference(p1);
        }
    };
     this.testHighFun((Function1)funObject);
    ((Function1)funObject).invoke(1);
    ((Function1)funObject).invoke(1);//funObject(1)最终是调用的funObject.invoke(1)
 }
 
 public final void funReference(int param) {
 }
 //可以看出这个testHighFun接收的是一个Function1类型的对象
 public final void testHighFun(@NotNull Function1 funParam) {
     Intrinsics.checkNotNullParameter(funParam, "funParam");
 }

以上就是关于函数引用的知识点了。

 理解了以上的用法,但是这种写法好像每次都需要去声明一个函数,那么有没有其他不需要重新声明函数的方法去调用高阶函数呢?那肯定还是有的,如果这都不支持那Kotlin的这个高阶函数这个特性不就有点鸡肋了吗?接下来就讲解另外两个知识点,kotlin中的匿名函数Lambda表达式

匿名函数

 来讲匿名函数,看定义就知道这是一个没有名字的'函数',注意这里的'函数'这两个字是带有引号的。首先来看看怎么在高阶函数中使用吧。

 //接着上面的例子讲
 //除了这种通过引用对象调用testHighFun(funObject)的方法,还可以直接把一个函数当做这个高阶函数的参数。
 val param = fun (param:Int){ //注意这里是没有函数名的,所以是匿名'函数'
     //doSomeThing
 }
 testHighFun(param)

 注意:通过之前的分析,我们可以知道,这个高阶函数testHighFun接收的参数是一个函数对象的引用,也就是说我们定义的val param是一个函数对象的引用,那么可以得出这个匿名'函数' fun(param:Int){},他的本质是一个函数对象。他并不是'函数'。我们可以看一下反编译出来的java代码

 //param是一个Function1类型的对象的引用
 Function1 param = (Function1)null.INSTANCE;
 this.testHighFun(param);

所以记住一点,Kotlin中的匿名函数,它的本质不是函数。而是对象。它和函数不是一个东西,它是一个函数类型的对象。对象和函数,它们是两个东西。

Lambda表达式

Lambda 表达式的完整语法形式如下:

 val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }

 Lambda 表达式总是括在花括号中, 完整语法形式的参数声明放在花括号内,并有可选的类型标注, 函数体跟在一个 -> 符号之后。如果推断出的该Lambda 的返回类型不是 Unit,那么该 Lambda 主体中的最后一个(或可能是单个) 表达式会视为返回值。

由于Kotlin中是支持类型推到的,所以以上的写法可以简化成如下两个格式:

 val sum= { x: Int, y: Int -> x + y }
 val sum: (Int, Int) -> Int = { x, y -> x + y }

 在kotlin中还支持,如果函数的最后一个参数是函数,那么作为相应参数传入的 Lambda 表达式可以放在圆括号之外:

 //比如我们上面的那个例子testHighFun,可以将lambda放到原括号之外
 testHighFun(){
 //doSomeThing
 }
 
 //如果该 lambda 表达式是调用时唯一的参数,那么圆括号可以完全省略:如下
 testHighFun{
 //doSomeThing
 }
 
 //一个 lambda 表达式只有一个参数是很常见的。
 //如果编译器自己可以识别出签名,也可以不用声明唯一的参数并忽略 ->。 该参数会隐式声明为 it: 如下
 testHighFun{
     //doSomeThing
     it.toString(it)
 }

从 lambda 表达式中返回一个值

 我们可以使用限定的返回语法从 lambda 显式返回一个值。 否则,将隐式返回最后一个表达式的值。参考官网的例子如下

 ints.filter {
     val shouldFilter = it > 0
     shouldFilter
 }
 
 ints.filter {
     val shouldFilter = it > 0
     return@filter shouldFilter
 }

 好了,以上就是Lambda的基本用法了。

 讲了这么多,我们只是讲解了Lambda怎么使用,那么它的本质是什么?其实仔细思考一下上面的testHighFun可以传入一个Lambda表达式就可以大概知道,Lambda的本质也是一个函数类型的对象。这一点也可以通过发编译的java代码去看。

匿名函数与Lambda表达式的总结:

  1. 两者都能作为高阶函数的参数进行传递。
  2. 两者的本质都是函数类型的对象。

备注:以上就是我个人对高阶函数,匿名函数,Lambda表达式的理解,有什么不对的地方,还请各位大佬指正。

收起阅读 »

高仿小米加载动画效果

前言 首先看一下小米中的加载动画是怎么样的,恩恩~~~~虽然只是张图片,因为录制不上全部,很多都是刚一加载就成功了,一点机会都不提供给我,所以就截了一张图,他这个加载动画特点就是左面圆圈会一直转。 仿照的效果如下: 实现过程 这个没有难度,只是学会一个公式...
继续阅读 »

前言


首先看一下小米中的加载动画是怎么样的,恩恩~~~~虽然只是张图片,因为录制不上全部,很多都是刚一加载就成功了,一点机会都不提供给我,所以就截了一张图,他这个加载动画特点就是左面圆圈会一直转。


image.png


仿照的效果如下:


录屏_选择区域_20210917141950.gif


实现过程


这个没有难度,只是学会一个公式就可以,也就是已知圆心,半径,角度,求圆上的点坐标,算出来的结果在这个点绘制一个实心圆即可,下面是自定义Dialog,让其在底部现实,其中的View也是自定义的一个。



class MiuiLoadingDialog(context: Context) : Dialog(context) {
private var miuiLoadingView : MiuiLoadingView= MiuiLoadingView(context);
init {
setContentView(miuiLoadingView)
setCancelable(false)
}

override fun show() {
super.show()
val window: Window? = getWindow();
val wlp = window!!.attributes

wlp.gravity = Gravity.BOTTOM
window.setBackgroundDrawable( ColorDrawable(Color.TRANSPARENT));
wlp.width=WindowManager.LayoutParams.MATCH_PARENT;
window.attributes = wlp
}
}

下面是主要的逻辑,在里面,首先通过clipPath方法裁剪出一个上边是圆角的形状,然后绘制一个外圆,这是固定的。


中间的圆需要一个公式,如下。


x1   =   x0   +   r   *   cos(a   *   PI   /180   ) 
y1   =   y0   +   r   *   sin(a   *   PI  /180   ) 

x0、y0就是外边大圆的中心点,r是中间小圆大小,a是角度,只需要一直变化这个角度,得出的x1、y1通过drawCircle绘制出来即可。


image.png



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

//Dialog上面圆角大小
val CIRCULAR: Float = 60f;

//中心移动圆位置
var rx: Float = 0f;
var ry: Float = 0f;

//左边距离
var MARGIN_LEFT: Int = 100;

//中心圆大小
var centerRadiusSize: Float = 7f;

var textPaint: Paint = Paint().apply {
textSize = 50f
color = Color.BLACK
}

var circlePaint: Paint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 8f
isAntiAlias = true
color = Color.BLACK
}

var centerCirclePaint: Paint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
color = Color.BLACK
}

var degrees = 360;

val TEXT = "正在加载中,请稍等";
var textHeight = 0;

init {

var runnable = object : Runnable {
override fun run() {
val r = 12;
rx = MARGIN_LEFT + r * Math.cos(degrees.toDouble() * Math.PI / 180).toFloat()
ry =
((measuredHeight.toFloat() / 2) + r * Math.sin(degrees.toDouble() * Math.PI / 180)).toFloat();
invalidate()
degrees += 5
if (degrees > 360) degrees = 0
postDelayed(this, 1)
}
}
postDelayed(runnable, 0)


var rect = Rect()
textPaint.getTextBounds(TEXT, 0, TEXT.length, rect)
textHeight = rect.height()
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(widthMeasureSpec, 220);
}

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

var path = Path()
path.addRoundRect(
RectF(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat()),
floatArrayOf(CIRCULAR, CIRCULAR, CIRCULAR, CIRCULAR, 0f, 0f, 0f, 0f), Path.Direction.CW
);
canvas.clipPath(path)
canvas.drawColor(Color.WHITE)


canvas.drawCircle(
MARGIN_LEFT.toFloat(), measuredHeight.toFloat() / 2,
35f, circlePaint
)

canvas.drawCircle(
rx, ry,
centerRadiusSize, centerCirclePaint
)


canvas.drawText(TEXT, (MARGIN_LEFT + 80).toFloat(), ((measuredHeight / 2)+(textHeight/2)).toFloat(), textPaint)
}
}

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

探究 Kotlin 的隐藏性能开销与避坑指南

在 2019 年 Google I/O 大会上,Google 宣布了今后 Android 开发将优先使用 Kotlin ,即 Kotlin-first,随之在 Android 开发界兴起了一阵全民学习 Kotlin 的热潮。之后 Google 也推出了一系列用...
继续阅读 »

在 2019 年 Google I/O 大会上,Google 宣布了今后 Android 开发将优先使用 Kotlin ,即 Kotlin-first,随之在 Android 开发界兴起了一阵全民学习 Kotlin 的热潮。之后 Google 也推出了一系列用 Kotlin 实现的 ktx 扩展库,例如 activity-ktxfragment-ktxcore-ktx等,提供了各种方便的扩展方法用于简化开发者的工作,Kotlin 协程目前也是官方在 Android 上进行异步编程的推荐解决方案


Google 推荐优先使用 Kotlin,也宣称不会放弃 Java,但目前各种 ktx 扩展库还是需要由 Kotlin 代码进行使用才能最大化地享受到其便利性,Java 代码来调用显得有点不伦不类。作为 Jetpack 主要组件之一的 Paging 3.x 版本目前也已经完全用 Kotlin 实现,为 Kotlin 协程提供了一流的支持。刚出正式版本不久的 Jetpack Compose 也只支持 Kotlin,Java 无缘声明式 UI


开发者可以感受到 Kotlin 在 Android 开发中的重要性在不断提高,虽然 Google 说不会放弃 Java,但以后的事谁说得准呢?开发者还是需要尽早迁移到 Kotlin,这也是必不可挡的技术趋势


Kotlin 在设计理念上有很多和 Java 不同的地方,开发者能够直观感受到的是语法层面上的差异性,背后也包含有一系列隐藏的性能开销以及一些隐藏得很深的“坑”,本篇文章就来介绍在使用 Kotlin 过程中存在的隐藏性能开销,帮助读者避坑,希望对你有所帮助 🤣🤣


慎用 @JvmOverloads


@JvmOverloads 注解大家应该不陌生,其作用在具有默认参数的方法上,用于向 Java 代码生成多个重载方法


例如,以下的 println 方法对于 Java 代码来说就相当于两个重载方法,默认使用空字符串作为入参参数


//Kotlin
@JvmOverloads
fun println(log: String = "") {

}

//Java
public void println(String log) {

}

public void println() {
println("");
}

@JvmOverloads 很方便,减少了 Java 代码调用 Kotlin 代码时的调用成本,使得 Java 代码也可以享受到默认参数的便利,但在某些特殊场景下也会引发一个隐藏得很深的 bug


举个例子


我们知道 Android 系统的 View 类包含有多个构造函数,我们在实现自定义 View 时至少就要声明一个包含有两个参数的构造函数,参数类型必须依次是 Context 和 AttributeSet,这样该自定义 View 才能在布局文件中使用。而 View 类的构造函数最多包含有四个入参参数,最少只有一个,为了省事,我们在用 Kotlin 代码实现自定义 View 时,就可以用 @JvmOverloads 来很方便地继承 View 类,就像以下代码


open class BaseView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes)

如果我们是像 BaseView 一样直接继承于 View 的话,此时使用@JvmOverloads就不会产生任何问题,可如果我们继承的是 TextView 的话,那么问题就来了


直接继承于 TextView 不做任何修改,在布局文件中分别使用 MyTextView 和 TextView,给它们完全一样的参数,看看运行效果


open class MyTextView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleRes: Int = 0
) : TextView(context, attrs, defStyleAttr, defStyleRes)

    <github.leavesc.demo.MyTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="业志陈"
android:textSize="42sp" />

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="业志陈"
android:textSize="42sp" />

此时两个 TextView 就会呈现出不一样的文本颜色了,十分神奇



这就是 @JvmOverloads 带来的一个隐藏问题。因为 TextView 的 defStyleAttr 实际上是有一个默认值的,即 R.attr.textViewStyle,当中就包含了 TextView 的默认文本颜色,而由于 MyTextView 为 defStyleAttr 指定了一个默认值 0,这就导致 MyTextView 丢失了一些默认风格属性


public TextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.textViewStyle);
}

因此,如果我们要直接继承的是 View 类的话可以直接使用@JvmOverloads,此时不会有任何问题,而如果我们要继承的是现有控件的话,就需要考虑应该如何设置默认值了


慎用 解构声明


有时我们会有把一个对象拆解成多个变量的需求,Kotlin 也提供了这类语法糖支持,称为解构声明


例如,以下代码就将 People 变量解构为了两个变量:name 和 nickname,变量名可以随意取,每个变量就按顺序对应着 People 中的字段


data class People(val name: String, val nickname: String)

private fun printInfo(people: People) {
val (name, nickname) = people
println(name)
println(nickname)
}

每个解构声明其实都会被编译成以下代码,解构操作其实就是在按照顺序获取特定方法的返回值


String name = people.component1();

String nickname = people.component2();

component1()component2() 函数是 Kotlin 为数据类自动生成的方法,People 反编译为 Java 代码后就可以看到,每个方法返回的其实都是成员变量,方法名包含的数字对应的就是成员变量在数据类中的声明顺序


public final class People {
@NotNull
private final String name;
@NotNull
private final String nickname;

@NotNull
public final String component1() {
return this.name;
}

@NotNull
public final String component2() {
return this.nickname;
}

}

解构声明和数据类配套使用时就有一个隐藏的知识点,看以下例子


假设后续我们为 People 添加了一个新字段 city,此时 printInfo 方法一样可以正常调用,但 nickname 指向的其实就变成了 people 变量内的 city 字段了,含义悄悄发生了变化,此时就会导致逻辑错误了


data class People(val name: String, val city: String, val nickname: String)

private fun printInfo(people: People) {
val (name, nickname) = people
println(name)
println(nickname)
}

数据类中的字段是可以随时增减或者变换位置的,从而使得解构结果和我们一开始预想的不一致,因此我觉得解构声明和数据类不太适合放在一起使用


慎用 toLowerCase 和 toUpperCase


当我们要以忽略大小写的方式比较两个字符串是否相等时,通常想到的是通过 toUpperCasetoLowerCase 方法将两个字符串转换为全大写或者全小写,然后再进行比较,这种方式完全可以满足需求,但当中也包含着一个隐藏开销


例如,以下的 Kotlin 代码反编译为 Java 代码后,可以看到每次调用toUpperCase方法都会创建一个新的临时变量,然后再去调用临时变量的 equals 方法进行比较


fun main() {
val name = "leavesC"
val nickname = "leavesc"
println(name.toUpperCase() == nickname.toUpperCase())
}

public static final void main() {
String name = "leavesC";
String nickname = "leavesc";
String var10000 = name.toUpperCase();
String var10001 = nickname.toUpperCase();
boolean var2 = Intrinsics.areEqual(var10000, var10001);
System.out.println(var2);
}

以上代码就多创建了两个临时变量,这样的代码无疑会比较低效


有一个更好的解决方案,就是通过 Kotlin 提供的支持忽略大小写的 equals 扩展方法来进行比较,此方法内部会调用 String 类原生的 equalsIgnoreCase来进行比较,从而避免了创建临时变量,相对来说会比较高效一些


fun main() {
val name = "leavesC"
val nickname = "leavesc"
println(name.equals(other = nickname, ignoreCase = true))
}

public static final void main() {
String name = "leavesC";
String nickname = "leavesc";
boolean var2 = StringsKt.equals(name, nickname, true);
boolean var3 = false;
System.out.println(var2);
}

慎用 arrayOf


Kotlin 中的数组类型可以分为两类:



  • IntArray、LongArray、FloatArray 形式的基本数据类型数组,通过 intArrayOf、longArrayOf、floatArrayOf 等方法来声明

  • Array<T> 形式的对象类型数组,通过 arrayOf、arrayOfNulls 等方法来声明


例如,以下的 Kotlin 代码都是用于声明整数数组,但实际上存储的数据类型并不一样


val intArray: IntArray = intArrayOf(1, 2, 3)

val integerArray: Array<Int> = arrayOf(1, 2, 3)

将以上代码反编译为 Java 代码后,就可以明确地看出一种是基本数据类型 int,一种是包装类型 Integer,arrayOf 方法会自动对入参值进行装箱


private final int[] intArray = new int[]{1, 2, 3};

private final Integer[] integerArray = new Integer[]{1, 2, 3};

为了表示基本数据类型的数组,Kotlin 为每一种基本数据类型都提供了若干相应的类并做了特殊的优化。例如,IntArray、ByteArray、BooleanArray 等类型都会被编译成普通的 Java 基本数据类型数组:int[]、byte[]、boolean[],这些数组中的值在存储时不会进行装箱操作,而是使用了可能的最高效的方式


因此,如果没有必要的话,我们在开发中要慎用 arrayOf 方法,避免不必要的装箱消耗


慎用 vararg


和 Java 一样,Kotlin 也支持可变参数,允许将任意多个参数打包到一个数组中再一并传给函数,Kotlin 通过使用 varage 关键字来声明可变参数


我们可以向 printValue 方法传递任意数量的入参参数,也可以直接传入一个数组对象,但 Kotlin 要求显式地解包数组,以便每个数组元素在函数中能够作为单独的参数来调用,这个功能被称为展开运算符,使用方式就是在数组前加一个 *


fun printValue(vararg values: Int) {
values.forEach {
println(it)
}
}

fun main() {
printValue()
printValue(1)
printValue(2, 3)
val values = intArrayOf(4, 5, 6)
printValue(*values)
}

如果我们是以直接传递若干个入参参数的形式来调用 printValue 方法的话,Kotlin 会自动将这些参数打包为一个数组进行传递,这里面就包含着创建数组的开销,这方面和 Java 保持一致。 如果我们传入的参数就已经是数组的话,Kotlin 相比 Java 就存在着一个隐藏开销,Kotlin 会复制现有数组作为参数拿来使用,相当于多分配了额外的数组空间,这可以从反编译后的 Java 代码看出来


   public static final void printValue(@NotNull int... values) {
Intrinsics.checkNotNullParameter(values, "values");
int $i$f$forEach = false;
int[] var3 = values;
int var4 = values.length;

for(int var5 = 0; var5 < var4; ++var5) {
int element$iv = var3[var5];
int var8 = false;
boolean var9 = false;
System.out.println(element$iv);
}

}

public static final void main() {
printValue();
printValue(1);
printValue(2, 3);
int[] values = new int[]{4, 5, 6};
//复制后再进行调用
printValue(Arrays.copyOf(values, values.length));
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}

可以看到 Kotlin 会通过 Arrays.copyOf 复制现有数组,将复制后的数组作为参数进行调用,这样做的好处就是可以避免 printValue 方法影响到原有数组,坏处就是会额外消耗多一份的内存空间


慎用 lazy


我们经常会使用lazy()函数来惰性加载只读属性,将加载操作延迟到需要使用的时候,适用于某些不适合立刻加载或者加载成本较高的情况


例如,以下的 lazyValue 只会等到我们调用到的时候才会被赋值


val lazyValue by lazy {
"it is lazy value"
}

而在使用lazy()函数时很容易被忽略的地方就是其包含有一个可选的 model 参数:



  • LazyThreadSafetyMode.SYNCHRONIZED。只允许由单个线程来完成初始化,且初始化操作包含有双重锁检查,从而使得所有线程都得到相同的值

  • LazyThreadSafetyMode.PUBLICATION。允许多个线程同时执行初始化操作,但只有第一个初始化成功的值会被当做最终值,最终所有线程也都会得到相同的值

  • LazyThreadSafetyMode.NONE。允许多个线程同时执行初始化操作,不进行任何线程同步,导致不同线程可能会得到不同的初始化值,因此不应该用于多线程环境


lazy()函数默认情况下使用的就是LazyThreadSafetyMode.SYNCHRONIZED,从 SynchronizedLazyImpl 可以看到,其内部就使用到了synchronized来实现多线程同步,以此避免多线程竞争


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}

对于 Android 开发者来说,大多数情况下我们都是在主线程中调用 lazy() 函数,此时使用 LazyThreadSafetyMode.SYNCHRONIZED 就会带来不必要的线程同步开销,因此可以根据实际情况考虑替换为LazyThreadSafetyMode.NONE


慎用 lateinit var


lateinit var 适用于某些不方便马上就初始化变量的场景,用于将初始化操作延后,同时也存在一些使用上的限制:如果在未初始化的情况下就使用该变量的话会导致 NPE


例如,如果在 name 变量还未初始化时就调用了 print 方法的话,此时就会导致 NPE。且由于 lateinit var 变量不允许为 null,因此此时我们也无法通过判空来得知 name 是否已经被初始化了,而且判空操作本身也相当于在调用 name 变量,在未初始化的时候一样会导致 NPE


lateinit var name: String

fun print() {
println(name)
}

我们可以通过另一种方式来判断 lateinit 变量是否已初始化


lateinit 实际上是通过代理机制来实现的,关联的是 KProperty0 接口,KProperty0 就提供了一个扩展属性用于判断其代理的值是否已经初始化了


@SinceKotlin("1.2")
@InlineOnly
inline val @receiver:AccessibleLateinitPropertyLiteral KProperty0<*>.isInitialized: Boolean
get() = throw NotImplementedError("Implementation is intrinsic")

因此我们可以通过以下方式来进行判断,从而避免不安全的访问操作


lateinit var name: String

fun print() {
if (this::name.isInitialized) {
println("isInitialized true")
println(name)
} else {
println("isInitialized false")
println(name) //会导致 NPE
}
}

lambda 表达式


lambda 表达式在语义上很简洁,既避免了冗长的函数声明,也解决了以前需要强类型声明函数类型的情况


例如,以下代码就通过 lambda 表达式声明了一个回调函数 callback,我们无需创建一个具体的函数类型,而只需声明需要的入参参数、入参类型、函数返回值就可以


fun requestHttp(callback: (code: Int, data: String) -> Unit) {
callback(200, "success")
}

fun main() {
requestHttp { code, data ->
println("code: $code")
println("data: $data")
}
}

lambda 表达式语法虽然方便,但也隐藏着两个性能问题:



  • 每次调用 lambda 表达式都相当于在创建一个对象

  • lambda 表达式内部隐藏了自动装箱和自动拆箱的操作


将以上代码反编译为 Java 代码后,可以看到 callback 最终的实际类型就是 Function2,每次调用requestHttp 方法就相当于是在创建一个 Function2 变量


   public static final void requestHttp(@NotNull Function2 callback) {
Intrinsics.checkNotNullParameter(callback, "callback");
callback.invoke(200, "success");
}

Function2 是 Kotlin 提供的一个的泛型接口,数字 2 即代表其包含两个入参值


public interface Function2<in P1, in P2, out R> : Function<R> {
/** Invokes the function with the specified arguments. */
public operator fun invoke(p1: P1, p2: P2): R
}

Kotlin 会在编译阶段将开发者声明的 lambda 表达式转换为相应的 FunctionX 对象,调用 lambda 表达式就相当于在调用其 invoke 方法,以此为低版本 JVM 平台(例如 Java 6 / 7)也能提供 lambda 表达式功能。此外,我们也知道泛型类型不可能是基本数据类型,因此我们在 Kotlin 中声明的 Int 最终会被自动装箱为 Integer,lambda 表达式内部自动完成了装箱和拆箱的操作


所以说,简洁的 lambda 表达式背后就隐藏了自动创建 Function 对象进行中转调用,自动装箱和自动拆箱的过程,且最终创建的方法总数要多于表面上看到的


如果想要避免 lambda 表达式的以上开销,可以通过使用 inline 内联函数来实现


在使用 inline 关键字修饰 requestHttp 方法后,可以看到此时 requestHttp 的逻辑就相当于被直接复制到了 main 方法内部,不会创建任何多余的对象,且此时使用的也是 int 而非 Integer


inline fun requestHttp(callback: (code: Int, data: String) -> Unit) {
callback(200, "success")
}

fun main() {
requestHttp { code, data ->
println("code: $code")
println("data: $data")
}
}

   public static final void main() {
String data = "success";
int code = 200;
String var4 = "code: " + code;
System.out.println(var4);
var4 = "data: " + data;
System.out.println(var4);
}

通过内联函数,可以使得编译器直接在调用方中使用内联函数体中的代码,相当于直接把内联函数中的逻辑复制到了调用方中,完全避免了调用带来的开销。对于高阶函数,作为参数传递的 lambda 表达式的主体也将被内联,这使得:



  • 声明和调用 lambda 表达式时,不会实例化 Function 对象

  • 没有自动装箱和拆箱的操作

  • 不会导致方法数增多,但如果内联函数方法体较大且被多处调用的话,可能导致最终代码量显著增加


init 的声明顺序很重要


看以下代码,我们可以在 init 块中调用 parameter1,却无法调用 parameter2,从 IDE 的提示信息 Variable 'parameter2' must be initialized也可以看出来,对于 init 块来说 parameter2 此时还未赋值,自然就无法使用了


class KotlinMode {

private val parameter1 = "leavesC"

init {
println(parameter1)
//error: Variable 'parameter2' must be initialized
//println(parameter2)
}

private val parameter2 = "业志陈"

}

从反编译出的 Java 代码也可以看出来,由于 parameter2 是声明在 init 块之后,所以 parameter2 的赋值操作其实是放在构造函数中的最后面,因此 IDE 的语法检查器就会阻止我们在 init 块中来调用 parameter2 了


public final class KotlinMode {
private final String parameter1 = "leavesC";
private final String parameter2;

public KotlinMode() {
String var1 = this.parameter1;
System.out.println(var1);
this.parameter2 = "业志陈";
}
}

IDE 会阻止开发者去调用还未初始化的变量,防止我们写出不安全的代码,我们也可以用以下方式来绕过语法检查,但同时也写出了不安全的代码


我们可以通过在 init 块中调用 print() 方法的方式来间接访问 parameter2,此时代码是可以正常编译的,但此时 parameter2 也只会为 null


class KotlinMode {

private val parameter1 = "leavesC"

init {
println(parameter1)
print()
}

private fun print() {
println(parameter2)
}

private val parameter2 = "业志陈"

}

从反编译出的 Java 代码可以看出来,print()方法依旧是会在 parameter2 初始化之前被调用,此时print()方法访问到的 parameter2 也只会为 null,从而引发意料之外的 NPE


public final class KotlinMode {
private final String parameter1 = "leavesC";
private final String parameter2;

private final void print() {
String var1 = this.parameter2;
System.out.println(var1);
}

public KotlinMode() {
String var1 = this.parameter1;
System.out.println(var1);
this.print();
this.parameter2 = "业志陈";
}
}

所以说,init 块和成员变量之间的声明顺序决定了在构造函数中的初始化顺序,我们应该先声明成员变量再声明 init 块,否则就有可能导致 NPE


Gosn & data class


来看个小例子,猜猜其运行结果会是怎样的


UserBean 是一个 dataClass,其 userName 字段被声明为非 null 类型,而 json 字符串中 userName 对应的值明确就是 null,那用 Gson 到底能不能反序列化成功呢?程序能不能成功运行完以下三个步骤?


data class UserBean(val userName: String, val userAge: Int)

fun main() {
val json = """{"userName":null,"userAge":26}"""
val userBean = Gson().fromJson(json, UserBean::class.java) //第一步
println(userBean) //第二步
printMsg(userBean.userName) //第三步
}

fun printMsg(msg: String) {

}

实际上程序能够正常运行到第二步,但在执行第三步的时候反而直接报 NPE 异常了


UserBean(userName=null, userAge=26)
Exception in thread "main" java.lang.NullPointerException: Parameter specified as non-null is null: method temp.TestKt.printMsg, parameter msg
at temp.TestKt.printMsg(Test.kt)
at temp.TestKt.main(Test.kt:16)
at temp.TestKt.main(Test.kt)

printMsg 方法接收了参数后实际上什么也没做,为啥会抛出 NPE ?


printMsg反编译为 Java 方法,可以发现方法内部会对入参进行空校验,当发现为 null 时就会直接抛出 NPE。这个比较好理解,毕竟 Kotlin 的类型系统会严格区分 可 null不可为 null 两种类型,其区分手段之一就是会自动在我们的代码里插入一些类型校验逻辑,即自动加上了非空断言,当发现不可为 null 的参数传入了 null 的话就会马上抛出 NPE,即使我们并没有使用到该参数


   public static final void printMsg(@NotNull String msg) {
Intrinsics.checkNotNullParameter(msg, "msg");
}

那既然 UserBean 中的 userName 字段已经被声明为非 null 类型了,那么为什么还可以反序列化成功呢?按照我自己的第一直觉,应该在进行反序列的时候就直接抛出异常才对


将 UserBean 反编译为 Java 代码后,也可以看到其构造函数中是有对 userName 进行 null 检查的,当发现为 null 的话会直接抛出 NPE


public final class UserBean {
@NotNull
private final String userName;
private final int userAge;

@NotNull
public final String getUserName() {
return this.userName;
}

public final int getUserAge() {
return this.userAge;
}

public UserBean(@NotNull String userName, int userAge) {
//进行 null 检查
Intrinsics.checkNotNullParameter(userName, "userName");
super();
this.userName = userName;
this.userAge = userAge;
}

···

}

那 Gson 是怎么绕过 Kotlin 的 null 检查的呢?


其实,通过查看 Gson 内部源码,可以知道 Gson 是通过 Unsafe 包来实例化 UserBean 对象的,Unsafe 提供了一个非常规实例化对象的方法:allocateInstance,该方法提供了通过 Class 对象就可以创建出相应实例的功能,而且不需要调用其构造函数、初始化代码、JVM 安全检查等,即使构造函数是 private 的也能通过此方法进行实例化。因此 Gson 实际上并不会调用到 UserBean 的构造函数,相当于绕过了 Kotlin 的 null 检查,所以即使 userName 值为 null 最终也能够反序列化成功



此问题的出现场景大多是在移动端解析服务端传来的数据的时候,移动端将数据声明为非空类型,但服务端给过来的数据却为 null 值,此时用户看到的可能就是应用崩溃了……


一方面,我觉得移动端应该对服务端传来的数据保持不信任的态度,不能觉得对方传来的数据就一定是符合约定的,为了保证安全需要将数据均声明为可空类型。另一方面,这也无疑导致移动端需要加上很多多余的判空操作,简直有点无解 =_=


ARouter & JvmField


在 Java 中,字段和其访问器的组合被称作属性。在 Kotlin 中,属性是头等的语言特性,完全替代了字段和访问器方法。在类中声明一个属性和声明一个变量一样是使用 val 和 var 关键字,两者在使用上的差异就在于赋值后是否还允许修改,在字节码上的差异性之一就在于是否会自动生成相应的 setValue 方法


例如,以下的 Kotlin 代码在反编译为 Java 代码后,可以看到两个属性的可见性都变为了 private, name 变量会同时包含有getValuesetValue 方法,而 nickname 变量只有 getValue 方法,这也是我们在 Java 代码中只能以 kotlinMode.getName() 的方式来访问 name 变量的原因


class KotlinMode {

var name = "业志陈"

val nickname = "leavesC"

}

public final class KotlinMode {
@NotNull
private String name = "业志陈";
@NotNull
private final String nickname = "leavesC";

@NotNull
public final String getName() {
return this.name;
}

public final void setName(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.name = var1;
}

@NotNull
public final String getNickname() {
return this.nickname;
}
}

为了不让 Kotlin 的 var / val 变量自动生成 getValuesetValue 方法,达到和在 Java 代码中声明公开变量一样的效果,此时就需要为属性添加 @JvmField 注解了,添加后就会变为 public 类型的成员变量,且不包含任何 getValuesetValue 方法


class KotlinMode {

@JvmField
var name = "业志陈"

@JvmField
val nickname = "leavesC"

}

public final class KotlinMode {
@JvmField
@NotNull
public String name = "业志陈";
@JvmField
@NotNull
public final String nickname = "leavesC";
}



@JvmField 的一个使用场景就是在配套使用 ARouter 的时候。我们在使用 ARouter 进行参数自动注入时,就需要为待注入的参数添加 @JvmField注解,就像以下代码一样,不添加的话就会导致编译失败


@Route(path = RoutePath.USER_HOME)
class UserHomeActivity : AppCompatActivity() {

@Autowired(name = RoutePath.USER_HOME_PARAMETER_ID)
@JvmField
var userId: Long = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user_home)
ARouter.getInstance().inject(this)
}

}

那为什么不添加该注解就会导致编译失败呢?


其实,ARouter 实现参数自动注入是需要依靠注解处理器生成的辅助文件来实现的,即会生成以下的辅助代码,当中会以 substitute.userIdsubstitute.userName的形式来调用 Activity 中的两个参数值,如果不添加 @JvmField注解,辅助文件就没法以直接调用变量名的方式来完成注入,自然就会导致编译失败了


public class UserHomeActivity$$ARouter$$Autowired implements ISyringe {

private SerializationService serializationService;

@Override
public void inject(Object target) {
serializationService = ARouter.getInstance().navigation(SerializationService.class);
UserHomeActivity substitute = (UserHomeActivity)target;
substitute.userId = substitute.getIntent().getLongExtra("userHomeId", substitute.userId);
}
}

Kotlin 这套为属性自动生成 getValuesetValue 方法的机制有一个缺点,就是可能会导致方法数极速膨胀,使得 Android App 的 dex 文件很快就达到最大方法数限制,不得不进行分包处理


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

Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager

引子 上一篇用“动画”方案实现了弹幕效果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始位置置于容器控件右边的外侧,每条弹幕都通过从右向左的动画来实现贯穿屏幕的平移。 这个方案的性能有待改善,打开 GPU 呈现模式: 原因在于容器控件会提前构建所有...
继续阅读 »

引子


上一篇用“动画”方案实现了弹幕效果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始位置置于容器控件右边的外侧,每条弹幕都通过从右向左的动画来实现贯穿屏幕的平移。


这个方案的性能有待改善,打开 GPU 呈现模式:


1629556466944.gif


原因在于容器控件会提前构建所有弹幕视图并将它们堆积在屏幕的右侧。若弹幕数据量大,则容器控件会因为子视图过多而耗费大量 measure + layout 时间。


既然是因为提前加载了不需要的弹幕才导致的性能问题,那是不是可以只预加载有限个弹幕?


只加载有限个子视图且可滚动的控件,不就是 RecyclerView 吗!它并不会把 Adapter 中所有的数据提前全部转换成 View,而是只预加载一屏的数据,然后随着滚动再持续不断地加载新数据。


为了用 RecyclerView 实现弹幕效果,就得 “自定义 LayoutManager”


自定义布局参数


自定义 LayoutManager 的第一步:继承RecyclerView.LayoutManger


class LaneLayoutManager: RecyclerView.LayoutManager() {
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
}

根据 AndroidStudio 的提示,必须实现一个generateDefaultLayoutParams()的方法。它用于生成一个自定义的LayoutParams对象,目的是在布局参数中携带自定义的属性。


当前场景中没有自定义布局参数的需求,遂可以这样实现这个方法:


override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
RecyclerView.LayoutParams.WRAP_CONTENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
}

表示沿用RecyclerView.LayoutParams


初次填充弹幕


自定义 LayoutManager 最重要的环节就是定义如何布局表项。


对于LinearLayoutManager来说,表项沿着一个方向线性铺开。当列表第一次展示时,从列表顶部到底部,表项被逐个填充,这称为“初次填充”。


对于LaneLayoutManager来说,初次填充即是“将一列弹幕填充到紧挨着列表尾部的地方(在屏幕之外,可不见)”。


关于LinearLayoutManager如何填充表项的源码分析,在之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?中分析过,现援引结论如下:




  1. LinearLayoutManager 在onLayoutChildren()方法中布局表项。

  2. 布局表项的关键方法包括fill()layoutChunk(),前者表示列表的一次填充动作,后者表示填充单个表项。

  3. 在一次填充动作中通过一个while循环不断地填充表项,直到列表剩余空间用完。用伪代码表示这个过程如下所示:


public class LinearLayoutManager {
// 布局表项
public void onLayoutChildren() {
// 填充表项
fill() {
while(列表有剩余空间){
// 填充单个表项
layoutChunk(){
// 让表项成为子视图
addView(view)
}
}
}
}
}


  1. 为了避免每次填充新表项时都重新创建视图,需要从 RecyclerView 的缓存中获取表项视图,即调用Recycler.getViewForPosition()。关于该方法的详解可以点击RecyclerView 缓存机制 | 如何复用表项?



看过源码,理解原理后,弹幕布局就可以仿照着写:


class LaneLayoutManager : RecyclerView.LayoutManager() {
private val LAYOUT_FINISH = -1 // 标记填充结束
private var adapterIndex = 0 // 列表适配器索引

// 弹幕纵向间距
var gap = 5
get() = field.dp

// 布局孩子
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
fill(recycler)
}
// 填充表项
private fun fill(recycler: RecyclerView.Recycler?) {
// 可供弹幕布局的高度,即列表高度
var totalSpace = height - paddingTop - paddingBottom
var remainSpace = totalSpace
// 只要空间足够,就继续填充表项
while (goOnLayout(remainSpace)) {
// 填充单个表项
val consumeSpace = layoutView(recycler)
if (consumeSpace == LAYOUT_FINISH) break
// 更新剩余空间
remainSpace -= consumeSpace
}
}

// 是否还有剩余空间用于填充 以及 是否有更多数据
private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && currentIndex in 0 until itemCount

// 填充单个表项
private fun layoutView(recycler: RecyclerView.Recycler?): Int {
// 1. 从缓存池中获取表项视图
// 若缓存未命中,则会触发 onCreateViewHolder() 和 onBindViewHolder()
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return LAYOUT_FINISH // 获取表项视图失败,则结束填充
// 2. 将表项视图成为列表孩子
addView(view)
// 3. 测量表项视图
measureChildWithMargins(view, 0, 0)
// 可供弹幕布局的高度,即列表高度
var totalSpace = height - paddingTop - paddingBottom
// 弹幕泳道数,即列表纵向可以容纳几条弹幕
val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)
// 计算当前表项所在泳道
val index = currentIndex % laneCount
// 计算当前表项上下左右边框
val left = width // 弹幕左边位于列表右边
val top = index * (view.measuredHeight + gap)
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
// 4. 布局表项(该方法考虑到了 ItemDecoration)
layoutDecorated(view, left, top, right, bottom)
val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
// 继续获取下一个表项视图
adapterIndex++
// 返回填充表项消耗像素值
return getDecoratedMeasuredHeight(view) + verticalMargin
}
}

每一条水平的,供弹幕滚动的,称之为“泳道”。


泳道是从列表顶部往底部垂直铺开的,列表高度/泳道高度 = 泳道的数量。


fill()方法中就以“列表剩余高度>0”为循环条件,不断地向泳道中填充表项,它得经历了四个步骤:



  1. 从缓存池中获取表项视图

  2. 将表项视图成为列表孩子

  3. 测量表项视图

  4. 布局表项


这四步之后,表项相对于列表的位置就确定下来,并且表项的视图已经渲染完成。


运行下 demo,果然~,什么也没看到。。。


列表滚动逻辑还未加上,所以布局在列表右边外侧的表项依然处于不可见位置。但可以利用 AndroidStudio 的Layout Inspector工具来验证初次填充代码的正确性:


微信截图_20210919225802.png


Layout Inspector中会用线框表示屏幕以外的控件,如图所示,列表右边的外侧被四个表项占满。


自动滚动弹幕


为了看到填充的表项,就得让列表自发地滚动起来。


最直接的方案就是不停地调用RecyclerView.smoothScrollBy()。为此写了一个扩展法方法用于倒计时:


fun <T> countdown(
duration: Long, // 倒计时总时长
interval: Long, // 倒计时间隔
onCountdown: suspend (Long) -> T // 倒计时回调
): Flow<T> =
flow { (duration - interval downTo 0 step interval).forEach { emit(it) } }
.onEach { delay(interval) }
.onStart { emit(duration) }
.map { onCountdown(it) }
.flowOn(Dispatchers.Default)

使用Flow构建了一个异步数据流,该流每次都会发射一个倒计时的剩余时间。关于Flow的详细解释可以点击Kotlin 异步 | Flow 应用场景及原理


然后就能像这样实现列表自动滚动:


countdown(Long.MAX_VALUE, 50) {
recyclerView.smoothScrollBy(10, 0)
}.launchIn(MainScope())

每 50 ms 向左滚动 10 像素。效果如下图所示:


1632126431947.gif


持续填充弹幕


因为只做了初次填充,即每个泳道只填充了一个表项,所以随着第一排的表项滚入屏幕后,就没有后续弹幕了。


LayoutManger.onLayoutChildren()只会在列表初次布局时调用一次,即初次填充弹幕只会执行一次。为了持续不断地展示弹幕,必须在滚动时不停地填充表项。


之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?分析过列表滚动时持续填充表项的源码,现援引结论如下:




  1. RecyclerView 在滚动发生之前,会根据预计滚动位移大小来决定需要向列表中填充多少新的表项。

  2. 表现在源码上,即是在scrollVerticallyBy()中调用fill()填充表项:


public class LinearLayoutManager {
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
return scrollBy(dy, recycler, state);
}

int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// 填充表项
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
}
}


对于弹幕的场景,也可以仿照着写一个类似的:


class LaneLayoutManager : RecyclerView.LayoutManager() {
override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
}

override fun canScrollHorizontally(): Boolean {
return true // 表示列表可以横向滚动
}
}

重写canScrollHorizontally()返回 true 表示列表可横向滚动。


RecyclerView 的滚动是一段一段进行的,每一段滚动的位移都会通过scrollHorizontallyBy()传递过来。通常在该方法中根据位移大小填充新的表项,然后再触发列表的滚动。关于列表滚动的源码分析可以点击RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势


scrollBy()封装了根据滚动持续填充表项的逻辑。(稍后分析)


持续填充表项比初次填充的逻辑更复杂一点,初次填充只要将表项按照泳道从上到下依次铺开填满列表的高度即可。而持续填充得根据滚动距离计算出哪个泳道即将枯竭(没有弹幕展示的泳道),只对枯竭的泳道填充表项。


为了快速获取枯竭泳道,得抽象出一个“泳道”结构,以保存该泳道的滚动信息:


// 泳道
data class Lane(
var end: Int, // 泳道末尾弹幕横坐标
var endLayoutIndex: Int, // 泳道末尾弹幕的布局索引
var startLayoutIndex: Int // 泳道头部弹幕的布局索引
)

泳道结构包含三个数据,分别是:



  1. 泳道末尾弹幕横坐标:它是泳道中最后一个弹幕的 right 值,即它的右侧相对于 RecyclerView 左侧的距离。该值用于判断经过一段位移的滚动后,该泳道是否会枯竭。

  2. 泳道末尾弹幕的布局索引:它是泳道中最后一个弹幕的布局索引,记录它是为了方便地通过getChildAt()获取泳道中最后一个弹幕的视图。(布局索引有别于适配器索引,RecyclerView 只会持有有限个表项,所以布局索引的取值范围是[0,x],x的取值比一屏表项稍多一点,而对于弹幕来说,适配器索引的取值是[0,∞])

  3. 泳道头部弹幕的布局索引:与 2 类似,为了方便地获得泳道第一个弹幕的视图。


借助于泳道这个结构,我们得重构下初次填充表项的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
// 初次填充过程中的上一个被填充的弹幕
private var lastLaneEndView: View? = null
// 所有泳道
private var lanes = mutableListOf<Lane>()
// 初次填充弹幕
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
fillLanes(recycler, lanes)
}
// 通过循环填充弹幕
private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
lastLaneEndView = null
// 如果列表垂直方向上还有空间则继续填充弹幕
while (hasMoreLane(height - lanes.bottom())) {
// 填充单个弹幕到泳道中
val consumeSpace = layoutView(recycler, lanes)
if (consumeSpace == LAYOUT_FINISH) break
}
}
// 填充单个弹幕,并记录泳道信息
private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return LAYOUT_FINISH
measureChildWithMargins(view, 0, 0)
val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin
// 若列表垂直方向还可以容纳一条新得泳道,则新建泳道,否则停止填充
if (height - lanes.bottom() - consumed > 0) {
lanes.add(emptyLane(adapterIndex))
} else return LAYOUT_FINISH

addView(view)
// 获取最新追加的泳道
val lane = lanes.last()
// 计算弹幕上下左右的边框
val left = lane.end + horizontalGap
val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
// 定位弹幕
layoutDecorated(view, left, top, right, bottom)
// 更新泳道末尾横坐标及布局索引
lane.apply {
end = right
endLayoutIndex = childCount - 1 // 因为是刚追加的表项,所以其索引值必然是最大的
}

adapterIndex++
lastLaneEndView = view
return consumed
}
}

初次填充弹幕也是一个不断在垂直方向上追加泳道的过程,判断是否追加的逻辑如下:列表高度 - 当前最底部泳道的 bottom 值 - 这次填充弹幕消耗的像素值 > 0,其中lanes.bottom()是一个List<Lane>的扩展方法:


fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 0

它获取泳道列表中的最后一个泳道,然后再获取该泳道中最后一条弹幕视图的 bottom 值。其中getEndView()被定义为Lane的扩展方法:


class LaneLayoutManager : RecyclerView.LayoutManager() {
data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)
}

理论上“获取泳道中最后一条弹幕视图”应该是Lane提供的方法。但偏偏把它定义成Lane的扩展方法,并且还定义在LaneLayoutManager的内部,这是多此一举吗?


若定义在 Lane 内部,则在该上下文中无法访问到LayoutManager.getChildAt()方法,若只定义为LaneLayoutManager的私有方法,则无法访问到endLayoutIndex。所以此举是为了综合两个上下文环境。


再回头看一下滚动时持续填充弹幕的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
}
// 根据位移大小决定填充多少表项
private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
// 若列表没有孩子或未发生滚动则返回
if (childCount == 0 || dx == 0) return 0
// 在滚动还未开始前,更新泳道信息
updateLanesEnd(lanes)
// 获取滚动绝对值
val absDx = abs(dx)
// 遍历所有泳道,向其中的枯竭泳道填充弹幕
lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
}
// 滚动列表的落脚点:将表项向手指位移的反方向平移相同的距离
offsetChildrenHorizontal(-absDx)
return dx
}
}

滚动时持续填充弹幕逻辑遵循这样的顺序:



  1. 更新泳道信息

  2. 向枯竭泳道填充弹幕

  3. 触发滚动


其中 1,2 都发生在真实的滚动之前,在滚动之前,已经拿到了滚动位移,根据位移就可以计算出滚动发生之后即将枯竭的泳道:


// 泳道是否枯竭
private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
// 获取表项的 right 值
private fun getEnd(view: View?) =
if (view == null) Int.MIN_VALUE
else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin

泳道枯竭的判定依据是:泳道最后一个弹幕的右边向左平移 dx 后是否小于列表宽度。若小于则表示泳道中的弹幕已经全展示完了,此时就要继续填充弹幕:


// 弹幕滚动时填充新弹幕
private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) {
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return
measureChildWithMargins(view, 0, 0)
addView(view)

val left = lane.end + horizontalGap
val top = lane.getEndView()?.top ?: paddingTop
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
layoutDecorated(view, left, top, right, bottom)
lane.apply {
end = right
endLayoutIndex = childCount - 1
}
adapterIndex++
}

填充逻辑和初次填充的几乎一样,唯一的区别是,滚动时的填充不可能因为空间不够而提前返回,因为是找准了泳道进行填充的。


为什么要在填充枯竭泳道之前更新泳道信息?


// 更新泳道信息
private fun updateLanesEnd(lanes: MutableList<Lane>) {
lanes.forEach { lane ->
lane.getEndView()?.let { lane.end = getEnd(it) }
}
}

因为 RecyclerView 的滚动是一段一段进行的,看似滚动了一丢丢距离,scrollHorizontallyBy()可能要回调十几次,每一次回调,弹幕都会前进一小段,即泳道末尾弹幕的横坐标会发生变化,这变化得同步到Lane结构中。否则泳道枯竭的计算就会出错。


无限滚动弹幕


经过初次和持续填充,弹幕已经可以流畅的滚起来了。那如何让仅有的弹幕数据无限轮播呢?


只需要在Adapter上做一个小手脚:


class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
// 数据集
private val dataList = MutableList()
override fun getItemCount(): Int {
// 设置表项为无穷大
return Int.MAX_VALUE
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val realIndex = position % dataList.size
...
}

override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
val realIndex = position % dataList.size
...
}
}

设置列表的数据量为无穷大,当创建表项视图及为其绑定数据时,对适配器索引取模。


回收弹幕


剩下的最后一个难题是,如何回收弹幕。若没有回收,也对不起RecyclerView这个名字。


LayoutManager中就定义有回收表项的入口:


public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
removeView(child);
recycler.recycleView(child);
}

回收逻辑最终会委托给Recycler实现,关于回收表项的源码分析,可以点击下面的文章:



  1. RecyclerView 缓存机制 | 回收些什么?

  2. RecyclerView 缓存机制 | 回收到哪去?

  3. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  4. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  5. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


对于弹幕场景,什么时候回收弹幕?


当然是弹幕滚出屏幕的那一瞬间!


如何才能捕捉到这个瞬间 ?


当然是通过在每次滚动发生之前用位移计算出来的!


在滚动时除了要持续填充弹幕,还得持续回收弹幕(源码里就是这么写的,我只是抄袭一下):


private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
if (childCount == 0 || dx == 0) return 0
updateLanesEnd(lanes)
val absDx = abs(dx)
// 持续填充弹幕
lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
}
// 持续回收弹幕
recycleGoneView(lanes, absDx, recycler)
offsetChildrenHorizontal(-absDx)
return dx
}

这是scrollBy()的完整版,滚动时先填充,紧接着马上回收:


fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {
recycler ?: return
// 遍历泳道
lanes.forEach { lane ->
// 获取泳道头部弹幕
getChildAt(lane.startLayoutIndex)?.let { startView ->
// 如果泳道头部弹幕已经滚出屏幕则回收它
if (isGoneByScroll(startView, dx)) {
// 回收弹幕视图
removeAndRecycleView(startView, recycler)
// 更新泳道信息
updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
lane.startLayoutIndex += lanes.size - 1
}
}
}
}

回收和填充一样,也是通过遍历找到即将消失的弹幕,回收之。


判断弹幕消失的逻辑如下:


fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 0

如果弹幕的 right 向左平移 dx 后小于 0 则表示弹幕已经滚出列表。


回收弹幕之后,会将其从 RecyclerView 中 detach,这个操作会影响列表中其他弹幕的布局索引值。就好像数组中某一元素被删除,其后面的所有元素的索引值都会减一:


fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
lanes.forEach { lane ->
if (lane.startLayoutIndex > recycleIndex) {
lane.startLayoutIndex--
}
if (lane.endLayoutIndex > recycleIndex) {
lane.endLayoutIndex--
}
}
}

遍历所有泳道,只要泳道头部弹幕的布局索引大于回收索引,则将其减一。


性能


再次打开 GPU 呈现模式:


1629555943171.gif


这次体验上就很丝滑,柱状图也没有超过警戒线。


talk is cheap, show me the code


完整代码可以点击这里,在这个repo中搜索LaneLayoutManager


总结


之前花了很多时间看源码,也产生过“看源码那么费时,到底有什么用?”这样的怀疑。这次性能优化是一次很好的回应。因为看过 RecyclerView 的源码,它解决问题的思想方法就种在脑袋里了。当遇到弹幕性能问题时,这颗种子就会发芽。解决方案是多种多样的,脑袋中有怎样的种子,就会长出怎样的芽。所以看源码是播撒种子,虽不能立刻发芽,但总有一天会结果。


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

iOS RXSwift 3.2

iOS
函数式编程 -> 函数响应式编程现在大家已经了解我们是如何运用函数式编程来操作序列的。其实我们可以把这种操作序列的方式再升华一下。例如,你可以把一个按钮的点击事件看作是一个序列:// 假设用户在进入页面到离开页面期间,总共点击按钮 3 次 // 按钮点...
继续阅读 »

函数式编程 -> 函数响应式编程

现在大家已经了解我们是如何运用函数式编程来操作序列的。其实我们可以把这种操作序列的方式再升华一下。例如,你可以把一个按钮的点击事件看作是一个序列:

// 假设用户在进入页面到离开页面期间,总共点击按钮 3 次

// 按钮点击序列
let taps: Array<Void> = [(), (), ()]

// 每次点击后弹出提示框
taps.forEach { showAlert() }

这样处理点击事件是非常理想的,但是问题是这个序列里面的元素(点击事件)是异步产生的,传统序列是无法描叙这种元素异步产生的情况。为了解决这个问题,于是就产生了可监听序列Observable<Element>。它也是一个序列,只不过这个序列里面的元素可以是同步产生的,也可以是异步产生的:

// 按钮点击序列
let taps: Observable<Void> = button.rx.tap.asObservable()

// 每次点击后弹出提示框
taps.subscribe(onNext: { showAlert() })

这里 taps 就是按钮点击事件的序列。然后我们通过弹出提示框,来对每一次点击事件做出响应。这种编程方式叫做响应式编程。我们结合函数式编程以及响应式编程就得到了函数响应式编程

passwordOutlet.rx.text.orEmpty
.map { $0.characters.count >= minimalPasswordLength }
.bind(to: passwordValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

我们通过不同的构建函数,来创建所需要的数据序列。最后通过适当的方式来响应这个序列。这就是函数响应式编程

收起阅读 »

iOS RXSwift 3.1

iOS
函数响应式编程函数响应式编程是种编程范式。它是通过构建函数操作数据序列,然后对这些序列做出响应的编程方式。它结合了函数式编程以及响应式编程这里先介绍一下函数式编程。函数式编程函数式编程是种编程范式,它需要我们将函数作为参数传递,或者作为返回值返还。我们可以通过...
继续阅读 »

函数响应式编程

函数响应式编程是种编程范式。它是通过构建函数操作数据序列,然后对这些序列做出响应的编程方式。它结合了函数式编程以及响应式编程

这里先介绍一下函数式编程


函数式编程

函数式编程是种编程范式,它需要我们将函数作为参数传递,或者作为返回值返还。我们可以通过组合不同的函数来得到想要的结果。

我们来看一下这几个例子:

// 全校学生
let allStudents: [Student] = getSchoolStudents()

// 三年二班的学生
let gradeThreeClassTwoStudents: [Student] = allStudents
.filter { student in student.grade == 3 && student.class == 2 }

由于我们想要得到三年二班的学生,所以我们把三年二班的判定函数作为参数传递给 filter 方法,这样就能从全校学生中过滤出三年二班的学生。

// 三年二班的每一个男同学唱一首《一剪梅》
gradeThreeClassTwoStudents
.filter { student in student.sex == .male }
.forEach { boy in boy.singASong(name: "一剪梅") }

同样的我们将性别的判断函数传递给 filter 方法,这样就能从三年二班的学生中过滤出男同学,然后将唱歌作为函数传递给 forEach 方法。于是每一个男同学都要唱《一剪梅》😄。

// 三年二班学生成绩高于90分的家长上台领奖
gradeThreeClassTwoStudents
.filter { student in student.score > 90 }
.map { student in student.parent }
.forEach { parent in parent.receiveAPrize() }

用分数判定来筛选出90分以上的同学,然后用map转换为学生家长,最后用forEach让每个家长上台领奖。

// 由高到低打印三年二班的学生成绩
gradeThreeClassTwoStudents
.sorted { student0, student1 in student0.score > student1.score }
.forEach { student in print("score: \(student.score), name: \(student.name)") }

将排序逻辑的函数传递给 sorted方法,这样学生就按成绩高低排序,最后用forEach将成绩和学生名字打印出来。

整体结构

值得注意的是,我们先从三年二班筛选出男同学,后来又从三年二班筛选出分数高于90的学生。都是用的 filter 方法,只是传递了不同的判定函数,从而得出了不同的筛选结果。如果现在要实现这个需求:二年一班分数不足60的学生唱一首《我有罪》。

相信大家要不了多久就可以找到对应的实现方法。

这就是函数式编程,它使我们可以通过组合不同的方法,以及不同的函数来获取目标结果。你可以想象如果我们用传统的 for 循环来完成相同的逻辑,那将会是一件多么繁琐的事情。所以函数试编程的优点是显而易见的:

  • 灵活
  • 高复用
  • 简洁
  • 易维护
  • 适应各种需求变化

如果想了解更多有关于函数式编程的知识。可以参考这本书籍 《函数式 Swift》

收起阅读 »

iOS RXSwift 二

iOS
你好 RxSwift!我的第一个 RxSwift 应用程序 - 输入验证:这是一个模拟用户登录的程序。当用户输入用户名时,如果用户名不足 5 个字就给出红色提示语,并且无法输入密码,当用户名符合要求时才可以输入密码。同样的当用户输入的密码不到 5 个字时也给出...
继续阅读 »

你好 RxSwift!

我的第一个 RxSwift 应用程序 - 输入验证:

这是一个模拟用户登录的程序。

  • 当用户输入用户名时,如果用户名不足 5 个字就给出红色提示语,并且无法输入密码,当用户名符合要求时才可以输入密码。
  • 同样的当用户输入的密码不到 5 个字时也给出红色提示语。
  • 当用户名和密码有一个不符合要求时底部的绿色按钮不可点击,只有当用户名和密码同时有效时按钮才可点击。
  • 当点击绿色按钮后弹出一个提示框,这个提示框只是用来做演示而已。

你可以下载这个例子并在模拟器上运行,这样可以帮助于你理解整个程序的交互:

这个页面主要由 5 各元素组成:

  1. 用户名输入框
  2. 用户名提示语(红色)
  3. 密码输入框
  4. 密码提示语(红色)
  5. 操作按钮(绿色)
class SimpleValidationViewController : ViewController {

@IBOutlet weak var usernameOutlet: UITextField!
@IBOutlet weak var usernameValidOutlet: UILabel!

@IBOutlet weak var passwordOutlet: UITextField!
@IBOutlet weak var passwordValidOutlet: UILabel!

@IBOutlet weak var doSomethingOutlet: UIButton!
...
}

这里需要完成 4 个交互:

  • 当用户名输入不到 5 个字时显示提示语,并且无法输入密码

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 用户名是否有效
    let usernameValid = usernameOutlet.rx.text.orEmpty
    // 用户名 -> 用户名是否有效
    .map { $0.count >= minimalUsernameLength }
    .share(replay: 1)

    ...

    // 用户名是否有效 -> 密码输入框是否可用
    usernameValid
    .bind(to: passwordOutlet.rx.isEnabled)
    .disposed(by: disposeBag)

    // 用户名是否有效 -> 用户名提示语是否隐藏
    usernameValid
    .bind(to: usernameValidOutlet.rx.isHidden)
    .disposed(by: disposeBag)

    ...
    }

    当用户修改用户名输入框的内容时就会产生一个新的用户名, 然后通过 map 方法将它转化成用户名是否有效, 最后通过 bind(to: ...) 来决定密码输入框是否可用以及提示语是否隐藏。

  • 当密码输入不到 5 个字时显示提示文字

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 密码是否有效
    let passwordValid = passwordOutlet.rx.text.orEmpty
    // 密码 -> 密码是否有效
    .map { $0.count >= minimalPasswordLength }
    .share(replay: 1)

    ...

    // 密码是否有效 -> 密码提示语是否隐藏
    passwordValid
    .bind(to: passwordValidOutlet.rx.isHidden)
    .disposed(by: disposeBag)

    ...
    }

    这个和用用户名来控制提示语的逻辑是一样的。

  • 当用户名和密码都符合要求时,绿色按钮才可点击

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 用户名是否有效
    let usernameValid = ...

    // 密码是否有效
    let passwordValid = ...

    ...

    // 所有输入是否有效
    let everythingValid = Observable.combineLatest(
    usernameValid,
    passwordValid
    ) { $0 && $1 } // 取用户名和密码同时有效
    .share(replay: 1)

    ...

    // 所有输入是否有效 -> 绿色按钮是否可点击
    everythingValid
    .bind(to: doSomethingOutlet.rx.isEnabled)
    .disposed(by: disposeBag)

    ...
    }

    通过 Observable.combineLatest(...) { ... } 来将用户名是否有效以及密码是都有效合并出两者是否同时有效,然后用它来控制绿色按钮是否可点击。

  • 点击绿色按钮后,弹出一个提示框

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 点击绿色按钮 -> 弹出提示框
    doSomethingOutlet.rx.tap
    .subscribe(onNext: { [weak self] in self?.showAlert() })
    .disposed(by: disposeBag)
    }

    func showAlert() {
    let alertView = UIAlertView(
    title: "RxExample",
    message: "This is wonderful",
    delegate: nil,
    cancelButtonTitle: "OK"
    )

    alertView.show()
    }

    在点击绿色按钮后,弹出一个提示框

这样 4 个交互都完成了,现在我们纵观全局看下这个程序是一个什么样的结构:

然后看一下完整的代码:

override func viewDidLoad() {
super.viewDidLoad()

usernameValidOutlet.text = "Username has to be at least \(minimalUsernameLength) characters"
passwordValidOutlet.text = "Password has to be at least \(minimalPasswordLength) characters"

let usernameValid = usernameOutlet.rx.text.orEmpty
.map { $0.count >= minimalUsernameLength }
.share(replay: 1)

let passwordValid = passwordOutlet.rx.text.orEmpty
.map { $0.count >= minimalPasswordLength }
.share(replay: 1)

let everythingValid = Observable.combineLatest(
usernameValid,
passwordValid
) { $0 && $1 }
.share(replay: 1)

usernameValid
.bind(to: passwordOutlet.rx.isEnabled)
.disposed(by: disposeBag)

usernameValid
.bind(to: usernameValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

passwordValid
.bind(to: passwordValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

everythingValid
.bind(to: doSomethingOutlet.rx.isEnabled)
.disposed(by: disposeBag)

doSomethingOutlet.rx.tap
.subscribe(onNext: { [weak self] in self?.showAlert() })
.disposed(by: disposeBag)
}

func showAlert() {
let alertView = UIAlertView(
title: "RxExample",
message: "This is wonderful",
delegate: nil,
cancelButtonTitle: "OK"
)

alertView.show()
}

你会发现你可以用几行代码完成如此复杂的交互。这可以大大提升我们的开发效率。

更多疑问

  • share(replay: 1) 是用来做什么的?

    我们用 usernameValid 来控制用户名提示语是否隐藏以及密码输入框是否可用。shareReplay 就是让他们共享这一个源,而不是为他们单独创建新的源。这样可以减少不必要的开支。

  • disposed(by: disposeBag) 是用来做什么的?

    和我们所熟悉的对象一样,每一个绑定也是有生命周期的。并且这个绑定是可以被清除的。disposed(by: disposeBag)就是将绑定的生命周期交给 disposeBag 来管理。当 disposeBag 被释放的时候,那么里面尚未清除的绑定也就被清除了。这就相当于是在用 ARC 来管理绑定的生命周期。 这个内容会在 Disposable 章节详细介绍。

收起阅读 »

iOS RXSwift 一

iOS
为什么要使用 RxSwift ?我们先看一下 RxSwift 能够帮助我们做些什么:Target Action传统实现方法:button.addTarget(self, action: #selector(buttonTapped), for: .touchU...
继续阅读 »

为什么要使用 RxSwift ?

我们先看一下 RxSwift 能够帮助我们做些什么:

Target Action

传统实现方法:

button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
func buttonTapped() {
print("button Tapped")
}

通过 Rx 来实现:

button.rx.tap
.subscribe(onNext: {
print("button Tapped")
})
.disposed(by: disposeBag)

你不需要使用 Target Action,这样使得代码逻辑清晰可见。

代理

传统实现方法:

class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
}

extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("contentOffset: \(scrollView.contentOffset)")
}
}

通过 Rx 来实现:

class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()

scrollView.rx.contentOffset
.subscribe(onNext: { contentOffset in
print("contentOffset: \(contentOffset)")
})
.disposed(by: disposeBag)
}
}

你不需要书写代理的配置代码,就能获得想要的结果。

闭包回调

传统实现方法:

URLSession.shared.dataTask(with: URLRequest(url: url)) {
(data, response, error) in
guard error == nil else {
print("Data Task Error: \(error!)")
return
}

guard let data = data else {
print("Data Task Error: unknown")
return
}

print("Data Task Success with count: \(data.count)")
}.resume()

通过 Rx 来实现:

URLSession.shared.rx.data(request: URLRequest(url: url))
.subscribe(onNext: { data in
print("Data Task Success with count: \(data.count)")
}, onError: { error in
print("Data Task Error: \(error)")
})
.disposed(by: disposeBag)

回调也变得十分简单

通知

传统实现方法:

var ntfObserver: NSObjectProtocol!

override func viewDidLoad() {
super.viewDidLoad()

ntfObserver = NotificationCenter.default.addObserver(
forName: .UIApplicationWillEnterForeground,
object: nil, queue: nil) { (notification) in
print("Application Will Enter Foreground")
}
}

deinit {
NotificationCenter.default.removeObserver(ntfObserver)
}

通过 Rx 来实现:

override func viewDidLoad() {
super.viewDidLoad()

NotificationCenter.default.rx
.notification(.UIApplicationWillEnterForeground)
.subscribe(onNext: { (notification) in
print("Application Will Enter Foreground")
})
.disposed(by: disposeBag)
}

你不需要去管理观察者的生命周期,这样你就有更多精力去关注业务逻辑。

多个任务之间有依赖关系

例如,先通过用户名密码取得 Token 然后通过 Token 取得用户信息,

传统实现方法:

/// 用回调的方式封装接口
enum API {

/// 通过用户名密码取得一个 token
static func token(username: String, password: String,
success: (String)
-> Void,
failure: (Error) -> Void) { ... }

/// 通过 token 取得用户信息
static func userinfo(token: String,
success: (UserInfo)
-> Void,
failure: (Error) -> Void) { ... }
}
/// 通过用户名和密码获取用户信息
API.token(username: "beeth0ven", password: "987654321",
success: { token in
API.userInfo(token: token,
success: { userInfo in
print("获取用户信息成功: \(userInfo)")
},
failure: { error in
print("获取用户信息失败: \(error)")
})
},
failure: { error in
print("获取用户信息失败: \(error)")
})

通过 Rx 来实现:

/// 用 Rx 封装接口
enum API {

/// 通过用户名密码取得一个 token
static func token(username: String, password: String) -> Observable<String> { ... }

/// 通过 token 取得用户信息
static func userInfo(token: String) -> Observable<UserInfo> { ... }
}
/// 通过用户名和密码获取用户信息
API.token(username: "beeth0ven", password: "987654321")
.flatMapLatest(API.userInfo)
.subscribe(onNext: { userInfo in
print("获取用户信息成功: \(userInfo)")
}, onError: { error in
print("获取用户信息失败: \(error)")
})
.disposed(by: disposeBag)

这样你可以避免回调地狱,从而使得代码易读,易维护。

等待多个并发任务完成后处理结果

例如,需要将两个网络请求合并成一个,

通过 Rx 来实现:

/// 用 Rx 封装接口
enum API {

/// 取得老师的详细信息
static func teacher(teacherId: Int) -> Observable<Teacher> { ... }

/// 取得老师的评论
static func teacherComments(teacherId: Int) -> Observable<[Comment]> { ... }
}
/// 同时取得老师信息和老师评论
Observable.zip(
API.teacher(teacherId: teacherId),
API.teacherComments(teacherId: teacherId)
).subscribe(onNext: { (teacher, comments) in
print("获取老师信息成功: \(teacher)")
print("获取老师评论成功: \(comments.count) 条")
}, onError: { error in
print("获取老师信息或评论失败: \(error)")
})
.disposed(by: disposeBag)

这样你可用寥寥几行代码来完成相当复杂的异步操作。


那么为什么要使用 RxSwift ?

  • 复合 - Rx 就是复合的代名词
  • 复用 - 因为它易复合
  • 清晰 - 因为声明都是不可变更的
  • 易用 - 因为它抽象的了异步编程,使我们统一了代码风格
  • 稳定 - 因为 Rx 是完全通过单元测试的
收起阅读 »

从最简单的角度走上读源码

1.前言 很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候...
继续阅读 »

1.前言


很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候,想起若川大佬经常有一些源码的研究,并参加了他发起的源码共读活动,受益良多。本次读的部分是vue3的工具函数,这是若川哥文章的地址juejin.cn/post/699497…


这里从一个源码初学者的角度对这一次共读进行一些记录和总结


2.项目准备


万事开头难,很多时候正是因为没有想好怎样去有一个好的开始,而一直搁置。


先是在若川的引导下,我先去看了vue-next的readme和相关协作文档,其实之前也会看,但没有想法去仔细想一下,并动手去实践起来,虽然是英文的,但是可以先稍微读慢一些


vue-next贡献文档里有写到过,当有一些多个编译方式都要用的方法函数的时候,要写到share模块里,当时我会感觉这是一个很困难的部分,但还是继续去做好了


当对文档有一定的了解之后,开始把vue-next下载到本地进行浏览


git clone https://github.com/vuejs/vue-next.git

cd vue-next

npm install --global yarn

yarn

yarn build

以上流程大家应该都比较熟悉


有个要说的就是,在大家yarn的时候 很有可能也会遇到The engine "node" is incompatible with this module的错误


这是vue-next的代码不久前有一个在engine里对node版本有限制,大家只要把node更新到相应的版本就可以了,用nvm可以很方便的进行。或者用yarn install --ignore-engines 对这个限制进行无视


还有个重要的就是在我们build之后,因为vue-next的代码基本都用ts进行了重构,build完会有一个vue-next/packages/shared/dist/shared.esm-bundler.js 文件,这是对本文件夹ts的js转义输出,这里的文件位置可以在tsconfig里找到。(忽然找到一个一边学源码一边复习ts的好方法!


3.源码调试


在源码调试的时候有一个困难的事情,就是代码经过各种步骤输出后,是没有办法直接调试的,所以我们往往会通过sourceMap去进行帮助,sourcemap是一个记录位置的文件,让我们能在经过巨大变化的代码里找到我们原来开发的样子


这是贡献指南里说提供的:Build with Source Maps Use the --sourcemap or -s flag to build with source maps. Note this will make the build much slower.


所以在 vue-next/package.json 追加 "dev:sourcemap": "node scripts/dev.js --sourcemap",yarn dev:sourcemap执行,即可生成sourcemap,或者直接 build。


然后会在控制台输出类似vue-next/packages/vue/src/index.ts → packages/vue/dist/vue.global.js的信息。


我们在文件里引入这个文件,就会有效果啦~


4.工具代码


上文有说道过,当初觉得这个模块是困难的,但其实真正去看的话,很多写法其实也都是平时会用到的,我们看这类源码,要抛开其他,对一些对我们有帮助的代码写法进行学习。我们从vue-next/packages/shared/src/index.ts开始。


前边的一些其实都是为了更加方便使用和严谨,但其实并不难。但有一些我们平时没有那么常用的方法,其实某些时候也都会有用,至少都应该有印象,比如对象的方法,Object.freeze({}),还有es6的字符串方法Startwith等


还有很有用的是,在学习工具源码的过程中,复习到了一些之前的知识,比如原型链的一些相关


hasOwn:判断一个属性是否属于某个对象


const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
val: object,
key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key)

toRawType:对象转字符串


const objectToString = Object.prototype.toString;
const toTypeString = (value) => objectToString.call(value);
const toRawType = (value) => {
// extract "RawType" from strings like "[object RawType]"
return toTypeString(value).slice(8, -1);
};

这里是三个函数,我把三个放在一起去进行总结,typeof很多时候是不准的,这个时候用这个方法可以进行一些补充


比如可以分出array和普通object


// typeof 返回值目前有以下8种
'undefined'
'object'
'boolean'
'number'
'bigint'
'string'
'symobl'
‘function'

isPromise判断是不是promise


const isPromise = (val) => {
return isObject(val) && isFunction(val.then) && isFunction(val.catch);
};

// 判断是不是Promise对象
const p1 = new Promise(function(resolve, reject){
resolve('');
});
isPromise(p1); // true

之前没有想到这种思路,很简单实用


cacheStringFunction函数缓存


const cacheStringFunction = (fn) => {
const cache = Object.create(null);
return ((str) => {
const hit = cache[str];
return hit || (cache[str] = fn(str));
});
};

5.总结


能写出这篇文章要很感谢若川哥的帮助


这一期的读源码很有收获



  1. 有了开始研究困难源码的信心和方向

  2. 对项目的github文档更加重视并懂得去理解

  3. 学会了通过sourcemap帮助我们调试源码

  4. 学习了vue工具函数的写法,复习了相关知识,并在工作中有意识借鉴


链接:https://juejin.cn/post/7010004468292911118

收起阅读 »

巧用CSS counter属性

前言 你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。 CSS 提供了计数器功能,可以动态设置编号。 CSS计数器 要实现CSS计数器的,先了解CSS计数器的属性和方法 co...
继续阅读 »

前言


你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。


image.png
image.png


CSS 提供了计数器功能,可以动态设置编号。


CSS计数器


要实现CSS计数器的,先了解CSS计数器的属性和方法


counter-reset
counter-increment
counter()

counter-reset


counter-reset 用于定义和初始化一个或者多个css计数器。设置计数器的名称和初始值。
使用语法:


counter-reset:[<标识符><整数>?]+|none|inherit

每个计数器名称后面都可以跟一个可选的<整数>值,该值指定计数器的初始值。
计数器的初始值不是计数器显示时的第一个数字,如果希望计数器从1开始显示,则需要设置coutter-reset中的初始值设置为0。


someSelector{
counter-reset:counterA;/*计数器counterA初始,初始值为0*/
counter-reset:counterA 6;/*计数器counterA初始,初始值为6*/
counter-reset:counterA 4 counter B;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为0*/
counter-reset:counterA 4 counterB 2;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为2*/
}

counter-increment


counter-increment属性用于指定一个或多个CSS计数器的增量值。它将一个或多个标识符作为值,指定要递增的计数器的名称。


使用语法:


counter-increment:[<标识符><整数>?]+|none|inherit

每个计数器名称(标识符)后面都可以跟一个可选<整数>值,该值指定对于我们所编号的元素每次出现时,计数器需要递增多少。默认增量为1。允许零和负整数。如果指定了负整数,则计数器被递减。


counter-increment属性必须和counter-reset属性配合使用


article{/*定义和初始化计数器*/
  counter-reset:section;/*'section'是计数器的名称*/
}
article h2{/*每出现一次h2,计数器就增加1*/
  counter-increment:section;/*相当于计数器增量:第1节;*/
}

couter()


counter()函数必须和content属性一起使用,用来显示CSS计数器。它以CSS计数器名称作为参数,并作为值传递给content属性,而content属性就会使用:before伪元素将计数器显示为生成的内容。


h2:before{
  content:counter(section);
}

counter()函数有两种形式:counter(name)和counter(name,style)。
name参数就是要显示的计数器的名称;使用counter-reset属性就可以指定计数器的名称。


couters()


counters()函数也必须和content属性一起使用,用来显示CSS计数器。和counter()函数一样,counters()函数也作为值传递给content属性;然后,content属性在使用:before伪元素将计数器显示为生成的内容。


counters()函数也有两种形式:counters(name,string)和counters(name,string,style)。
name参数也是要显示的计数器的名称。可以使用counter-reset属性来指定计数器的名称。


而counters()函数与counter()函数(单数形式)区别在于:counters()函数可以用于设置嵌套计数器。


嵌套计数器是用于为嵌套元素(如嵌套列表)提供自动编号。如果您要将计数器应用于嵌套列表,则可以对第一级项目进行编号,例如,1,2,3等。第二级列表项目将编号为1.1,1.2,1.3等。第三级项目将是1.1.1,1.1.2,1.1.3,1.2.1,1.2.2,1.2.3等。


string参数用作不同嵌套级别的数字之间的分隔符。例如,在'1.1.2'中,点('.')用于分隔不同的级别编号。如果我们使用该counters()函数将点指定为分隔符,则它可能如下所示:


  content:counters(counterName,".")

  如果希望嵌套计数器由另一个字符分隔,例如,如果希望它们显示为“1-1-2”,则可以使用短划线而不是点作为字符串值:


  content:counters(counterName,"-")

总结


使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:



  • counter-reset: 定义计数器的名称和初始值。

  • counter-increment:用来标识计数器与实际相关联的范围。

  • content:用来生成内容,其为:before:after::before::after的一个属性。在生成计数器内容,主要配合counter()一起使用。

  • counter():该函数用来设置插入计数器的值。

  • :before :after:配合content用来生成计数器内容。



链接:https://juejin.cn/post/7010031620983881735

收起阅读 »

rgb和hex相互转换

前言 这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算 hex(16进制):#FFF,#ffffff等等16进制颜色 rgb:rgb(255,255,255),rgb(123,125,241)等等 笔者第一次遇到颜色转换时,懵了,没有思...
继续阅读 »

前言


这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算


hex(16进制):#FFF,#ffffff等等16进制颜色


rgb:rgb(255,255,255),rgb(123,125,241)等等


笔者第一次遇到颜色转换时,懵了,没有思路,害,想着放着后面再来看看,结果放着放着,哦豁,再一次遇到了它,唉,被它逮住了。


这题,必须得剿,不剿不行呀,码着代码看着题,结果它来了。哈哈哈哈


因为笔者想自己输入hex或者rgb然后转换,就想着写个输入框,获取转换前后颜色。


设计



  • test1和test2的背景颜色由输入颜色和转化后颜色决定

  • 需要一个输入框inChange来让我输入

  • 提交按钮colorBtn提交我输入颜色


html片段:


<div>
<div>
输入颜色
</div>
<div>
转换颜色
</div>
</div>
<div>
<input type="text">
<button>提交</button>
</div>

css片段:


    * {
padding: 0;
margin: 0;
}

.box {
display: flex;
justify-content: center;
}

.test1,
.test2 {
width: 200px;
height: 100px;
text-align: center;
border: 1px solid red;
margin: 10px 20px;
}

.boxin {
width: 100%;
text-align: center;
}

这里的js我把实现转换的核心代码在下面注释给分割出来,方便复制


注意:



  • 16进制每个字符所占4位,超过32位溢出.

  • hex有6个16进制字符,24位。


js


//hex转换成rgb
function hexToRgb(hex) {
   //用于判断hex的格式对不对
let regExp = /^#([0-9A-F]{3}|[0-9A-F]{6})$/i;
//判断hex的格式是否正确
if (!regExp.test(hex)) {
return false;
}

   //-----hex到rgb转换核心代码
   //获取#后的16进制数
let str = hex.substr(1,);
//当str长度为3时,它是简写,需要把它装回6位
if (str.length == 3) {
let tempStr = "";
for (let i = 0; i < 3; i++) {
tempStr += str[i] + str[i];
}
str = tempStr;
}
//16进制
str = "0x" + str;
   //16进制每个字符占4个字节,16/4=4,对应的是(例子:0xaf54ff)af
let r = str >> 16;
   //对应af54在与运算符后,对应54
let g = str >> 8 & 0xff;
   //对应ff
let b = str & 0xff;
   let rgb = `rgb(${r}, ${g}, ${b})`;
   //-----hex到rgb转换完毕

document.querySelector(".test1").style.backgroundColor = hex;
   document.querySelector(".test1").innerHTML = hex;
   document.querySelector(".test2").style.backgroundColor = rgb;
   document.querySelector(".test2").innerHTML = rgb;
}

function rgbToHex(rgb) {
   //正则太长,直接复制粘贴会有空格,请自行删除(有空格报错噢,嘿嘿嘿)
let regExp = /^rgb(\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*)$/i;
   //判断rgb的格式是否正确
if (!regExp.test(rgb)) {
return false;
}

   //-----rgb到hex转换核心代码
   //获取rgb的数字
let arr = rgb.split(",");
let r = + arr[0].split("(")[1];
let g = + arr[1];
let b = + arr[2].split(")")[0];
   //hex有6位,占了4*6=24字节,每一步将rgb还原
let value = (1 << 24) + (r << 16) + (g << 8) + b;
   //只要24位,其实有25位,16进制转换后高位多出了一个1,提取1后面的16进制数。
let hex = "#" + value.toString(16).slice(1);
   //-----rgb到hex转换完毕

document.querySelector(".test1").style.backgroundColor = rgb;
   document.querySelector(".test1").innerHTML = rgb;
   document.querySelector(".test2").style.backgroundColor = hex;
   document.querySelector(".test2").innerHTML = hex;
}
function hexOrRgb() {
   //获取输入框的值
let val = document.querySelector(".inChange").value;
   //调用,不是执行下一个
hexToRgb(val) || rgbToHex(val);
}
function init() {
//监听按钮的点击
let btn = document.querySelector(".colorBtn");
btn.addEventListener("click", () => {
hexOrRgb();
  });
}
//入口
init();


链接:https://juejin.cn/post/7010212491363893279

收起阅读 »

React 的 Fiber 树是什么?

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。 其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没...
继续阅读 »

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。


其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没了,还写不完,并且我现在的能力并不能很好的组织它们。


所以,我还是放弃了,我决定把它拆开,分多篇博客更新,拆分的结果大概是这样子的:



  1. React 的 Fiber 树是什么;

  2. 更新流程中的 Render 阶段;

  3. 更新流程中的 Commit 阶段;

  4. 通过 useEffect 里调用 useSate, 把 2、3 结合起来。



What I cannot [re]create, I do not understand.



分享完上面的内容后,我们应该就可以有能力自己实现一个 Mini 版 React 了。为了真正的掌握,我会和大家一起实现一个支持 Hooks 的 Mini 版 React,可能以文章的形式放出,也可能就把源码贴在这里,不过,那肯定是 10 月份或者 11 月份的事情了。


刚开始听到 「Fiber」 这个词的时候,觉得高端极了,当时甚至没有去网上搜索一下这个到底是什么,就默认自己不可能理解了。逃的了一时,逃不了一世,为了不当框架熟练工,最终还是要克服它。


幸运的是,了解之后发现,这东西既没有想得那么难,也没有想得那么简单,只要花一点时间,大家都还是能理解的。


你要试着了解一下吗,如果选择是的话,那我们就开始吧。


Fiber 树与 DOM 树


DOM 树大家都很熟悉,下面是我们的一段 HTML 的片段:


<div>
<ul>
<li>1</li>
<li>2</li>
</ul>
<button>submit</button>
<div>

对应到 DOM 结构,就是下面这样子:


image.png


大家可能都知道 React 使用了虚拟 DOM 来提高性能。


虚拟 DOM 是一个描述 DOM 节点信息的 JS 对象,操作 DOM 是一个比较昂贵的操作,使用虚拟 DOM 这项技术,我们就能通过在 JS 对象中进行新老节点的对比,尽量减小查询、更新 DOM 操作的频次和范围。在 React 中,虚拟 DOM 对应的就是 Fiber 树。


说到 Fiber 树,名字中带一个「树」字,大家的第一印象会把它和树结构联系起来,认为它和 DOM 树的结构是一样的,但这里还真就有点不同了,它和我们见过的树都不一样。


还是用上面那段 HTMl 代码,我们假设它是一段用 JSX 语法书写的,它的结构实际是如下图这般:


image.png


父节点只和它的第一个孩子节点相连接,而第一个孩子和后面的兄弟节点相连接,它们之间构成了一个单项链表的结构。最后,每个孩子都有一个指向父节点的指针。


因为比较重要,我们再来复述一遍:在上面的结构中,我们会有一个 child 指针指向它的孩子节点,也会有一个 return 指针指向它的父节点,另外会有一个叫做 sibling 的指针指向它的兄弟节点,如果它没有孩子节点、兄弟节点或父节点,他们的指向就为空。


乍一看,这种结构还是很奇怪的,你可能会疑问,为什么不用树结构,这个我们放在本文的后面讨论。


Fiber 树的遍历


在我们学习树的时候,我们学的第一个算法往往是遍历算法,在继续下面的内容之前,我们也先来看一下怎么去遍历下面这样结构的 Fiber 树。


这一块还是很重要的,因为在后面 React 的更新流程中,它要遍历整个 Fiber 去收集更新,理解了这一块就有助于我们理解后面它的遍历过程。


我们先来描述一下它的遍历顺序:



  1. 把当前遍历的节点名记作 aa

  2. 遍历当前节点 aa,完成对这个节点要做的事

  3. 判断 a.childa.child 是否为空

  4. a.childa.child 不为空,则把 a.childa.child 记作 aa,回到 [步骤 1]

  5. a.childa.child 为空,则判断 a.sibinga.sibing 是否为空,不为空将 a.sibinga.sibing 记为 aa,回到 [步骤 1]

  6. a.childa.childa.siblinga.sibling 都为空,则证明当前节点和和他兄弟节点都遍历完了,那就返回它的父节点,找父节点中还没有遍历的兄弟节点,找到了,回到步骤 1

  7. 如此反复,直到遍历到顶点,结束。


只看逻辑可能不太直观,我们举一个例子。


<div id="a"> 
<ul id="b">
<li id="c">1</li>
<li id="d">2</li>
</ul>
<button id="e">submit</button>
<div>

对于上面这段代码,我们的遍历顺序会是:a -> b -> c -> d -> e,和正常树结构的前序遍历的结果是一样的。


如果看着还是有点懵,没关系,这很正常,接下来我会和大家演示代码。


为了方便起见,我们就固定写好的一个 Fiber 树结构,它对应我们上面那段 HTML。


// 为了简单起见,我把 TextNode 节点省略了
function createFiberTree() {
let rootFiber = {
type: 'div',
sibling: null,
return: null,
child: {
type: 'ul',
return: null,
sibling: {
type: 'button',
return: null,
sibling: null,
child: null
},
child: {
type: 'li',
return: null,
child: null,
sibling: {
type: 'li',
return: null,
child: null
}
}
}
}


rootFiber.return = null;
rootFiber.child.return = rootFiber
rootFiber.child.sibling.return = rootFiber;

let ul = rootFiber.child;
rootFiber.child.child.return = ul;
rootFiber.child.child.sibling.return = ul;

return rootFiber;
}

上面那段代码很有点长,不用管,大家就知道它根据上面的 HTML 结构构造了 Fiber 对象就好了。


接下来我们要去遍历这个树,下面就是我们的遍历方法,大家可以稍微停一会看一下这个算法,在React 的更新流程的 Render 阶段,遍历 Fiber 树的地方都是沿用这个思路。


function traverse(node) {
const root = node;
let current = node;

while(true) {
console.log('当前遍历的节点是:' + current.type)

if (current.child) {
current = current.child
continue
}

if (current.sibling) {
current = current.sibling
continue
}

while(!current.sibling) {
if (
current.return === null || current.return === root) {
return;
}
current = current.return;
}
current = current.sibling
}
}

我们在控制台运行上面遍历方法的结果如下:


image.png


Fiber 树结构的优势


好了,现在我们就已经和大家讨论清楚 Fiber 树大体是什么样了,并且我们了解了怎样去遍历一棵 Fiber 树,接下来讨论一下,为什么需要这么样的设计。


刚开始的时候,我也很疑惑,为什么不和 DOM 一样,使用普通的多叉树呢?


type Fiber {
type: string;
children: Array<Fiber>
}

这样子的话,我们不需要维护孩子节点之间的指针,找某个节点的孩子的话,直接读取 children 属性就好了。这样看起来是没问题的,我们知道,在遍历树的时候,我们最常用的是使用递归去写,如果我们采用上面的多叉树结构,遍历节点可能就是这样的:


function traverse(node) {
if (!node || !node.children) {
return;
}

for (let i = 0; i < node.children.length; i++) {
traverse(node.children[i]);
}
}

看起来确实是简洁了很多,但是如果我们的 DOM 层级很深就会引发严重的性能问题,在一个普通的项目里,几百层的 DOM 嵌套是经常发生的,这样以来,使用递归会占用大量的 JS 调用栈,不仅如此,我们的调用栈肯定不是只给这一块遍历 Fiber 节点的呀,我们还有其他的事情要去做,这对性能来说是很不能接受的。


但是,如果用我们上面提到的那种架构,我们就能做到不使用递归去遍历链表,就能始终保持遍历时,调用栈只使用了一个层,这就很大的提升了性能。


除此之外,上述遍历 Fiber 节点的过程是发生在整个更新流程的 Render 阶段,在这个阶段,React 是允许低优先级的任务被更高优先级的任务所打断的。所以说,遍历过程也可能随时被中断。为了能在下次更新时继续从上次中断的点开始,我们就需要记录下上一次的中断点。


如果使用普通的树结构,是很难记录下中断点的,假设我们有一段这样一段 HTML:


<div>
<ul>
<li>
<a>在这里中断了</a>
</li>
<!-- 可能还有很多项 -->
</ul>
<!-- 可能还有很多项 -->
</div>

按照上面的遍历算法,假设我们在遍历到 a 标签的时候中断了。


当遍历到 a 标签的时候,我们还有很多节点没有遍历的,包括 ul 的其他孩子节点、div 的其他孩子节点,也就是我标注 '可能还有很多项' 的那个地方,为了下一次能继续下去,我们就需要把这些都保存下来,当这些节点很多的时候,这在内存上是一个巨大的开销。


使用当前 Fiber 架构呢?只需要把当前节点记录在一个变量里就好了,等下次更新,它还是可以按照一样的逻辑,先遍历自己,再遍历 child 节点,再遍历 sibling 节点......


因此,我们最终选用了刚开始看起来有点怪的 Fiber 树结构。


Fiber 节点部分属性介绍


在 React官网的这一章节,讲述了 Diff 算法的大致流程,这里 Diff 的东西就是两棵新旧 Fiber 树。


说了这么多,我们还没看过一个 Fiber 节点到底长什么样。


不妨,我们先用 Babel 转译一段 JSX 看看。就编译下面这一小段吧:


<div>
<span key="1" className="box">hello world</span>
</div>


结果是下面这样的:


const a = React.createElement("div", null, 
React.createElement("span", {
key: "1",
className: "box"
}, "hello world"))

我们会根据这个结果去构建 Fiber 对象,就是这样:


image.png



注意:
上面的截图并不是全部的属性,本人只截取了一部分。



我们再根据上面的图,介绍几个 Fiber 节点常用的属性。


alternate:Diff 过程要新老节点对比,他们就是通过这个找到对方。所以,新节点的 Fiber.alternate 就指向它对应的老节点;同时,老节点的 alternate 也指向新节点。


child: 指向第一个孩子节点,我们这里就是指向了 span 那个节点。


elementType: 和 React.createElement 的第一个参数相同,DOM 元素是它的类型,组件的话就是对应的构造函数,比如函数式组件就是对应的函数,类组件就是对应的类。


sibling:指向下一个兄弟节点


return:指向父节点


stateNode:对应的 DOM 节点


memoizedProps 存储的计算好了的 props,可能是已经更新到页面上的了;也可能是刚根据 pendingProps 计算好,还没有来得及更新到页面上,准备和旧节点进行对比


memoizedState:和 memoziedProps 一样。像 usetState 能保存状态,就是因为上一次的值被存到了这个属性里面。


关于 Fiber 的属性,我们就先介绍这几个,后面等我们用到了再介绍更多。


好了,这就是我们今天的全部内容了,相信看完了上面的内容就对 Fiber 树是什么有大体印象了吧。之前我写的 useState 源码解读可能不是特别好,可能原因就是不太明白某些朋友不了解 Fiber 到底是什么,现在我通过这篇文章把它补上了,希望能弥补一下吧。


中秋回家的朋友,你们现在在归程了吗?


链接:https://juejin.cn/post/7010263907008937997

收起阅读 »

让你用最简单的方式使用Vue2 + Web Worker + js-xlsx 解析excel数据

vue
最简单的应该就是 C V 大法了吧!!! 说明 本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺 由SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel...
继续阅读 »

最简单的应该就是 C V 大法了吧!!!


cv.jpg


说明


本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺


SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel的工具库,功能强大,支持格式众多,支持xls、xlsx、ods(一种OpenOffice专有表格文件格式)等十几种格式。本文以xlsx格式为例。github:github.com/SheetJS/she…


为什么使用Web Worker呢?为了加快解析速度,提高用户体验度🤡。Web Worker具体介绍看阮老师的博客就好😀
Web Worker 使用教程 - 阮一峰的网络日志


本文配套demo仓库:gitee.com/ardeng/work…


效果演示


演示效果


上代码


HTML


普普通通、简简单单的element ui 上传组件


<el-upload
ref="input"
action="/"
:show-file-list="false"
:auto-upload="false"
:on-change="importExcel"
type="file"
>
<el-button type="primary">上传</el-button>
</el-upload>

JS部分


先来个无 Web Worker 版


Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。


Web Worker 有以下几个使用注意点。



  1. 同源限制 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

  2. DOM 限制 Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以navigator对象和location对象。

  3. 通信联系 Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

  4. 脚本限制 Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

  5. 文件限制 Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。


由于以上的限制,所以不想搞Worker也可以。 直接解析文件对象 转换数据即可。


importExcel(file) {
// 验证文件是否为excel
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return
}
this.fileToExcel(file).then(tabJson => {
// 这里拿到excel的数据
console.log(tabJson)
})
},
// excel数据转为json数组
fileToExcel(file) {
// 不使用 Promise 也可以 只是把读文件做成异步更合理
return new Promise(function (resolve, reject) {
const reader = new FileReader()
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result
// XLSX 解析的配置 type: 'binary' 必写
const excelData = XLSX.read(result, { type: 'binary' })
// 注意要加 { header: 1 }, 此配置项 可生成二维数组
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]],
{ header: 1 }) //! 读取去除工作簿中的数据
resolve(data)
}
// 调用方法 读取二进制字符串
reader.readAsBinaryString(file.raw)
})
}

Web Worker版


想用Worker 一些前置工作是必不可少的



  1. 下载 worker-loader


npm i -D worker-loader


  1. vue.config.js中配置loader


// 设置解析以worker.js 结尾的文件使用worker-loader 解析
chainWebpack: config => {
config.module.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
.options({ inline: 'fallback' })
}

正式进入使用Web Worker


封装一下 Web Worker 命名规则如下:xxx.worker.js


下面代码中,self代表子线程自身,即子线程的全局对象。


// src\utils\excel.worker.js

import XLSX from 'xlsx'

/**
* 处理错误的函数 主线程可以监听 Worker 是否发生错误。
* 如果发生错误,Worker 会触发主线程的`error`事件。
*/
const ERROR = () => {
// 发送错误信息
self.postMessage({ message: 'error', data: [] })

// `self.close()`用于在 Worker 内部关闭自身。
self.close()
}

// 错误处理
self.addEventListener('error', (event) => {
ERROR()

// 输出错误信息
console.log('ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message)
})

/**
* @description: Worker 线程内部需要有一个监听函数,监听`message`事件。 工作线程接收到主线程的消息
* @param {object} event event.data 获取到主线程发送过来的数据
*/
self.addEventListener('message', async (event) => {
// 向主线程发送消息
// postMessage(event.data);

// 解析excel数据
parsingExcel(event.data)
}, false)

/**
* @description: 解析excel数据
* @param {object} data.excelFileData 文件数据
* @param {object} data.config 配置信息
*/
const parsingExcel = (data) => {
try {
// 注意 { header: 1 }, 此配置项 可生成二维数组
const { excelFileData, config = { header: 1 } } = data

// 创建实例化对象
const reader = new FileReader()

// 处理数据
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result;
const excelData = XLSX.read(result, { type: 'binary' })
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]], config) //! 读取去除工作簿中的数据

// 发送消息
self.postMessage({ message: 'success', data })
};
// 调用方法 读取二进制字符串
reader.readAsBinaryString(excelFileData.raw);
} catch (err) {
ERROR()
console.log('解析excel数据时 catch到的错误===>', err)
}
}

使用


引入文件


import Worker from '@/utils/excel.worker.js'

业务相关的逻辑


importExcel(file) {
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return;
}

// 创建实列
const worker = new Worker()

// 主线程调用`worker.postMessage()`方法,向 Worker 发消息
worker.postMessage({
excelFileData: file,
config: { header: 1 }
})

// 主线程通过`worker.onmessage`指定监听函数,接收子线程发回来的消息
worker.onmessage = (event) => {
const { message, data } = event.data
if (message === 'success') {
// data是个二维数组 表头在上边
console.log(data)
// Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate()
}
}
}

可能遇到的问题



  1. npm run dev 启动不了,并且 webpack 报错:检查下 webpack worker-loader 的版本。

  2. 控制台报错包含 not a function 或 not a constructor:检查下 webpck 配置。最好是查看英文文档 webpack,因为中文文档更新不及时。

  3. 控制台报错 window is not defined:改成 self 试试。参考 Webpack worker-loader - import doesn’t work


链接:https://juejin.cn/post/7010046891480055815

收起阅读 »

react-native拆包&热更体系搭建-代码拆包

一、前言 触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是...
继续阅读 »

一、前言


触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是也有一些弊端,在刚开始接触热更的时候我有遇到bundle包因为某种来自东方的神秘力量阻挡无法下载的问题,后来我又发现了code-push-server这个开源的配套服务,细心的小伙伴们会发现这个项目最后一次commit已经在两年前,而且配套的cli工具早就升级到与某软的xxx center强绑定了,在此我个人不推荐再使用这个库来实现热更了。


二.CLI工具 (rn-multi-bundle)


image.png


Github:rn-multi-bundle


这是我开发的一款辅助拆包的cli工具,使用方法很简单


安装cli工具


npm install rn-multi-bundle -D
yarn add rn-multi-bundle -D

修改模块注册格式如下:
必须修改,cli工具通过分析ComponentMap对象拆离每个业务包


const ComponentMap = {
[appName]: Home,
[ComponentName.Home]: Home,
[ComponentName.Test]: Test,
};

Object.keys(ComponentMap).forEach(name => {
AppRegistry.registerComponent(name, () => ComponentMap[name]);
});

打包方法(公共包和所有业务包)


yarn rn-multi-bundle

打业务增量包方法


yarn rn-multi-bundle -u

后面考虑会做内联优化RAM Bundles 和内联引用优化


三、拆包与热更的关系


image.png


在大型项目中一般会使用AppRegistry.registerComponent来注册多个模块,每个模块各司其职可以是衍生产品或者零时活动,研究过cp的人都知道它的原理是替换单个bundle包,即时你改动一行代码发版那也是替换整个bundle包,这样造成的问题:一是对资源的浪费很多代码其实还是能够复用的用户的流量也是要钱的、二是不利于优化rn的启动速度。我想要实现的是每个模块可以独立更新,所以拆包对热更来说很重要!!!


四、如何实现拆包?


image.png


react-native的官方打包工具叫metro,metro提供了两个重要的apiprocessModuleFiltercreateModuleIdFactory,两个api都能获取到模块(文件)的路径,也就是栗如/src/utils/constant.tsx这样的字符串,从方法名可以了解到processModuleFilter可以判断是否要过滤出某个文件返回true表示这个文件需要打入bundle中,false则相反。createModuleIdFactory是为每个文件生成一个id,这个id其实就是commonjs规范中每个模块生成的id也就是一个索引。


五、拆出公共包


image.png


公共包顾名思义里边全是公共的代码可复用程度高,可以被各个模块使用到,比如node_modules中的第三方依赖,公共组件components、以及一些工具方法utils,所以我们只需要把文件路径属于这几个文件夹的文件用processModuleFilterapi过滤出来,这样就产生了一个公共包。


六、拆出业务包


image.png


在拆公共包的过程中,生成一个source map记录每个文件的id。利用这个文件使用processModuleFilter把已经存在公共包中的模块过滤掉,然后再使用createModuleIdFactory返回对应的id,这样业务包就能调用公共包中的各种模块


七、打包结果


初始包


image.png


业务增量包


image.png


作者:soul96816
链接:https://juejin.cn/post/7010014852307484685

收起阅读 »

kotlin修炼指南6-Sealed到底密封了啥

在代码中,我们经常需要限定一些有限集合的状态值,例如:网络请求:成功——失败账户状态:VIP——穷逼VIP——普通工具栏:展开——半折叠——收缩等等。通常情况下,我们会使用enum class来做封装,将可见的状态值通过枚举来使用。enum class Net...
继续阅读 »

在代码中,我们经常需要限定一些有限集合的状态值,例如:

  • 网络请求:成功——失败
  • 账户状态:VIP——穷逼VIP——普通
  • 工具栏:展开——半折叠——收缩

等等。

通常情况下,我们会使用enum class来做封装,将可见的状态值通过枚举来使用。

enum class NetworkState(val value: Int) {
SUCCESS(0),
ERROR(1)
}

但枚举的缺点也很明显,首先,枚举比普通代码更占内存,同时,每个枚举只能定义一个实例,不能拓展更多信息。

除此之外,还有种方式,通过抽象类来对状态进行封装,但这种方式的缺点也很明显,它打破了枚举的限制性,所以,Kotlin给出了新的解决方案——Sealed Class(密封类)。

创建状态集

下面我们以网络请求的例子来看下具体如何使用Sealed Class来进行状态的封装。

和抽象类类似,Sealed Class可用于表示层级关系。它的子类可以是任意的类:data class、普通Kotlin对象、普通的类,甚至也可以是另一个密封类,所以,我们定义一个Result Sealed Class:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}

当然,也不一定非要写在顶层类中:

sealed class Result<out T : Any> 
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()

这样也是可以的,它们的区别在于引用的时候,是否包含顶层类来引用而已。

大部分场景下,还是建议第一种方式,可以比较清晰的展示调用的层级关系。

在这个例子中,我们定义了两个场景,分别是Success和Error,它表示我们假设的网络状态就这两种,分别在每种状态下,例如Success,都可以传入自定义的数据类型,因为它本身就是一个class,所以借助这一点,就可以自定义状态携带的场景值。在上面这个例子中,我们定义在Success中,传递data,而在Error时,传递Exception信息。

所以,使用Sealed Class的第一步,就是对场景进行封装,梳理具体的场景枚举,并定义需要传递的数据类型。

如果场景值不需要传递数据,那么可以简单的使用:object xxxx,定义一个变量即可。

使用

接下来,我们来看下如何使用Sealed Class。

fun main() {
// 模拟封装枚举的产生
val result = if (true) {
Result.Success("Success")
} else {
Result.Error(Exception("error"))
}

when (result) {
is Result.Success -> print(result.data)
is Result.Error -> print(result.exception)
}
}

大部分场景下,Sealed Class都会配合when一起使用,同时,如果when的参数是Sealed Class,在IDE中可以快速补全所有分支,而且不会需要你单独补充else 分支,因为Sealed Class已经是完备的了。

所以when和Sealed Class真是天作之合。

进一步简化

其实我们还可以进一步简化代码的调用,因为我们每次使用Sealed Class的时候,都需要when一下,有些时候,也会产生一些代码冗余,所以,借助拓展函数,我们进一步对代码进行简化。

inline fun Result<Any>.doSuccess(success: (Any) -> Unit) {
if (this is Result.Success) {
success(data)
}
}

inline fun Result<Any>.doError(error: (Exception?) -> Unit) {
if (this is Result.Error) {
error(exception)
}
}

这里我对Result进行了拓展,增加了doSuccess和doError两个拓展,同时接收两个高阶函数来接收处理行为,这样我们在调用的时候就更加简单了。

result.doSuccess { }
result.doError { }

所以when和Sealed Class和拓展函数,真是天作之合。

那么你一定好奇了,Sealed Class又是怎么实现的,其实反编译一下就一目了然了,实际上Sealed Class也是通过抽象类来实现的,编译器生成了一个只能编译器调用的构造函数,从而避免其它类进行修改,实现了Sealed Class的有限性。

封装?

Sealed Class与抽象类类似,可以对逻辑进行拓展,我们来看下面这个例子。

sealed class TTS {

abstract fun speak()

class BaiduTTS(val value: String) : TTS() {
override fun speak() = print(value)
}

class TencentTTS(val value: String) : TTS() {
override fun speak() = print(value)
}
}

这时候如果要进行拓展,就很方便了,代码如下所示。

class XunFeiTTS(val value: String) : TTS() {
override fun speak() = print(value)
}

所以,Sealed Class可以说是在抽象类的基础上,增加了对状态有限性的控制,拓展与抽象,比枚举更加灵活和方便了。

再例如前面网络的封装:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
sealed class Error(val exception: Exception) : Result<Nothing>() {
class RecoverableError(exception: Exception) : Error(exception)
class NonRecoverableError(exception: Exception) : Error(exception)
}

object InProgress : Result<Nothing>()
}

通过Sealed Class可以很方便的对Error类型进行拓展,同时,增加新的状态也非常简单,更重要的是,通过IDE的自动补全功能,IDE可以自动生成各个条件分支,避免人工编码的遗漏。


收起阅读 »

2021年度28个开箱即用的MQTT开源项目合集【附源码】

随着云通讯行业持续增长,5G建设逐步推进、音视频技术快速迭代,都是为了满足人与人、设备与人、设备与设备之间的消息传输。这无疑是一个消息传输的时代。在这个时代中,所有的协议、配置都在于恰到好处。正如MQTT消息传输协议基于物联网,但又不局限于物联网,同样可以在移...
继续阅读 »

随着云通讯行业持续增长,5G建设逐步推进、音视频技术快速迭代,都是为了满足人与人、设备与人、设备与设备之间的消息传输。这无疑是一个消息传输的时代。在这个时代中,所有的协议、配置都在于恰到好处。正如MQTT消息传输协议基于物联网,但又不局限于物联网,同样可以在移动互联网中承担多种功能。

MQTT是一个极其轻量级的发布/订阅消息传输协议,它解除时间与空间耦合,可以在应用内实现推送、通知等功能;它简约、轻量,极小的SDK空间占用,适用于嵌入Android、iOS、RTOS等多端平台;它数据包小、功耗低,适用于低带宽、高延迟或不可靠的网络环境。

环信MQTT消息云的产品定位就是充分发挥MQTT协议优势,为开发者提供应用与应用之间、设备与应用之间、应用与平台之间的消息传输服务。为了让大家更深入了解MQTT协议优势,环信举办了首届MQTT创意编程挑战赛,通过编程实战实际感受MQTT协议在应用间消息传输的优势。

经过1个多月的实战开发,首届环信MQTT创意编程挑战赛最终产生28个参赛作品,覆盖多领域多平台,生动有趣的展示了开发者们天马行空的创造力。下面我们一起看看那些优秀的开源作品吧~!

 

作品1:音乐广播神器 - 一起听

一起听是一款在线音频共享收听软件,用户可以快速创建一个包含音频播放器的网页,与众多好友实时在线点播收听和赏析歌曲。


一起听主播端截图

【一起听】终端用户分类两类 a.主播端用户(默认用户) b. 听众端用户(通过广播链接进入页面的用户)。主播角色用户登入一起听页面后,将系统自动生成的广播地址分享给好友,好友即听众端,通过该链接与主播角色进入同一个频道,进行歌曲播放,收听,进度同步。

一起听通过环信MQTT实现了以下功能:
(1)实时数据同步:包括歌曲播放同步、快进同步、切歌同步;
(2)控制指令:传输“我想听XXX”格式消息,可点播歌曲,“上一首/下一首”可控制切歌;
(3)实时消息传输:实现大型聊天室即时聊天功能;
(4)在线人数:获取好友上下线动态,实时更新在线人数;


该作品作为一款共享音乐神器,通过MQTT消息实现实时消息传输,控制命令,实时在线人数等功能,充分发挥了MQTT消息体小、实时传输、一对多广播等特性,同时支持数百万级客户端同时接入,消息毫秒级到达,并且可以灵活复制到共享音视频、在线直播、在线教学(云教室)等场景。


预览地址:https://www.easemob.com/product/mqtt/demo/musicplayer


作品2:手速王——在线PK - 趣味应用小游戏

手速王是一款与在线用户拼手速,实时PK的小游戏。


该小游戏基于环信MQTT实现实时在线人数的心跳连接,实时广播当前在线用户以及用户点击屏幕次数,点击态给与爱心动画展示增加趣味性,通过图表的方式实时刷新当前点击次数,依赖MQTT广播该数据,汇总当前在线人数的点击速度,同步更新排名结果,冠军会有对应的动画以鼓励。

手速王通过环信MQTT实现了以下功能:
(1)实时数据同步:实现更新排名;
(2)控制指令:点击时触发爱心动画;
(3)实时消息传输:实时传输用户点击屏幕次数;
(4)在线人数:获取好友上下线动态,实时更新在线人数;
该作品主要实现实时图表更新功能,通过MQTT消息实现高频高并发消息传输,并通过发送控制命令触发动画特效,增强了趣味性。



作品3:在线打击乐

在线打击乐是一款基于mikutap的高颜值多人互动网页游戏,用户在开始游戏后,通过敲击键盘,拖拽鼠标等操作,实现打击乐的效果,并可与同时在线用户同步音效。


(在线打击乐demo演示)


在线打击乐通过环信MQTT实现了以下功能:
(1)实时同步:实时同步用户敲击键盘的音效;
通过MQTT消息实现了不同设备间的音效同步,达到很好的互动效果。
(2)控制指令:实时发送鼠标点击或敲击键盘等控制动作消息,从而触发不同的音效。

预览地址:https://www.easemob.com/product/mqtt/demo/onlinecombat



作品4:小说阅读室

小说阅读室是一款基于环信MQTT实现的多人在线阅读App,可与阅读同一本书的用户同步阅读进度,切换章节,同步小说的字体与背景等操作,同时基于环信MQTT实现了实时在线阅读的同时进行即时IM沟通,通过读书遇到志同道合的人,为阅读增加了趣味性和社交属性。



(小说阅读室demo演示)

小说阅读室App内置4部本地小说,同时支持从服务端下载并缓存小说,进入指定小说之后可依据个人阅读习惯,设置字体,背景,同时支持章节快速切换,退出阅读记忆上次位置等。

小说阅读室通过环信MQTT实现了以下功能:
(1)实时数据同步:同步阅读进度;
(2)控制指令:切换章节,小说的字体与背景等操作;
(3)实时消息传输:支持在线聊天功能;
(4)更新好友动态:获取好友上下线动态;
该作品基于小说阅读室,通过MQTT实现控制指令、更新好友上下线动态、在线聊天等功能,增强了阅读场景中的社交属性,提高了阅读趣味性。



作品5:互动画板

互动画板是一款多人在线实时绘画的小工具。



(互动画板demo演示)

互动画板的用户数据存储到每个客户端上,通过环信mqtt绘制实时同步,画笔每条路径通过mqtt通知到其他客户端,路径中包含当前用户数据,同时可以同步线上用户数量。除此外,清除功能也采用环信mqtt发送控制指令,对应的用户作图立即清除。

互动画板通过环信MQTT实现了以下功能:
(1)实时数据同步:同步绘画轨迹;
(2)控制指令:清空画板指令;
(3)在线人数:获取好友上下线动态,实时更新在线人数;
该作品主要实现实时互动功能,通过MQTT消息小,延迟低,高频高并发等特性,实现了多人多端绘画轨迹0延迟同步,提升了互动绘画体验。



除以上作品外,还有很多优秀作品由于篇幅原因无法一一呈现,感兴趣的小伙伴可以前往官方Github仓库进行查看哦~

区块链空气币
多人实时位置共享APP
实时图表
在线选座
im聊天室(作者林鹏 - linpeng)
实时互动
热搜话题聊天室
在线点餐
基于Electron开发的喝水提醒
基于环信mqtt服务的shell远程命令执行
喝水提醒
基于环信MQTT的Serverless任务看板


Github地址:https://github.com/easemob/Creative-Challenge-MQTT
Gitee地址:
https://gitee.com/huanxin666
环信MQTT官方地址:
https://www.easemob.com/product/mqtt


收起阅读 »

flutter 优秀日志库 ulog

ulog ulog的想法和代码风格,设计方式与 Android logger库几乎无差别,差别在于ulog第一个版本不支持文件打印,但支持动态json库配置 库源码:github.com/smartbackme… v0.0.1只有基础的console打印,后面...
继续阅读 »

ulog


ulog的想法和代码风格,设计方式与 Android logger库几乎无差别,差别在于ulog第一个版本不支持文件打印,但支持动态json库配置
库源码:github.com/smartbackme…


v0.0.1只有基础的console打印,后面将会增加文件打印


开始使用


添加库
dependencies:
flutter_ulog: ^0.0.1


//Initialization
//构建基础adapter isLoggable可以通过不同type来拦截打印,或者关闭打印
class ConsoleAdapter extends ULogConsoleAdapter{
@override
bool isLoggable(ULogType type, String? tag) => true;
}
//初始化配置json库
ULog.init((value){
return "";
});
//添加打印适配器
ULog.addLogAdapter(ConsoleAdapter());

输出基别


  verbose
debug
info
warning
error

如何输出
ULog.v("12321321\ndfafdasfdsa\ndafdasf");
ULog.d("12321321");
ULog.i("12321321");
ULog.w("12321321");
ULog.e("1321231",error: NullThrownError());
var map = [];
map.add("1232");
map.add("1232");
map.add("1232");
map.add("1232");
ULog.e(map,error: NullThrownError());
ULog.json('''
{
"a1": "value",
"a2": 42,
"bs": [
{
"b1": "any value",
"b2": 13
},
{
"b1": "another value",
"b2": 0
}
]
}
''');

ULog.e("1321231",error: NullThrownError(),tag: "12312");
ULog.e("1232132112321321x");


优点:



  1. 可打印json字符串

  2. 打印行数很多时候会自动折行

  3. 可以打印模型

  4. 颜色区分

  5. 可扩展性强


打印效果:
打印分级
在这里插入图片描述
json打印
在这里插入图片描述


折行打印
在这里插入图片描述



收起阅读 »

iOS底层-内存对齐

iOS
一、什么是内存对齐? 我们先看下以下例子: struct struct0 { int a; char c; }s; NSLog(@"s : %lu", sizeof(s)); //输出8 在32位下,int占4byte, char占1b...
继续阅读 »

一、什么是内存对齐?


我们先看下以下例子:


struct struct0 {
int a;
char c;
}s;

NSLog(@"s : %lu", sizeof(s)); //输出8

32位下int4bytechar1byte,那么放到结构体应该是4+1=5byte啊,但实际得到的结果却是8,这就是内存对齐导致的。



元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。 从结构体存储的 首地址开始,每个元素放置到内存中时,它都会认为内存是按照自己的大小(通常它为4或8)来划分的,因此元素放置的位置一定会在自己宽度的整数倍上开始,这就是所谓的内存对齐



二、为什么要进行内存对齐?


1.平台限制



  • 各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取,它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存。


2.性能原因



  • 例如在32位下的int型变量,如果按照对齐的方式,读一次就可以读出,如果忽略对齐,int存放在奇数位或者不在4的倍数位上,则需要读两次,再进行拼接才能得到数据。

    • 例如,没有内存对齐机制,一个int变量从地址1开始存储,如下图:




截屏2021-06-09 11.51.18.png
这种情况,处理器需要:剔除不要的,再合并到寄存器,做了些额外的操作。而在内存对齐的环境中,一次就可以读出数据,提高了效率


三、内存对齐规则


1. 概念:



  • 每个特定平台上的编译器都有自己的默认对齐系数(也叫对齐模数)。可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数,这个n就是要制定的对齐系数

  • 有效对齐值:给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

  • Xcode中默认#pragma pack(8),也就是8字节对齐


2. 规则:



  • (1): 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节

  • (2): 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节


3.举例:


结构体


环境(Xcode 12.3)

例一


struct Struct1 {
double a;
char b;
int c;
short d;
}s1;

分析



  • 对齐系数为:pack(8),也就是8字节,最长的数据类型是:double,也是8字节,则 有效对齐值min(8,8) = 8

  • 根据规则(1)(2),可得出:


截屏2021-06-09 14.45.19.png



每个成员的大小都要和有效对齐值对比,取比较小的数,然后这个数的整数倍作为这个成员变量的offset起始存储位置,如果中间不是整数倍,则需要填充字节。成员变量存储完后,如果整体大小不是有效对齐值的整数倍,则需要在 末尾填充字节 至整体大小为有效对齐值的整数倍为止。



分析的结果是 24字节,我们打印来验证下:


截屏2021-06-09 13.25.26.png


打印的结果和我们分析的一样~


例二


struct Struct2 {
double a;
int b;
char c;
short d;
}s2;

printf("\n Struct1 size: %lu\n", sizeof(s2)); // 输出 16


这两个结构体成员变量都是一样的,只不过位置换了下,怎么内存大小也变了?啊这...



我们继续来分析下:


截屏2021-06-09 14.45.43.png
分析得到的结果是16字节,我们再来打印验证下:

截屏2021-06-09 13.40.57.png


原来结构体成员变量的顺序对内存也是有影响的


例三


struct Struct3 {
double a; // 8 [0,7]
int b; // 4 [8,11]
char c; // 1 [12]
short d; // 2 (13, [14,15]
int e; // 4 [16, 19]
struct Struct1 {
double a; // 8 (20, 21, 22, 23, [24, 31]
char b; // 1 [32]
int c; // 4 (33, 34, 35 [36, 39]
short d; // 2 [40, 41],42, 43, 44, 45, 46, 47, 48
}s1;
}s3;

分析



  • 对齐系数为:pack(8) = 8Struct1中最大为8字节Struct3其他的成员最大为8字节, 则Struct3 有效对齐值min(pack(8),8) = 8


截屏2021-06-09 17.02.54.png
打印验证下:


截屏2021-06-09 15.30.12.png

结果是正确的~


可能存在的误区


  1. offset:计算成员的offset时,有可能会将成员大小的整数倍当做其offset,这个是错误的,offset是由有效对齐值成员大小 二者的最小值的整数倍决定的

  2. 整体大小:整体大小(如果需要补充字节,就是补充后的)是最大成员的整数倍,这是不准确的。整体大小是有效对齐值的整数倍


我们在举一个例子:


32位下


// 32 位下对齐系数 pack(4)
struct Struct4 {
int a; // 4 [0,3]
double b; // 8 [4,11]
}s4;

printf("\n\n Struct4 size: %lu\n", sizeof(s4)); // 输出 12

64位下


// 64 位下对齐系数 pack(8)
struct Struct4 {
int a; // 4 [0,3]
double b; // 8 (4, 5, 6, 7, [8, 15]
}s4;

printf("\n\n Struct4 size: %lu\n", sizeof(s4)); // 输出 16

打印如下:


image_2021-06-09_17-35-22.png




  • 32位中,Struct4有效对齐值min(pack(4), 8) = 4a从首地址开始,boffsetmin( min(pack(4), 8), 8) = 4,所以得出结果为12

  • 64位中,Struct4有效对齐值min(pack(8), 8) = 8a从首地址开始,boffsetmin( min(pack(8), 8), 8) = 8,所以得出结果为16



总结


  • 计算成员的offset是由有效对齐值成员大小二者的最小值的整数倍决定的。

  • 整体大小(如果需要补充字节,就是补充后的)是有效对齐值的整数倍。

  • 结构体成员的顺序不同会导致内存不同,而对象的本质就是一个结构体,我们可以调整对象属性的位置来达到内存优化的目的。


对象


获取对象内存大小的方式


  • sizeof

  • class_getInstanceSize

  • malloc_size


sizeof


  1. sizeof运算符,而不是一个函数

  2. sizeof传进来的是类型,用来计算这个类型占多大内存,这个在编译器编译阶段就会确定大小并直接转化成8 、16、24这样的常数,而不是在运行时计算。参数可以是数组、指针、类型、对象、结构体、函数等。

  3. 它的功能是:获得保证能容纳实现所建立的最大对象的字节大小


class_getInstanceSize


  • class_getInstanceSizeruntime提供的api,计算对象实际占用的内存大小,采用8字节对齐的方式进行运算。


malloc_size


  • malloc_size用来计算对象实际分配的内存大小,这个是由系统采用16字节对齐的方式运算的


可以通过下面代码测试:


@interface LGPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nickName;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;

@end
// LGPerson 中有4个属性 NSString *name,NSString *nickName,int age,long height
int main(int argc, const char * argv[]) {
LGPerson *person = [LGPerson alloc];
person.name = @"Cc";
person.nickName = @"KC";

NSLog(@"\n%lu\n - %lu\n - %lu\n", sizeof(person), class_getInstanceSize([LGPerson class]), malloc_size((__bridge const void *)(person)));
// 输出 8, 40, 48
return 0;
}

链接:https://juejin.cn/post/6971750424198152200

收起阅读 »

iOS底层-多线程之GCD(下)

iOS
前言 前面的文章讲述了同步和异步的底层分析步骤,今天来讲GCD实际的应用相关的函数及原理,主要是:栅栏函数,信号量,线程组和Dispatch_source 栅栏函数 栅栏函数有一个比较直接的效果:控制任务的执行顺序,导致同步的效果。 栅栏函数有两种: di...
继续阅读 »

前言


前面的文章讲述了同步和异步的底层分析步骤,今天来讲GCD实际的应用相关的函数及原理,主要是:栅栏函数信号量线程组Dispatch_source


栅栏函数



  • 栅栏函数有一个比较直接的效果:控制任务的执行顺序,导致同步的效果

  • 栅栏函数有两种:

    • dispatch_barrier_async

    • dispatch_barrier_sync




下面通过案例来分析他们的作用:


dispatch_barrier_async


先来看看异步栅栏的案例:


- (void)testAsync_barrier {
dispatch_queue_t concurrent = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT);
NSLog(@" 开始啦 ~ ");
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_async(concurrent, ^{
NSLog(@"——————— 大栅栏 ———————");
});
dispatch_async(concurrent, ^{
NSLog(@"3");
});
dispatch_async(concurrent, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 为了更直接的观察,在栅栏前面的异步函数里加上了sleep,打印结果如下:


截屏2021-08-18 14.07.21.png



  • 从打印结果可以看出来,栅栏函数拦住的是同一个线程中的任务,并 不会阻塞线程


dispatch_barrier_sync


再来看看同步栅栏:


- (void)testSync_barrier {
dispatch_queue_t concurrent = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT);
NSLog(@" 开始啦 ~ ");
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_sync(concurrent, ^{
NSLog(@"——————— 同步大栅栏 ———————");
});
dispatch_async(concurrent, ^{
NSLog(@"3");
});
dispatch_async(concurrent, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码

先看看打印结果:


截屏2021-08-18 14.14.17.png



  • 从结果中能够看出来,同步栅栏函数也拦住的是同一个队列中的任务,但 阻塞线程


全局队列栅栏


我们平常用的栅栏函数都是创建的并发队列,那么使用在全局队列使用呢?


- (void)testGlobalBarrier {
dispatch_queue_t global = dispatch_get_global_queue(0, 0);
NSLog(@" 开始啦 ~ ");
dispatch_async(global, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(global, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_async(global, ^{
NSLog(@"——————— 大栅栏 ———————");
});
dispatch_async(global, ^{
NSLog(@"3");
});

dispatch_async(global, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 打印结果如下:


截屏2021-08-18 14.27.05.png



  • 结果什么也没有拦住,说明全局队列比较特殊。



疑问:
1. 为什么栅栏函数可以控制任务
2. 为什么栅栏函数在全局队列不起作用




底层源码



  • dispatch_barrier_sync来分析,搜索跟流程最终会进入_dispatch_barrier_sync_f_inline方法


截屏2021-08-18 16.28.41.png



  • 通过符号断点调试,发现最终会走_dispatch_sync_f_slow方法,同时设置DC_FLAG_BARRIER标记,再跟进


截屏2021-08-18 16.40.18.png



  • 再根据下符号断点,确定走_dispatch_sync_invoke_and_complete_recurse方法,并且将DC_FLAG_BARRIER参数传入


截屏2021-08-18 16.42.27.png



  • 进入_dispatch_sync_function_invoke_inline方法能够发现是_dispatch_client_callout方法,也就是栅栏函数的调用,但我们研究的核心是为什么会控制任务,通过下符号断点发现,栅栏函数执行后会走_dispatch_sync_complete_recurse方法


截屏2021-08-18 16.46.26.png



  • 于是跟进_dispatch_sync_complete_recurse方法


static void
_dispatch_sync_complete_recurse(dispatch_queue_t dq, dispatch_queue_t stop_dq,
uintptr_t dc_flags)
{
bool barrier = (dc_flags & DC_FLAG_BARRIER);
do {
if (dq == stop_dq) return;
if (barrier) {
dx_wakeup(dq, 0, DISPATCH_WAKEUP_BARRIER_COMPLETE);
} else {
_dispatch_lane_non_barrier_complete(upcast(dq)._dl, 0);
}
dq = dq->do_targetq;
barrier = (dq->dq_width == 1);
} while (unlikely(dq->do_targetq));
}
复制代码


  • 此处是一个do-while循环,首先dc_flags传入的是DC_FLAG_BARRIER,所以(dc_flags & DC_FLAG_BARRIER)一定有值的,于是在循环中会走dx_wakeup,去唤醒队列中的任务,唤醒完成后也就是栅栏结束,就会走_dispatch_lane_non_barrier_complete函数,也就是继续栅栏之后的流程。

  • 再查看此时dx_wakeup函数,此时flag传入为DISPATCH_WAKEUP_BARRIER_COMPLETE


截屏2021-08-18 17.01.29.png


_dispatch_lane_wakeup



  • 并发流程_dispatch_root_queue_wakeup中,根据flag判断会走_dispatch_lane_barrier_complete方法:


截屏2021-08-18 17.06.05.png



  • 当串行或者栅栏时,会调用_dispatch_lane_drain_barrier_waiter阻塞任务,直到确认当前队列前面任务执行完就会继续后面任务的执行


_dispatch_root_queue_wakeup


void
_dispatch_root_queue_wakeup(dispatch_queue_global_t dq,
DISPATCH_UNUSED dispatch_qos_t qos, dispatch_wakeup_flags_t flags)
{
if (!(flags & DISPATCH_WAKEUP_BLOCK_WAIT)) {
DISPATCH_INTERNAL_CRASH(dq->dq_priority,
"Don't try to wake up or override a root queue");
}
if (flags & DISPATCH_WAKEUP_CONSUME_2) {
return _dispatch_release_2_tailcall(dq);
}
}
复制代码


  • 全局并发队列源码中什么没做,此时的栅栏相当于一个普通的异步并发函数没起作用,为什么呢?全局并发是系统创建的并发队列,如果阻塞可能会导致系统任务出现问题,所以在使用栅栏函数时,不能使用全部并发队列


总结





    1. 栅栏函数可以阻塞当前线程的任务,达到控制任务的效果,但只能在创建的并发队列中使用





    1. 栅栏函数也可以在多读单写的场景中使用





    1. 栅栏函数只能在当前线程使用,如果多个线程就会起不到想要的效果




信号量



  • 程序员的自我修养中这本书的26页,有对二元信号量的讲解,它只有01两个状态,多元信号量简称信号量

  • GCD中的信号量dispatch_semaphore_t中主要有三个函数:

    • dispatch_semaphore_create:创建信号

    • dispatch_semaphore_wait:等待信号

    • dispatch_semaphore_signal:释放信号




案例分析


- (void)testDispatchSemaphore {
dispatch_queue_t global = dispatch_get_global_queue(0, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

NSLog(@"~~ 开始 ~~");
dispatch_async(global, ^{
NSLog(@"~~ 0 ~~");
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"~~ 1 ~~");
});

dispatch_async(global, ^{
NSLog(@"~~ 2 ~~");
sleep(2);
dispatch_semaphore_signal(semaphore);
NSLog(@"~~ 3 ~~");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 运行结果如下:


截屏2021-08-18 19.52.23.png



  • 从打印结果中可以看出来,在先走了一个异步任务所以打印了0,但是由于没有信号,所以在dispatch_semaphore_wait就原地等待,导致1没法执行,此时第二个异步任务执行了就打印了2,然后dispatch_semaphore_signal释放信号,之后1就可以打印了


源码解读


再来看看源码分析


dispatch_semaphore_create


截屏2021-08-18 22.33.40.png



  • 首先如果信号为小于0,则返回一个DISPATCH_BAD_INPUT类型对象,也就是返回个_Nonnull

  • 如果信号大于等于0,就会对dispatch_semaphore_t对象dsema进行一些赋值,并返回dsema对象


dispatch_semaphore_wait


intptr_t
dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)
{
long value = os_atomic_dec2o(dsema, dsema_value, acquire);
if (likely(value >= 0)) {
return 0;
}
return _dispatch_semaphore_wait_slow(dsema, timeout);
}
复制代码


  • 等待信号主要是通过os_atomic_dec2o函数对信号进行自减,当值大于等于0时返回0

  • 当值小于0时,会走_dispatch_semaphore_wait_slow方法


_dispatch_semaphore_wait_slow

截屏2021-08-18 23.02.51.png




  • 当设置了timeout(超时时间),则会根据类型进行相关操作,本文使用的是DISPATCH_TIME_FOREVER,此时调用_dispatch_sema4_wait进行等待处理:


    截屏2021-08-18 23.10.06.png



    • 这里主要是一个do-while循环里队等待信号,当不满足条件后才会跳出循环,所以会出现一个等待的效果




dispatch_semaphore_signal


intptr_t
dispatch_semaphore_signal(dispatch_semaphore_t dsema)
{
long value = os_atomic_inc2o(dsema, dsema_value, release);
if (likely(value > 0)) {
return 0;
}
if (unlikely(value == LONG_MIN)) {
DISPATCH_CLIENT_CRASH(value,
"Unbalanced call to dispatch_semaphore_signal()");
}
return _dispatch_semaphore_signal_slow(dsema);
}
复制代码


  • 发送信号主要是通过os_atomic_inc2o对信号进行自增,如果自增后的结果大于0,就返回0

  • 如果自增后还是小于0,就会走到_dispatch_semaphore_signal_slow方法


_dispatch_semaphore_signal_slow

intptr_t
_dispatch_semaphore_signal_slow(dispatch_semaphore_t dsema)
{
_dispatch_sema4_create(&dsema->dsema_sema, _DSEMA4_POLICY_FIFO);
_dispatch_sema4_signal(&dsema->dsema_sema, 1);
return 1;
}
复制代码


  • 这里有个_dispatch_sema4_signal函数进行缓慢发送信号


截屏2021-08-18 23.14.05.png


调度组



  • 调度组最直接的作用就是:控制任务的执行顺序Api主要有以下几个方法:

    • dispatch_group_create:创建组

    • dispatch_group_async:进组任务

    • dispatch_group_notify:组任务执行完毕的通知

    • dispatch_group_enter:进组

    • dispatch_group_leave:出组

    • dispatch_group_wait:等待组任务时间




案例分析


dispatch_group_async


- (void)testDispatchGroup1 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
dispatch_group_async(group, globalQueue, ^{
sleep(1);
NSLog(@"1");
});

NSLog(@"2");
dispatch_group_async(group, globalQueue, ^{
sleep(1);
NSLog(@"3");
});

NSLog(@"4");
dispatch_group_notify(group, globalQueue, ^{
NSLog(@"5");
});

NSLog(@"~~ 6 ~~");
}
复制代码


  • 讲任务1和3放到组任务,然后将5任务放到dispatch_group_notify中执行,输出结果如下:


截屏2021-08-19 11.24.37.png



  • 输出结果可以得出结论:



      1. 调度组不会阻塞线程





      1. 组任务执行没有顺序,相当于异步并发队列





      1. 组任务执行完后才会执行dispatch_group_notify任务






dispatch_group_wait





    1. 将任务1和3分别放到两个任务组,然后在下面执行dispatch_group_wait等待10秒




- (void)testDispatchGroup1 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
dispatch_group_async(group, globalQueue, ^{
sleep(5);
NSLog(@"1");
});

NSLog(@"2");
dispatch_group_async(group, globalQueue, ^{
sleep(5);
NSLog(@"3");
});

dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 10);
dispatch_group_wait(group, time);

NSLog(@"~~ 6 ~~");
}
复制代码


  • 输出结果如下:


截屏2021-08-19 11.49.47.png

发现等待的是10秒,但5秒后任务执行完,立即执行任务6





    1. 在将等待时间改成3秒




截屏2021-08-19 11.53.34.png

这里是等3秒,发现组任务还没执行完就去执行6任务



  • 总结dispatch_group_wait的作用是阻塞调度组之外的任务



      1. 当等待时间结束时,组任务还没完成,就结束阻塞执行其他任务





      1. 当组任务完成,等待时间还未结束时,会结束阻塞执行其他任务






进组 + 出组


- (void)testDispatchGroup2 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);

dispatch_group_enter(group);
dispatch_async(globalQueue, ^{
sleep(1);
NSLog(@"1");
dispatch_group_leave(group);
});

NSLog(@"2");

dispatch_group_enter(group);
dispatch_async(globalQueue, ^{
sleep(1);
NSLog(@"3");
dispatch_group_leave(group);
});

dispatch_group_notify(group, globalQueue, ^{
NSLog(@"4");
});

NSLog(@"5");
}
复制代码


  • 进组+出组的案例基本和上面的一致,只是将进组任务拆分成进组+出组,执行结果如下:


截屏2021-08-19 11.29.38.png




  • 输出结果和dispatch_group_async基本一致,说明他们两个作用相同




  • 进组+出组组合有几个 注意事项,他们必须是成对出现,必须先进组后出组,否则会出现以下问题:





      1. 如果少一个dispatch_group_leave,则dispatch_group_notify不会执行




    截屏2021-08-19 11.40.04.png





      1. 如果少一个dispatch_group_enter,则任务执行完会走notify但不成对的dispatch_group_leave处会崩溃




    截屏2021-08-19 11.37.34.png





      1. 如果先leaveenter,此时会直接崩溃在leave




    截屏2021-08-19 11.43.49.png





疑问:
1. 调度组是如何达到流程控制的?
2. 为什么进组+出组要搭配使用,且效果和dispatch_group_async是一样的?
3. 为什么先dispatch_group_leave会崩溃?



带着问题我们去源码中探索究竟


原理分析


dispatch_group_creat


dispatch_group_t
dispatch_group_create(void)
{
return _dispatch_group_create_with_count(0);
}
复制代码

dispatch_group_create方法会调用_dispatch_group_create_with_count方法,并传入参数0


static inline dispatch_group_t
_dispatch_group_create_with_count(uint32_t n)
{
dispatch_group_t dg = _dispatch_object_alloc(DISPATCH_VTABLE(group),
sizeof(struct dispatch_group_s));
dg->do_next = DISPATCH_OBJECT_LISTLESS;
dg->do_targetq = _dispatch_get_default_queue(false);
if (n) {
os_atomic_store2o(dg, dg_bits,
(uint32_t)-n * DISPATCH_GROUP_VALUE_INTERVAL, relaxed);
os_atomic_store2o(dg, do_ref_cnt, 1, relaxed); // <rdar://22318411>
}
return dg;
}
复制代码


  • 方法的核心是创建dispatch_group_t对象dg,并对它的do_nextdo_targetq参数进行赋值,然后调用os_atomic_store2o进行存储


dispatch_group_enter


void
dispatch_group_enter(dispatch_group_t dg)
{
// The value is decremented on a 32bits wide atomic so that the carry
// for the 0 -> -1 transition is not propagated to the upper 32bits.
uint32_t old_bits = os_atomic_sub_orig2o(dg, dg_bits,
DISPATCH_GROUP_VALUE_INTERVAL, acquire);
uint32_t old_value = old_bits & DISPATCH_GROUP_VALUE_MASK;
if (unlikely(old_value == 0)) {
_dispatch_retain(dg); // <rdar://problem/22318411>
}
if (unlikely(old_value == DISPATCH_GROUP_VALUE_MAX)) {
DISPATCH_CLIENT_CRASH(old_bits,
"Too many nested calls to dispatch_group_enter()");
}
}
复制代码

主要调用os_atomic_sub_orig2o进行自减操作,也就是由0 -> -1,这块和信号量的操作很像,但没有wait的步骤


dispatch_group_leave


void
dispatch_group_leave(dispatch_group_t dg)
{
// The value is incremented on a 64bits wide atomic so that the carry for
// the -1 -> 0 transition increments the generation atomically.
uint64_t new_state, old_state = os_atomic_add_orig2o(dg, dg_state,
DISPATCH_GROUP_VALUE_INTERVAL, release);
uint32_t old_value = (uint32_t)(old_state & DISPATCH_GROUP_VALUE_MASK);

if (unlikely(old_value == DISPATCH_GROUP_VALUE_1)) {
old_state += DISPATCH_GROUP_VALUE_INTERVAL;
do {
new_state = old_state;
if ((old_state & DISPATCH_GROUP_VALUE_MASK) == 0) {
new_state &= ~DISPATCH_GROUP_HAS_WAITERS;
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
} else {
// If the group was entered again since the atomic_add above,
// we can't clear the waiters bit anymore as we don't know for
// which generation the waiters are for
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
}
if (old_state == new_state) break;
} while (unlikely(!os_atomic_cmpxchgv2o(dg, dg_state,
old_state, new_state, &old_state, relaxed)));
return _dispatch_group_wake(dg, old_state, true);
}

if (unlikely(old_value == 0)) {
DISPATCH_CLIENT_CRASH((uintptr_t)old_value,
"Unbalanced call to dispatch_group_leave()");
}
}
复制代码

通过os_atomic_add_orig2o进行自增(-1 ~ 0)得到old_state = 0


DISPATCH_GROUP_VALUE_MASK       0x00000000fffffffcULL
DISPATCH_GROUP_VALUE_1          DISPATCH_GROUP_VALUE_MASK
复制代码




    1. old_value等于old_state & DISPATCH_GROUP_VALUE_MASK等于0,此时old_value != DISPATCH_GROUP_VALUE_1,再由于判断是unlikely,所以会进入if判断





    1. 由于DISPATCH_GROUP_VALUE_INTERVAL = 4,所以此时old_state = 4,再进入do-while循环,此时会走else判断new_state &= ~DISPATCH_GROUP_HAS_NOTIFS= 4 & ~2 = 4,这时old_statenew_state相等,然后跳出循环执行_dispatch_group_wake方法,也就是唤醒dispatch_group_notify方法





    1. 假如enter两次,则old_state=-1,加上4后3,也就是在do-while循环时,new_state = old_state = 3。然后new_state &= ~DISPATCH_GROUP_HAS_NOTIFS = 3 & ~2 = 1,然后不等与old_state会再进行循环,当再次进行leave自增操作后,新的循环判断会走到_dispatch_group_wake函数进行唤醒




dispatch_group_notify


static inline void
_dispatch_group_notify(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_continuation_t dsn)
{
uint64_t old_state, new_state;
dispatch_continuation_t prev;

dsn->dc_data = dq;
_dispatch_retain(dq);

prev = os_mpsc_push_update_tail(os_mpsc(dg, dg_notify), dsn, do_next);
if (os_mpsc_push_was_empty(prev)) _dispatch_retain(dg);
os_mpsc_push_update_prev(os_mpsc(dg, dg_notify), prev, dsn, do_next);
if (os_mpsc_push_was_empty(prev)) {
os_atomic_rmw_loop2o(dg, dg_state, old_state, new_state, release, {
new_state = old_state | DISPATCH_GROUP_HAS_NOTIFS;
if ((uint32_t)old_state == 0) {
os_atomic_rmw_loop_give_up({
return _dispatch_group_wake(dg, new_state, false);
});
}
});
}
}
复制代码

主要是通过os_atomic_rmw_loop2o进行do-while循环判断,知道old_state == 0时就会走_dispatch_group_wake唤醒,也就是会去走block执行



  • 此时还有一个问题没有解决,就是dispatch_group_async为什么和进组+出组效果一样,再来分析下源码


dispatch_group_async


void
dispatch_group_async(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_block_t db)
{
dispatch_continuation_t dc = _dispatch_continuation_alloc();
uintptr_t dc_flags = DC_FLAG_CONSUME | DC_FLAG_GROUP_ASYNC;
dispatch_qos_t qos;

qos = _dispatch_continuation_init(dc, dq, db, 0, dc_flags);
_dispatch_continuation_group_async(dg, dq, dc, qos);
}
复制代码

这个函数内容比较熟悉,与异步的函数很像,但此时dc_flags = DC_FLAG_CONSUME | DC_FLAG_GROUP_ASYNC,然后走进_dispatch_continuation_group_async函数:


static inline void
_dispatch_continuation_group_async(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_continuation_t dc, dispatch_qos_t qos)
{
dispatch_group_enter(dg);
dc->dc_data = dg;
_dispatch_continuation_async(dq, dc, qos, dc->dc_flags);
}
复制代码

这里我们看到了dispatch_group_enter,但是没有看到leave函数,我猜想肯定在block执行的地方会执行leave,不然不能确保组里任务执行完,于是根据global类型的dq_push = _dispatch_root_queue_push最终找到_dispatch_continuation_invoke_inline函数


截屏2021-08-19 15.57.50.png


如果是普通异步类型会走到_dispatch_client_callout函数,如果是DC_FLAG_GROUP_ASYNC组类型,会走_dispatch_continuation_with_group_invoke函数


static inline void
_dispatch_continuation_with_group_invoke(dispatch_continuation_t dc)
{
struct dispatch_object_s *dou = dc->dc_data;
unsigned long type = dx_type(dou);
if (type == DISPATCH_GROUP_TYPE) {
_dispatch_client_callout(dc->dc_ctxt, dc->dc_func);
_dispatch_trace_item_complete(dc);
dispatch_group_leave((dispatch_group_t)dou);
} else {
DISPATCH_INTERNAL_CRASH(dx_type(dou), "Unexpected object type");
}
}
复制代码

此时,当类型是DISPATCH_GROUP_TYPE时,就会先执行_dispatch_client_callout,然后执行dispatch_group_leave,至此前面的问题全部解决


信号源Dispatch_source



  • 信号源Dispatch_source是一个尽量不占用资源,且CPU负荷非常小的Api,它不受Runloop影响,是和Runloop平级的一套Api。主要有以下几个函数组成:



      1. dispatch_source_create:创建信号源





      1. dispatch_source_set_event_handler:设置信号源回调





      1. dispatch_source_merge_data:源时间设置数据





      1. dispatch_source_get_data:获取信号源数据





      1. dispatch_resume:继续





      1. dispatch_suspend:挂起





  • 在任一线程上调用函数dispatch_source_merge_data后,会执行Dispatch Source事先定义好的句柄(可以理解为一个block),这个过程叫Custom event用户事件,是Dispatch Source支持处理的一种事件


信号源类型



  • 创建源
    dispatch_source_t
    dispatch_source_create(dispatch_source_type_t type,
    uintptr_t handle,
    uintptr_t mask,
    dispatch_queue_t _Nullable queue);
    复制代码


    • 第一个参数是dispatch_source_type_t类型的type,然后handlemask都是uintptr_t类型的,最后要传入一个队列



  • 使用方法:
    dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());
    复制代码


  • 源的类型dispatch_source_type_t



      1. DISPATCH_SOURCE_TYPE_DATA_ADD:用于ADD合并数据





      1. DISPATCH_SOURCE_TYPE_DATA_OR:用于按位或合并数据





      1. DISPATCH_SOURCE_TYPE_DATA_REPLACE跟踪通过调用dispatch_source_merge_data获得的数据的分派源,新获得的数据值将替换 尚未交付给源处理程序 的现有数据值





      1. DISPATCH_SOURCE_TYPE_MACH_SEND:用于监视Mach端口无效名称通知的调度源,只能发送没有接收权限





      1. DISPATCH_SOURCE_TYPE_MACH_RECV:用于监视Mach端口挂起消息





      1. DISPATCH_SOURCE_TYPE_MEMORYPRESSURE:用于监控系统内存压力变化





      1. DISPATCH_SOURCE_TYPE_PROC:用于监视外部进程的事件





      1. DISPATCH_SOURCE_TYPE_READ监视文件描述符以获取可读取的挂起字节的分派源





      1. DISPATCH_SOURCE_TYPE_SIGNAL监控当前进程以获取信号的调度源





      1. DISPATCH_SOURCE_TYPE_TIMER:基于计时器提交事件处理程序块的分派源





      1. DISPATCH_SOURCE_TYPE_VNODE:用于监视文件描述符中定义的事件的分派源





      1. DISPATCH_SOURCE_TYPE_WRITE监视文件描述符以获取可写入字节的可用缓冲区空间的分派源。






定时器


下面使用Dispatch Source来封装一个定时器


- (void)testTimer {

self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
dispatch_time_t startTime = dispatch_time(DISPATCH_TIME_NOW, 0);
dispatch_source_set_timer(self.timer, startTime, 1 * NSEC_PER_SEC, 0);

__block int a = 0;
dispatch_source_set_event_handler(self.timer, ^{
a++;
NSLog(@"a 的 值 %d", a);
});

dispatch_resume(self.timer);
self.isRunning = YES;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (self.isRunning) {
dispatch_suspend(self.timer);
// dispatch_source_cancel(self.timer);
self.isRunning = NO;
NSLog(@" 中场休息下~ ");
} else {
dispatch_resume(self.timer);
self.isRunning = YES;
NSLog(@" 继续喝~ ");
}
}
复制代码

输出结果如下:


截屏2021-08-19 18.34.34.png



  • 创建定时器dispatch_source_create时,一定要用属性或者实例变量接收,不然定时器不会执行

  • dispatch_source_set_timer的第二个参数start是从什么时候开始,第三个参数interval是时间间隔,leeway是计时器的纳秒偏差

  • 计时器停止有两种:

    • dispatch_resume:计时器暂停但一直在线,可以唤醒

    • dispatch_source_cancel:计时器释放,执行dispatch_resume唤醒,会崩溃




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

iOS底层-多线程之GCD(上)

iOS
iOS底层-多线程之GCD(上)前言说到多线程,我们肯定就不会忽视GCD,因为它用法比较简洁,Api也比较易懂,对于处理多个任务等都是比较简单的,接来下将对GCD进行总结和探究。简介GCD全称是Grand Central Dispatch,纯C语言Api,提供...
继续阅读 »

iOS底层-多线程之GCD(上)

前言

说到多线程,我们肯定就不会忽视GCD,因为它用法比较简洁,Api也比较易懂,对于处理多个任务等都是比较简单的,接来下将对GCD进行总结和探究。

简介

    1. GCD全称是Grand Central Dispatch,纯C语言Api,提供了非常多的强大函数
    1. GCD优势:
      1. GCD是苹果公司为多核的并行运算提出的解决方案
      1. GCD会自动利用更多的CPU内核(如:双核、四核)
      1. GCD自动管理线程的生命周期:创建线程调度任务销毁线程,程序员只需要告诉GCD想要执行什么任务,不需要编写任何线程管理代码

函数

  • 任务:GCD的任务使用block封装,block没有参数没有返回值

执行任务的函数:

  • 异步dispatch_async:不用等待当前语句执行完毕,就可以执行下一条语句
    • 会开启线程执行block的任务
    • 异步是多线程的代名词
  • 同步dispatch_sync:必须等待当前语句执行完毕,才会执行下一条语句
    • 不会开启线程
    • 在当前线程执行block任务

队列

队列分为串行队列并行队列,他们是一个数据结构,都遵循FIFO(先进先出)原则

串行队列

  • 串行队列在同一时间只能执行一个任务,如图所示:

截屏2021-08-08 18.03.50.png

  • 在根据FIFO原则先进先出,所以后面的任务必须等前面的任务执行完毕才能执行,就导致串行队列是顺序执行

并行队列

  • 并行队列是一次可以调度多个任务,但并不一定都能执行,线程的状态必须是runable时才能执行,所以先调度不一定先执行:

截屏2021-08-08 18.03.58.png

  • 如果可以看出,并行在同一时间能执行多个任务

案例分析

    1. 并发异步任务
- (void)textDemo1{
dispatch_queue_t queue = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT); //并发队列
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
NSLog(@"4");
});
NSLog(@"5");
}

  • 执行顺序是什么呢,分析如下:

    • 首先1在主线程且顺序执行,所以1最新打印
    • 然后进入dispatch_async异步函数的block块,块里先执行2,同步函数会阻塞4的打印,所以快中3在2和4中间
    • 5是看异步函数执行的速度,有可能在1后面,也可能在234后面
  • 所以可能的结果是15234125341235412345

    1. 串行异步任务
- (void)textDemo1{
dispatch_queue_t queue = dispatch_queue_create("wushuang.serial", NULL); //串行队列
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
NSLog(@"4");
});
NSLog(@"5");
}

  • 这个顺序回是怎样的呢,再来分析下
    • 首先1先执行
    • 然后执行dispatch_async块中的代码,块中的同步函数次时与第一种不一样,根据串行队列FIFO原则且是顺序执行,所以3执行的前提是dispatch_async函数执行完,但dispatch_async函数执行完的前提是块中dispatch_sync及之后代码能执行完,于是就出现了相互等待,造成了死锁

函数与关系

4种组合

函数与队列可以分为四种组合异步函数串行队列并发队列异步函数同步函数并发队列同步函数串行队列

    1. 异步函数串行队列开启线程,任务一个接着一个
    1. 异步函数并发队列开启线程,在当前线程执行任务,任务执行没有顺序,和cpu调度有关
    1. 同步函数并发队列不会开启线程,在当前线程执行任务,任务一个接着一个
    1. 同步函数串行队列不会开启线程,在当前线程执行任务,任务一个接着一个执行,会产生阻塞

主队列和全局队列

  • 主队列:专门在主线程上调度任务的串行队列不会开启线程,如果当前主线程正在执行任务,那么无论主队列中当前被添加了什么任务,都不会被调度dispatch_get_main_queue()
  • 全局队列:为了方便程序员的使用,苹果提供了全局队列dispatch_get_global_queue(0,0),全局队列是并发队列,在使用多线程时,如果对队列没有特殊要求,在执行异步任务时,可以直接使用全局队列
案例
- (void)viewDidLoad {
[super viewDidLoad];

dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"会来吗?");
});
}

  • ViewDidLoad中执行主线程同步任务,那么会打印吗?结果产生死锁
  • 分析:因为ViewDidLoad,在主线程上,主线程上调度任务的是主队列,主队列遵循FIFO原则,而要执行该同步任务也在主队列执行,所以必须等ViewDidLoad函数执行完才能执行,而ViewDidLoad函数执行完的前提是该同步任务执行完,所以就产生了相互等待,产生死锁

图解总结

截屏2021-08-08 22.17.19.png

队列源码分析

根据队列的介绍,我们知道有2种队列:串行并发,其中主队列是特殊的串行队列,全局队列是特殊的并发队列,那么在他们是怎么区分的呢?我们去打印堆栈看看:

截屏2021-08-09 16.16.42.png

主队列

在源码中搜索dispatch_get_main_queue

/
The main queue is meant to be used in application context to interact with the main thread and the main runloop.

Returns the main queue. This queue is created automatically on behalf of the main thread before main() is called.
*/

dispatch_queue_main_t
dispatch_get_main_queue(void)
{
return DISPATCH_GLOBAL_OBJECT(dispatch_queue_main_t, _dispatch_main_q);
}

  • 根据注释可知:

    1. 主队列与程序的主线程runloop进行交互
    2. 主队列在main()之前程序自动创建的
  • 根据代码可知它的核心是调用了DISPATCH_GLOBAL_OBJECT函数,其中的有两个参数,第一个是类型,再来看看第二个参数_dispatch_main_q,搜索得到:

struct dispatch_queue_static_s _dispatch_main_q = {
DISPATCH_GLOBAL_OBJECT_HEADER(queue_main),
#if !DISPATCH_USE_RESOLVERS
.do_targetq = _dispatch_get_default_queue(true),
#endif
.dq_state = DISPATCH_QUEUE_STATE_INIT_VALUE(1) |
DISPATCH_QUEUE_ROLE_BASE_ANON,
.dq_label = "com.apple.main-thread",
.dq_atomic_flags = DQF_THREAD_BOUND | DQF_WIDTH(1),
.dq_serialnum = 1,
};

  • 根据观察可发现队列的区分可能与dq_atomic_flagsdq_serialnum两个参数有关,值分别为DQF_THREAD_BOUND | DQF_WIDTH(1)1

全局队列

  • 再搜索dispatch_get_global_queue
dispatch_queue_global_t
dispatch_get_global_queue(intptr_t identifier, uintptr_t flags);

dispatch_queue_global_t
dispatch_get_global_queue(intptr_t priority, uintptr_t flags)
{
dispatch_assert(countof(_dispatch_root_queues) ==
DISPATCH_ROOT_QUEUE_COUNT);

if (flags & ~(unsigned long)DISPATCH_QUEUE_OVERCOMMIT) {
return DISPATCH_BAD_INPUT;
}
dispatch_qos_t qos = _dispatch_qos_from_queue_priority(priority);
#if !HAVE_PTHREAD_WORKQUEUE_QOS
if (qos == QOS_CLASS_MAINTENANCE) {
qos = DISPATCH_QOS_BACKGROUND;
} else if (qos == QOS_CLASS_USER_INTERACTIVE) {
qos = DISPATCH_QOS_USER_INITIATED;
}
#endif
if (qos == DISPATCH_QOS_UNSPECIFIED) {
return DISPATCH_BAD_INPUT;
}
return _dispatch_get_root_queue(qos, flags & DISPATCH_QUEUE_OVERCOMMIT);
}

  • identifier可以设置一些优先级,有四种优先级:

    1. DISPATCH_QUEUE_PRIORITY_HIGH
    2. DISPATCH_QUEUE_PRIORITY_DEFAULT
    3. DISPATCH_QUEUE_PRIORITY_LOW
    4. DISPATCH_QUEUE_PRIORITY_BACKGROUND
  • flags是留给将来使用,任务非0值都可能导致NULL,通常传0

  • 这里返回的是_dispatch_get_root_queue函数的调用

static inline dispatch_queue_global_t
_dispatch_get_root_queue(dispatch_qos_t qos, bool overcommit)
{
if (unlikely(qos < DISPATCH_QOS_MIN || qos > DISPATCH_QOS_MAX)) {
DISPATCH_CLIENT_CRASH(qos, "Corrupted priority");
}
return &_dispatch_root_queues[2 * (qos - 1) + overcommit];
}

  • 再继续查看_dispatch_root_queues函数:

截屏2021-08-09 16.53.42.png

此时找到了与dq_atomic_flags中相关参数DQF_WIDTH,传入的值为DISPATCH_QUEUE_WIDTH_POOL,也就是DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 1),但不能确定dq_serialnum,它的值跟label有关,再来打印下_dispatch_root_queueslabel

globalQueue: <OS_dispatch_queue_global: com.apple.root.default-qos>

  • 于是根据label在源码中确定dq_serialnum10,目前还是不能确定那个队列的区分和哪个参数有关

自定义队列

  • 再来搜索队列的创建dispatch_queue_create,得到:
dispatch_queue_t
dispatch_queue_create(const char *label, dispatch_queue_attr_t attr)
{
return _dispatch_lane_create_with_target(label, attr,
DISPATCH_TARGET_QUEUE_DEFAULT, true);
}

  • 在搜索返回函数_dispatch_lane_create_with_target
static dispatch_queue_t
_dispatch_lane_create_with_target(const char *label, dispatch_queue_attr_t dqa,
dispatch_queue_t tq, bool legacy) // tq NULL, legacy true
{
dispatch_queue_attr_info_t dqai = _dispatch_queue_attr_to_info(dqa); // 面向对象封装

...

dispatch_lane_t dq = _dispatch_object_alloc(vtable,
sizeof(struct dispatch_lane_s)); // 开辟内存
_dispatch_queue_init(dq, dqf, dqai.dqai_concurrent ?
DISPATCH_QUEUE_WIDTH_MAX : 1, DISPATCH_QUEUE_ROLE_INNER |
(dqai.dqai_inactive ? DISPATCH_QUEUE_INACTIVE : 0)); // 初始化

dq->dq_label = label; //label赋值
dq->dq_priority = _dispatch_priority_make((dispatch_qos_t)dqai.dqai_qos,
dqai.dqai_relpri);
if (overcommit == _dispatch_queue_attr_overcommit_enabled) {
dq->dq_priority |= DISPATCH_PRIORITY_FLAG_OVERCOMMIT;
}
if (!dqai.dqai_inactive) {
dispatch_queue_priority_inherit_from_target(dq, tq);
dispatch_lane_inherit_wlh_from_target(dq, tq);
}
_dispatch_retain(tq);
dq->do_targetq = tq;
_dispatch_object_debug(dq, "%s", __func__);
return _dispatch_trace_queue_create(dq)._dq; // 创建痕迹标识,方便查找
}

  • 该函数第一个参数label我们比较熟悉,就是创建的线程的名字
  • _dispatch_queue_attr_to_info传入的第二参数:
dispatch_queue_attr_info_t
_dispatch_queue_attr_to_info(dispatch_queue_attr_t dqa)
{
dispatch_queue_attr_info_t dqai = { }; // 初始为NULL,
if (!dqa) return dqai;

...
}
  • 这里先初始一个dispatch_queue_attr_info_t类型对象,然后在根据dpa类型进行相关赋值,如果dqa为不存在则直接返回,这就是串行可以传NULL的原因
  • 做好相关的准备工作后,接着在调用_dispatch_object_alloc方法对线程开辟内存
  • 再调用初始化函数_dispatch_queue_init,此处第三个参数有判断是否并判断,如果是并发传入为DISPATCH_QUEUE_WIDTH_MAX,串行则传入1,继续查看方法的实现:

截屏2021-08-09 17.32.24.png

  • 此处可以看出又出现了DQF_WIDTH()函数和dq_serialnum,并发DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 2),串行为DQF_WIDTH(1),但根据队列类型传入的参数只和DQF_WIDTH有关,那么dq_serialnum是什么呢?
  • 搜索_dispatch_queue_serial_numbers
unsigned long volatile _dispatch_queue_serial_numbers =
DISPATCH_QUEUE_SERIAL_NUMBER_INIT;

  • 然后再搜索DISPATCH_QUEUE_SERIAL_NUMBER_INIT
// skip zero
// 1 - main_q
// 2 - mgr_q
// 3 - mgr_root_q
// 4,5,6,7,8,9,10,11,12,13,14,15 - global queues
// 17 - workloop_fallback_q
// we use 'xadd' on Intel, so the initial value == next assigned
#define DISPATCH_QUEUE_SERIAL_NUMBER_INIT 17

  • 根据注释得知1代表 main_14~15 代码 global queues,但能区分队列吗,还得看看os_atomic_inc_orig函数的实现:
#define os_atomic_inc_orig(p, m) \
os_atomic_add_orig((p), 1, m)


#define os_atomic_add_orig(p, v, m) \
os_atomic_c11_op_orig((p), (v), m, add, +)


#define _os_atomic_c11_op_orig(p, v, m, o, op) \
atomic_fetch_##o##_explicit(_os_atomic_c11_atomic(p), v, \
memory_order_##m)


  • 最终得到C++方法atomic_fetch_add_explicit方法,网页搜索:

截屏2021-08-09 17.48.29.png

  • 原来是原子相关的操作,没啥用

总结:
1. 串行队列:DQF_WIDTH(1)
2. 全局队列:DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 1)
3. 创建的并发队列:DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 2)

队列的继承

  • 在队列开辟内存时调用的是_dispatch_object_alloc方法,为什么不是_dispatch_dispatch_alloc?接下来根据队列的类型进行分析下

  • 先搜索队列的类型dispatch_queue_t

DISPATCH_DECL(dispatch_queue);

  • 再查看DISPATCH_DECL的实现:
#define DISPATCH_DECL(name) \
typedef struct name##_s : public dispatch_object_s {} *name##_t


  • 根据传入的参数dispatch_queue,得到:
struct dispatch_queue_s : public dispatch_object_s {} *dispatch_queue_t

  • 于是得到队列的继承关系:dispatch_queue_t : dispatch_queue_s : dispatch_object_s
收起阅读 »

LeakCanary源码分析

LeakCanary使用 LeakCanary是一个用于Android的内存泄漏检测库.本文从如下四点分析源码 检查哪些内存泄漏 检查内存泄漏的时机 如何判定内存泄漏 如何分析内存泄漏(只有一点点,可能跟没有一样) 内存泄漏误报 1.检查哪些内存泄漏 A...
继续阅读 »

LeakCanary使用


LeakCanary是一个用于Android的内存泄漏检测库.本文从如下四点分析源码



  • 检查哪些内存泄漏

  • 检查内存泄漏的时机

  • 如何判定内存泄漏

  • 如何分析内存泄漏(只有一点点,可能跟没有一样)

  • 内存泄漏误报


1.检查哪些内存泄漏


AddWatchers.png
AppWatcherInstaller继承于ContentProvider,调用时机是介于Application的attachBaseContext(Context)和 onCreate() 之间.通过这种方式初始化.


方法2manualInstall实现了默认参数watchersToInstall,通过这个方法我们看到Activity,FragmentAndViewModel,RootView,Service四个观察者


fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}

2.检查内存泄漏的时机


2.1 ActivityWatcher


activity触发OnDestory检查是否回收Activity实例


private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy() callback"
)
}
}

2.2 FragmentAndViewModelWatcher


fragment触发onFragmentDestroyed或onFragmentViewDestroyed检查是否可以回收Fragment实例

viewModel触发onClear检查是否可以回收ViewModel实例


123.png


2.2.1 检查哪些Fragment


由于Android现在有三种Fragment

androidx.fragment.app

android.app.fragment

android.support.v4.app.Fragment

leakCanary通过反射先去检查是否引入上面三种Fragment,如果有就反射创建对应的watcher加入到
fragmentDestroyWatchers中


private fun getWatcherIfAvailable(
fragmentClassName: String,
watcherClassName: String,
reachabilityWatcher: ReachabilityWatcher
): ((Activity) -> Unit)? {

return if (classAvailable(fragmentClassName) &&
classAvailable(watcherClassName)
) {
val watcherConstructor =
Class.forName(watcherClassName).getDeclaredConstructor(ReachabilityWatcher::class.java)
@Suppress("UNCHECKED_CAST")
watcherConstructor.newInstance(reachabilityWatcher) as (Activity) -> Unit
} else {
null
}
}

2.2.2 Fragment内存泄漏检查时机


(1)application注册activity生命周期回调

(2)当监听到ctivity被创建时,获取该activity的对应的fragmentManager创建fragment的生命周期观察者

(3)当onFragmentViewDestroyed/onFragmentDestroyed触发时,遍历集合然后检查是否可以回收Fragment实例


private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

override fun onFragmentViewDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
val view = fragment.view
if (view != null) {
reachabilityWatcher.expectWeaklyReachable(
view, "${fragment::class.java.name} received Fragment#onDestroyView() callback " +
"(references to its views should be cleared to prevent leaks)"
)
}
}

override fun onFragmentDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
reachabilityWatcher.expectWeaklyReachable(
fragment, "${fragment::class.java.name} received Fragment#onDestroy() callback"
)
}
}

2.2.3 检查哪些ViewModel内存泄漏


既然fragment/activity被销毁了,fragment/activity对象被回收了,那么fragment/activity绑定的所有viewmodel实例也应该销毁,所以leakCanary增加了viewmodel的内存检查

(1)监听当activity被创建时,绑定一个间谍viewmodel实例


//AndroidXFragmentDestroyWatcher
override fun invoke(activity: Activity) {
if (activity is FragmentActivity) {
val supportFragmentManager = activity.supportFragmentManager
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
ViewModelClearedWatcher.install(activity, reachabilityWatcher)
}
}

(2)监听当fragment被创建时,绑定一个间谍viewmodel实例


//AndroidXFragmentDestroyWatcher##fragmentLifecycleCallbacks
override fun onFragmentCreated(
fm: FragmentManager,
fragment: Fragment,
savedInstanceState: Bundle?
) {
ViewModelClearedWatcher.install(fragment, reachabilityWatcher)
}

2.2.4 ViewModel内存泄漏检查时机


(1)利用反射获得fragment/activity绑定的viewModel集合

(2)当leakcanary绑定的viewmodel生命周期走到onCleared时,就去检查所有viewmodel实例是否可以回收(这边就是为啥作者取名叫spy)


//ViewModelClearedWatcher
override fun onCleared() {
viewModelMap?.values?.forEach { viewModel ->
reachabilityWatcher.expectWeaklyReachable(
viewModel, "${viewModel::class.java.name} received ViewModel#onCleared() callback"
)
}
}

2.3 RootViewWatcher


view触发onViewDetachedFromWindow检查是否回收View实例

利用Curtains获得视图变化,检查所有被添加到phoneWindow上面的,windowLayoutParams.title为Toast或者是Tooltip,或者除PopupWindow之外的所有view.


//RootViewWatcher
rootView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {

val watchDetachedView = Runnable {
reachabilityWatcher.expectWeaklyReachable(
rootView, "${rootView::class.java.name} received View#onDetachedFromWindow() callback"
)
}

override fun onViewAttachedToWindow(v: View) {
WindowManager.LayoutParams.TYPE_PHONE
mainHandler.removeCallbacks(watchDetachedView)
}

override fun onViewDetachedFromWindow(v: View) {
mainHandler.post(watchDetachedView)
}
})

2.4 ServiceWatcher


service触发onDestroy检查是否回收Service实例


private fun onServiceDestroyed(token: IBinder) {
servicesToBeDestroyed.remove(token)?.also { serviceWeakReference ->
serviceWeakReference.get()?.let { service ->
reachabilityWatcher.expectWeaklyReachable(
service, "${service::class.java.name} received Service#onDestroy() callback"
)
}
}
}

3.如何判定内存泄漏


234.png
ReferenceQueue : 引用队列,在检测到适当的可到达性更改后,垃圾回收器将已注册的引用对象添加到该队列中


(1)将待检查对象加入到weakReference和watchedObjects中


@Synchronized override fun expectWeaklyReachable(
watchedObject: Any,
description: String
) {
if (!isEnabled()) {
return
}
removeWeaklyReachableObjects()
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference =
KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
SharkLog.d {
"Watching " +
(if (watchedObject is Class<*>) watchedObject.toString() else "instance of ${watchedObject.javaClass.name}") +
(if (description.isNotEmpty()) " ($description)" else "") +
" with key $key"
}

watchedObjects[key] = reference
checkRetainedExecutor.execute {
moveToRetained(key)
}
}

(6)执行GC后,遍历ReferenceQueue,删除watchedObjects集合中保存的对象


private fun removeWeaklyReachableObjects() {
// WeakReferences are enqueued as soon as the object to which they point to becomes weakly
// reachable. This is before finalization or garbage collection has actually happened.
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
watchedObjects.remove(ref.key)
}
} while (ref != null)
}

(3)判断watchedObjects长度是否发生改变,如果改变就认为内存泄漏


private fun checkRetainedCount(
retainedKeysCount: Int,
retainedVisibleThreshold: Int,
nopeReason: String? = null
): Boolean {
val countChanged = lastDisplayedRetainedObjectCount != retainedKeysCount
...
if (retainedKeysCount < retainedVisibleThreshold) {
if (applicationVisible || applicationInvisibleLessThanWatchPeriod) {
if (countChanged) {
onRetainInstanceListener.onEvent(BelowThreshold(retainedKeysCount))
}
showRetainedCountNotification(
objectCount = retainedKeysCount,
contentText = application.getString(
R.string.leak_canary_notification_retained_visible, retainedVisibleThreshold
)
)
scheduleRetainedObjectCheck(
delayMillis = WAIT_FOR_OBJECT_THRESHOLD_MILLIS
)
return true
}
}
return false
}

(10) 当检查到5次内存泄漏就会生成hprof文件


override fun dumpHeap(): DumpHeapResult {
...
val durationMillis = measureDurationMillis {
Debug.dumpHprofData(heapDumpFile.absolutePath)
}
...
}

4.如何分析内存泄漏


image.png
利用Shark分析工具分析hprof文件

(8)这里通过解析hprof文件生成heapAnalysis对象.SharkLog打印并存入数据库


override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) {
SharkLog.d { "\u200B\n${LeakTraceWrapper.wrap(heapAnalysis.toString(), 120)}" }

val db = LeaksDbHelper(application).writableDatabase
val id = HeapAnalysisTable.insert(db, heapAnalysis)
db.releaseReference()
...
}

5.内存泄漏误报


Java虚拟机的主流垃圾回收器采取的是可达性分析算法,
可达性算法是通过从GC root往外遍历,如果从root节点无法遍历该节点表明该节点对应的对象处于可回收状态.
反之不会回收.


public class MainActivity2 extends FragmentActivity {
Fragment mFragmentA;
Fragment mFragmentB;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main2);
mFragmentA = new FragmentA();
mFragmentB = new FragmentB();
findViewById(R.id.buttona).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
replaceFragment(mFragmentA);
}
});
findViewById(R.id.buttonb).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
replaceFragment(mFragmentB);
}
});
}
private void replaceFragment(Fragment fragment) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.container, fragment).commit();
}
}

以fragment为例,leakcanary认为fragment走onDestory了,就应该释放fragment.但是这种情况真的是内存泄漏么?


    ├─ com.example.MainActivity2 instance
│ Leaking: NO (Activity#mDestroyed is false)
│ ↓ MainActivity2.mFragmentA
│ ~~~~~~~~~~
╰→ com.example.FragmentA instance
Leaking: YES (ObjectWatcher was watching this because com.example.FragmentA
received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
key = 216c8cf8-2cdb-4509-84e9-8404afefffeb
watchDurationMillis = 3804
retainedDurationMillis = -1
key = eaa41c88-bccb-47ac-8fb7-46b27dec0356
watchDurationMillis = 6113
retainedDurationMillis = 1112
key = 77d5f271-382b-42ec-904b-1e8a6d4ab097
watchDurationMillis = 7423
retainedDurationMillis = 2423
key = 8d79952f-a300-4830-b513-62e40cda8bba
watchDurationMillis = 15771
retainedDurationMillis = 10765
13858 bytes retained by leaking objects
Signature: f1d17d3f6aa4713d4de15a4f465f97003aa7

根据堆栈信息,leakcanary认为fragmentA走了onDestory应该要回收这个fragmentA对象,但是发现还被MainActivity2对象持有无法回收,然后判定是内存泄漏. 放在我们这个逻辑里面,fragment不释放是对的.
只不过这种实现不是内存最佳罢了.


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

内联函数产生原因和原理

为什么要内联函数因为: Kotlin为了书写简单,所以引入了lambda。但是: lambda会造成性能消耗。所以: 引入了内联函数来解决这个问题。如何证明lambda书写简单我们来实现个需求,diff一下有lambda和无lambda的代码便知。需求: 实现...
继续阅读 »

为什么要内联函数

  • 因为: Kotlin为了书写简单,所以引入了lambda。

  • 但是: lambda会造成性能消耗。

  • 所以: 引入了内联函数来解决这个问题。

如何证明lambda书写简单

我们来实现个需求,diff一下有lambda和无lambda的代码便知。

需求: 实现一个函数回调,回调一个String给我。

Java版本(无lambda):

// 首先需要定义一个回调
public interface Action {
void click(String fuck);
}

// 然后定义这个方法,参数就是回调的接口
public void func(Action action) {
String str = "hello";
action.click(str);
}

// 最后调用它
public static void main(String[] args) {
// 这里需要创建一个匿名类
func(new Action() {
@Override
public void click(String fuck) {
System.out.println(fuck);
}
});
}

然后我们来看kotlin版:

// 直接定义方法,参数是个表达式函数
fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)
}

// 调用
fun main() {
// 参数直接传入lambda就完事,it是lambda的默认参数
func { println(it) }
}

没有对比就没有伤害,java费了十牛三虎之力写了好几行,kotlin短短几行就实现了,这就是lambda的优点: 简洁省事。其实说白了就是:不用创建对象了。

虽然可读性差了点,管它呢,反正看不懂也是别人的事,别人读不懂才能凸显我的不可替代性。

事实证明,lambda确实大大简化了代码的书写过程,我们不用敲创建对象的代码了

那么,lambda有什么缺点呢?

lambda的缺点

lambda的最大缺点就是性能损耗!

让我们反编译上述kotlin代码来看:

// 这个参数已经被替换成Function1了,这个Function1是kotlin中定义的一个接口
public static final void func(@NotNull Function1 action) {
Intrinsics.checkNotNullParameter(action, "action");
String str = "hello";
action.invoke(str);
}

// main函数
public static final void main() {
// 这里其实是创建了一个匿名类
func((Function1)null.INSTANCE);
}

我们看到,kotlin中的lambda最终会在编译期变成一个匿名类,这跟java好像没什么区别啊,都是生成一个匿名类。为什么说kotlin的lambda效率低,因为:kotlin创建匿名类是在编译期

而java在1.7之后就引入了invokedynamic指令,java中的lambda在编译期会被替换为invokedynamic指令,在运行期,如果invokedynamic被调用,就会生成一个匿名类来替换这个指令,后续调用都是用这个匿名类来完成

说白了,对于java来说,如果lambda不被调用,就不会创建匿名类。而对于kotlin来说,不管lambda是否被调用,都会提前创建一个匿名类。这就等价于:java把创建匿名类的操作后置了,有需要才搞,这就变相节省了开销。因为创建匿名类会增加类个数和字节码大小。

那么,kotlin为什么不也这么干呢,为什么非要在编译时 就提前做 将来不一定用到的东西呢?因为kotlin需要兼容java6,java6是目前Android的主要开发语言,而invokedynamic又是在java7之后引入的...,mmp!

那么,kotlin怎么擦好这个屁股呢?使用内联函数!

内联函数的实现原理

还是上述代码,我们把func改成内联的,如下:

fun main() {
func { print(it) }
}

// 方法用inline修饰了
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)
}

同样,我们反编译下看看:

// 这个函数没变化
public static final void func(@NotNull Function1 action) {
Intrinsics.checkNotNullParameter(action, "action");
String str = "hello";
action.invoke(str);
}

// 哦,调用方变了:直接把func函数体拷贝过来了,six six six
public static final void main() {
String str$iv = "hello";
System.out.print(str$iv);
}

我们看到,添加了inline后,kotlin会直接把被调用函数的函数体,复制到调用它的地方。

这样就不用创建匿名对象了!而且,还少一次调用过程。因为调用匿名对象的函数,本身还多一次调用呢。比如:

// 内联前
public void test(){
A a = new a();
a.hello(); // 这里调用一次hello()
}

// 内联后
public void test(){
// a.hello()的代码直接拷贝进来,不用调hello()了!
}

所以,内联牛逼,万岁万岁万万岁。

但是,内联也有缺点!比如,我现在有个内联函数test(),里面有1000行代码,如果有10个地方调用它,那么就会把它复制到这10个地方,这一下就是10000行。。。这就导致class文件变相增大,进而导致apk变大,用户看见就不想下了。

怎么办呢,那就不内联!也就是说:根据函数的大小,以及被调用次数的多少,来决定是否需要内联

这是个业务的决策问题,这里不再废话。

内联函数的其他规则

好,我们来看下内联函数的一些规则。

内联函数的局限性

内联函数作为参数,只能传递给另一个内联函数。比如:

// func2是非内联的
fun func2(action: (String) -> Unit) {

}

// func是内联的
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// action此时是内联的,传递给非内联函数func2,就会报错
func2(action) // 报错
}

现在我们讲func2改为内联的:

// 将func2改为内联
inline fun func2(action: (String) -> Unit) {

}

// func是内联的
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// 将action传递给另一个内联函数func2,正常
func2(action) // ok
}

如果,不希望修改func2()为内联的怎么办呢,此时可以使用noinline修饰action参数:

// func2是非内联的
fun func2(action: (String) -> Unit) {

}

// func是内联的,但是action被标记为非内联的
inline fun func(noinline action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// action此时是非内联的,可以传递给非内联函数func2
func2(action) // ok
}

内联函数引的非局部返回

局部返回

我们知道,一般函数调用的返回都是局部的,比如:

// 这里直接return,也就是返回到调用它的地方
fun tReturn() {
return
}

fun func() {
println("before")
// 调用了toRetrun()
tReturn()
println("after")
}

// 测试
fun main() {
func()
}

结果如下:

before
after

这是正常的,因为func()函数先打印before,然后调用tReturn(),tReturn()入栈,执行return,tReturn()出栈,回到func()函数,接着向下打印after。

但是,如果将func()声明为内联的,然后将tReturn()作为参数传入,那么func()方法体就变了,比如:

// func声明为内联的,然后传入action参数
inline fun func(action: () -> Unit) {
println("before")
action.invoke()
println("after")
}

fun main() {
// 参数跟tReturn一样
func { return }
}

结果:

before

原理也很简单,因为参数action会被复制到func()函数中,也就合并为一个方法了,等价于:

inline fun func() {
println("before")
return // 这就是参数action的函数体,直接返回了
println("after")
}

这个不难理解,那么,如果不加inline,只是修改参数为action可以吗,比如:

// 这里没有加inline 参数一样是action
fun func(action: () -> Unit) {
println("before")
action.invoke()
println("after")
}

fun main() {
func { return } // 报错
}

这会直接报错:

Kotlin: 'return' is not allowed here

这是不允许的,因为它不知道你要return到哪个地方,但是可以这样写:

fun main() {
// return 添加了标记,标记为返回到func这个地方
func { return@func }
}

结果:

before
after

综上,一句话: 普通函数参数的return都是局部返回的,而内联函数是全局返回的

那么,怎么防备这种风险呢,或者说: 怎么让一个函数既可以内联,又不让它的参数有全局返回的return呢?比如:

inline fun func(action: () -> Unit) {
println("before")
action() // 希望这里不要有return,有就直接报错
println("after")
}

使用crossinline即可!我们修改函数如下:

// 参数用crossinline修饰
inline fun func(crossinline action: () -> Unit) {
println("before")
action()
println("after")
}

// 调用
fun main() {
func { return } // 报错: Kotlin: 'return' is not allowed here
func { return@func } // 正常
}

可以看到,corssinline在保证函数是内联的情况下,限制了全局返回

总结

  • kotlin为了书写简洁,引入了lambda
  • 但是lambda有性能开销
  • 性能开销在java7优化了,但是kotlin兼容java6,无法享受这个优化
  • 所以kotlin引入内联来解决这个问题
  • 内联是在编译期将被调用的函数拷贝到调用方的函数体,从而避免创建内部类
  • 使用inline可以将函数声明为内联的,内联函数参数是全局返回的
  • 使用noinline可以修饰函数参数为不内联
  • 使用crossinline可以修饰函数参数为内联,而且不能全局返回


作者:奔波儿灞取经
链接:https://juejin.cn/post/7008422441026322462
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

相比 XML , Compose 性能到底怎么样?

前言 最近Compose已经正式发布了1.0版本,这说明谷歌认为Compose已经可以用于正式生产环境了 那么相比传统的XML,Compose的性能到底怎么样呢? 本文主要从构建性能与运行时两个方面来分析Compose的性能,数据主要来源于:Jetpack C...
继续阅读 »

前言


最近Compose已经正式发布了1.0版本,这说明谷歌认为Compose已经可以用于正式生产环境了

那么相比传统的XML,Compose的性能到底怎么样呢?


本文主要从构建性能与运行时两个方面来分析Compose的性能,数据主要来源于:Jetpack Compose — Before and afterMeasuring Render Performance with Jetpack Compose , 想了解更多的同学可以直接点击查看


构建性能


Compose构建性能主要以 tivi 为例来进行说明

Tivi是一个开源的电影App,原本基于FragmentXML构建,同时还使用了DataBinding等使用了注解处理器的框架

后来迁移到使用Compose构建UI,迁移过程分为两步



  1. 第一步:迁移到NavigationFragment,每个FragmentUI则由Compose构建

  2. 第二步:移除Fragment,完全基于Compose实现UI


下面我们就对Pre-Compose,Fragments + Compose,Entirely Compose三个阶段的性能进行分析对比


APK体积


包体积是我们经常关注的性能指标之一,我们一起看下3个阶段的包体积对比


p1.png

p2.png

可以看出,TiviAPK 大小缩减了 46%,从 4.49MB 缩减到 2.39MB,同时方法数也减少了17%


值得注意的是,在刚开始在应用中采用Compose时,有时您会发现APK大小反而变大了

这是因为迁移没有完成,老的依赖没有完成移除,而新的依赖已经添加了,导致APK体积变大

而在项目完全迁移到Compose后,APK 大小会减少,并且优于原始指标。


代码行数


我们知道在比较软件项目时,代码行数并不是一个特别有用的统计数据,但它确实提供了对事物如何变化的一个观察指标。

我们使用cloc工具来计算代码行数


cloc . --exclude-dir=build,.idea,schemas

结果如下图所示:


p4.png
可以看出,在迁移到Compose后,毫无意外的,XML代码行减少了76%

有趣的是kotlin代码同样减少了,可能是因为我们可以减少很多模板代码,同时也可以移除之前写的一些View Helper代码


构建速度


随着项目的不断变大,构建速度是开发人员越来越关心的一个指标。

在开始重构之前,我们知道,删除大量的注解处理器会有助于提高构建速度,但我们不确定会有多少。


我们运行以下命令5次,然后取平均值


./gradlew --profile --offline --rerun-tasks --max-workers=4 assembleDebug

结果如下


p3.png

这里考虑的是调试构建时间,您在开发期间会更关注此时间。


在迁移到Compose前,Tivi 的平均构建时间为 108.71 秒。

在完全迁移到 Compose 后,平均构建时间缩短至 76.96 秒!构建时间缩短了 29%

构建时间能缩短这么多,当然不仅仅是Compose的功劳,在很大程度上受两个因素的影响:



  1. 一个是移除了使用注解处理器的DataBindingEpoxy

  2. 另一个是HiltAGP 7.0 中的运行速度更快。


运行时性能


上面我们介绍了Compose在构建时的性能,下面来看下Compose在运行时渲染的性能怎么样


分析前的准备


使用Compose时,可能有多种影响性能的指标



  • 如果我们完全在Compose中构建UI会怎样?

  • 如果我们对复杂视图使用Compose(例如用 LazyColumn 替换 RecyclerViews),但根布局仍然添加在XML

  • 如果我们使用Compose替换页面中一个个元素,而不是整个页面,会怎么样?

  • 是否可调试和R8编译器对性能的影响有多大?


为了开始回答这些问题,我们构建了一个简单的测试程序。

在第一个版本中,我们添加了一个包含50个元素的列表(其中实际绘制了大约 12 个)。该列表包括一个单选按钮和一些随机文本。


p5.jpeg

为了测试各种选项的影响,我们添加以下4种配置,以下4种都是开启了R8同时关闭了debug



  1. 纯Compose

  2. 一个XML中,只带有一个ComposeView,具体布局写在Compose

  3. XML中只包含一个RecyclerView,但是RecyclerView的每一项是一个ComposeView

  4. XML


同时为了测试build type对性能的影响,也添加了以下3种配置



  1. Compose,关闭R8并打开debug

  2. Compose,关闭R8并关闭debug

  3. XML,关闭R8并打开debug


如何定义性能?


Compose运行时性能,我们一般理解的就是页面启动到用户看到内容的时间

因此下面几个时机对我们比较重要



  1. Activity启动时间,即onCreate

  2. Activity启动完成时间,即onResume

  3. Activity渲染绘制完成时间,即用户看到内容的时间


onCreateonResume的时机很容易掌握,重写系统方法即可,但如何获得Activity完全绘制的时间呢?

我们可以给页面根View添加一个ViewTreeObserver,然后记录最后一次onDraw调用的时间


使用Profile查看上面说的过程,如下分别为使用XML渲染与使用Compose渲染的具体过程,即从OnCreate到调用最后一次onDraw的过程


使用XML

使用Compose


渲染性能分析


知道了如何定义性能,我们就可以开始测试了



  1. 每次测试都在几台设备上运行,包括最近的旗舰、没有Google Play服务的设备和一些廉价手机。

  2. 每次测试在同一台手机上都会运行10次,因此我们不仅可以获取首次渲染时间,也可以获取二次渲染时间

  3. 测试Compose版本为1.0.0


我们根据上面定义的配置,重复跑了多次,得到了一些数据,感兴趣的同学可以直接查看所有数据


p8.png

分析结果如上图所示,我们可以得出一些结论



  • R8和是否可调试对Jetpack Compose渲染时间产生了显着影响。在每次实验中,禁用R8和启用可调试性的构建所花费的时间是没有它们的构建的两倍多。在我们最慢的设备上,R8 将渲染速度加快了半秒以上,而禁用debug又使渲染速度加快了半秒。

  • XML中只包含一个ComposeView的渲染时间,跟纯Compose的耗时差不多

  • RecyclerView中包含多个ComposeView是最慢的。这并不奇怪,在XML中使用ComposeView是有成本的,所以页面中使用的ComposeView越少越好。

  • XML在呈现方面比Compose更快。没有办法解决这个问题,在每种情况下,Compose 的渲染时间比 XML 长约 33%。

  • 第一次启动总是比后续启动花费更长的时间来渲染。如果您查看完整的数据,第一个页面的渲染时间几乎是后续的两倍。


比较让我惊讶的是,尽管Compose没有了将XML转化成ViewIO操作,测量过程也因为固有特性测量提高了测量效率,但性能仍然比XML要差

不过,根据Leland Richardson说法,当从Google Play安装应用程序时,由于捆绑的AOT编译,Compose 在启动时渲染会更快,从而进一步缩小了与XML的差距


总结


经过上面对Compose性能方面的分析,总结如下



  1. 如果完全迁移到Compose,在包体积,代码行数,编译速度等方面应该会有比较大的改善

  2. 如果处于迁移中的阶段,可能因为旧的依赖没有去除,而已经引入了新的依赖,反而导致包体积等变大

  3. 尽管没有了XML转换的IO操作,测量过程也通过固有特性测量进行了优化,Compose的渲染性能比起XML仍然有一定差距

  4. 尽管目前Compose在性能方面略有欠缺(在大多数设备上仅超过一两帧),但由于其在开发人员生产力、代码重用和声明式UI的强大特性等方面的优势,Compose仍被推荐使用

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

Android知识点之Service(四)

2、service的生命周期,两种启动模式的区别 (1)、通过startService()方法来启动服务 生命周期:onCreate() -> onStart()或者onStartCommand() -> onDestroy() onStart()...
继续阅读 »

2、service的生命周期,两种启动模式的区别


(1)、通过startService()方法来启动服务

生命周期:onCreate() -> onStart()或者onStartCommand() -> onDestroy()


onStart()方法是在android 4.1以上版本废弃,采用onStartCommand()方法代替,当服务已经启动时,调用startService()方法不会重复调用onCreate()方法(只有在启动时调用一次),但会调用onStart()或者onStartCommand()方法,这在SystemServer进程的ActiveServices的bringUpServiceLocked方法中有体现:

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
//如果服务已经启动,则进入此判断
if (r.app != null && r.app.thread != null) {
sendServiceArgsLocked(r, execInFg, false);
return null;
}
····
}

private final void sendServiceArgsLocked(ServiceRecord r, boolean execInFg,
boolean oomAdjusted) throws TransactionTooLargeException {
····
//通过跨进程调用应用进程的ActivityThread的ApplicationThread中的scheduleServiceArgs启动服务
//最终调用到应用服务的onStartCommand()方法
r.app.thread.scheduleServiceArgs(r, slice);
····
}

当不使用服务时,调用stopService()来关闭,此时会调用服务的onDestroy()方法


特点:



  • 服务运行与注册者无关联,注册者退出服务不会退出,除非调用stopService()方法


(2)、通过bindService()方法绑定服务

生命周期:onCreate() -> onBind() -> onUnbind() -> onDestroy()


当服务已经绑定,通过unbindService()解绑(没有最终调用spotService()退出服务情况下),此时onUnbind()返回true,再通过bindService()绑定服务,此时不会再调用onBind(),而是调用onRebind(),生命周期如下:


onCreate() -> onBind() -> 调用unbindService()解绑 -> onUnbind(true) -> 调用bindService()绑定 -> onRebind() -> onUnbind() -> onDestroy()


绑定的服务退出,绑定者退出或者调用unbindService()解绑退出


特点:



  • 绑定者可以通过服务内部自定义的Binder实现类来持有服务并且调用服务中的方法

  • 绑定者退出,那么服务也会跟着退出


3、service与activity怎么实现通信


1、通过Intent方式从Activity发送数据给Service

2、使用绑定服务的ServiceConnection通过Binder进行

3、内容提供者、存储的方式

4、广播

5、socket通信

6、全局静态变量方式

7、反射注入的方式(eventBus)


4、IntentService是什么?IntentService的原理?应用场景以及与service的区别


IntentService是一个可以执行耗时操作的服务,内部维护着HandlerThread封装的子线程消息队列来执行耗时任务,在任务执行完时调用stopSelf()方法自动退出服务


原理:

frameworks/base/core/java/android/app/IntentService.java


public abstract class IntentService extends Service {
····
//定义一个Handler对象用于接收与发送消息
private final class ServiceHandler extends Handler {
public ServiceHandler(Looper looper) {
super(looper);
}

@Override
public void handleMessage(Message msg) {
//一个抽象方法,可以被继承者重写,运行在子线程消息队列中
onHandleIntent((Intent)msg.obj);
//任务执行完,自定关闭当前服务
stopSelf(msg.arg1);
}
}
·····

@Override
public void onCreate() {
super.onCreate();
//创建一个HandlerThread对象,HandlerThread是子线程消息循环队列
HandlerThread thread = new HandlerThread("IntentService[" + mName + "]");
thread.start();
mServiceLooper = thread.getLooper();
//创建一个子线程的Handler对象,发送的消息运行在子线程消息队列中
mServiceHandler = new ServiceHandler(mServiceLooper);
}

@Override
public void onStart(@android.annotation.Nullable Intent intent, int startId) {
//使用Handler向子线程消息循环队列发送一条消息
Message msg = mServiceHandler.obtainMessage();
msg.arg1 = startId;
msg.obj = intent;
mServiceHandler.sendMessage(msg);
}

@Override
public int onStartCommand(@android.annotation.Nullable Intent intent, int flags, int startId) {
//发送消息运行在子线程消息队列中
onStart(intent, startId);
return mRedelivery ? START_REDELIVER_INTENT : START_NOT_STICKY;
}

//抽象方法,运行在子线程消息队列中
protected abstract void onHandleIntent(@android.annotation.Nullable Intent intent);
}

应用场景:



  • 后台耗时操作

  • 短暂的耗时服务,如下载资源等


IntentService与service区别:



  • service不能直接执行耗时任务否则会引起ANR,IntentService可以执行耗时任务

  • service启动后需要手动调用stopService()关闭服务,IntentService启动后在执行完任何后会自动关闭服务


5、Service的onStartCommand方法有几种返回值?各代表什么意思?


有四种返回值:




  • START_STICKY:当某个服务被系统杀死时(不是正常结束服务),如果返回值为START_STICKY,则系统会尝试重启该服务,并且调用服务的onStartCommand()方法,但是onStartCommand()方法的Intent参数为Null。




  • START_NOT_STICKY:当某个服务被系统杀死时,如果返回值为START_NOT_STICKY,则系统不会重启该服务。




  • START_REDELIVER_INTENT:当某个服务被系统杀死时,如果返回值为START_REDELIVER_INTENT,则系统会尝试重启该服务,并且调用服务的onStartCommand()方法,并且会创建之前启动服务时传入的Intent,即onStartCommand()方法的Intent参数不为Null。




  • START_STICKY_COMPATIBILITY:这是START_STICKY的兼容版本,不能保证onStartCommand()方法一定会被重新调用。




6、bindService和startService混合使用的生命周期以及怎么关闭?



  • 调用startService()


生命周期 : onCreate() -> onStart()或者onStartCommand() -> onDestroy()

通过调用stopService()方法关闭服务



  • 调用bindService()


生命周期:onCreate() -> onBind() -> onUnbind() -> onDestroy()

通过调用unbindService()方法关闭服务



  • 先调用startService()后再调用bindService()


生命周期:onCreate() -> onStart()或者onStartCommand() -> onBind() -> onUnbind() -> onDestroy()

先调用stopService()再调用unbindService()关闭服务



  • 先调用bindService()后再调用startService()


生命周期:onCreate() -> onBind() -> onStart()或者onStartCommand() -> onUnbind() -> onDestroy()

先调用unbindService()再调用stopService()关闭服务



  • 先调用bindService()后调用unbindService(),最后调用bindService()


生命周期:onCreate() -> onBind() -> 调用unbindService()解绑 -> onUnbind(true) -> 调用bindService()绑定 -> onRebind() -> onUnbind() -> onDestroy()

通过调用unbindService()方法关闭服务


作者:小狼人爱吃萝卜
链接:https://juejin.cn/post/7008699606372450341
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android知识点之Service(三)

(3)、异进程服务启动绑定流程 由(1)和(2)可以知道,无论是启动服务还是绑定服务,最终是通过SystemServer进程中的ActiveServices对象的bringUpServiceLocked方法来执行,bringUpServiceLocked方法是...
继续阅读 »
(3)、异进程服务启动绑定流程

(1)和(2)可以知道,无论是启动服务还是绑定服务,最终是通过SystemServer进程中的ActiveServices对象的bringUpServiceLocked方法来执行,bringUpServiceLocked方法是进程判断与启动新进程的入口,当在应用的AndroidManifest.xml文件中Service设置为新进程执行,那么就会在bringUpServiceLocked方法中启动一个新的进程


a、SystemServer进程与Zygote进程通信流程

android应用层次的进程都是通过Zygote进程去卵化启动的,Service进程也是一样
frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
//启动一个新的进程
if (app == null && !permissionsReviewRequired) {
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
·····
return msg;
}
if (isolated) {
r.isolatedProc = app;
}
}
····
return null;
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final ProcessRecord startProcessLocked(String processName,
ApplicationInfo info, boolean knownToBeDead, int intentFlags,
HostingRecord hostingRecord, int zygotePolicyFlags, boolean allowWhileBooting,
boolean isolated, boolean keepIfLarge) {
//mProcessList是一个ProcessList对象
return mProcessList.startProcessLocked(processName, info, knownToBeDead, intentFlags,
hostingRecord, zygotePolicyFlags, allowWhileBooting, isolated, 0 /* isolatedUid */,
keepIfLarge, null /* ABI override */, null /* entryPoint */,
null /* entryPointArgs */, null /* crashHandler */);
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ProcessList.java


boolean startProcessLocked(ProcessRecord app, HostingRecord hostingRecord,
int zygotePolicyFlags, boolean disableHiddenApiChecks, boolean disableTestApiChecks,
boolean mountExtStorageFull, String abiOverride) {
····
//新进程启动的入口类,即是android.app.ActivityThread类的main方法
final String entryPoint = "android.app.ActivityThread";
return startProcessLocked(hostingRecord, entryPoint, app, uid, gids,
runtimeFlags, zygotePolicyFlags, mountExternal, seInfo, requiredAbi,
instructionSet, invokeWith, startTime);
····
}

boolean startProcessLocked(HostingRecord hostingRecord, String entryPoint, ProcessRecord app,
int uid, int[] gids, int runtimeFlags, int zygotePolicyFlags, int mountExternal,
String seInfo, String requiredAbi, String instructionSet, String invokeWith,
long startTime) {
····
final Process.ProcessStartResult startResult = startProcess(hostingRecord,
entryPoint, app,
uid, gids, runtimeFlags, zygotePolicyFlags, mountExternal, seInfo,
requiredAbi, instructionSet, invokeWith, startTime);
····
}


private Process.ProcessStartResult startProcess(HostingRecord hostingRecord, String entryPoint,
ProcessRecord app, int uid, int[] gids, int runtimeFlags, int zygotePolicyFlags,
int mountExternal, String seInfo, String requiredAbi, String instructionSet,
String invokeWith, long startTime) {
····
//SystemServer进程与Zygote进程通信是通过LocalSocket和LocalServerSocket进行通信,并非是Binder
startResult = Process.start(entryPoint,
app.processName, uid, uid, gids, runtimeFlags, mountExternal,
app.info.targetSdkVersion, seInfo, requiredAbi, instructionSet,
app.info.dataDir, invokeWith, app.info.packageName, zygotePolicyFlags,
isTopApp, app.mDisabledCompatChanges, pkgDataInfoMap,
whitelistedAppDataInfoMap, bindMountAppsData, bindMountAppStorageDirs,
new String[]{PROC_START_SEQ_IDENT + app.startSeq});
····
return startResult;
····
}
复制代码

SystemServer进程与Zygote进程通信是通过LocalSocket和LocalServerSocket进行通信,并非是Binder,最终Zygote进程启动好新进程则会调用android.app.ActivityThread类的main方法,此时新进程启动完成


b、Zygote进程与新启动进程通信流程

新进程启动完会调用android.app.ActivityThread类的main方法
frameworks/base/core/java/android/app/ActivityThread.java


public static void main(String[] args) {
····
Looper.prepareMainLooper();
····
//创建一个ActivityThread,同时创建一个ApplicationThread的binder对象,用于跨进程通讯
ActivityThread thread = new ActivityThread();
//启动一个Application
thread.attach(false, startSeq);
····
Looper.loop();
····
}

private void attach(boolean system, long startSeq) {
····
final IActivityManager mgr = ActivityManager.getService();
try {
//跨进程调用AMS的attachApplication方法
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
····
}
复制代码

c、新启动进程与SystemServer进程(AMS)通信流程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public final void attachApplication(IApplicationThread thread, long startSeq) {
····
synchronized (this) {
····
attachApplicationLocked(thread, callingPid, callingUid, startSeq);
····
}
}

private boolean attachApplicationLocked(@NonNull IApplicationThread thread,
int pid, int callingUid, long startSeq) {
·····
if (!badApp) {
try {
//启动服务,mServices为ActiveServices对象
didSomething |= mServices.attachApplicationLocked(app, processName);
·····
} catch (Exception e) {
·····
}
}
·····
return true;
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


boolean attachApplicationLocked(ProcessRecord proc, String processName)
throws RemoteException {
····
//此时进程已经启动,启动服务
realStartServiceLocked(sr, proc, sr.createdFromFg);
····
return didSomething;
}
复制代码

realStartServiceLocked方法的执行源码流程,可以参考(1)和(2),realStartServiceLocked方法中主要是启动ANR超时任务监测,以及是启动服务还是绑定服务的生命周期调用分配,这样两种不同形式的服务独自执行各自的生命周期


(4)、服务启动超时(ANR)监测处理流程流程


Service生命周期的执行,最终通过ActiveServices对象的realStartServiceLocked方法去调配,此时也是加入了超时任务监测(ANR)


a、启动Server超时任务

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动超时定时ANR任务
bumpServiceExecutingLocked(r, execInFg, "create");
//接下来就是启动Server一系列操作,或者计算在Server中的onCreate()生命周期方法是否有耗时操作
····
try {
····
//进入应用进程启动,移除超时定时ANR任务操作也在此处
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
//完成启动
created = true;
} catch (DeadObjectException e) {
····
}
···
}

private final void bumpServiceExecutingLocked(ServiceRecord r, boolean fg, String why) {
····
//开启超时任务
scheduleServiceTimeoutLocked(r.app);
···
}

void scheduleServiceTimeoutLocked(ProcessRecord proc) {
····
//通过Handler发送一个定时任务,当时间到没有移除这个任务,则认为是超时
Message msg = mAm.mHandler.obtainMessage(
ActivityManagerService.SERVICE_TIMEOUT_MSG);
msg.obj = proc;
mAm.mHandler.sendMessageDelayed(msg,
proc.execServicesFg ? SERVICE_TIMEOUT : SERVICE_BACKGROUND_TIMEOUT);
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final class MainHandler extends Handler {
····
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
····
//当Server超时的时候,回调到此处,准备弹ANR窗口
case SERVICE_TIMEOUT_MSG: {
mServices.serviceTimeout((ProcessRecord)msg.obj);
} break;
···
//当内容提供者超时,回调到此处,准备弹ANR窗口
case CONTENT_PROVIDER_PUBLISH_TIMEOUT_MSG: {
ProcessRecord app = (ProcessRecord)msg.obj;
synchronized (ActivityManagerService.this) {
processContentProviderPublishTimedOutLocked(app);
}
} break;
····
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


//前台server超时时间
static final int SERVICE_TIMEOUT = 20 * 1000 * Build.HW_TIMEOUT_MULTIPLIER;
//后台server超时时间
static final int SERVICE_BACKGROUND_TIMEOUT = SERVICE_TIMEOUT * 10;

void serviceTimeout(ProcessRecord proc) {
String anrMessage = null;
synchronized(mAm) {
····
if (timeout != null && mAm.mProcessList.mLruProcesses.contains(proc)) {
····
//超时后来到此处anrMessage赋值
anrMessage = "executing service " + timeout.shortInstanceName;
} else {
//初始化定时,设定超时时间
Message msg = mAm.mHandler.obtainMessage(
ActivityManagerService.SERVICE_TIMEOUT_MSG);
msg.obj = proc;
mAm.mHandler.sendMessageAtTime(msg, proc.execServicesFg
? (nextTime+SERVICE_TIMEOUT) : (nextTime + SERVICE_BACKGROUND_TIMEOUT));
}
}
//当时间达到了server超时时间,则进入此处
if (anrMessage != null) {
//最终通过此处处理ANR
mAm.mAnrHelper.appNotResponding(proc, anrMessage);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/AnrHelper.java


class AnrHelper {
private static class AnrRecord {
····
final ProcessRecord mApp;
····
void appNotResponding(boolean onlyDumpSelf) {
//通过一系列操作最终调用ProcessRecord对象的appNotResponding方法
mApp.appNotResponding(mActivityShortComponentName, mAppInfo,
mParentShortComponentName, mParentProcess, mAboveSystem, mAnnotation,
onlyDumpSelf);
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ProcessRecord.java


void appNotResponding(String activityShortComponentName, ApplicationInfo aInfo,
String parentShortComponentName, WindowProcessController parentProcess,
boolean aboveSystem, String annotation, boolean onlyDumpSelf) {
····
synchronized (mService) {
····
//mService对应的是ActivityManagerService对象
if (mService.mUiHandler != null) {
// Bring up the infamous App Not Responding dialog
Message msg = Message.obtain();
msg.what = ActivityManagerService.SHOW_NOT_RESPONDING_UI_MSG;
msg.obj = new AppNotRespondingDialog.Data(this, aInfo, aboveSystem);
//向UiHandler发送一条数据
mService.mUiHandler.sendMessage(msg);
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final class UiHandler extends Handler {
···
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
····
case SHOW_NOT_RESPONDING_UI_MSG: {
//同Handler机制回调到此处弹起ANR窗口
mAppErrors.handleShowAnrUi(msg);
ensureBootCompleted();
} break;
·····
}
}
}
复制代码

b、移除Server超时任务

frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
···
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public void serviceDoneExecuting(IBinder token, int type, int startId, int res) {
synchronized(this) {
//mServices对应的是ActiveServices对象,移除超时任务
mServices.serviceDoneExecutingLocked((ServiceRecord)token, type, startId, res);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


void serviceDoneExecutingLocked(ServiceRecord r, int type, int startId, int res) {
····
if (r != null) {
····
//移除超时任务
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
···
}

private void serviceDoneExecutingLocked(ServiceRecord r, boolean inDestroying,
boolean finishing) {
····
if (r.executeNesting <= 0) {
if (r.app != null) {
···
if (r.app.executingServices.size() == 0) {
····
//向MainHandler移除SERVICE_TIMEOUT_MSG的超时任务
mAm.mHandler.removeMessages(ActivityManagerService.SERVICE_TIMEOUT_MSG, r.app);
}
····
}
····
}
}
复制代码


作者:小狼人爱吃萝卜
链接:https://juejin.cn/post/7008699606372450341
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android知识点之Service(二)

(2)、绑定服务流程(同进程) a、应用内调用到AMS过程 在Activity环境下调用bindService方法 frameworks/base/core/java/android/content/ContextWrapper.java public boo...
继续阅读 »
(2)、绑定服务流程(同进程)

a、应用内调用到AMS过程

在Activity环境下调用bindService方法
frameworks/base/core/java/android/content/ContextWrapper.java


public boolean bindService(Intent service, ServiceConnection conn,
int flags) {
//mBase就是Activity在启动时在ActivityThread中创建的ContextImpl.java对象
return mBase.bindService(service, conn, flags);
}
复制代码

frameworks/base/core/java/android/app/ContextImpl.java


public boolean bindService(Intent service, ServiceConnection conn, int flags) {
····
return bindServiceCommon(service, conn, flags, null, mMainThread.getHandler(), null,
getUser());
}

private boolean bindServiceCommon(Intent service, ServiceConnection conn, int flags,
String instanceName, Handler handler, Executor executor, UserHandle user) {
IServiceConnection sd;
····
//创建一个IServiceConnection的binder实现对象,内部封装ServiceConnection对象
//该对象传输到AMS所在进程中,AMS通过IServiceConnection的binder实现对象可以调用应用中IServiceConnection实现方法
if (mPackageInfo != null) {
//当在绑定服务调用bindService方法有传入Executor对象,则executor不为null
if (executor != null) {
sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), executor, flags);
} else {
sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), handler, flags);
}
}
····
try {
····
//通过Binder机制跨进程调用AMS的bindIsolatedService方法
//sd为IServiceConnection对象,是一个Binder实现类,通过封装ServiceConnection得到
//sd的作用也是跨进程通信,当通过bindIsolatedService方法进入AMS进程处理时,
//AMS通过需要绑定的服务的`public IBinder onBind(Intent intent)`方法获取到一个自定义的Binder对象实现类,
//通过sd的connected方法将这个自定义的Binder实现类回传到应用进程ServiceConnection类的onServiceConnected方法中
//这个自定义的Binder实现类可以跨进程通信,绑定的服务是不同进程的可以通过这个参数跨进程通信
int res = ActivityManager.getService().bindIsolatedService(
mMainThread.getApplicationThread(), getActivityToken(), service,
service.resolveTypeIfNeeded(getContentResolver()),
sd, flags, instanceName, getOpPackageName(), user.getIdentifier());
····
return res != 0;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
复制代码

IServiceConnection的binder实现对象创建过程
frameworks/base/core/java/android/app/LoadedApk.java


public final IServiceConnection getServiceDispatcher(ServiceConnection c,
Context context, Handler handler, int flags) {
return getServiceDispatcherCommon(c, context, handler, null, flags);
}

public final IServiceConnection getServiceDispatcher(ServiceConnection c,
Context context, Executor executor, int flags) {
return getServiceDispatcherCommon(c, context, null, executor, flags);
}

private IServiceConnection getServiceDispatcherCommon(ServiceConnection c,
Context context, Handler handler, Executor executor, int flags) {
synchronized (mServices) {
·····
//当在绑定服务调用bindService方法有传入Executor对象,则executor不为null
if (executor != null) {
sd = new ServiceDispatcher(c, context, executor, flags);
} else {
sd = new ServiceDispatcher(c, context, handler, flags);
}
····
return sd.getIServiceConnection();
}
}

static final class ServiceDispatcher {

private final ServiceDispatcher.InnerConnection mIServiceConnection;

//创建出IServiceConnection对象
private static class InnerConnection extends IServiceConnection.Stub {
····
}

ServiceDispatcher(ServiceConnection conn,
Context context, Handler activityThread, int flags) {
mIServiceConnection = new InnerConnection(this);
····
}

ServiceDispatcher(ServiceConnection conn,
Context context, Executor activityExecutor, int flags) {
mIServiceConnection = new InnerConnection(this);
····
}

IServiceConnection getIServiceConnection() {
return mIServiceConnection;
}
}
复制代码

b、AMS处理以及调用ActivityThread过程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public int bindIsolatedService(IApplicationThread caller, IBinder token, Intent service,
String resolvedType, IServiceConnection connection, int flags, String instanceName,
String callingPackage, int userId) throws TransactionTooLargeException {
····
synchronized(this) {
//mServices对应的是ActiveServices.java对象
//caller为ActivityThread中的ApplicationThread的Binder机制实现类,用于与应用进程通信
//connection为IServiceConnection的Binder机制实现类,用于与应用进程通信
return mServices.bindServiceLocked(caller, token, service,
resolvedType, connection, flags, instanceName, callingPackage, userId);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


int bindServiceLocked(IApplicationThread caller, IBinder token, Intent service,
String resolvedType, final IServiceConnection connection, int flags,
String instanceName, String callingPackage, final int userId)
throws TransactionTooLargeException {
····
//flags在绑定过程的时候传的是Context.BIND_AUTO_CREATE,即是1
if ((flags & Context.BIND_AUTO_CREATE) != 0) {
s.lastActivity = SystemClock.uptimeMillis();
//启动服务
if (bringUpServiceLocked(s, service.getFlags(), callerFg, false,
permissionsReviewRequired) != null) {
return 0;
}
}
····
return 1;
}

private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
····
ProcessRecord app;
//判断启动的服务进程是否存在
if (!isolated) {
app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
···
if (app != null && app.thread != null) {
try {
····
//存在则直接启动服务
realStartServiceLocked(r, app, execInFg);
return null;
}
···
}
} else {
//启动的服务进程不存在,需要创建HostingRecord辅助开启创建进程
if ((r.serviceInfo.flags & ServiceInfo.FLAG_USE_APP_ZYGOTE) != 0) {
hostingRecord = HostingRecord.byAppZygote(r.instanceName, r.definingPackageName,
r.definingUid);
}
}
//启动的服务进程不存在,创建一个新的进程
if (app == null && !permissionsReviewRequired) {
//启动一个新进程
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
····
}
····
}
····
return null;
}

private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "create");
····
try {
····
//跨进程调用应用ActivityThread对象,进而调用service的onCreate生命周期方法
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
created = true;
} catch (DeadObjectException e) {
····
} finally {
//启动出现异常,那么ANR弹窗任务需要移除掉
if (!created) {
····
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
}
···
//如果是绑定服务,那么需要把自定义的Binder对象通过IServiceConnection回传
requestServiceBindingsLocked(r, execInFg);
···
}
复制代码

绑定服务是有自定义的Binder对象,这个需要通过AMS回传到应用进程中,也就是回传到ActivityThread里,看到requestServiceBindingsLocked方法的操作
frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private final void requestServiceBindingsLocked(ServiceRecord r, boolean execInFg)
throws TransactionTooLargeException {
for (int i=r.bindings.size()-1; i>=0; i--) {
//只有是绑定服务当前IntentBindRecord对象才有具体绑定的设置值
IntentBindRecord ibr = r.bindings.valueAt(i);
if (!requestServiceBindingLocked(r, ibr, execInFg, false)) {
break;
}
}
}

private final boolean requestServiceBindingLocked(ServiceRecord r, IntentBindRecord i,
boolean execInFg, boolean rebind) throws TransactionTooLargeException {
····
//防止重复绑定判断
if ((!i.requested || rebind) && i.apps.size() > 0) {
try {
···
//调用应用进程ActivityThread中的ApplicatonThread的scheduleBindService方法,进行数据回传
r.app.thread.scheduleBindService(r, i.intent.getIntent(), rebind,
r.app.getReportedProcState());
//防止重复绑定的限制
if (!rebind) {
i.requested = true;
}
····
} catch (TransactionTooLargeException e) {
····
//绑定出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
throw e;
} catch (RemoteException e) {
···
//绑定出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
return false;
}
}
return true;
}
复制代码

c、应用ActivityThread处理过程

service的onCreate生命周期方法流程
frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
····
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}
复制代码

绑定service的onBind或者onRebind生命周期方法流程
frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleBindService(IBinder token, Intent intent,
boolean rebind, int processState) {
updateProcessState(processState, false);
BindServiceData s = new BindServiceData();
s.token = token;
s.intent = intent;
s.rebind = rebind;
....
//向主线程发送一条消息
sendMessage(H.BIND_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case BIND_SERVICE:
···
handleBindService((BindServiceData)msg.obj);
···
break;
····
}
}
}

private void handleBindService(BindServiceData data) {
Service s = mServices.get(data.token);
····
if (s != null) {
try {
····
try {
if (!data.rebind) {
//获取到服务自定义的Binder对象,也是调用onBind生命周期方法
IBinder binder = s.onBind(data.intent);
//调用AMS所在进程的publishService方法进一步处理
ActivityManager.getService().publishService(
data.token, data.intent, binder);
} else {
//重复绑定时执行的方法,代表已经绑定过
//调用onRebind生命周期方法
s.onRebind(data.intent);
//调用AMS所在进程的serviceDoneExecuting方法移除ANR任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
}
}
····
} catch (Exception e) {
····
}
}
}
复制代码

d、AMS继续处理绑定服务内的Binder实现类流程

此过程的目的是将绑定的服务内部onBind方法自定义的Binder实现对象回传到应用进程ServiceConnection类的onServiceConnected方法中
frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public void publishService(IBinder token, Intent intent, IBinder service) {
····
synchronized(this) {
····
//service是绑定服务内部onBind方法自定义的Binder实现类
//token是ActiveServices中创建的Binder实现类
mServices.publishServiceLocked((ServiceRecord)token, intent, service);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


void publishServiceLocked(ServiceRecord r, Intent intent, IBinder service) {
···
try {
····
if (r != null) {
····
if (b != null && !b.received) {
····
for (int conni = connections.size() - 1; conni >= 0; conni--) {
ArrayList<ConnectionRecord> clist = connections.valueAt(conni);
for (int i=0; i<clist.size(); i++) {
····
try {
//调用到应用层注册的IServiceConnection的binder实现对象
//IServiceConnection是在应用层的LoadedApk.java对象内部类ServiceDispatcher中的内部类InnerConnection实现的
//InnerConnection对象继承了IServiceConnection.Stub
c.conn.connected(r.name, service, false);
}
····
}
}
}
//移除ANR超时任务
serviceDoneExecutingLocked(r, mDestroyingServices.contains(r), false);
}
}
····
}
复制代码

e、应用进程处理绑定服务内的Binder实现类流程

frameworks/base/core/java/android/app/LoadedApk.java


static final class ServiceDispatcher {
//跨进程通信Binder实现类
private static class InnerConnection extends IServiceConnection.Stub {

····

//AMS所在的进程调用此方法把绑定服务内部onBind中实现的Binder对象回调到ServiceConnection注册者
public void connected(ComponentName name, IBinder service, boolean dead)
throws RemoteException {
LoadedApk.ServiceDispatcher sd = mDispatcher.get();
if (sd != null) {
sd.connected(name, service, dead);
}
}
}

public void connected(ComponentName name, IBinder service, boolean dead) {
//如果在应用绑定服务在bindService方法中有传Executor对象这个参数,那么mActivityExecutor就不为null
if (mActivityExecutor != null) {
mActivityExecutor.execute(new RunConnection(name, service, 0, dead));
} else if (mActivityThread != null) {
///如果在应用绑定服务在bindService方法中没有传Executor对象这个参数,
// 那么会通过mMainThread.getHandler()获取到ActivityThread中的H对象,此时mActivityThread不为null
mActivityThread.post(new RunConnection(name, service, 0, dead));
} else {
//基本不会调用到这里
doConnected(name, service, dead);
}
}

//定义一个
private final class RunConnection implements Runnable {
···
public void run() {
···
//调用此方法进一步处理
doConnected(mName, mService, mDead);
····
}
····
}

public void doConnected(ComponentName name, IBinder service, boolean dead) {
·····
//mConnection对应ServiceConnection对象,最终通过onServiceConnected将绑定服务的内部Binder对象返回给注册者
if (service != null) {
mConnection.onServiceConnected(name, service);
} else {
// The binding machinery worked, but the remote returned null from onBind().
mConnection.onNullBinding(name);
}
}
}
复制代码


作者:小狼人爱吃萝卜
链接:https://juejin.cn/post/7008699606372450341
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android知识点之Service(一)

1、service 启动流程(Android 11)(1)、启动服务流程(同进程)a、应用内调用到AMS过程在Activity环境下调用startService方法 frameworks/base/core/java/android/content/Conte...
继续阅读 »


1、service 启动流程(Android 11)

(1)、启动服务流程(同进程)

a、应用内调用到AMS过程

在Activity环境下调用startService方法 frameworks/base/core/java/android/content/ContextWrapper.java

public ComponentName startService(Intent service) {
//mBase就是Activity在启动时在ActivityThread中创建的ContextImpl.java对象
return mBase.startService(service);
}

frameworks/base/core/java/android/app/ContextImpl.java

public ComponentName startService(Intent service) {
····
return startServiceCommon(service, false, mUser);
}

private ComponentName startServiceCommon(Intent service, boolean requireForeground,
UserHandle user) {
try {
····
//通过Binder机制跨进程调用AMS的startService方法
ComponentName cn = ActivityManager.getService().startService(
mMainThread.getApplicationThread(), service,
service.resolveTypeIfNeeded(getContentResolver()), requireForeground,
getOpPackageName(), getAttributionTag(), user.getIdentifier());
····
return cn;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

frameworks/base/core/java/android/app/ActivityManager.java

public static IActivityManager getService() {
return IActivityManagerSingleton.get();
}

@UnsupportedAppUsage
private static final Singleton<IActivityManager> IActivityManagerSingleton =
new Singleton<IActivityManager>() {
@Override
protected IActivityManager create() {
//IActivityManager是Binder机制实现接口类
//ServiceManager是缓存Binder的实现对象,通过getService()可以获取到跨进程通信的Binder实现对象
//例如WMS、PMS等都可以通过这个ServiceManager对象获取到
final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
final IActivityManager am = IActivityManager.Stub.asInterface(b);
return am;
}
};

至此,启动服务就进入了SystemServer进程继续工作

b、AMS处理以及调用ActivityThread过程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java

public ComponentName startService(IApplicationThread caller, Intent service,
String resolvedType, boolean requireForeground, String callingPackage,
String callingFeatureId, int userId)
throws TransactionTooLargeException {
····
synchronized(this) {
···
ComponentName res;
try {
//mServices对应的是ActiveServices.java对象
//caller为ActivityThread中的ApplicationThread的Binder机制实现类,用于与应用进程通信
res = mServices.startServiceLocked(caller, service,
resolvedType, callingPid, callingUid,
requireForeground, callingPackage, callingFeatureId, userId);
}
····
return res;
}
}

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java

ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
int callingPid, int callingUid, boolean fgRequired, String callingPackage,
@Nullable String callingFeatureId, final int userId)
throws TransactionTooLargeException {
return startServiceLocked(caller, service, resolvedType, callingPid, callingUid, fgRequired,
callingPackage, callingFeatureId, userId, false);
}

ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
int callingPid, int callingUid, boolean fgRequired, String callingPackage,
@Nullable String callingFeatureId, final int userId,
boolean allowBackgroundActivityStarts) throws TransactionTooLargeException {
····
//一切准备就绪,调用此处来启动服务
ComponentName cmp = startServiceInnerLocked(smap, service, r, callerFg, addToStarting);
····
return cmp;
}

ComponentName startServiceInnerLocked(ServiceMap smap, Intent service, ServiceRecord r,
boolean callerFg, boolean addToStarting) throws TransactionTooLargeException {
····
//启动服务
String error = bringUpServiceLocked(r, service.getFlags(), callerFg, false, false);
····
return r.name;
}

private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
····
ProcessRecord app;
//判断启动的服务进程是否存在
if (!isolated) {
app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
···
if (app != null && app.thread != null) {
try {
····
//存在则直接启动服务
realStartServiceLocked(r, app, execInFg);
return null;
}
···
}
} else {
//启动的服务进程不存在,需要创建HostingRecord辅助开启创建进程
if ((r.serviceInfo.flags & ServiceInfo.FLAG_USE_APP_ZYGOTE) != 0) {
hostingRecord = HostingRecord.byAppZygote(r.instanceName, r.definingPackageName,
r.definingUid);
}
}
//启动的服务进程不存在,创建一个新的进程
if (app == null && !permissionsReviewRequired) {
//启动一个新进程
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
····
}
····
}
····
return null;
}

private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "create");
····
try {
····
//跨进程调用应用ActivityThread对象,进而调用service的onCreate生命周期方法
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
created = true;
} catch (DeadObjectException e) {
····
} finally {
//启动出现异常,那么ANR弹窗任务需要移除掉
if (!created) {
····
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
}
//跨进程调用应用ActivityThread对象,进而调用service的onStartCommand生命周期方法
//绑定服务在这个方法中会限制调用,不会调用service的onStartCommand生命周期方法
sendServiceArgsLocked(r, execInFg, true);
····
}

private final void sendServiceArgsLocked(ServiceRecord r, boolean execInFg,
boolean oomAdjusted) throws TransactionTooLargeException {
····
while (r.pendingStarts.size() > 0) {
···
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "start");
···
}
····
try {
//通过跨进程调用应用进程的ActivityThread的ApplicationThread中的scheduleServiceArgs启动服务
r.app.thread.scheduleServiceArgs(r, slice);
} catch (TransactionTooLargeException e) {
···
caughtException = e;
} catch (RemoteException e) {
····
caughtException = e;
} catch (Exception e) {
····
caughtException = e;
}
if (caughtException != null) {
···
for (int i = 0; i < args.size(); i++) {
//启动出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
}
···
}
}

service启动在SystemServer进程需要判断是否需要创建新进程,还需要监测service启动的超时时间,除此之后还需要判断权限等等,最终通过Binder机制调用应用进程方法调用service生命周期方法

c、应用ActivityThread处理过程

service的onCreate生命周期方法流程 frameworks/base/core/java/android/app/ActivityThread.java

public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
····
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}

service的onStartCommand生命周期方法流程 frameworks/base/core/java/android/app/ActivityThread.java

public final void scheduleServiceArgs(IBinder token, ParceledListSlice args) {
List<ServiceStartArgs> list = args.getList();
for (int i = 0; i < list.size(); i++) {
ServiceStartArgs ssa = list.get(i);
ServiceArgsData s = new ServiceArgsData();
s.token = token;
s.taskRemoved = ssa.taskRemoved;
s.startId = ssa.startId;
s.flags = ssa.flags;
s.args = ssa.args;
//向主线程发送一条消息
sendMessage(H.SERVICE_ARGS, s);
}
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case SERVICE_ARGS:
····
//处理服务启动
handleServiceArgs((ServiceArgsData) msg.obj);
····
break;
····
}
}
}

private void handleServiceArgs(ServiceArgsData data) {
Service s = mServices.get(data.token);
if (s != null) {
try {
····
if (!data.taskRemoved) {
//调用service的onStartCommand生命周期方法
res = s.onStartCommand(data.args, data.flags, data.startId);
}
····
try {
//跨进程调用AMS方法将ANR任务移除
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_START, data.startId, res);
}
···
} catch (Exception e) {
····
}
}
}

收起阅读 »

Xcode 12 使用技巧

iOS
1 class成员构造函数生成Swift 可以为 struct 合成成员构造函数,但不能为 class 合成。Xcode 可以帮助生成代码,先选中类名,然后选择菜单 Editor —> Refactor —> Generate Memberwise...
继续阅读 »

1 class成员构造函数生成

Swift 可以为 struct 合成成员构造函数,但不能为 class 合成。Xcode 可以帮助生成代码,先选中类名,然后选择菜单 Editor —> Refactor —> Generate Memberwise Initializer。

2 设置App的“外观”

运行 App 到模拟器以后可以找到环境面板,点开它可以设置 Dynamic Type size, 暗黑模式等以查看 App 的变化。

3 选中代码块

选择某个代码块的左侧括号{,然后双击。

4 检查拼写错误

选择 Edit —> Format —> Spelling and Grammar —> Check Spelling While Typing,将检查代码是否有错别字。

5 修复多个错误

程序出现多个错误时,可以选择 Editor —> Fix All Issues 修复多个错误。

6 搜索查看

在 Find navigator 面板里搜索某个内容时,如果出现多个结果,在使用完一个结果时可以使用 Backspace 剔除该结果,这样剩下的都是未操作过的搜索结果。

7 Canvas切换

Canvas 暂停时,按 Opt+Cmd+P 恢复预览。也使用 Opt+Cmd+Return 来完全隐藏画布。

8 模拟器分屏

选中模拟器,进入 Window 菜单,选择 Tile Window To Right Of Screen,然后选择左边的 Xcode 进行屏幕空间分割调整,这样模拟器就一直在右边显示。

9 代码提示宽度

当代码提示出现以后,如果某个方法特别长,可以选中提示面板的边缘,并将其拖动到想要的宽度。

10 快速添加断点

使用 Cmd+\ 在当前行上添加或删除断点。 

11 测试顺序

有时一个测试的输出会影响另一个测试的输入。此时可以进入 Product 菜单,按住 Option,然后点击 Test。在 Info 选项卡中,单击 Options,然后选中 Randomize Execution Order,这样进行测试时每次都会以不同的顺序运行。

12 筛选方法和设备

可以使用 Ctrl+6 快速查看当前文件的方法列表,列表出现以后可以直接输入过滤信息进行方法的筛选,这个操作方式也可以用于模拟器的过滤筛选。

13 查看interface

按住 Ctrl+Cmd+↑,会生成当前文件的 interface,显示当前文件的属性、函数签名和注释。如果存在该文件的测试文件,可以再按一次就会跳转到测试文件。

14 快速补齐文档注释

在某个方法上按住 Option+Cmd+/ 就会生成文档注释。

15 快速查找文件

  • 选中项目或者文件夹,右击选择 Sort By Name,此时文件就会按照 A-Z 的顺序排序。
  • 项目文件的最下方法,有个过滤框,可以输入关键字进行查找。

16 代码变化提醒

Xcode 偏好设置 —> Source Control —> 勾选 Show Source Control changes,然后进行代码的修改,在修改代码的左边会看到一个蓝色的条状提醒,点击它点并选择 Show Change,就会同时显示新旧代码。

17 使用minimap

在浏览长代码时,可以通过 Editor —> Minimap 调出 minimap,方便查看代码。

18 运行最后一次测试

编写失败的测试很常见,Xcode 有一个快捷键可以只运行最后一个测试:Ctrl+Opt+Cmd+G。

19 修改快捷键

Xcode 偏好设置 —> Key Bindings,然后根据需要搜索和修改。

20 查找选项

Show the Find navigator 界面,每个菜单都可以通过点击弹出更多选项,合理搭配可以提高查找的效率。比如可以点击放大镜查看最近的搜索。

21 粘贴代码格式化

有时候从别的地方粘贴代码到项目中时缩进不对,可以使用 Ctrl+I 进行格式化。

22 内购测试

可以在没有 App Store Connect 的情况下测试应用内购买。创建一个新的 StoreKit Config 文件,并添加 IAP。然后进入菜单 Product,按住 Option 然后点击 Run,在弹出窗口的 Options 选项卡中,更改 StoreKit Configuration 为添加的 StoreKit Config 文件,就可以测试添加的 IAP。

23 查看Build Settings含义

一般很难记住 Build Settings 的作用,可以选择其中一项使用 Quick Help 检查器查看大多数 Settings 的文档,或者按住 Option 并双击以获得内联帮助。

24 多文件Canvas预览

当一个视图被分割成不同文件时,Canvas 预览起来有点困难,此时在预览界面,使用底部的图钉来保持当前预览的活动状态,这样可以在预览一个文件的同时更改另一个文件并能及时反馈到预览里。

收起阅读 »

iOS - 数据存储

iOS
Bundle简单理解就是资源文件包,会将许多图片、xib、文本文件组织在一起,打包成一个 Bundle 文件,这样可以在其他项目中引用包内的资源。// 获取当前项目的Bundle let bundle = Bundle.main // 加载资源 let mp...
继续阅读 »

Bundle

简单理解就是资源文件包,会将许多图片、xib、文本文件组织在一起,打包成一个 Bundle 文件,这样可以在其他项目中引用包内的资源。

// 获取当前项目的Bundle
let bundle = Bundle.main

// 加载资源
let mp3 = Bundle.main.path(forResource: "xxx", ofType: "mp3")

沙盒

每一个 App 只能在自己的创建的文件系统(存储区域)中进行文件的操作,不能访问其他 App 的文件系统(存储区域),该文件系统(存储区域)被成为沙盒。所有的非代码文件都要保存在此,例如图像,图标,声音,plist,文本文件等。

沙盒机制保证了 App 的安全性,因为只能访问自己沙盒文件下的文件。

Home目录

沙盒的主目录,可以通过它查看沙盒目录的整体结构。

// 获取程序的Home目录
let homeDirectory = NSHomeDirectory()

Documents目录

保存应用程序运行时生成的持久化数据。可被iTunes备份,可备份到 iCloud。

// 方法1
let documentPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentPath = documentPaths[0]

// 方法2
let documentPath2 = NSHomeDirectory() + "/Documents"

上面的获取方式最后得到的是String,如果希望获取的是URL,可以通过下面的方式:

let manager = FileManager.default
let urlForDocument = manager.urls(for: .documentDirectory, in:.userDomainMask)
let url: URL = urlForDocument[0]

NSSearchPathForDirectoriesInDomains

  • 访问沙盒目录常用的函数,它返回值为一个数组,在 iOS 中由于只有一个唯一路径,所以直接取数组第一个元素即可。
func NSSearchPathForDirectoriesInDomains(
_ directory: FileManager.SearchPathDirectory,
_ domainMask: FileManager.SearchPathDomainMask,
_ expandTilde: Bool) -> [String]

  • directory:指定搜索的目录名称。
  • domainMask:搜索主目录的位置。userDomainMask 表示搜索的范围限制于当前应用的沙盒目录(参考定义注释)。
  • expandTilde:是否获取完整的路径。
let documentPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, false)
let documentPath = documentPaths[0] // ~/Documents

let documentPaths2 = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentPath2 = documentPaths2[0] // /Users/yangfan/Library/Developer/XCPGDevices/982B6CBA-747B-4831-9D87-F82160197333/data/Containers/Data/Application/56C657D5-B36B-449D-AC6C-E2417EA65D00/Documents

Library目录

存储程序的默认设置和其他信息,其下有两个重要目录:

  • Library/Preferences 目录:包含应用程序的偏好设置文件。不应该直接创建偏好设置文件,而是应该使用UserDefaults类来取得和设置应用程序的偏好。
  • Library/Caches 目录:主要存放缓存文件,此目录下文件不会在应用退出时删除。
// Library目录-方法1
let libraryPaths = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true)
let libraryPath = libraryPaths[0]

// Library目录-方法2
let libraryPath2 = NSHomeDirectory() + "/Library"

// Cache目录-方法1
let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
let cachePath = cachePaths[0]

// Cache目录-方法2
let cachePath2 = NSHomeDirectory() + "/Library/Caches"
  • tmp目录:存储临时文件,当在退出程序或设备重启时,文件会被清除。
// 方法1
let tmpDir = NSTemporaryDirectory()

// 方法2
let tmpDir2 = NSHomeDirectory() + "/tmp"

注意

每次编译代码会生成新的沙盒路径,所以模拟器运行同一个 App 时所得到的沙盒路径是不一样的,但上架的 App 在真机上运行不存在这种情况。

plist读写

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 获取本地plist
let path = Bundle.main.path(forResource: "cityData", ofType: "plist")
if let path = path {
let root = NSDictionary(contentsOfFile: path) // 借助于NSDictionary
// print(root!.allKeys)
// print(root!.allKeys[31])
// 获取所有数据
let cities = root![root!.allKeys[31]] as! NSArray // 借助于NSArray
// print(cities)
// 沙盒路径
let documentDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
let filePath = documentDir! + "/localData.plist"
// 写入沙盒
cities.write(toFile: filePath, atomically: true)
}
}
}

偏好设置

  • 一般用于保存如用户名、密码、版本等轻量级数据。
  • 通过UserDefaults来设置和读取偏好设置。
  • 偏好设置以key-value的方式进行读写操作。
  • 默认情况下数据自动以plist形式存储在沙盒的Library/Preferences目录。

案例

  • 记住密码
class ViewController: UIViewController {
@IBOutlet weak var username: UITextField!
@IBOutlet weak var password: UITextField!
@IBOutlet weak var swit: UISwitch!
// UserDefaults
let userDefaults = UserDefaults.standard

override func viewDidLoad() {
super.viewDidLoad()

// 取出存储的数据
let name = userDefaults.string(forKey: "name")
let pwd = userDefaults.string(forKey: "pwd")
let isOn = userDefaults.standard.bool(forKey: "isOn")
// 填充输入框
username.text = name
password.text = pwd
// 设置开关状态
swit.isOn = isOn
}

@IBAction func login(_ sender: Any) {
print("密码已经记住")
}

@IBAction func remember(_ sender: Any) {
let swit = sender as! UISwitch
// 如果记住密码开关打开
if swit.isOn {
let name = username.text
let pwd = password.text
// 存储用户名和密码
userDefaults.set(name, forKey: "name")
userDefaults.set(pwd, forKey: "pwd")
// 同时存储开关的状态
userDefaults.set(swit.isOn, forKey: "isOn")
// 最后进行同步
userDefaults.synchronize()
}
}
}
  • 新特性界面
class SceneDelegate: UIResponder, UIWindowSceneDelegate { 
var window: UIWindow?
// 当前版本号
var currentVersion: Double!
// UserDefaults
let userDefaults = UserDefaults.standard

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
if isNewVersion {
// 新特性界面
let newVC = UIViewController()
newVC.view.backgroundColor = .green
window?.rootViewController = newVC
// 存储当前版本号
userDefaults.set(currentVersion, forKey: "localVersion")
userDefaults.synchronize()
} else {
// 主界面
let mainVC = UIViewController()
mainVC.view.backgroundColor = .red
window?.rootViewController = mainVC
}

window?.makeKeyAndVisible()
}
}

extension SceneDelegate {
// 是否新版本
private var isNewVersion: Bool {
// 获取当前版本号
currentVersion = Double(Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String)!
// 本地版本号
let localVersion = userDefaults.double(forKey: "localVersion")
// 比较大小
return currentVersion > localVersion
}
}

默认值

如果需要在使用时设置 UserDefaults 的默认值,可以使用register方法。

enum Keys: String {
case name // 名字
case isRem // 记住密码
}

// 设置默认值
UserDefaults.standard.register(defaults: [
Keys.name.rawValue: "UserA",
Keys.isRem.rawValue: false
])

注意:在设置默认值后如果修改了其中的属性值,即使再次执行register方法也不会重置。

跨域

一般情况下使用UserDefaults.standard没有太大问题,但当 App 足够复杂时就会产生几个问题:

  • 需要保证设置数据 key 具有唯一性,防止产生冲突。
  • 同一个 plist 文件越来越大造成的读写效率降低。
  • 无法便捷的清除特定的偏好设置数据。

因此还有另外一种获取 UserDefaults 对象的方法:UserDefaults(suiteName: String?),可以根据传入的 suiteName 参数进行处理:

  • 传入 nil:等同于UserDefaults.standard
  • 传入 App Groups 的 ID:操作共享目录中的 plist 文件,以便在跨 App 或宿主 App 与扩展应用之间(如 App 与 Widget)共享数据。
  • 传入其他值:操作Documents/Library/Preferences目录下以suiteName命名的 plist 文件。

可以通过如下的方式删除指定suiteName的 plist 文件里的全部数据。

let userDefaults = UserDefaults(suiteName: "abc")
userDefaults?.removePersistentDomain(forName: "abc")

归档与反归档

  • 归档(序列化)是把对象转为Data,反归档(反序列化)是从Data还原出对象。
  • 可以存储自定义数据。
  • 存储的数据需要继承自NSObject并遵循NSSecureCoding协议。

案例

  • 自定义对象
class Person: NSObject, NSSecureCoding {   
var name:String?
var age:Int?

override init() {
}

static var supportsSecureCoding: Bool = true

// 编码- 归档调用
func encode(with aCoder: NSCoder) {
aCoder.encode(age, forKey: "age")
aCoder.encode(name, forKey: "name")
}

// 解码-反归档调用
required init?(coder aDecoder: NSCoder) {
super.init()
age = aDecoder.decodeObject(forKey: "age") as? Int
name = aDecoder.decodeObject(forKey: "name") as? String
}
}
  • 归档与反归档
class ViewController: UIViewController {
var data: Data!
var origin: Person!

override func viewDidLoad() {
super.viewDidLoad()
}

// 归档
@IBAction func archiver(_ sender: Any) {
let p = Person()
p.age = 20
p.name = "zhangsan"

do {
try data = NSKeyedArchiver.archivedData(withRootObject: p, requiringSecureCoding: true)
} catch {
print(error)
}
}

// 反归档
@IBAction func unarchiver(_ sender: Any) {
do {
try origin = NSKeyedUnarchiver.unarchivedObject(ofClass: Person.self, from: data)
print(origin!.age!)
print(origin!.name!)
} catch {
print(error)
}
}
}

数据库—sqlite3

由于 Swift 直接操作 sqlite3 非常不方便,所以借助于SQLite.swift的框架。

  • Model
struct Person {    
var name : String = ""
var phone : String = ""
var address : String = ""
}
  • DBTools
import SQLite

struct DBTools {
// 数据库路径
let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! + "/person.db"
// 数据库连接
var db: Connection!
// 表名与字段
let personTable = Table("t_person") // 表名
let personID = Expression<Int>("id") // id
let personName = Expression<String>("name") // name
let personPhone = Expression<String>("phone") // phone
let personAddress = Expression<String>("address") // address

// MARK: - 构造函数,数据库有则连接 没有就创建后连接
init() {
do {
db = try Connection(dbPath)
print("数据库创建/连接成功")
} catch {
print("数据库创建/连接失败")
}
}

// MARK: - 创建表格,表若存在不会再次创建,直接进入catch
func createTable() {
// 创表
do {
try db.run(personTable.create(block: { t in
t.column(personID, primaryKey: .autoincrement)
t.column(personName)
t.column(personPhone)
t.column(personAddress)
}))
print("数据表创建成功")
} catch {
print("数据表创建失败")
}
}

// MARK: - 插入数据
func insertPerson(person: Person) {
let insert = personTable.insert(personName <- person.name, personPhone <- person.phone, personAddress <- person.address)
// 插入
do {
try db.run(insert)
print("插入数据成功")
} catch {
print("插入数据失败")
}
}

// MARK: - 删除数据
func deletePerson(name: String) {
// 筛选数据
let p = personTable.filter(personName == name)
// 删除
do {
let row = try db.run(p.delete())
if row == 0 {
print("暂无数据删除")
} else {
print("数据删除成功")
}
} catch {
print("删除数据失败")
}
}

// MARK: - 更新数据
func updatePerson(person: Person) {
// 筛选数据
let p = personTable.filter(personName == person.name)
// 更新
do {
let row = try db.run(p.update(personPhone <- person.phone, personAddress <- person.address))
if row == 0 {
print("暂无数据更新")
} else {
print("数据更新成功")
}
} catch {
print("数据更新失败")
}
}

// MARK: - 查询数据
func selectPerson() -> [Person]? {
// 保存查询结果
var response: [Person] = []
// 查询
do {
let select = try db.prepare(personTable)
for person in select {
let p = Person(name: person[personName], phone: person[personPhone], address: person[personAddress])
response.append(p)
}

if !response.isEmpty {
print("数据查询成功")
} else {
print("对不起,暂无数据")
}
return response
} catch {
print("数据查询失败")
return nil
}
}
}
  • ViewController
class ViewController: UIViewController {    
var dbTools: DBTools?

override func viewDidLoad() {
super.viewDidLoad()
}

@IBAction func createDB(_ sender: Any) {
dbTools = DBTools()
}

@IBAction func createTab(_ sender: Any) {
dbTools?.createTable()
}

@IBAction func insertData(_ sender: Any) {
let p = Person(name: "zhangsan", phone: "18888888888", address: "AnHuiWuhu")
dbTools?.insertPerson(person: p)
}

@IBAction func deleteData(_ sender: Any) {
dbTools?.deletePerson(name: "zhangsan")
}

@IBAction func updateData(_ sender: Any) {
let p = Person(name: "zhangsan", phone: "17777777777", address: "JiangSuNanJing")
dbTools?.updatePerson(person: p)
}

@IBAction func selectData(_ sender: Any) {
let person = dbTools?.selectPerson()
if let person = person {
for p in person {
print(p)
}
}
}
}
收起阅读 »

iOS - 触摸与手势识别

iOS
触摸概念UITouch用于描述触摸的窗口、位置、运动和力度。一个手指触摸屏幕,就会生成一个 UITouch 对象,如果多个手指同时触摸,就会生成多个 UITouch 对象。属性 (1)window:触摸时所处的 UIWindow。 (2)view:触摸时所处的...
继续阅读 »

触摸

概念

UITouch

用于描述触摸的窗口、位置、运动和力度。一个手指触摸屏幕,就会生成一个 UITouch 对象,如果多个手指同时触摸,就会生成多个 UITouch 对象。

  • 属性 (1)window:触摸时所处的 UIWindow。 (2)view:触摸时所处的 UIView。 (3)tapCount:短时间内点按屏幕的次数。可据此判断单击和双击操作。 (4)timestamp:时间戳,单位秒。记录了触摸事件产生或变化时的时间。 (5)phase:触摸事件的周期,即触摸开始、触摸点移动、触摸结束和中途取消。

  • 方法

// 返回一个CGPoint类型的值,表示触摸在view上的位置。
// 返回的位置是针对view的坐标系。
// 调用时传入的view参数为空的话,返回的是触摸点在整个窗口的位置 。
open func location(in view: UIView?) -> CGPoint

// 该方法记录了前一个坐标值,返回值的含义与上面一样。
open func previousLocation(in view: UIView?) -> CGPoint

UIEvent

一个完整的触摸操作是一个 UIEvent,它包含一组相关的 UITouch 对象,可以通过 UIEvent 的allTouches属性获得 UITouch 的集合。

UIResponder

  • 响应者对象。
  • 只有继承了 UIResponder 的对象才能接收并处理触摸事件。
  • AppDelegate、UIApplication、UIWindow、UIViewController、UIView 都继承自 UIResponder,因此它们都是响应者对象,都能够接收并处理触摸事件。
  • 响应者通过下列几个方法来响应触摸事件。
// 手指触碰屏幕,触摸开始
open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
// 手指在屏幕上移动
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
// 手指离开屏幕,触摸结束
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
// 触摸结束前,某个系统事件中断了触摸,如电话来电
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

触摸事件传递与响应

当触摸事件产生以后,App 里有很多的 UIView 或 UIViewController,到底应该谁去响应这个事件呢?在响应之前,必须要找到那个最合适的对象(最佳响应者),这个过程称之为事件传递或寻找最佳响应者(Hit-Testing)。

事件传递

  1. 当 iOS 程序中发生触摸事件后,系统会将事件加入到 UIApplication 管理的一个任务队列中。
  2. UIApplication 取出最前面的事件传递给 UIWindow。
  3. UIWindow 接收到事件后,首先判断自己能否响应触摸事件。如果能,那么 UIWindow 会从后往前遍历自己的子 UIView,将事件向下传递。
  4. 遍历每一个子 UIView 时,都会重复上面的操作(判断能否响应触摸事件,能则继续遍历子 UIView,直到找到一个 UIView)直到找到最合适的 UIView。如果没有找到合适的,那么事件不再往下传递,而当前 UIView 就是最合适的对象。

两个方法

寻找最佳响应者的原理是什么?需要借助以下两个方法。

// 寻找最佳响应者的核心方法,传递事件的桥梁
// 1. 判断点是否在当前view的内部(即调用第二个方法)
// 2. 如果在(即返回true)则遍历其子UIView继续
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
}

// 判断点是否在这个View的内部
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
}

  • UIApplication 调用 UIWindow 的hitTest方法将触摸事件传递给 UIWindow,如果 UIWindow 能够响应触摸事件,则调用其子 UIView 的hitTest方法将事件传递给其子 UIView,这样循环寻找与传递下去,直到获取最佳响应者。
  • 通过这两个方法可以做很多事情,其中一个经典的案例是自定义中间有凸起按钮的 UITabBar。此时需要重写 UITabBar 的point方法,判断当前触摸位置是否在中间凸起按钮的坐标范围内,如果在返回 true。这样可以让触摸事件传递到凸起按钮,并让其成为最佳响应者。

事件响应

  1. 当找到最合适的响应者之后,响应者对于触摸事件,有以下 3 种操作: (1)不拦截,事件会沿着默认的响应链自动传递。(默认操作) (2)拦截,事件不再往上传递,重写touchesBegan方法,但不调用父类的touchesBegan方法。 (3)不拦截,事件继续往上传递,重写touchesBegan方法,并调用父类的touchesBegan方法,即super.touchesBegan(touches, with: event)

  2. 响应者对于触摸事件的响应和传递都是在touchesBegan方法中完成的。该方法默认是将事件顺着响应者链向上传递,即将事件交给上一个响应者进行处理。每一个响应者对象都有一个next属性,用来获取下一个响应者。默认的next对象为: (1)UIView:若当前响应者是 UIViewController 的view,则next是 UIViewController,否则上一个响应者是其父 UIView。 (2)UIViewController:若当前响应者是 UIWindow 的rootViewController,则next是 UIWindow;若是被 present 显示的则nextpresentingViewController。 (3)UIWindow:next为 UIApplication。 (4)UIApplication:next为 AppDelegate。 (5)AppDelegate:next为 nil。

事件不响应的原因

  1. 触摸点不在当前范围内。
  2. alpha < 0.01,透明度小于 0.01。
  3. hidden = true,隐藏不可见。
  4. userInteractionEnabled = false,不允许交互。

手势识别

类型

  • UITapGestureRecognizer:轻点手势识别。
  • UILongPressGestureRecognizer:长按手势识别。
  • UIPinchGestureRecognizer:捏合手势识别。
  • UIRotationGestureRecognizer:旋转手势识别。
  • UISwipeGestureRecognizer:轻扫手势识别。
  • UIPanGestureRecognizer:拖动手势识别。
  • UIScreenEdgePanGestureRecognizer:屏幕边缘拖动手势识别。

使用步骤

  1. 创建手势实例,指定回调方法,当手势开始,改变、或结束时,回调方法被调用。
  2. 将手势添加到需要的 UIView 上。每个手势只对应一个 UIView,当屏幕触摸在当前 UIView 里时,如果手势和预定的一样,回调方法就会调用。
  3. 手势可以通过 storyboard 或者纯代码使用。
class ViewController: UIViewController {
@IBOutlet var blueView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

// 创建手势
let tap = UITapGestureRecognizer(target: self, action: #selector(gesture))
// UITapGestureRecognizer可以设置tap次数
tap.numberOfTapsRequired = 2

let longPress = UILongPressGestureRecognizer(target: self, action: #selector(gesture))

let pinch = UIPinchGestureRecognizer(target: self, action: #selector(gesture))

let rotate = UIRotationGestureRecognizer(target: self, action: #selector(gesture))

let swipe = UISwipeGestureRecognizer(target: self, action: #selector(gesture))
// UISwipeGestureRecognizer需要设置direction
swipe.direction = .right

let pan = UIPanGestureRecognizer(target: self, action: #selector(gesture))

let edgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(gesture))
// UIScreenEdgePanGestureRecognizer需要设置edges
edgePan.edges = UIRectEdge.all

// 添加手势
blueView.addGestureRecognizer(edgePan)
}

@objc func gesture(gestureRecognizer: UIGestureRecognizer) {
print(#function)
}
}

代理

class ViewController: UIViewController {    
@IBOutlet weak var blueView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

let gestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(gesture))
// 设置代理
gestureRecognizer.delegate = self
// 添加手势
blueView.addGestureRecognizer(gestureRecognizer)
}

@objc func gesture(gestureRecognizer:UIGestureRecognizer){
print(#function)
}
}

extension ViewController: UIGestureRecognizerDelegate {
// 手势识别器是否解释此次手势
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer.state == .possible {
print("手势开始")
return true
}
else if gestureRecognizer.state == .cancelled {
print("手势结束")
return true
}
return true
}
}

注意

  1. 一个手势只能对应一个 UIView,但是一个 UIView 可以有多个手势。
  2. 继承自 UIControl 的 UIView 都可以通过 Target-Action 方式添加事件,如果同时给它们添加手势识别, 则 Target-Action 的行为会失效,因为手势识别的优先级更高。
收起阅读 »

iOS14开发- 国际化

iOS
介绍如果 App 需要给不同语言的用户使用,需要进行国际化处理。如果 App 需要进行国际化,在开发之初就需要考虑,在开发时统一使用某一种语言(建议英文),待开发完成以后再进行国际化处理。配置国际化语言在进行国际化之前,必须要添加需要国际化的语言,选中国际化的...
继续阅读 »

介绍

如果 App 需要给不同语言的用户使用,需要进行国际化处理。如果 App 需要进行国际化,在开发之初就需要考虑,在开发时统一使用某一种语言(建议英文),待开发完成以后再进行国际化处理。

配置国际化语言

在进行国际化之前,必须要添加需要国际化的语言,选中国际化的项目 —> PROJECT —> Info —> Localizations,点击+添加需要的国际化语言(默认已经存在英文)。

App名国际化

  1. 新建一个Strings File,必须命名为InfoPlist.strings
  2. 在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,然后勾选配置的国际化语言。
  3. InfoPlist.strings左侧多了一个箭头,点击箭头可以展开,Strings File里面都是形如Key = Value的键值对,操作时一定要保证多个国际化文件中Key的一致性
  4. 设置 App 名字的 Key 为"CFBundleName"
// 英文名
"CFBundleName" = "I18N";

// 中文名
"CFBundleName" = "国际化";

文本国际化

  1. 新建一个Strings File,必须命名为Localizable.strings
  2. 在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,然后勾选配置的国际化语言。
  3. Localizable.strings的各个国际化版本中写上需要国际化文本的Key = Value对。
"title" = "Info";
"message" = "This is a Dialog";
"btnTitle" = "Cancel";

"title" = "温馨提示";
"message" = "这是一个对话框";
"btnTitle" = "取消";

  1. 在需要的地方使用NSLocalizedString(key, comment)读取,其中第一个参数就是上面的key
NSLocalizedString("title", comment: "")
NSLocalizedString("message", comment: "")
NSLocalizedString("btnTitle", comment: "")

图片国际化

图片和文本国际化的使用方式一样,首先在Localizable.strings中进行图片名称的设置,然后通过NSLocalizedString(key, comment)来读取图片名,再根据不同的图片名获取不同的图片。

let imageName = NSLocalizedString("img", comment: "")
let image = UIImage(named: imageName)
imageView.image = image

storyboard/xib国际化

  1. 二者使用方式几乎一样,以 storyboard 为例。
  2. 配置国际化语言时,会弹出选择需要国际化的 storyboard 的对话框,选择以后对应的 storyboard 左侧就会多一个箭头,点击箭头可以展开,里面有storyboard名.stringsStrings File
  3. 选中storyboard名.strings,在 Xcode 的右侧文件检查器中找到Localization,点击Localize...
  4. storyboard名.strings文件对应位置填写相应的国际化信息。
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "bRH-7c-qiE"; */
"bRH-7c-qiE.normalTitle" = "Button";

/* Class = "UILabel"; text = "UserName"; ObjectID = "dp4-Bf-56s"; */
"dp4-Bf-56s.text" = "UserName";

/* Class = "UITextField"; placeholder = "Please input your name"; ObjectID = "fen-IE-aUn"; */
"fen-IE-aUn.placeholder" = "Please input your name";
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "bRH-7c-qiE"; */
"bRH-7c-qiE.normalTitle" = "登录";

/* Class = "UILabel"; text = "UserName"; ObjectID = "dp4-Bf-56s"; */
"dp4-Bf-56s.text" = "用户名";

/* Class = "UITextField"; placeholder = "Please input your name"; ObjectID = "fen-IE-aUn"; */
"fen-IE-aUn.placeholder" = "请输入用户名";

注意:如果在弹出的对话框中没有勾选 storyboard,也可以选中 storyboard 文件,再在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,选择 Base,点击Localize,然后在Localization中勾选需要的国际化语言,会生成各个国际化语言的Strings File,最后进行国际化信息的填充。

收起阅读 »

Flutter手势探索——原理与实现的背后

作者:闲鱼技术——子东在日常开发中,手势和事件无处不在,比如在 Flutter 应用中点击一个点赞按钮,长按弹出 BottomSheet 和商品列表的滑动等等都存在事件传递和手势识别,Flutter 内部是如何确定哪个控件响应了事件,事件是如何在控件之间传递的...
继续阅读 »

作者:闲鱼技术——子东

在日常开发中,手势和事件无处不在,比如在 Flutter 应用中点击一个点赞按钮,长按弹出 BottomSheet 和商品列表的滑动等等都存在事件传递和手势识别,Flutter 内部是如何确定哪个控件响应了事件,事件是如何在控件之间传递的,包括像 Tap 和 DoubleTap 等手势是如何区分的。为了回答以上的问题,我们接下来深入探索 Flutter 手势的原理。

手势原理

事件分发

Flutter 中的事件是从 Window.onPointerDataPacket 的回调中获取的,将原始事件转化成 PointerEvent 加入到待处理的事件队列中,然后逐个处理队列中的 PointerEvent。

其中 _handlePointerEvent 将生成 HitTestResult 将所有的命中测试结果存在 _path (HitTestResult 中的一个命中测试对象的集合),最后遍历 HitTestResult 的 _path 进行事件分发。

命中测试

那么 HitTestResult 是如何收集这些命中测试结果的呢,与 Native 的 HitTest 类似,Flutter 中也是不断在遍历(调用 HitTest)child 判断 point 和 child 的大小比较直到找到最深一个 child 也就是离我们最近的一个 RenderBox。如果把 Widget 的结构理解成树的结构,那么 _path 中 entry 的顺序正好是从叶子节点往根节点回溯的顺序。

手势识别

了解了 Flutter 的事件分发与命中测试,接下来我们看看手势是如何识别。在 Flutter 提供了一个封装各种手势监听的 Widget —— GestureDetector,其内部实现了各种手势识别器和其回调,然后传给 RawGestureDetector 。在 RawGestureDetector 里监听了 PointerDownEvent 事件,并遍历所有识别器并调用 addPointer 方法。

我们以最简单的识别器 TapGestureRecognizer 为例,先了解 addPointer 的实现中做了哪些事情,最终调用 startTrackingPointer 方法,在事件路由里注册 handleEvent,并将其加入到竞争场(后面会讲手势竞争)中。当事件分发时根据 pointer 调用对应的 handleEvent 方法。在 handleEvent 方法实现中判断 pointer 的移动距离是否超过阈值,这个阈值的默认大小是 18 个像素点。如果超过这个阈值将拒绝事件并停止事件追踪。反之调用 TapGestureRecognizer 识别器实现的 handlePrimaryPointer,最终处理监听的回调。

手势竞争

当我们同时使用多种手势时会产生冲突,为了解决这个问题,Flutter 引入了 GestureArena(手势竞争场)的概念。在处理多种手势时把这些手势加入到竞争场中,胜出的手势会继续响应接下来的事件。 在手势竞争场中胜出者遵循两个规律:

•在竞争场中只存在一个手势识别器时,它将胜出。•当有一个手势识别器胜出,那么其他的都将失败。

举个例子,在一个 Widget 上同时监听 Horizontal 和 Vertical 手势时,当手指按下的时候两者都会进入手势竞争场,当用户手指在水平方向上移动一定距离,Horizontal 手势将胜出并响应事件。相同的,用户手指在垂直方向上移动 Vertical 手势胜出。

小结

上面分析了在 Flutter 中从事件分发到手势识别的原理,其中以 TapGestureRecognizer 为例介绍了手势识别,除了此以外还有 ScaleGestureRecognizer,PanGestureRecognizer 等等,识别这些手势的原理基本相同,重写 handleEvent 实现各自具体手势判断。接下来具体介绍在实际项目中遇到的手势冲突问题以及解决方案。

案例分析

近期团队正在优化图片浏览器的用户体验。我们与 UED 共同梳理了实现一个图片浏览器所包含的功能点:

1.点击关闭图片2.支持左右滑动切换图片3.支持双击放大4.长按唤起更多操作 ... ...

从上面的功能点分析之后,我们采用 Flutter 的系统控件 PageView 作为图片浏览器的基础组件,在其基础之上扩展出图片放大、双击和长按等手势。所以组件的框架图如下所示:

在 PageView 的 ItemView 使用 ImageWrapper 封装之后接管 ItemView 的手势来处理自定义的手势,比如缩放 ScaleGestureRecognizer 和 TapGestureRecognizer 等等。 从上面的框架图看,基于系统控件 PageView 的框架分层比较简单,尽可能利用系统控件原有的功能,即能减少实现复杂逻辑的实现,同时也避免了在多种系统和设备上的兼容性问题。在这个过程中也遇到一些手势冲突的问题。

图片放大滚动与 PageView 滑动的冲突

分析冲突原因:在 ImageWrapper 中使用 ScaleGestureRecognizer 追踪缩放事件。PageView 是在 Scrollable 的基础上实现的,Scrollable 则是利用 HorizontalDragGestureRecognizer 追踪水平拖拽事件来实现滑动。Scale 和 HorizontalDrag 同时存在必然会发生竞争,因为在水平滑动时 HorizontalDrag 手势胜出,图片无法滚动直接滑到下一页。 通过上面的分析,我们需要解决两个问题:

•图片支持滚动•图片滚动到边界时滑到下一页

一个简单的想法是在图片放大时禁止 PageView 滑动(PageView 的 physics 设置为 NeverScrollableScrollPhysics),当放大图片滚动到边界时允许 PageView 滑动下一页。该方案在实现之后,发现滚动到边界时与 PageView 滑动到下一页两者衔接的体验并不流畅。 从上面对 PageView 的源码分析,在 ImageWrapper 中实现 HorizontalDragGestureRecognizer 手势拦截了 PageView 内部的水平拖拽手势,图片放大时通过 Scale 手势回调计算位置(图片移动),当图片移动到边界时,将手势描述(DragStartDetails)传给外部的 PageView,在回调中 PageController 的 ScrollPosition 生成一个 Drag,紧接着 DragUpdateDetails 用于 drag 对象的更新。需要注意在手势事件结束时需要调用 drag.end 保持手势事件的完整性。这种方法较完美的解决了上面冲突的问题,并且通过 Flutter 自身提供的方法实现,在 HorizontalDrag 手势结束时 PageController 会处理这部分滑动的动画。

Scale 手势与 HorizontalDrag 手势的冲突

在极端的情况下,双指不同时接触到屏幕,并且至少有一根手指是横向移动,图片缩放和位置会出现异常。通过上面的竞争分析,在其中一根手指出现横向滑动的时,HorizontalDrag 在竞争中胜出,此处图片的位置会被 HorizontalDrag 手势的回调改变(图片浏览器 ImageWrapper 实现是在 Scale 和 HorizontalDrag 手势回调中协同控制图片的缩放和位移)。 由于两个手势在以上的情况下会互相切换导致异常。首先将 Scale 和 HorizontalDrag 两个手势的职责划分清楚,HorizontalDrag 的回调处理图片滚动到边界时将 Drag 事件抛出给 PageView 的 PageController 处理;Scale 的回调只处理缩放和除边界以外的位移。划分清职责之后,让两个手势同时存在那么就不存在竞争胜出者的切换的问题,那么图片缩放和位置会也就不会出现异常。 通过继承 ScaleGestureRecognizer 重写 rejectGesture 方法强制让 Scale 手势生效。从 GestureArena 的源码分析,rejectGesture 方法只在竞争结束之后收尾处理调用的,所以不会影响竞争场的竞争。并且重写 rejectGesture 方法之后可以继续追踪事件(ScaleGestureRecognizer 中 rejectGesture 实现是停止事件追踪)。

小结

解决完上面两个比较棘手的冲突问题,图片浏览器组件的雏形也有了。由于篇幅原因,很多实现的细节没有一一列举,比如如何去计算边界,图片移动距离计算等等。在解决上面的问题也花费一定的时间,在解决问题没有思路可能要回归到问题本身,拆解问题,再逐个突破。好在 Flutter 是开源的,我们可以通过源码找到问题解决的思路和方法。希望以上的解决方案能帮助到开发者,提供解决问题的思路。

展望

图片浏览器想要更好体验接下来还需要对交互细节和临界状态处理更加细致。比如在图片放大之后滚动支持一定的加速度;图片放大之后滚动到边缘时增加阻尼等等。要想极致的用户体验,这几个内容都是我们将来可能要探索的方向。


作者:闲鱼技术
链接:https://juejin.cn/post/7007996934757548069
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

实践 | Google I/O 应用是如何适配大尺寸屏幕 UI 的?

5 月 18 日至 20 日,我们以完全线上的形式举办了 Google 每年一度的 I/O 开发者大会,其中包括 112 场会议、151 个 Codelab、79 场开发者聚会、29 场研讨会,以及众多令人兴奋的发布。尽管今年的大会没有发布新版的 Google...
继续阅读 »

5 月 18 日至 20 日,我们以完全线上的形式举办了 Google 每年一度的 I/O 开发者大会,其中包括 112 场会议、151 个 Codelab、79 场开发者聚会、29 场研讨会,以及众多令人兴奋的发布。尽管今年的大会没有发布新版的 Google I/O 应用,我们仍然更新了代码库来展示时下 Android 开发最新的一些特性和趋势。


应用在大尺寸屏幕 (平板、可折叠设备甚至是 Chrome OS 和台式个人电脑) 上的使用体验是我们的关注点之一: 在过去的一年中,大尺寸屏幕的设备越来越受欢迎,用户使用率也越来越高,如今已增长到 2.5 亿台活跃设备了。因此,让应用能充分利用额外的屏幕空间显得尤其重要。本文将展示我们为了让 Google I/O 应用在大尺寸屏幕上更好地显示而用到的一些技巧。


响应式导航


在平板电脑这类宽屏幕设备或者横屏手机上,用户们通常握持着设备的两侧,于是用户的拇指更容易触及侧边附近的区域。同时,由于有了额外的横向空间,导航元素从底部移至侧边也显得更加自然。为了实现这种符合人体工程学的改变,我们在用于 Android 平台的 Material Components 中新增了 Navigation rail


△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。


△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。


Google I/O 应用在主 Activity 中使用了两个不同的布局,其中包含了我们的人体工程学导航。其中在 res/layout 目录下的布局中包含了 BottomNavigationView,而在 res/layout-w720dp 目录下的布局中则包含了 NavigationRailView。在程序运行过程中,我们可以通过 Kotlin 的安全调用操作符 (?.) 来根据当前的设备配置确定呈现给用户哪一个视图。


private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)

// 根据配置不同,可能存在下面两种导航视图之一。
binding.bottomNavigation?.apply {
configureNavMenu(menu)
setupWithNavController(navController)
setOnItemReselectedListener { } // 避免导航到同一目的界面。
}
binding.navigationRail?.apply {
configureNavMenu(menu)
setupWithNavController(navController)
setOnItemReselectedListener { } // 避免导航到同一目的界面。
}
...
}

小贴士: 即使您不需要数据绑定的所有功能,您仍然可以使用 视图绑定 来为您的布局生成绑定类,这样就能避免调用 findViewById 了。


单窗格还是双窗格


在日程功能中,我们用列表-详情的模式来展示信息的层次。在宽屏幕设备上,显示区域被划分为左侧的会议列表和右侧的所选会议详细信息。这种布局方式带来的一个特别的挑战是,同一台设备在不同的配置下可能有不同的最佳显示方式,比如平板电脑竖屏对比横屏显示就有差异。由于 Google I/O 应用使用了 Jetpack Navigation 实现不同界面之间的切换,这个挑战对导航图有怎样的影响,我们又该如何记录当前屏幕上的内容呢?


△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。


△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。


我们采用了 SlidingPaneLayout,它为上述问题提供了一个直观的解决方案。双窗格会一直存在,但根据屏幕的尺寸,第二窗格可能不会显示在可视范围当中。只有在给定的窗格宽度下仍然有足够的空间时,SlidingPaneLayout 才会同时将两者显示出来。我们分别为会议列表和详情窗格分配了 400dp 和 600dp 的宽度。经过一些实验,我们发现即使是在大屏幕的平板上,竖屏模式同时显示出双窗格内容会使得信息的显示过于密集,所以这两个宽度值可以保证只在横屏模式下才同时展现全部窗格的内容。


至于导航图,日程的目的地页面现在是双窗格 Fragment,而每个窗格中可以展示的目的地都已经被迁移到新的导航图中了。我们可以用某窗格的 NavController 来管理该窗格内包含的各个目的页面,比如会议详情、讲师详情。不过,我们不能直接从会议列表导航到会议详情,因为两者如今已经被放到了不同的窗格中,也就是存在于不同的导航图里。


我们的替代方案是让会议列表和双窗格 Fragment 共享同一个 ViewModel,其中又包含了一个 Kotlin 数据流。每当用户从列表选中一个会议,我们会向数据流发送一个事件,随后双窗格 Fragment 就可以收集此事件,进而转发到会议详情窗格的 NavController:


val detailPaneNavController = 
(childFragmentManager.findFragmentById(R.id.detail_pane) as NavHostFragment)
.navController
scheduleTwoPaneViewModel.selectSessionEvents.collect { sessionId ->
detailPaneNavController.navigate(
ScheduleDetailNavGraphDirections.toSessionDetail(sessionId)
)
// 在窄屏幕设备上,如果会议详情窗格尚未处于最顶端时,将其滑入并遮挡在列表上方。
// 如果两个窗格都已经可见,则不会产生执行效果。
binding.slidingPaneLayout.open()
}

正如上面的代码中调用 slidingPaneLayout.open() 那样,在窄屏幕设备上,滑入显示详情窗格已经成为了导航过程中的用户可见部分。我们也必须要将详情窗格滑出,从而通过其他方式 "返回" 会议列表。由于双窗格 Fragment 中的各个目的页面已经不属于应用主导航图的一部分了,因此我们无法通过按设备上的后退按钮在窗格内自动向后导航,也就是说,我们需要实现这个功能。


上面这些情况都可以在 OnBackPressedCallback 中处理,这个回调在双窗格 Fragment 的 onViewCreated() 方法执行时会被注册 (您可以在这里了解更多关于添加 自定义导航 的内容)。这个回调会监听滑动窗格的移动以及关注各个窗格导航目的页面的变化,因此它能够评估下一次按下返回键时应该如何处理。


class ScheduleBackPressCallback(
private val slidingPaneLayout: SlidingPaneLayout,
private val listPaneNavController: NavController,
private val detailPaneNavController: NavController
) : OnBackPressedCallback(false),
SlidingPaneLayout.PanelSlideListener,
NavController.OnDestinationChangedListener {

init {
// 监听滑动窗格的移动。
slidingPaneLayout.addPanelSlideListener(this)
// 监听两个窗格内导航目的页面的变化。
listPaneNavController.addOnDestinationChangedListener(this)
detailPaneNavController.addOnDestinationChangedListener(this)
}

override fun handleOnBackPressed() {
// 按下返回有三种可能的效果,我们按顺序检查:
// 1. 当前正在详情窗格,从讲师详情返回会议详情。
val listDestination = listPaneNavController.currentDestination?.id
val detailDestination = detailPaneNavController.currentDestination?.id
var done = false
if (detailDestination == R.id.navigation_speaker_detail) {
done = detailPaneNavController.popBackStack()
}
// 2. 当前在窄屏幕设备上,如果详情页正在顶层,尝试将其滑出。
if (!done) {
done = slidingPaneLayout.closePane()
}
// 3. 当前在列表窗格,从搜索结果返回会议列表。
if (!done && listDestination == R.id.navigation_schedule_search) {
listPaneNavController.popBackStack()
}

syncEnabledState()
}

// 对于其他必要的覆写,只需要调用 syncEnabledState()。

private fun syncEnabledState() {
val listDestination = listPaneNavController.currentDestination?.id
val detailDestination = detailPaneNavController.currentDestination?.id
isEnabled = listDestination == R.id.navigation_schedule_search ||
detailDestination == R.id.navigation_speaker_detail ||
(slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen)
}
}

SlidingPaneLayout 最近也针对可折叠设备进行了优化更新。更多关于使用 SlidingPaneLayout 的信息,请参阅: 创建双窗格布局


资源限定符的局限


搜索应用栏也在不同屏幕内容下显示不同内容。当您在搜索时,可以选择不同的标签来过滤需要显示的搜索结果,我们也会把当前生效的过滤标签显示在以下两个位置之一: 窄模式时位于搜索文本框下方,宽模式时位于搜索文本框的后面。可能有些反直觉的是,当平板电脑横屏时属于窄尺寸模式,而当其竖屏使用时属于宽尺寸模式。


△ 平板横屏时的搜索应用栏 (窄模式)


△ 平板横屏时的搜索应用栏 (窄模式)


△ 平板竖屏时的搜索应用栏 (宽模式)


△ 平板竖屏时的搜索应用栏 (宽模式)


此前,我们通过在搜索 Fragment 的视图层次中的应用栏部分使用 <include> 标签,并提供两种不同版本的布局来实现此功能,其中一个被限定为 layout-w720dp 这样的规格。如今此方法行不通了,因为在那种情况下,带有这些限定符的布局或是其他资源文件都会被按照整屏幕宽度解析,但事实上我们只关心那个特定窗格的宽度。


要实现这一特性,请参阅搜索 布局 的应用栏部分代码。请注意两个 ViewStub 元素 (第 27 和 28 行)。


<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
... >

<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="?actionBarSize">

<!-- Toolbar 不支持 layout_weight,所以我们引入一个中间布局 LinearLayout。-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:showDividers="middle"
... >

<SearchView
android:id="@+id/searchView"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
... />

<!-- 宽尺寸时过滤标签的 ViewStub。-->
<ViewStub
android:id="@+id/active_filters_wide_stub"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
android:layout="@layout/search_active_filters_wide"
... />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>

<!-- 窄尺寸时过滤标签的 ViewStub。-->
<ViewStub
android:id="@+id/active_filters_narrow_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/search_active_filters_narrow"
... />
</com.google.android.material.appbar.AppBarLayout>

两个 ViewStub 各自指向不同的布局,但都只包含了一个 RecyclerView (虽然属性略有不同)。这些桩 (stub) 在运行时直到内容 inflate 之前都不会占据可视空间。剩下要做的就是当我们知道窗格有多宽之后,选择要 inflate 的桩。所以我们只需要使用 doOnNextLayout 扩展函数,等待 onViewCreated() 中对 AppBarLayout 进行首次布局即可。


binding.appbar.doOnNextLayout { appbar ->
if (appbar.width >= WIDE_TOOLBAR_THRESHOLD) {
binding.activeFiltersWideStub.viewStub?.apply {
setOnInflateListener { _, inflated ->
SearchActiveFiltersWideBinding.bind(inflated).apply {
viewModel = searchViewModel
lifecycleOwner = viewLifecycleOwner
}
}
inflate()
}
} else {
binding.activeFiltersNarrowStub.viewStub?.apply {
setOnInflateListener { _, inflated ->
SearchActiveFiltersNarrowBinding.bind(inflated).apply {
viewModel = searchViewModel
lifecycleOwner = viewLifecycleOwner
}
}
inflate()
}
}
}

转换空间


Android 一直都可以创建在多种屏幕尺寸上可用的布局,这都是由 match_parent 尺寸值、资源限定符和诸如 ConstraintLayout 的库来实现的。然而,这并不总是能在特定屏幕尺寸下为用户带来最佳的体验。当 UI 元素拉伸过度、相距过远或是过于密集时,往往难以传达信息,触控元素也变得难以辨识,并导致应用的可用性受到影响。


对于类似 "Settings" (设置) 这样的功能,我们的短列表项在宽屏幕上会被拉伸地很严重。由于这些列表项本身不太可能有新的布局方式,我们可以通过 ConstraintLayout 限制列表宽度来解决。


<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintWidth_percent="@dimen/content_max_width_percent">

<!-- 设置项……-->

</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

在第 10 行,@dimen/content_max_width_percent 是一个浮点数类型的尺寸值,根据不同的屏幕宽度可能有不同的值。这些值从小屏幕的 1.0 开始渐渐减少到宽屏幕的 0.6,所以当屏幕变宽,UI 元素也不会因为拉伸过度而产生割裂感。


△ 宽屏幕设备上的设置界面


△ 宽屏幕设备上的设置界面


请您阅读这则关于支持不同屏幕尺寸的 指南,获得常见尺寸分界点的参考信息。


转换内容


Codelabs 功能与设置功能有相似的结构。但我们想要充分利用额外的屏幕空间,而不是限制显示内容的宽度。在窄屏幕设备上,您会看到一列项目,它们会在点击时展开或折叠。在宽尺寸屏幕上,这些列表项会转换为一格一格的卡片,卡片上直接显示了详细的内容。


△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。


△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。


这些独立的网格卡片是定义在 res/layout-w840dp 下的 备用布局,数据绑定处理信息如何与视图绑定,以及卡片如何响应点击,所以除了不同样式下的差异之外,不需要实现太多内容。另一方面,整个 Fragment 没有备用布局,所以让我们看看在不同的配置下实现所需的样式和交互都用到了哪些技巧吧。


所有的一切都集中在这个 RecyclerView 元素上:


<androidx.recyclerview.widget.RecyclerView
android:id="@+id/codelabs_list"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingHorizontal="@dimen/codelabs_list_item_spacing"
android:paddingVertical="8dp"
app:itemSpacing="@{@dimen/codelabs_list_item_spacing}"
app:layoutManager="@string/codelabs_recyclerview_layoutmanager"
app:spanCount="2"
……其他的布局属性……/>

这里提供了两个资源文件,每一个在我们为备用布局选择的尺寸分界点上都有不同的值:






















资源文件无限定符版本 (默认)-w840dp
@string/codelabs_recyclerview_layoutmanagerLinearLayoutManagerStaggeredGridLayoutManager
@dimen/codelabs_list_item_spacing0dp8dp

我们通过在 XML 文件中把 app:layoutManager 的值设置为刚才的字符串资源,然后同时设置 android:orientationapp:spanCount 实现布局管理器的配置。注意,朝向属性 (orientation) 对两种布局管理器而言是相同的,但是横向跨度 (span count) 只适用于 StaggeredGridLayoutManager,如果被填充的布局管理器是 LinearLayoutManager,那么它会简单地忽略设定的横向跨度值。


用于 android:paddingHorizontal 的尺寸资源同时也被用于另一个属性 app:itemSpacing。它不是 RecyclerView 的标准属性,那它从何而来?这其实是由 Binding Adapter 定义的一个属性,而 Binding Adapter 是我们向数据绑定库提供自定义逻辑的方法。在应用运行时,数据绑定会调用下面的函数,并将解析自资源文件的值作为参数传进去。


@BindingAdapter("itemSpacing")
fun itemSpacing(recyclerView: RecyclerView, dimen: Float) {
val space = dimen.toInt()
if (space > 0) {
recyclerView.addItemDecoration(SpaceDecoration(space, space, space, space))
}
}

SpaceDecorationItemDecoration 的一种简单实现,它在每个元素周围保留一定空间,这也解释了为什么我们会在 840dp 或更宽的屏幕上 (需要为 @dimen/codelabs_list_item_spacing 给定一个正值) 得到始终相同的元素间隔。将 RecyclerView 自身的内边距也设置为相同的值,会使得元素同 RecyclerView 边界的距离与元素间的空隙保持相同的大小,在元素周围形成统一的留白。为了让元素能够一直滚动显示到 RecyclerView 的边缘,需要设置 android:clipToPadding="false"



屏幕越多样越好


Android 一直是个多样化的硬件生态系统。随着更多的平板和可折叠设备在用户中普及,请确保在这些不同尺寸和屏幕比例中测试您的应用,这样一些用户就不会觉得自己被 "冷落" 了。Android Studio 同时提供了 可折叠模拟器自由窗口模式 以简化这些测试过程,因此您可以通过它们来检查您的应用对于上述场景的响应情况。


我们希望这些 Google I/O 应用上的变动能启发您构建充分适配各种形状和尺寸设备的美观、高质量的应用。欢迎您从 Github 下载代码,动手试一试。


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

Android Camera了解一下

Camera 演进简介最近在项目中遇到 Camera相关的场景,之前对这块不是很了解,趁机补了一下盲区。Android Camera 相关也是生态碎片化较为严重的一块,Android FrameWorkt提供Camera API来实现拍照与屏幕录制的能力,目前...
继续阅读 »

Camera 演进简介

最近在项目中遇到 Camera相关的场景,之前对这块不是很了解,趁机补了一下盲区。Android Camera 相关也是生态碎片化较为严重的一块,Android FrameWorkt提供Camera API来实现拍照与屏幕录制的能力,目前Android有三类API

  • Camera (为了便于区分 下面简称 Camera1)

此类是用于控制设备相机的旧版API,在Android5.0以下使用,现已Deprecated

Android 5.0以上升级的方案,控制设备相机的API,并且开放出硬件支持级别的厂商定制

(谷歌开放出官方库CameraView 帮助解决相机兼容性问题,也有其他一些三方库)

JetPack中引入,基与Cmaera2 API封装,简化了开发流程,并增加生命周期控制

\

那么 Camera和Camera2如何使用?Camera2优秀在哪里?官方开发的的CameraView和CameraX又是为了解决什么问题,下面来一个个了解下。

Camera1和Camera2实践

相机开发核心流程如下

  1. 检查权限
  2. 检测设备摄像头,打开相机
  1. 创建预览帧 显示实时画面 (一般是通过 SurfaceView、TextureView进行实时预览),每个相机有支持预览的尺寸比如4:3或者16:9、11:9等,比如定制或者横竖屏场景 需要计算合适的预览尺寸
  2. 设置相机参数,进行拍照监听
  1. 拍照 保存图片或者操作原始数据
  2. 释放相机资源

其中步骤3在Camera1和Camera2中稍有不同,Camera1拍照前必须先开启预览,而Camera2流程做了解耦,可以无需预览直接拍照

Camera1

代码

权限声明

<uses-feature
android:name="android.hardware.camera"
android:required="true" />

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

在Android 6.0以上需要动态申请权限

ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA},
REQUEST_CAMERA_PERMISSION);

打开相机

支持传入id

Camera.open() //默认后置摄像头
Camera.open(int id)

创建预览

private SurfaceView mSurfaceView;
private SurfaceHolder mSurfaceHolder;
...
mSurfaceHolder = mSurfaceView.getHolder();
mSurfaceHolder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
...
startPreview();
}

@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

}

@Override
public void surfaceDestroyed(SurfaceHolder holder) {
releaseCamera();
}
});
...
private void startPreview() {
try {
//设置实时预览
mCamera.setPreviewDisplay(mSurfaceHolder);
//Orientation
setCameraDisplayOrientation();
//开始预览
mCamera.startPreview();
……
} catch (Exception e) {
e.printStackTrace();
}
}

在设置预览时候可以通过 setPreviewCallback( Camera.PreviewCallback

)监听预览数据的数据

设置相机参数

Camera.Parameters

//获取Parameters对象
mParameters = camera?.parameters

设置相机相关参数内容非常丰富,这里介绍几个常用的

  • setFocusMode 设置对焦模式
  • setPreviewSize 设置预览图片大小 (相机支持不同的预览尺寸,比如横竖屏需要计算预览尺寸d可以看下谷歌的CameraView中的CmaeraView1#chooseOptimalSize处理)
  • setPreviewFormat 设置预览格式 默认返回NV21
  • setPictureSize 设置保存图片的大小
  • setPreviewDisplay 设置实时预览 SurfaceHolder
  • setPreviewCallback 监听相机预览数据回调

拍照

mCamera.takePicture

private void takePicture() {
if (null != mCamera) {
mCamera.takePicture(new Camera.ShutterCallback() {
@Override
public void onShutter() {
//按下快门后的回调

}
}, new Camera.PictureCallback() {
@Override
public void onPictureTaken(byte[] data, Camera camera) {
//回调没压缩的 base data
}
}, new Camera.PictureCallback() {
@Override
public void onPictureTaken(final byte[] data, Camera camera) {
mCamera.startPreview();
//save data
}
});
}
}

释放相机资源

mCamera.stopPreview

mCamera.release

private void releaseCamera() {
if (null != mCamera) {
mCamera.stopPreview();
mCamera.stopFaceDetection();
mCamera.setPreviewCallback(null);
mCamera.release();
mCamera = null;
}
}

Camera2

从 Android 5.0 开始,Google 引入了一套全新的相机框架 Camera2(android.hardware.camera2)并且废弃了旧的相机框架 Camera1(android.hardware.Camera) ,相较于Camera1,Camera2架构上发生了变化,主要是将相机设备模拟成一个管道,按照顺序处理每一帧的请求并返回给调用方,API上的使用难度,本节先介绍下Camera2在核心流程上的使用

\

由于Camera2架构设计成了管道,在拍照流程中细分出了通过3个类来协同

  • CaptureRequest

相机捕获图像的设置请求,包含传感器,镜头,闪光灯等

  • CaptureRequest.Builder

CaptureRequest的构造器,使用Builder模式,设置更加方便

  • CameraCaptureSession

请求抓取相机图像帧的会话,会话的建立主要会建立起一个通道。一个CameraDevice一次只能开启一个CameraCaptureSession。

源端是相机,另一端是 Target,Target可以是Preview,也可以是ImageReader。

相机过程中处理数据也做了一些优化,抽象出了

  • ImageReader

用于从相机打开的通道中读取需要的格式的原始图像数据,可以设置多个ImageReader。

  • CameraCharacteristics

主要用于获取相机信息,内部携带大量的信息信息

  • CameraDevice

相机设备类,和Camera1中的Camera同级

  • CmaeraManager

相机系统服务,用于管理和连接相机设备

代码

\

拍摄流程重新抽象

  • 创建一个用于从Pipeline获取图片的CaptureRequest
  • 修改CaptureRequest的配置
  • 创建两个不同尺寸的Surface用于接收图片数据,并且将他们添加到CaptureRequest中
  • 发送配置好的CaptureRequest 到Pieline中等待结果

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束

获取相机服务

CameraManager cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

根据相机ID获取相机信息 CameraCharateristics

private boolean chooseCameraIdByFacing() {
try {
int internalFacing = INTERNAL_FACINGS.get(mFacing);
final String[] ids = mCameraManager.getCameraIdList();
……
for (String id : ids) {
CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(id);
Integer level = characteristics.get(
CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL);
if (level == null ||
level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
continue;
}
Integer internal = characteristics.get(CameraCharacteristics.LENS_FACING);
if (internal == null) {
throw new NullPointerException("Unexpected state: LENS_FACING null");
}
if (internal == internalFacing) {
mCameraId = id;
mCameraCharacteristics = characteristics;
return true;
}
}
// Not found
……
// The operation can reach here when the only camera device is an external one.
// We treat it as facing back.
mFacing = Constants.FACING_BACK;
return true;
} catch (CameraAccessException e) {
throw new RuntimeException("Failed to get a list of camera devices", e);
}
}

初始化ImageReader

ImageReader是获取图像数据的重要途径,通过它可以获取到不同格式的图像数据,例如JPEG、YUV、RAW等。通过ImageReader.newInstance(int width, int height, int format, int maxImages)创建ImageReader对象,有4个参数:

  • width:图像数据的宽度
  • height:图像数据的高度
  • format:图像数据的格式,例如ImageFormat.JPEG,ImageFormat.YUV_420_888等
  • maxImages:最大Image个数,Image对象池的大小,指定了能从ImageReader获取Image对象的最大值,过多获取缓冲区可能导致OOM,所以最好按照最少的需要去设置这个值

ImageReader其他相关的方法和回调:

  • ImageReader.OnImageAvailableListener:有新图像数据的回调
  • acquireLatestImage():从ImageReader的队列里面,获取最新的Image,删除旧的,如果没有可用的Image,返回null
  • acquireNextImage():获取下一个最新的可用Image,没有则返回null
  • close():释放与此ImageReader关联的所有资源
  • getSurface():获取为当前ImageReader生成Image的Surface
private void prepareImageReader() {
if (mImageReader != null) {
mImageReader.close();
}
Size largest = mPictureSizes.sizes(mAspectRatio).last();
mImageReader = ImageReader.newInstance(largest.getWidth(), largest.getHeight(),
ImageFormat.JPEG, /* maxImages */ 2);
mImageReader.setOnImageAvailableListener(mOnImageAvailableListener, null);
}

private final ImageReader.OnImageAvailableListener mOnImageAvailableListener
= new ImageReader.OnImageAvailableListener() {

@Override
public void onImageAvailable(ImageReader reader) {
try (Image image = reader.acquireNextImage()) {
Image.Plane[] planes = image.getPlanes();
if (planes.length > 0) {
ByteBuffer buffer = planes[0].getBuffer();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
mCallback.onPictureTaken(data);
}
}
}

};

打开相机设备

cameraManager.openCamera(@NonNull String cameraId,@NonNull final CameraDevice.StateCallback callback, @Nullable Handler handler)的三个参数:

  • cameraId:摄像头的唯一标识
  • callback:设备连接状态变化的回调
  • handler:回调执行的Handler对象,传入null则使用当前的主线程Handler
mCameraManager.openCamera(mCameraId, mCameraDeviceCallback, null);

private final CameraDevice.StateCallback mCameraDeviceCallback
= new CameraDevice.StateCallback() {

@Override
public void onOpened(@NonNull CameraDevice camera) {
//表示相机打开成功,可以真正开始使用相机,创建Capture会话
mCamera = camera;
mCallback.onCameraOpened();
//创建Capture会话
startCaptureSession();
}

@Override
public void onClosed(@NonNull CameraDevice camera) {
//调用Camera.close()后的回调方法
mCallback.onCameraClosed();
}

@Override
public void onDisconnected(@NonNull CameraDevice camera) {
//当相机断开连接时回调该方法,需要进行释放相机的操作
mCamera = null;
}

@Override
public void onError(@NonNull CameraDevice camera, int error) {
//当相机打开失败时,需要进行释放相机的操作
Log.e(TAG, "onError: " + camera.getId() + " (" + error + ")");
mCamera = null;
}

};

创建 CaptureRequest及其target配置

Camera2是通过管道链接 request+target建立会话,首先我们得通过CaptureRequest.Builder配置好

使用 TEMPLATE_STILL_CAPTURE 模板创建一个用于拍照的 CaptureRequest.Builder 对象,并且添加拍照的 Surface 和预览的 Surface 到其中:

captureImageRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
captureImageRequestBuilder.addTarget(previewDataSurface)
captureImageRequestBuilder.addTarget(jpegSurface)

通过CameraDevice.createCaptureRequest()创建CaptureRequest.Builder对象,传入一个templateType参数,templateType用于指定使用何种模板创建CaptureRequest.Builder对象,templateType的取值:

  • TEMPLATE_PREVIEW:预览模式
  • TEMPLATE_STILL_CAPTURE:拍照模式
  • TEMPLATE_RECORD:视频录制模式
  • TEMPLATE_VIDEO_SNAPSHOT:视频截图模式
  • TEMPLATE_MANUAL:手动配置参数模式

除了模式的配置,CaptureRequest还可以配置很多其他信息,例如图像格式、图像分辨率、传感器控制、闪光灯控制、3A(自动对焦-AF、自动曝光-AE和自动白平衡-AWB)控制等。在createCaptureSession的回调中可以进行设置

// Auto focus should be continuous for camera preview.
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
// Flash is automatically enabled when necessary.
setAutoFlash(mPreviewRequestBuilder);

// Finally, we start displaying the camera preview.
mPreviewRequest = mPreviewRequestBuilder.build();

拍照和预览

在Camera2中拍照和预览是解耦开的,有了CaptureRequest之后,需要借助CaptureSession(Capture会话)来描述

mCameraDevice.createCaptureSession()创建Capture会话,它接受了三个参数:

  • outputs:用于接受图像数据的surface集合
  • callback:用于监听 Session 状态的CameraCaptureSession.StateCallback对象
  • handler:用于执行CameraCaptureSession.StateCallback的Handler对象,传入null则使用当前的主线程Handler

\

拍照

mCaptureSession.capture(mPreviewRequestBuilder.build(), mCaptureCallback, mBackgroundHandler);

该方法也有三个参数,和mCaptureSession.setRepeatingRequest一样:

  • request:CaptureRequest对象
  • listener:监听Capture 状态的回调
  • handler:用于执行CameraCaptureSession.CaptureCallback的Handler对象,传入null则使用当前的主线程Handler

这里设置了mCaptureCallback:

   PictureCaptureCallback mCaptureCallback = new PictureCaptureCallback() {

@Override
public void onPrecaptureRequired() {
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START);
setState(STATE_PRECAPTURE);
try {
mCaptureSession.capture(mPreviewRequestBuilder.build(), this, null);
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE);
} catch (CameraAccessException e) {
Log.e(TAG, "Failed to run precapture sequence.", e);
}
}

@Override
public void onReady() {
captureStillPicture();
}

};

\

预览

Camera2中,通过连续重复的Capture实现预览功能,每次Capture会把预览画面显示到对应的Surface上。连续重复的Capture操作通过mCaptureSession.setRepeatingRequest(mPreviewRequest,mCaptureCallback, mBackgroundHandler)实现,该方法有三个参数:

  • request:CaptureRequest对象
  • listener:监听Capture 状态的回调
  • handler:用于执行CameraCaptureSession.CaptureCallback的Handler对象,传入null则使用当前的主线程Handler

停止预览使用mCaptureSession.stopRepeating()方法。

\

释放相机资源

先后对CaptureSession,CameraDevice,ImageReader进行close操作,释放资源

void stop() {
if (mCaptureSession != null) {
mCaptureSession.close();
mCaptureSession = null;
}
if (mCamera != null) {
mCamera.close();
mCamera = null;
}
if (mImageReader != null) {
mImageReader.close();
mImageReader = null;
}
}

\

Cmaera2做了哪些优化

架构升级

参考 android 官网 source.android.com/devices/cam…

架构上,Camera2 的 API 模型被设计成一个 Pipeline(管道),它按顺序处理每一帧的请求并返回请求结果给客户端。下面这张来自官方的图展示了 Pipeline 的工作流程

\

在CaptureRequest中设置不同的Surface用于接收不同的图片数据,最后从不同的Surface中获取到图片数据和包含拍照相关信息的CaptureResult

假设我们想要同时拍摄两张不同尺寸的图片,并且在拍摄的过程中闪光灯必须亮起来。整个拍摄流程如下:

  1. 创建一个用于从 Pipeline 获取图片的 CaptureRequest。
  2. 修改 CaptureRequest 的闪光灯配置,让闪光灯在拍照过程中亮起来。
  1. 创建两个不同尺寸的 Surface 用于接收图片数据,并且将它们添加到 CaptureRequest 中。
  2. 发送配置好的 CaptureRequest 到 Pipeline 中等待它返回拍照结果。

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束。

其中Caputure有以下三种工作模式

  • 单次模式 One-shot

指的是一次的Capture操作,例如设置闪光灯、对焦模式、拍一张照片,多个单次模式的Capture会进入队列按照顺序执行

  • 多次模式 Burst

指的是连续多次执行指定的Capture操作,该模式执行期间不允许插入其他Capture操作,如连续拍摄100张照片,在这100张照片拍摄期间任何新的capture都会等待

  • 重复模式 Repeating

指的是不断重复执行指定的Capture操作,当有其他模式的Capture提交会暂停改模式转而执行其他模式的Capture

\

Supported Hardware Level

相机功能的强大与否和硬件息息相关,不同厂商对 Camera2 的支持程度也不同,所以 Camera2 定义了一个叫做 Supported Hardware Level 的重要概念,其作用是将不同设备上的 Camera2 根据功能的支持情况划分成多个不同级别以便开发者能够大概了解当前设备上 Camera2 的支持情况。截止到 Android P 为止,从低到高一共有 LEGACY、LIMITED、FULL 和 LEVEL_3 四个级别:

  • LEGACY:向后兼容的级别,处于该级别的设备意味着它只支持 Camera1 的功能,不具备任何 Camera2 高级特性。
  • LIMITED:除了支持 Camera1 的基础功能之外,还支持部分 Camera2 高级特性的级别。
  • FULL:支持所有 Camera2 的高级特性。
  • LEVEL_3:新增更多 Camera2 高级特性,例如 YUV 数据的后处理等

\

新特性

  • 支持在开启相机前检查相机信息
  • 在不开启预览情况下拍照
  • 一次拍摄多张不同格式和尺寸的图片
  • 控制曝光时间
  • 连拍

\

简化开发谷歌做的努力

其他优秀三方库

github.com/CameraKit/c…

github.com/natario1/Ca…

CameraView

主要是为了解决不同版本Camer使用兼容性问题

根据官方的说明:

API LevelCamera APIPreview View
9-13Camera1SurfaceView
14-20Camera1TextureView
21-23Camera2TextureView
24Camera2SurfaceView

  • Camera 区分:Android5.0(21)以下使用 Camera1,以上使用 Camera2
  • Preview View:Android6.0(23)以上使用SurfaceView(SurfaceView在Android7.0上增加了新特性(平移、旋转等)),这里应该是 Android7.0以上(>23)使用SurfaceView,其他都使用TextureView,最新的源码sdk最低版本要求14。

类图如下

通过桥接模式+ 适配器模式,抽象出相机操作的抽象类CameraViewImpl和预览抽象类PreviewImpl,业务侧只操作接口

public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
……
// Internal setup
// 1.创建预览视图
final PreviewImpl preview = createPreviewImpl(context);
mCallbacks = new CallbackBridge();
// 2.根据 Android SDK 版本选择不同的 Camera
if (Build.VERSION.SDK_INT < 21) {
mImpl = new Camera1(mCallbacks, preview);
} else if (Build.VERSION.SDK_INT < 23) {
mImpl = new Camera2(mCallbacks, preview, context);
} else {
mImpl = new Camera2Api23(mCallbacks, preview, context);
}
……
// 设置相机 ID,如前置或者后置
setFacing(a.getInt(R.styleable.CameraView_facing, FACING_BACK));
// 设置预览界面的比例,如 4:3 或者 16:9
String aspectRatio = a.getString(R.styleable.CameraView_aspectRatio);
if (aspectRatio != null) {
setAspectRatio(AspectRatio.parse(aspectRatio));
} else {
setAspectRatio(Constants.DEFAULT_ASPECT_RATIO);
}
// 设置对焦方式
setAutoFocus(a.getBoolean(R.styleable.CameraView_autoFocus, true));
// 设置闪光灯
setFlash(a.getInt(R.styleable.CameraView_flash, Constants.FLASH_AUTO));
a.recycle();
// Display orientation detector
// 初始化显示设备(主要指手机屏幕)的旋转监听,主要用来设置相机的旋转方向
mDisplayOrientationDetector = new DisplayOrientationDetector(context) {
@Override
public void onDisplayOrientationChanged(int displayOrientation) {
mImpl.setDisplayOrientation(displayOrientation);
}
};
}

createPreViewImpl实现

private PreviewImpl createPreviewImpl(Context context) {
PreviewImpl preview;
if (Build.VERSION.SDK_INT >= 23) {
preview = new SurfaceViewPreview(context, this);
} else {
preview = new TextureViewPreview(context, this);
}
return preview;
}

使用起来也比较简单

<com.google.android.cameraview.CameraView
android:id="@+id/camera"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:keepScreenOn="true"
android:adjustViewBounds="true"
app:autoFocus="true"
app:aspectRatio="4:3"
app:facing="back"
app:flash="auto"/>

CameraX


CameraX 是一个 Jetpack 支持库,目的是简化Camera的开发工作,它是基于Camera2 API的基础,向后兼容至 Android 5.0(API 级别 21)。
它有以下几个特性:

  • 易用性,只需要几行代码就可以实现预览和拍照
  • 保持设备的一致性,在不同相机设备上,对宽高比、屏幕方向、旋转、预览大小和高分辨率图片大小,做到都可以正常使用
  • 相机特性的扩展,增加人像、HDR、夜间模式和美颜等功能
  • 具备生命周期的管理


作者:渡口一艘船
链接:https://juejin.cn/post/7008579597696499742
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

JetPack——ViewModel简析

简介ViewModel以生命周期的方式存储和管理界面相关的数据。让数据在发生屏幕旋转等配置更改后得以继续留存。同时,可以将数据操作从UI控制器(Activity)里分离出来,这样就只需要Activity控制UI逻辑而无需处理数据业务逻辑。在需要进行一些异步操作...
继续阅读 »

简介

ViewModel以生命周期的方式存储和管理界面相关的数据。让数据在发生屏幕旋转等配置更改后得以继续留存。同时,可以将数据操作从UI控制器(Activity)里分离出来,这样就只需要Activity控制UI逻辑而无需处理数据业务逻辑。在需要进行一些异步操作的时候,免去了在Activity里大量的维护工作,并避免了在Activity销毁时潜在的内存泄漏问题。

总结一下主要优点:

可以更容易的将数据操作逻辑与Activity分离。

ViewModel的实现和基本使用

JetPack为UI控制器提供了 ViewModel 辅助程序类,该类负责为界面准备数据。在配置更改期间会自动保留 ViewModel 对象,以便它们存储的数据立即可供下一个 activity 或 fragment 实例使用。

代码如下:

public class MyViewModel extends ViewModel {
private MutableLiveData<List<String>> users;
public LiveData<List<String>> getUsers() {
if (users == null) {
users = new MutableLiveData<List<String>>();
loadUsers();
}
return users;
}

private void loadUsers() {

}
}

使用:


public class MainActivity extends AppCompatActivity {

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);

model.getUsers().observe(this, users -> {

});

}
}

ViewModel的创建

注意在上面的代码中,并没有采用new MyViewModel()的方式去创建ViewModel。而是使用:

MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);

对应的在kotlin中使用:

private val myViewModel: MyViewModel by viewModels();

先说结论:使用ViewModelProvider去创建ViewModel,确保了重新创建了相同Activity时,它接收的MyViewModel实例与第一个Activity创建的ViewModel实例相同。可以简单的理解为他是一个单例。

接下来看源码是如何实现的:

首先看ViewModelProvider的构造方法和实现:


private final Factory mFactory;

private final ViewModelStore mViewModelStore;

public ViewModelProvider(@NonNull ViewModelStoreOwner owner) {

this(owner.getViewModelStore(), owner instanceof HasDefaultViewModelProviderFactory? ((HasDefaultViewModelProviderFactory) owner).getDefaultViewModelProviderFactory():NewInstanceFactory.getInstance());

}

public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {

mFactory = factory;

mViewModelStore = store;

}

构造方法很简单,创建一个ViewModelStoreFactory

准备工作

ViewModelStore解析

查看ViewModelStore的代码如下:


public class ViewModelStore {

private final HashMap<String, ViewModel> mMap = new HashMap<>();

final void put(String key, ViewModel viewModel) {

ViewModel oldViewModel = mMap.put(key, viewModel);

if (oldViewModel != null) {

oldViewModel.onCleared();

}

}

final ViewModel get(String key) {

return mMap.get(key);

}

Set<String> keys() {

return new HashSet<>(mMap.keySet());
}

public final void clear() {

for (ViewModel vm : mMap.values()) {

vm.clear();
}

mMap.clear();

}

}

代码很简单,就是一个HashMap存储ViewModel的键值对(HashMap源码要点解析)。

ViewModelStore通过接口ViewModelStoreOwnergetViewModelStore()方法获取,以ComponentActivity为例,具体实现如下:


//ComponentActivity.java:

static final class NonConfigurationInstances {

Object custom;

ViewModelStore viewModelStore;

}

@NonNull

@Override

public ViewModelStore getViewModelStore() {

if (getApplication() == null) {

throw new IllegalStateException("Your activity is not yet attached to the "

+ "Application instance. You can't request ViewModel before onCreate call.");

}

ensureViewModelStore();

return mViewModelStore;

}

@SuppressWarnings("WeakerAccess") /* synthetic access */

void ensureViewModelStore() {

if (mViewModelStore == null) {

NonConfigurationInstances nc =(NonConfigurationInstances) getLastNonConfigurationInstance();

if (nc != null) {

mViewModelStore = nc.viewModelStore;
}

if (mViewModelStore == null) {

mViewModelStore = new ViewModelStore();

}

}

}

其中getLastNonConfigurationInstance方法实际上是返回一个activity,也可以理解为是一个ComponentActivity的实例,在这里不做深入讨论,其在Activity中的具体实现为:


@Nullable

public Object getLastNonConfigurationInstance() {

return mLastNonConfigurationInstances != null? mLastNonConfigurationInstances.activity : null;

}

不难发现,每个Activity都会持有一个mViewModelStoreensureViewModelStore()方法确保了所有ViewModel获取到的ViewModelStore都为同一个。也就说:Activity通过持有mViewModelStore,使用HaspMap管理ViewModel。也不难发现,activity和ViewModel是一对多的关系。

Factory解析

Factory顾名思义,就是一个工厂模式。它是一个定义在ViewModelProvider中的接口,代码很简单:


public interface Factory {

@NonNull

<T extends ViewModel> T create(@NonNull Class<T> modelClass);

}

就是用来创建ViewModel的。

AppCompatActivity为例:它通过继承ComponentActivity,实现了ViewModelStoreOwner接口,而在ViewModelProvider初始化时,结合上面的代码,Factory的具体实现为:NewInstanceFactory.getInstance();

具体代码如下:

public static class NewInstanceFactory implements Factory {

private static NewInstanceFactory sInstance;


@NonNull
static NewInstanceFactory getInstance() {
if (sInstance == null) {
sInstance = new NewInstanceFactory();
}
return sInstance;
}

@SuppressWarnings("ClassNewInstance")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
try {
return modelClass.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
}
}
}

一个静态单例,返回一个用来创建ViewModel的工厂类,使用create()方法通过反射创建ViewModel实例。

真正创建

完成了准备工作, 接下来就看ViewModel具体的创建过程:


@NonNull

@MainThread

public <T extends ViewModel> T get(@NonNull Class<T> modelClass) {

String canonicalName = modelClass.getCanonicalName();

if (canonicalName == null) {

throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");

}

//用类名作为Key值

return get(DEFAULT_KEY + ":" + canonicalName, modelClass);

}

@NonNull

@MainThread

public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {

//首先查看Map中是否有当前ViewModel的实例

ViewModel viewModel = mViewModelStore.get(key);

if (modelClass.isInstance(viewModel)) {

if (mFactory instanceof OnRequeryFactory) {

((OnRequeryFactory) mFactory).onRequery(viewModel);

}

return (T) viewModel;

} else {

if (viewModel != null) {
}

}

///没有实例就创建实例

if (mFactory instanceof KeyedFactory) {

viewModel = ((KeyedFactory) mFactory).create(key, modelClass);

} else {

viewModel = mFactory.create(modelClass);

}

mViewModelStore.put(key, viewModel);

return (T) viewModel;

}

可以看到,首先使用类名作为key值,判断Activity所持有的ViewModelStore是否包含当前ViewModel的实例,有就直接拿来使用。如果没有,就通过上文中的Factory.create创建一个实例,并添加到mViewModelStore中。

总结一下:

  1. Activity通过HashMap持有所有的实例化后的ViewModel。

  2. ViewModelProvider通过单例工厂模式和ViewModelStore,也就是HashMap实现对ViewModel的创建和获取,以此保证ViewModel在当前Activity中保持单例。

ViewModel的生命周期

ViewModel 对象存在的时间范围是获取 ViewModel 时传递给 ViewModelProvider 的 Lifecycle。ViewModel 将一直留在内存中,直到限定其存在时间范围的 Lifecycle 永久消失:对于 activity,是在 activity 完成时;而对于 fragment,是在 fragment 分离时。

其生命周期如下图所示:

image.png

通常在Activity的onCreate()方法里创建/获取ViewModel。若在旋转设备屏幕时,再次调用onCreate(),结合上文中ViewModel的创建,其实并不会创建新的ViewModel实例,而是从HashMap中取出本就存在的实例。

因此:ViewModel 存在的时间范围是从您首次请求 ViewModel 直到 activity 完成并销毁。

ViewModel的销毁

销毁源码如下:

@SuppressWarnings("WeakerAccess")
protected void onCleared() {
}

@MainThread
final void clear() {

if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
// see comment for the similar call in setTagIfAbsent
closeWithRuntimeException(value);
}
}
}
onCleared();
}

其中:clear()方法在ViewModelStore中调用,其相关代码在上文中已展示:

public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear();
}
mMap.clear();
}

ViewModelStore.clear()则在Activity中被调用:

getLifecycle().addObserver(new LifecycleEventObserver() {
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
// Clear out the available context
mContextAwareHelper.clearAvailableContext();
// And clear the ViewModelStore
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
});

通过Lifecycle管理的Activity的生命周期,在Activity销毁时,也就是onDestory时调用。

带参数的ViewModel实现

既然ViewModel不可以通过new关键字来初始化,那么如果需要再初始化时向ViewModel传参该怎么办呢。例如:

public class MyViewModel extends ViewModel {

private String tag;

public MyViewModel(String tag) {
this.tag = tag;
}

private MutableLiveData<List<String>> users;

public LiveData<List<String>> getUsers() {
if (users == null) {
users = new MutableLiveData<List<String>>();
loadUsers();
}
return users;
}

private void loadUsers() {

}
}

根据上文中的代码解析,不难发现,我们需要重写Factorycreate方法,结合ViewModelProvider的构造函数,可以创建一个工厂类:

class MyViewModelFactory implements ViewModelProvider.Factory {
private String tag;

public MyViewModelFactory(String tag) {
this.tag = tag;
}

@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MyViewModel(tag);
}
}

具体实现:


MyViewModelFactory myViewModelFactory = new MyViewModelFactory("TAG");

MyViewModel model = new ViewModelProvider(this,myViewModelFactory).get(MyViewModel.class);

当然了,也可以使用匿名内部类的方式实现:

MyViewModel model = new ViewModelProvider(this, new ViewModelProvider.Factory(){
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MyViewModel("tag");
}
}).get(MyViewModel.class);

在kolin中的扩展

koltin中使用扩展函数简化了ViewModel的实现:

@MainThread
public inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)

@MainThread
public fun <VM : ViewModel> Fragment.createViewModelLazy(
viewModelClass: KClass<VM>,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
defaultViewModelProviderFactory
}
return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
}


public class ViewModelLazy<VM : ViewModel> (
private val viewModelClass: KClass<VM>,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null

override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
ViewModelProvider(store, factory).get(viewModelClass.java).also {
cached = it
}
} else {
viewModel
}
}

override fun isInitialized(): Boolean = cached != null
}

其本质上还是用ViewModelProvider创建,只不过加了一些Kotlin的语法糖,简化了操作。

不仅如此,Kotlin还是支持使用注解,这样就可以省去创建Factory的麻烦。刚兴趣的可以自己去探索(Hilt 和 Jetpack 集成

总结

  • Activity持有ViewModel,一个Activity可以有多个类型的ViewModel;

  • 同一个类型的ViewModel在Activity中有且只有一个实例;

  • 通过HashMap和工厂模式保证了单一类型ViewModel的单例;

  • ViewModel生命周期贯穿整个Activity,且不会重复创建。

收起阅读 »

android高仿微信聊天消息列表自由复制文字,双击查看文本内容

掘金地址 github地址SelectTextHelper打造一个全网最逼近微信聊天消息自由复制,双击查看文本内容框架。 汇聚底层TextView框架、原理并加以整理得出的一个实用的Helper。 仅用两个类实现便实现如此强大的功能,用法也超级简单。...
继续阅读 »

掘金地址 github地址

SelectTextHelper打造一个全网最逼近微信聊天消息自由复制,双击查看文本内容框架。 汇聚底层TextView框架、原理并加以整理得出的一个实用的Helper。 仅用两个类实现便实现如此强大的功能,用法也超级简单。

项目演示

消息页效果查看内容效果
1631677218586.gif1631678150191.gif
 
消息页全选消息页自由复制放大镜
demo_1.jpgdemo_2.jpg
 
消息页选中文本查看内容
demo_3.jpgdemo_4.jpg

特点功能:

  • 支持自由选择文本
  • 支持自定义文本有:游标颜色、游标大小、选中文本颜色
  • 支持默认全选文字或选2个文字
  • 支持滑动依然显示弹窗
  • 支持放大镜功能
  • 支持全选情况下自定义弹窗
  • 支持操作弹窗:每行个数、图片、文字、监听回调、弹窗颜色、箭头图片
  • 支持 AndroidX

Demo

下载 APK-Demo

传送门

主要实现

通过 仿照的例子 并改进弹窗坐标位置、大小加上EventBus实现

简单用例

1.导入代码

把该项目里的selecttext Module放入你的项目里面

2.给你的 TextView 创建Helper和加监听

SelectTextHelper mSelectableTextHelper=new SelectTextHelper
.Builder(textView)// 放你的textView到这里!!
.setCursorHandleColor(0xFF1379D6/*mContext.getResources().getColor(R.color.colorAccent)*/)// 游标颜色 default 0xFF1379D6
.setCursorHandleSizeInDp(24)// 游标大小 单位dp default 24
.setSelectedColor(0xFFAFE1F4/*mContext.getResources().getColor(R.color.colorAccentTransparent)*/)// 选中文本的颜色 default 0xFFAFE1F4
.setSelectAll(true)// 初次选中是否全选 default true
.setScrollShow(true)// 滚动时是否继续显示 default true
.setSelectedAllNoPop(true)// 已经全选无弹窗,设置了true在监听会回调 onSelectAllShowCustomPop 方法 default false
.setMagnifierShow(true)// 放大镜 default true
.addItem(0/*item的图标*/,"复制"/*item的描述*/, // 操作弹窗的每个item
()->Log.i("SelectTextHelper","复制")/*item的回调*/)
.build();

mSelectableTextHelper.setSelectListener(new SelectTextHelper.OnSelectListener(){
/**
* 点击回调
*/

@Override
public void onClick(View v){
// clickTextView(textView.getText().toString().trim());
}

/**
* 长按回调
*/

@Override
public void onLongClick(View v){
// postShowCustomPop(SHOW_DELAY);
}

/**
* 选中文本回调
*/

@Override
public void onTextSelected(CharSequence content){
// selectedText = content.toString();
}

/**
* 弹窗关闭回调
*/

@Override
public void onDismiss(){
}

/**
* 点击TextView里的url回调
*/

@Override
public void onClickUrl(String url){
}

/**
* 全选显示自定义弹窗回调
*/

@Override
public void onSelectAllShowCustomPop(){
// postShowCustomPop(SHOW_DELAY);
}

/**
* 重置回调
*/

@Override
public void onReset(){
// SelectTextEventBus.getDefault().dispatch(new SelectTextEvent("dismissOperatePop"));
}

/**
* 解除自定义弹窗回调
*/

@Override
public void onDismissCustomPop(){
// SelectTextEventBus.getDefault().dispatch(new SelectTextEvent("dismissOperatePop"));
}

/**
* 是否正在滚动回调
*/

@Override
public void onScrolling(){
// removeShowSelectView();
}
});

3.demo中提供了查看文本内容的SelectTextDialog 和 消息列表自由复制MainActivity

查看文本内容方法:

  • 该方法比较简单,将textView参照步骤2放入SelectTextHelper中,在dismiss调用SelectTextHelper的reset()即可。
@Override
public void dismiss(){
mSelectableTextHelper.reset();
super.dismiss();
}

高仿微信聊天消息列表自由复制方法:

  • recycleView + adapter + 多布局的使用在这里不阐述,请看本项目demo。

  • 为adapter里text类型ViewHolder中的textView参照步骤2放入SelectTextHelper中,注册SelectTextEventBus。

  • SelectTextEventBus类特别说明、原理: SelectTextEventBus在EventBus基础上加功能。在register时记录下类和方法,方便在Activity/Fragment Destroy时unregister所有SelectTextEventBus的EventBus。

  • text类型ViewHolder 添加EventBus监听

/**
* 自定义SelectTextEvent 隐藏 光标
*/

@Subscribe(threadMode = ThreadMode.MAIN)
public void handleSelector(SelectTextEvent event){
if(null==mSelectableTextHelper){
return;
}
String type=event.getType();
if(TextUtils.isEmpty(type)){
return;
}
switch(type){
case"dismissAllPop":
mSelectableTextHelper.reset();
break;
case"dismissAllPopDelayed":
postReset(RESET_DELAY);
break;
}
}
  • 重写adapter里的onViewRecycled方法,该方法在回收View时调用
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder){
super.onViewRecycled(holder);
if(holder instanceof ViewHolderText){
// 注销
SelectTextEventBus.getDefault().unregister(holder);
}
}
  • 防抖
/**
* 延迟显示CustomPop
* 防抖
*/

private void postShowCustomPop(int duration){
textView.removeCallbacks(mShowCustomPopRunnable);
textView.postDelayed(mShowCustomPopRunnable,duration);
}

private final Runnable mShowCustomPopRunnable=
()->showCustomPop(text_rl_container,textMsgBean);

/**
* 延迟重置
* 为了支持滑动不重置
*/

private void postReset(int duration){
textView.removeCallbacks(mShowSelectViewRunnable);
textView.postDelayed(mShowSelectViewRunnable,duration);
}

private void removeShowSelectView(){
textView.removeCallbacks(mShowSelectViewRunnable);
}

private final Runnable mShowSelectViewRunnable=
()->mSelectableTextHelper.reset();

如果使用 AndroidX 先在 gradle.properties 中添加,两行都不能少噢~

android.useAndroidX=true
android.enableJetifier=true

收起阅读 »

iOS 开发Tips

iOS
开发Tips关于Xcode 12的Tab贡献者:highway不知道有多少同学困惑于Xcode 12的新tab模式,反正我是觉得这种嵌套的tab形式还不如旧版简洁明了。想切回旧版本tab模式的,可以按照此文操作: How to fix the inc...
继续阅读 »

开发Tips

关于Xcode 12的Tab

贡献者:highway

不知道有多少同学困惑于Xcode 12的新tab模式,反正我是觉得这种嵌套的tab形式还不如旧版简洁明了。

想切回旧版本tab模式的,可以按照此文操作: How to fix the incomprehensible tabs in Xcode 12

通过实验发现,Xcode 12下的“子tab”有以下几个特点:

A.当单击文件打开时,tab将显示为斜体,如果双击,则以普通字体显示。斜体表示为“临时”tab,普通字体表示为“静态”tab;

B.双击tab顶部文件名,或者对“临时”tab编辑后,“临时”tab将切换为“静态”tab;

C.如果当前位于“静态”tab,新打开的文件会新起一个tab,并排在当前tab之后;

D.新打开的“临时”文件会在原有的“临时”tab中打开,而不会新起一个“临时”tab;

E.使用Command + Shift + O打开的是“临时”文件。

modalPresentationCapturesStatusBarAppearance

贡献者:beatman423

这边遇到的问题是非全屏present一个导航控制器的时候,咋也控制不了这个导航控制器以及其子控制器的状态栏的style和hidden。后来找到了UIViewController的这个属性,将其设置为YES就可以了。

该属性的描述是:

Specifies whether a view controller, presented non-fullscreen, takes over control of status bar appearance from the presenting view controller. Defaults to NO.

那些Bug

fishhook在某些场景下只生效一次

贡献者:皮拉夫大王在此

问题背景

之前我们监控到钥匙串的API在主线程访问时存在卡死的情况,因此hook 相关API,将访问移到子线程。因此使用到fishook,当时测试并没有发现异常。

问题描述

前段时间在做技术优化时发现我们的hook代码只生效了一次,下次访问API时变成了直接访问系统原方法。

问题原因

由于hook之前没有调用过钥匙串API,因此可能此时并没有做bind,在我们hook后bind信息被替换成我们的函数,因此首次调用hook成功。但是在hook方法中我们又调用了原函数,此时触发了bind,内存中的函数地址又被替换成系统函数,因此第二次调用时hook失败。 解决方案:见https://github.com/facebook/fishhook/issues/36

编程概念

整理编辑:师大小海腾zhangferry

什么是 Homebrew

Homebrew 是一款 Mac OS 平台下的软件包管理工具,拥有安装、卸载、更新、查看、搜索等很多实用的功能。简单的一条指令,就可以实现包管理,而不用你关心各种依赖和文件路径的情况,十分方便快捷。

安装方法:

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

国内镜像:

$ /bin/bash -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"

什么是 Ruby

Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

Ruby的设计和Objective-C有些类似,都是受Smalltalk的影响。而这也一定程度促进了iOS开发工具较为广泛的使用Ruby写成。

较为知名的几个由Ruby写成的iOS开发工具有:CocoaPods、Fastlane、xcpretty。那这些库为啥使用Ruby开来发呢?

来自CocoaPods的主要作者Eloy Duran的说法:

Ruby和Objective-C具有很多来自Smalltalk的特性,有一定相似性;使用Ruby可以在Bundler和RubyGem之间分享代码;早期阶段MacRuby提供了很多解析Xcode projects的方法;作为CLI工具,Ruby具有强大的字符串处理能力。

来自Fastlane工具链的作者之一Felix的说法:

已经有部分iOS工具选择了Ruby,像是CocoaPods以及给Fastlane的开发带来灵感的nomad-cli。使用Ruby将会更容易与这些工具进行对接。

参考来源:A History of Ruby inside iOS Development

什么是 Rails

Rails(也叫Ruby on Rails)框架首次提出是在 2004 年 7 月,它的研发者是 26 岁的丹麦人 David Heinemeier Hansson。Rails 是使用 Ruby 语言编写的 Web 应用开发框架,目的是通过解决快速开发中的共通问题,简化 Web 应用的开发。与其他编程语言和框架相比,使用 Rails 只需编写更少代码就能实现更多功能。有经验的 Rails 程序员常说,Rails 让 Web 应用开发变得更有趣。

Rails的两大哲学是:不要自我重复(DRY),多约定,少配置。

松本行弘说过:Ruby能拥有现在的人气,基本上都是Ruby on Rails所作出的贡献。

什么是 rbenv

rbenv 和 RVM 都是目前流行的 Ruby 环境管理工具,它们都能提供不同版本的 Ruby 环境管理和切换。

进行 Ruby 版本管理的时候更推荐 rbenv 的方式,你也可以参考 rbenv 官方的 Why choose rbenv over RVM?,当前 rbenv 有两种安装方式:

手动安装

git clone https://github.com/rbenv/rbenv.git ~/.rbenv
# 用来编译安装 ruby
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
# 用来管理 gemset, 可选, 因为有 bundler 也没什么必要
git clone git://github.com/jamis/rbenv-gemset.git ~/.rbenv/plugins/rbenv-gemset
# 通过 rbenv update 命令来更新 rbenv 以及所有插件, 推荐
git clone git://github.com/rkh/rbenv-update.git ~/.rbenv/plugins/rbenv-update
# 使用 Ruby China 的镜像安装 Ruby, 国内用户推荐
git clone git://github.com/AndorChen/rbenv-china-mirror.git ~/.rbenv/plugins/rbenv-china-mirror

homebrew安装

$ brew install rbenv

配置

安装完成后,把以下的设置信息放到你的 Shell 配置文件里面,以保证每次打开终端的时候都会初始化 rbenv。

export PATH="$HOME/.rbenv/bin:$PATH" 
eval "$(rbenv init -)"
# 下面这句选填
export RUBY_BUILD_MIRROR_URL=https://cache.ruby-china.com

配置Ruby 环境,需重开一个终端。

$ rbenv install --list               # 列出所有 ruby 版本
$ rbenv install 2.7.3 # 安装 2.7.3版本
$ rbenv versions # 列出安装的版本
$ rbenv version # 列出正在使用的版本
# 下面三个命令可以根据需求使用
$ rbenv global 2.7.3 # 默认使用 1.9.3-p392
$ rbenv shell 2.7.3 # 当前的 shell 使用 2.7.3, 会设置一个`RBENV_VERSION` 环境变量
$ rbenv local 2.7.3 # 当前目录使用 2.7.3, 会生成一个 `.rbenv-version` 文件

什么是 RubyGems

The RubyGems software allows you to easily download, install, and use ruby software packages on your system. The software package is called a “gem” which contains a packaged Ruby application or library.

RubyGems 是 Ruby 的一个依赖包管理工具,管理着 Gem。用 Ruby 编写的工具或依赖包都称为 Gem。

RubyGems 还提供了 Ruby 组件的托管服务,可以集中式的查找和安装 library 和 apps。当我们使用 gem install 命令安装 Gem 时,会通过 rubygems.org 来查询对应的 Gem Package。而 iOS 日常中的很多工具都是 Gem 提供的,例如 Bundler,fastlane,jazzy,CocoaPods 等。

在默认情况下 Gems 总是下载 library 的最新版本,这无法确保所安装的 library 版本符合我们预期。因此还需要 Gem Bundler 配合。

参考:版本管理工具及 Ruby 工具链环境

什么是 Bundler

Bundler 是一个管理 Gem 依赖的 Gem,用来检查和安装指定 Gem 的特定版本,它可以隔离不同项目中 Gem 的版本和依赖环境的差异。

你可以执行 gem install bundler 命令安装 Bundler,接着执行 bundle init 就可以生成一个 Gemfile 文件,你可以在该文件中指定 CocoaPods 和 fastlane 等依赖包的特定版本号,比如:

source "https://rubygems.org"
gem "cocoapods", "1.10.0"
gem "fastlane", "> 2.174.0"

然后执行 bundle install 来安装 Gem。 Bundler 会自动生成一个 Gemfile.lock 文件来锁定所安装的 Gem 的版本。

这一步只是安装指定版本的 Gem,使用的时候我们需要在 Gem 命令前增加 bundle exec,以保证我们使用的是项目级别的 Gem 版本(也就是 Gemfile.lock 文件中锁定的 Gem 版本),而不是操作系统级别的 Gem 版本。

$ bundle exec pod install
$ bundle exec fastlane beta

参考:iOS开发进阶

优秀博客

整理编辑:皮拉夫大王在此

1、我在Uber亲历的最严重的工程灾难 -- 来自公众号:infoQ

准备或者已经接入Swfit可以先了解下

2、美团 iOS 工程 zsource 命令背后的那些事儿 -- 来自公众号: 美团技术团队

美团技术团队历史文章,对DWARF文件的另一种应用。文章还原了作者解决问题的思路历程,除了技术本身外,解决问题的思路历程也是值得借鉴的。

3、NSObject方法调用过程详细分析 -- 来自掘金:maniac_kk

字节跳动maniac_kk同学的一篇优质文章,无论深度还是广度都是非常不错的,很多底层知识融会贯通,值得细细品味

4、iOS疑难Crash的寄存器赋值追踪排查技术 -- 来自简书:欧阳大哥

在缺少行号信息时如何通过寄存器赋值推断出具体的问题代码,具有很高的参考价值,在遇到疑难问题时可以考虑是否能借鉴此思路

5、抖音 iOS 工程架构演进 -- 来自掘金:字节跳动技术团队

业务的发展引起工程架构做出调整,文章介绍了抖音的工程架构演进历程。作为日活过亿的产品,其工程架构的演变对多数APP来说都具有一定的借鉴意义。

6、Swift的一次函数式之旅 -- 来自公众号:搜狐技术产品

编程本身是抽象的,编程范例就是我们如何抽象这个世界的方法,而函数式编程就是其中一个编程范例。在函数式编程的世界里一切皆函数,那如何利用这个思想解决实际问题呢?文中给出了两个有趣的例子,希望可以帮你解决对函数式编程的疑惑。

7、Category无法覆写系统方法? -- 来自公众号:iOS成长之路

这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思。解决完全没有头绪的问题,我们应该避免陷入不断的猜测和佐证中。深挖问题,找到正确方向才更容易出现转机。

学习资料

整理编辑:Mimosa

1、CS-Notes

该「Notes」包含技术面试必备基础知识、Leetcode、计算机操作系统、计算机网络、系统设计、Java、Python、C++等内容,知识结构简练,内容扎实。该仓库的内容皆为作者及 Contributors 的原创,目前在 Github 上获 126k Stars。

2、Learn Git Branching

入门级的 Git 使用教程,用图形化的方式来介绍 Git 的各个命令,每一关都有一个小测试来巩固知识点。编者自己过了一遍了,体验很不错,同时填补了我自己一些 Git 知识上的漏洞和误区。

开发利器

整理编辑:brave723

OpenInTerminal

地址:https://github.com/Ji4n1ng/OpenInTerminal

软件状态:免费,开源

使用介绍

OpenInTerminal 是一款开发辅助工具,可以增强 Finder 工具栏以及右键菜单增加在当前位置打开终端的功能。另外还支持:在编辑器中打开当前目录以及在编辑器中打开选择的文件夹或文件

核心功能
  • 在终端(或编辑器)中打开目录或文件
  • 打开自定义应用
  • 支持 终端iTerm

SnippetsLib

地址:https://apps.apple.com/cn/app/snippetslab/id1006087419?mt=12

软件状态:$9.99

使用介绍

SnippetsLab是一款mac代码片段管理工具,使用SnippetsLab可以提高工作效率。它可以帮助您收集和组织有价值的代码片段,您可以随时轻松访问它们

收起阅读 »

iOS 14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

iOS 14开发-定位与地图

iOS
定位CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。模块与常见类定位所包含的类都在CoreLocation模块中,使用时必须导入。CLLocationManager:定位管理器,可以...
继续阅读 »

定位

CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。

模块与常见类

  • 定位所包含的类都在CoreLocation模块中,使用时必须导入。
  • CLLocationManager:定位管理器,可以理解为定位不能自己工作,需要有个类对它进行全过程管理。
  • CLLocationManagerDelegate:定位管理代理,不管是定位成功与失败,都会有相应的代理方法进行回调。
  • CLLocation:表示某个位置的地理信息,包含经纬度、海拔等。
  • CLPlacemark:位置信息,包含的信息如国家、城市、街道等。
  • CLGeocoder:地理编码。

工作流程

  1. 创建CLLocationManager,设置代理并发起定位。
  2. 实现CLLocationManagerDelegate中定位成功和失败的代理方法。
  3. 在成功的代理方法中获取CLLocation对象并通过CLGeocoder进行反向地理编码获取对应的位置信息CLPlacemark
  4. 通过CLPlacemark获取具体的位置信息。

权限

授权对话框

  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 程序中调用requestAlwaysAuthorization发起定位授权。

前台定位

  • 需要在 Info.plist 中配置Privacy - Location When In Use Usage Description
  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 弹出的授权对话框新增了精确位置开关,同时新增了小地图展示当前位置。

后台定位

  • 需要勾选 Capabilities —> Background Modes —> Location updates
  • 程序中允许后台定位:locationManager.allowsBackgroundLocationUpdates = true
  • 此时授权分为 2 种情况: (1)Privacy - Location When In Use Usage Description + requestWhenInUseAuthorization:可以后台定位,但会在设备顶部出现蓝条(刘海屏设备会出现在左边刘海)。 (2)Privacy - Location When In Use Usage Description + Privacy - Location Always and When In Use Usage Description + requestAlwaysAuthorization:可以后台定位,不会出现蓝条。这种方式会出现 2 次授权对话框:第一次和前台定位一样,在同意使用While Using App模式后,继续使用定位才会弹出第二次,询问是否切换到Always模式。

精度控制

  • iOS 14 新增了一种定位精度控制,在定位授权对话框中有一个精度切换开关,可以切换精确和模糊定位(默认精确)。
  • 可以通过CLLocationManageraccuracyAuthorization属性获取当前的定位精度权限。
  • 当已经获得定位权限且当前用户选择的是模糊定位,则可以使用CLLocationManagerrequestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil)方法申请一次临时精确定位权限,其中purposeKey为 Info.plist 中配置的Privacy - Location Temporary Usage Description Dictionary字段下某个具体原因的 key,可以设置多个 key 以应对不同的定位使用场景。
  • requestTemporaryFullAccuracyAuthorization方法并不能用于申请定位权限,只能用于从模糊定位升级为精确定位;如果没有获得定位权限,直接调用此 API 无效。
  • 如果不想使用精确定位,则可以在 Info.plist 中配置Privacy - Location Default Accuracy ReducedYES,此时申请定位权限的小地图中不再有精度切换开关。需要注意 2 点: (1)如果发现该字段不是 Bool 型,需要以源码形式打开 Info.plist,然后手动修改<key>NSLocationDefaultAccuracyReduced</key>为 Bool 型的值,否则无法生效。 (2)配置该字段后,如果 Info.plist 中还配置了Privacy - Location Temporary Usage Description Dictionary,则仍可以通过requestTemporaryFullAccuracyAuthorization申请临时的精确定位权限,会再次弹出授权对话框进行确认。

模拟器定位

由于定位需要 GPS,一般情况下需要真机进行测试。但对于模拟器,也可以进行虚拟定位,主要有 3 种方式。

  • 方式一
    (1)新建一个gpx文件,可以取名XXX.gpx,然后将自己的定位信息填写进 xml 对应的位置。 (2)gpx文件设置完成以后,首先需要运行一次 App,然后选择Edit Scheme,在Options中选择自己的gpx文件,这样模拟器运行的时候就会读取该文件的位置信息。然后可以选择Debug—>Simulate Location或底部调试栏上的定位按钮进行gpx文件或位置信息的切换。
<?xml version="1.0"?>
<gpx version="1.1" creator="Xcode">
<!--安徽商贸职业技术学院 谷歌地球:31.2906511800,118.3623587000-->
<wpt lat="31.2906511800" lon="118.3623587000">
<name>安徽商贸职业技术学院</name>
<cmt>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</cmt>
<desc>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</desc>
</wpt>
</gpx>

  • 方式二:运行程序开始定位 —> 模拟器菜单 —> Features —> Location —> Custom Location —> 输入经纬度。

实现步骤

  1. 导入CoreLocation模块。
  2. 创建CLLcationManager对象,设置参数和代理,配置 Info.plist 并请求定位授权。
  3. 调用CLLcationManager对象的startUpdatingLocation()requestLocation()方法进行定位。
  4. 实现代理方法,在定位成功的方法中进行位置信息的处理。
import CoreLocation
import UIKit

class ViewController: UIViewController {
// CLLocationManager
lazy var locationManager = CLLocationManager()
// CLGeocoder
lazy var gecoder = CLGeocoder()

override func viewDidLoad() {
super.viewDidLoad()

setupManager()
}

func setupManager() {
// 默认情况下每当位置改变时LocationManager就调用一次代理。通过设置distanceFilter可以实现当位置改变超出一定范围时LocationManager才调用相应的代理方法。这样可以达到省电的目的。
locationManager.distanceFilter = 300
// 精度 比如为10 就会尽量达到10米以内的精度
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// 代理
locationManager.delegate = self
// 第一种:能后台定位但是会在顶部出现大蓝条(打开后台定位的开关)
// 允许后台定位
locationManager.allowsBackgroundLocationUpdates = true
locationManager.requestWhenInUseAuthorization()
// 第二种:能后台定位并且不会出现大蓝条
// locationManager.requestAlwaysAuthorization()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 以下2个方法都会调用代理方法
// 1. 发起位置更新(定位)会一直轮询,耗电
locationManager.startUpdatingLocation()
// 2. 只请求一次用户的位置,省电
// locationManager.requestLocation()
}
}

extension ViewController: CLLocationManagerDelegate {
// 定位成功
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
// CLPlacemark -- 国家 城市 街道
if let placeMark = placeMarks?.first {
print(placeMark)
// print("\(placeMark.country!) -- \(placeMark.name!) -- \(placeMark.locality!)")
}
}
}
// 停止位置更新
locationManager.stopUpdatingLocation()
}

// 定位失败
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print(error.localizedDescription)
}
}

地图

  • 地图所包含的类都在MapKit模块中,使用时必须导入。
  • 除了可以显示地图,还支持在地图上进行标记处理。
  • 地图看似很复杂,其实它仅仅是一个控件 MKMapView,就和以前学习过的 UIButton、UITableView 等一样,可以在 storyboard 和代码中使用。
  • 地图上如果想要显示用户的位置,必须与定位配合,那么就需要创建定位管理器、设置权限等(参考定位知识),同时需要通过 storyboard 或者代码设置地图的相关属性。

准备工作

  1. 添加一个地图并设置相关属性。
  2. Info.plist 中配置定位权限。
  3. 创建 CLLocationManager 对象并请求定位权限。

基本使用

显示地图,同时显示用户所处的位置。点击用户的位置,显示一个气泡展示用户位置的具体信息。

import MapKit

class ViewController: UIViewController {
@IBOutlet var mapView: MKMapView!
lazy var locationManager: CLLocationManager = CLLocationManager()

override func viewDidLoad() {
super.viewDidLoad()

setupMapView()
}

func setupManager() {
locationManager.requestWhenInUseAuthorization()
// 不需要发起定位
}

func setupMapView() {
// 设置定位
setupManager()
// 地图类型
mapView.mapType = .hybridFlyover
// 显示兴趣点
mapView.showsPointsOfInterest = true
// 显示指南针
mapView.showsCompass = true
// 显示交通
mapView.showsTraffic = true
// 显示建筑
mapView.showsBuildings = true
// 显示级别
mapView.showsScale = true
// 用户跟踪模式
mapView.userTrackingMode = .followWithHeading
}
}

缩放级别

在之前功能的基础上实现地图的任意视角(“缩放级别”)。

// 设置“缩放级别”
func setRegion() {
if let location = location {
// 设置范围,显示地图的哪一部分以及显示的范围大小
let region = MKCoordinateRegion(center: mapView.userLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
// 调整范围
let adjustedRegion = mapView.regionThatFits(region)
// 地图显示范围
mapView.setRegion(adjustedRegion, animated: true)
}
}

标注

在地图上可以添加标注来显示一个个关键的信息点,用于对用户的提示。

分类

  • MKPinAnnotationView:系统自带的标注,继承于 MKAnnotationView,形状跟棒棒糖类似,可以设置糖的颜色,和显示的时候是否有动画效果 (Swift 不推荐使用)。
  • MKMarkerAnnotationView:iOS 11 推出,建议使用。
  • MKAnnotationView:可以用指定的图片作为标注的样式,但显示的时候没有动画效果,如果没有指定图片会什么都不显示(自定义时使用)。

创建模型

class MapFlag: NSObject, MKAnnotation {
// 标题
let title: String?
// 副标题
let subtitle: String?
// 经纬度
let coordinate: CLLocationCoordinate2D
// 附加信息
let urlString: String

init(title: String?, subtitle: String?, coordinate: CLLocationCoordinate2D, urlString: String) {
self.title = title
self.subtitle = subtitle
self.coordinate = coordinate
self.urlString = urlString
}
}

添加标注

  • 添加系统标注,点击能够显示标题和副标题。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let flag = MapFlag(title: "标题", subtitle: "副标题", coordinate: CLLocationCoordinate2D(latitude: 31.2906511800, longitude: 118.3623587000), urlString: "https://www.baidu.com")
mapView.addAnnotation(flag)
}

  • 添加系统标注,点击以气泡形式显示标题、副标题及自定义内容,此时需要重写地图的代理方法,返回标注的样式。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "marker"
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView
// 判空
if annotationView == nil {
annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
}

return annotationView
}
}

  • 如果希望标注的图标为自定义样式,只需要稍加更改代理方法并设置自己的标注图片即可。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "custom"
// 标注的自定义图片
let annotationImage = ["pin.circle.fill", "car.circle.fill", "airplane.circle.fill", "cross.circle.fill"]
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
// 判空
if annotationView == nil {
annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 图标,每次随机取一个
annotationView?.image = UIImage(systemName: annotationImage.randomElement()!)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
// 弹出的位置偏移
annotationView?.calloutOffset = CGPoint(x: -5.0, y: 5.0)
}

return annotationView
}
}

// 点击地图插入一个标注,标注的标题和副标题显示的是标注的具体位置
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchPoint = touches.first?.location(in: mapView)
// 将坐标转换成为经纬度,然后赋值给标注
let coordinate = mapView.convert(touchPoint!, toCoordinateFrom: mapView)
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let gecoder = CLGeocoder()
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
let placeMark = placeMarks?.first
if let placeMark = placeMark {
let flag = MapFlag(title: placeMark.locality, subtitle: placeMark.subLocality, coordinate: coordinate, urlString: "https://www.baidu.com")
self.mapView.addAnnotation(flag)
}
}
}
收起阅读 »

iOS 14开发- 通知

iOS
iOS 中的通知主要分为 2 种,本地通知和远程通知。本地通知使用步骤导入UserNotifications模块。申请权限。创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:...
继续阅读 »

iOS 中的通知主要分为 2 种,本地通知和远程通知。

本地通知

使用步骤

  1. 导入UserNotifications模块。
  2. 申请权限。
  3. 创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:通知副标题。 (3)body:通知体。 (4)sound:声音。 (5)badge:角标。 (6)userInfo:额外信息。 (7)categoryIdentifier:分类唯一标识符。 (8)attachments:附件,可以是图片、音频和视频,通过下拉通知显示。
  4. 指定本地通知触发条件,有 3 种触发方式: (1)UNTimeIntervalNotificationTrigger:一段时间后触发。 (2)UNCalendarNotificationTrigger:指定日期时间触发。 (3)UNLocationNotificationTrigger:根据位置触发。
  5. 根据通知内容和触发条件创建UNNotificationRequest
  6. UNNotificationRequest添加到UNUserNotificationCenter

案例

  • 申请授权(异步操作)。
import UserNotifications

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { // 横幅,声音,标记
(accepted, error) in
if !accepted {
print("用户不允许通知")
}
}

return true
}

  • 发送通知。
import CoreLocation
import UIKit
import UserNotifications

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

// 一段时间后触发
@IBAction func timeInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.subtitle = "Hi"
content.body = "这是一条基于时间间隔的测试通知"
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "feiji.wav"))
content.badge = 1
content.userInfo = ["username": "YungFan", "career": "Teacher"]
content.categoryIdentifier = "testUserNotifications1"
setupAttachment(content: content)

// 设置通知触发器
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications2"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 指定日期时间触发
@IBAction func dateInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于日期的测试通知"

// 时间
var components = DateComponents()
components.year = 2021
components.month = 5
components.day = 20
// 每周一上午8点
// var components = DateComponents()
// components.weekday = 2 // 周一
// components.hour = 8 // 上午8点
// components.minute = 30 // 30分
// 设置通知触发器
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications3"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 根据位置触发
@IBAction func locationInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于位置的测试通知"

// 位置
let coordinate = CLLocationCoordinate2D(latitude: 31.29065118, longitude: 118.3623587)
let region = CLCircularRegion(center: coordinate, radius: 500, identifier: "center")
region.notifyOnEntry = true // 进入此范围触发
region.notifyOnExit = false // 离开此范围不触发
// 设置触发器
let trigger = UNLocationNotificationTrigger(region: region, repeats: true)
// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications"

// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}
}

extension ViewController {
func setupAttachment(content: UNMutableNotificationContent) {
let imageURL = Bundle.main.url(forResource: "img", withExtension: ".png")!
do {
let imageAttachment = try UNNotificationAttachment(identifier: "iamgeAttachment", url: imageURL, options: nil)
content.attachments = [imageAttachment]
} catch {
print(error.localizedDescription)
}
}
}

远程通知(消息推送)

远程通知是指在联网的情况下,由远程服务器推送给客户端的通知,又称 APNs(Apple Push Notification Services)。在联网状态下,所有设备都会与 Apple 服务器建立长连接,因此不管应用是打开还是关闭的情况,都能接收到服务器推送的远程通知。

远程通知流程.png

实现原理

  1. App 打开后首先发送 UDID 和 BundleID 给 APNs 注册,并返回 deviceToken(图中步骤 1,2,3)。
  2. App 获取 deviceToken 后,通过 API 将 App 的相关信息和 deviceToken 发送给应用服务器,服务器将其记录下来。(图中步骤 4)
  3. 当要推送通知时,应用服务器按照 App 的相关信息找到存储的 deviceToken,将通知和 deviceToken 发送给 APNs。(图中步骤 5)
  4. APNs 通过 deviceToken,找到指定设备的指定 App, 并将通知推送出去。(图中步骤 6)

实现步骤

证书方式

  1. 在开发者网站的 Identifiers 中添加 App IDs,并在 Capabilities 中开启 Push Notifications
  2. 在 Certificates 中创建一个 Apple Push Notification service SSL (Sandbox & Production) 的 APNs 证书并关联第一步中的 App IDs,然后将证书下载到本地安装(安装完可以导出 P12 证书)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。注意:
    • App 重新启动后,deviceToken 不会变化。
    • App 卸载后重新安装,deviceToken 发生变化。
  7. 通知测试。

Token方式

  1. 在开发者网站的 Membership 中找到 Team ID 并记录。
  2. 在 Certificates, Identifiers & Profiles 的 Keys 中注册一个 Key 并勾选 Apple Push Notifications service (APNs) ,最后将生成的 Key ID 记录并将 P8 的 AuthKey 下载到本地(只能下载一次)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。
  7. 通知测试。

Token Authentication 是 APNs 新推出的推送鉴权方式,它如下优势: (1)同一个开发者账号下的所有 App 无论是测试还是正式版都能使用同一个 Key 来发送而不需要为每个 App 生成证书。 (2)生成 Key 的过程相对简单,不需要繁琐的证书操作过程,并且它不再有过期时间,无需像证书那样需要定期重新生成。。

AppDelegate

import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) {
accepted, _ in
if !accepted {
print("用户不允许通知。")
}
}

// 向APNs请求deviceToken
UIApplication.shared.registerForRemoteNotifications()

return true
}

// deviceToken请求成功回调
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
var deviceTokenString = String()
let bytes = [UInt8](deviceToken)
for item in bytes {
deviceTokenString += String(format: "x", item & 0x000000FF)
}

// 打印获取到的token字符串
print(deviceTokenString)

// 通过网络将token发送给服务端
}

// deviceToken请求失败回调
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print(error.localizedDescription)
}
}

注意:远程通知不支持模拟器(直接进入deviceToken请求失败回调),必须在真机测试。

测试

真机测试

  1. 将 App 安装到真机上。
  2. 通过软件(如 APNs)或者第三方进行测试,但都需要进行相关内容的设置。 (1)证书方式需要:P12 证书 + Bundle Identifier + deviceToken。 (2)Token 方式需要:P8 AuthKey + Team ID + Key ID + Bundle Identifier + deviceToken

模拟器测试—使用JSON文件

  • JSON文件。
{
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

  • 命令。
xcrun simctl push booted developer.yf.TestUIKit /Users/yangfan/Desktop/playload.json

模拟器测试—使用APNS文件

另一种方法是将 APNs 文件直接拖到 iOS 模拟器中。准备一个后缀名为.apns的文件,其内容和上面的 JSON 文件差不多,但是添加了一个Simulator Target Bundle,用于描述 App 的Bundle Identifier

  • APNs文件。
{
"Simulator Target Bundle": "developer.yf.TestUIKit",
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

前台处理

默认情况下,App 只有在后台才能收到通知提醒,在前台无法收到通知提醒,如果前台也需要提醒可以进行如下处理。

  • 创建 UNUserNotificationCenterDelegate。
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
// 前台展示通知
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// 前台通知一般不设置badge
completionHandler([.list, .banner, .sound])

// 如果不想显示某个通知,可以直接用 []
// completionHandler([])
}
}

  • 设置代理。
class AppDelegate: UIResponder, UIApplicationDelegate {
// 自定义通知回调类,实现通知代理
let notificationHandler = NotificationHandler()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 设置代理
UNUserNotificationCenter.current().delegate = notificationHandler

return true
}
}

角标设置

  • 不论是本地还是远程通知,前台通知一般不会设置角标提醒,所以只需要针对后台通知处理角标即可。
  • 通知的角标不需要手动设置,会自动根据通知进行设置
// 手动添加角标
UIApplication.shared.applicationIconBadgeNumber = 10

// 清理角标
UIApplication.shared.applicationIconBadgeNumber = 0
收起阅读 »

【kotlin从摸索到探究】- delay函数实现原理

简介这片文章主要讲解kotlin中delay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析。说明在分析delay源码实现过程中,由于对...
继续阅读 »

简介

这片文章主要讲解kotlindelay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析

说明

在分析delay源码实现过程中,由于对kotlin有些语法还不是很熟悉,所以并不会把每一步将得很透彻,只会梳理一个大致的流程,如果讲解有误的地方,欢迎指出。

例子先行

fun main() = runBlocking {
println("${treadName()}======start")
launch {
println("${treadName()}======delay 1s start")
delay(1000)
println("${treadName()}======delay 1s end")
}

println("${treadName()}======delay 3s start")
delay(3000)
println("${treadName()}======delay 3s end")
// 延迟,保活进程
Thread.sleep(500000)
}

输出如下:

main======start
main======delay 3s start
main======delay 1s start
main======delay 1s end
main======delay 3s end

根据日志可以看出:

  1. 日志输出环境是在主线程。
  2. 执行3s延迟函数后,切换到了**launch**携程体执行。
  3. delay挂起函数恢复后执行各自的打印函数。

疑问:

如果真像打印日志输出一样,所以的操作都是在一个线程(主线程)完成,那么问题来了。**第一:按照Java线程知识,单线程执行是按照顺序的,是单条线的。那么不管delay里是何等骚操作,只要没有重新起线程,应该不能够像上面输入的那样吧,你说sleepwait,如果你这么想,那么你可以去补一补Java多线程基础知识了。猜想:**1. 难得真有什么我不知道的骚操作可以在一个线程里面同时执行delay和其它代码,真像很多人说的,携程性能很好,使用挂起函数可以不用启动新的线程,就可以异步执行,那真的就很不错。2. delay启动了新的线程,上面的现象只不过是进行了线程切换,那么如果多次调用 delay那么岂不是要创建很多线程,这性能问题和资源问题怎么解决。3. delay基于某种任务调度策略。

delay源码

public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
cancellable.initCancellability()
block(cancellable)
cancellable.getResult()
}

cancellable是一个CancellableContinuationImpl对象,执行 block(cancellable),回到下面函数。

public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

看一下cont.context.delayget方法

internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

如果get(ContinuationInterceptor)Delay类型对象,那么直接返回该对象,如果不是返回DefaultDelay变量,看一下DefaultDelay初始化可以知道,它是一个DefaultExecutor对象,继承了EventLoopImplBase类。

runBlocking执行过程中有这样一行代码createCoroutineUnintercepted(receiver, completion).intercepted()会被ContinuationInterceptor进行包装。所以上面cont.context.delay返回的就是被包装的携程体上下文。

查看scheduleResumeAfterDelay方法。

    public override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
val timeNanos = delayToNanos(timeMillis)
if (timeNanos < MAX_DELAY_NS) {
val now = nanoTime()
DelayedResumeTask(now + timeNanos, continuation).also { task ->
continuation.disposeOnCancellation(task)
schedule(now, task)
}
}
}

创建DelayedResumeTask对象,在also执行相关计划任务,看一下schedule方法。

    public fun schedule(now: Long, delayedTask: DelayedTask) {
when (scheduleImpl(now, delayedTask)) {
SCHEDULE_OK -> if (shouldUnpark(delayedTask)) unpark()
SCHEDULE_COMPLETED -> reschedule(now, delayedTask)
SCHEDULE_DISPOSED -> {} // do nothing -- task was already disposed
else -> error("unexpected result")
}
}

这里返回SCHEDULE_OK,执行unpark函数,这里用到了Java提供的LockSupport线程操作相关知识。

读取线程

  val thread = thread
  • 如果delay是当前携程的上下文 那么把延时任务加入到队列后,那么又是怎么达到线程延迟呢。回到runBlocking执行流程,会执行coroutine.joinBlocking()这样一行代码。

      fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }

    执行:

     val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE

    看一下processNextEvent

      override fun processNextEvent(): Long {
    // unconfined events take priority
    if (processUnconfinedEvent()) return 0
    // queue all delayed tasks that are due to be executed
    val delayed = _delayed.value
    if (delayed != null && !delayed.isEmpty) {
    val now = nanoTime()
    while (true) {
    delayed.removeFirstIf {
    if (it.timeToExecute(now)) {
    enqueueImpl(it)
    } else
    false
    } ?: break // quit loop when nothing more to remove or enqueueImpl returns false on "isComplete"
    }
    }
    // then process one event from queue
    val task = dequeue()
    if (task != null) {
    task.run()
    return 0
    }
    return nextTime
    }

    从延迟队列取任务

    val delayed = _delayed.value

    挂起当前线程

    parkNanos(this, parkNanos)

    这里是一个while循环,当挂起时间到,线程唤醒,继续从任务队列中取任务执行。如果还是延迟任务,这根据当前时间点,计算线程需要挂起的时间,这也是为什么多个延迟任务好像是同时执行的。

  • 如果delay是DefaultExecutor 比如这个例子:携程上下文没有像CoroutineStart.DEFAULT那样进行包装。

    fun main() {
    GlobalScope.launch(start = CoroutineStart.UNDISPATCHED){
    println("${treadName()}======我开始执行了~")
    delay(1000)
    println("${treadName()}======全局携程~")
    }
    println("${treadName()}======我要睡觉~")
    Thread.sleep(3000)
    }

    然后调用DefaultExecutor类中thread的get方法:

      override val thread: Thread
    get() = _thread ?: createThreadSync()

    看一下createThreadSync函数

      private fun createThreadSync(): Thread {
    return _thread ?: Thread(this, THREAD_NAME).apply {
    _thread = this
    isDaemon = true
    start()
    }
    }

    创建一个叫"kotlinx.coroutines.DefaultExecutor的新线程,并且开始运行。这时候会执行DefaultExecutor中的run方法。在run方法中有这样一行代码:

    parkNanos(this, parkNanos)

    点进去看看:

    internal inline fun parkNanos(blocker: Any, nanos: Long) {
    timeSource?.parkNanos(blocker, nanos) ?: LockSupport.parkNanos(blocker, nanos)
    }

    调用Java提供的LockSupport.parkNanos(blocker, nanos)方法,阻塞当前线程,实现挂起,当达到阻塞的时间,恢复线程执行。

查看进行中线程情况方法

fun main() {
println("${treadName()}======doSuspendTwo")
Thread.sleep(500000)
}

运行main,通过命令jps找到对应Java进程(没有特别指定,进程名为文件名)号。

...
3406 KotlinCoreutinesSuspendKt
...

执行jstack 进程号查看进程对应的线程资源。


作者:Coolbreeze
链接:https://juejin.cn/post/7007769804505350152
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

【kotlin从摸索到探究】- 协程的执行流程

简介 这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。 例子先行 fun main(): Unit = runBlocking { launch {...
继续阅读 »

简介


这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。


例子先行


fun main(): Unit = runBlocking {
launch {
println("${treadName()}======1")
}
GlobalScope.launch {
println("${treadName()}======3")
}
launch {
println("${treadName()}======2")
}
println("${treadName()}======4")
Thread.sleep(2000)
}


输出如下:


DefaultDispatcher-worker-1======3
main======4
main======1
main======2

Process finished with exit code 0


根据打印,如果根据单线程执行流程来看,是不是感觉上面的日志打印顺序有点不好理解,下面我们就逐步来进行分解。




  • runBlocking携程体
    这里将其它代码省略到了,我这里都是按照一条简单的执行流程进行讲解。


    public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {

    val eventLoop: EventLoop?
    val newContext: CoroutineContext
    ...
    if (contextInterceptor == null) {
    eventLoop = ThreadLocalEventLoop.eventLoop
    newContext = GlobalScope.newCoroutineContext(context + eventLoop)
    }
    ...
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
    }


    看一下eventLoop的初始化,会 在当前线程(主线程)创建BlockingEventLoop对象。


    internal val eventLoop: EventLoop
    get() = ref.get() ?: createEventLoop().also { ref.set(it) }

    internal actual fun createEventLoop(): EventLoop = BlockingEventLoop(Thread.currentThread())


    看一下newContext初始化,这里会对携程上下文进行组合,返回新的上下文。最后返回的是一个BlockingEventLoop对象。


    public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
    val combined = coroutineContext + context
    val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
    return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
    debug + Dispatchers.Default else debug
    }


    开始对携程进行调度


     coroutine.start(CoroutineStart.DEFAULT, coroutine, block)


    看一下执行这句代码之前,各变量的值


    image


    而上面的代码最终调用的是CoroutineStart.DEFAULTinvoke方法。


      public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
    DEFAULT -> block.startCoroutineCancellable(completion)
    ATOMIC -> block.startCoroutine(completion)
    UNDISPATCHED -> block.startCoroutineUndispatched(completion)
    LAZY -> Unit // will start lazily
    }


    我们使用的是DEFAULT启动模式。然后会执行resumeCancellableWith方法。


      inline fun resumeCancellableWith(
    result: Result<T>,
    noinline onCancellation: ((cause: Throwable) -> Unit)?
    ) {
    val state = result.toState(onCancellation)
    if (dispatcher.isDispatchNeeded(context)) {
    _state = state
    resumeMode = MODE_CANCELLABLE
    dispatcher.dispatch(context, this)
    } else {
    executeUnconfined(state, MODE_CANCELLABLE) {
    if (!resumeCancelled(state)) {
    resumeUndispatchedWith(result)
    }
    }
    }
    }


    dispatcherBlockingEventLoop对象,没有重写isDispatchNeeded,默认返回true。然后调用dispatch继续进行分发。BlockingEventLoop继承了EventLoopImplBase并调用其dispatch方法。把任务加入到队列中。


    public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)


    回到最开始,在coroutine.start(CoroutineStart.DEFAULT, coroutine, block)执行完,还执行了coroutine.joinBlocking()看一下实现。


        fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }


    执行val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE,取出任务进行执行,也就是runBlocking携程体。




  • launch {} 执行流程


    public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
    ): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
    LazyStandaloneCoroutine(newContext, block) else
    StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
    }

    因为launch是直接在runBlocking(父携程体)里新的创建的子携程体,所以执行流程上和之前将的差不多,只不过不会像runBlocking再去创建BlockingEventLoop对象,而是直接用runBlocking(父携程体)的,然后把任务加到里面,所以通过这种方式其实就是单线程对任务的调度而已。所以在runBlocking(父携程体)内通过launch启动再多的携程体,其实都是在同一线程,按照任务队列的顺序执行的。





根据上面日志输出,并没有先执行两个launch携程体,这是为什么呢,根据上面的讲解,应用知道,runBlocking(父携程体)是第一被添加的队列的任务,其次是launch,所以是这样的顺序。那可以让launch立即执行吗?答案是可以的,这就要说携程的启动模式了。





  • CoroutineStart 是协程的启动模式,存在以下4种模式:



    1. DEFAULT 立即调度,可以在执行前被取消

    2. LAZY 需要时才启动,需要start、join等函数触发才可进行调度

    3. ATOMIC 立即调度,协程肯定会执行,执行前不可以被取消

    4. UNDISPATCHED 立即在当前线程执行,直到遇到第一个挂起点(可能切线程)



    我们使用UNDISPATCHED就可以使携程体马上在当前线程执行。看一下是怎么实现的。看一下实现:





使用这种启动模式执行UNDISPATCHED -> block.startCoroutineUndispatched(completion)方法。


internal fun <T> (suspend () -> T).startCoroutineUndispatched(completion: Continuation<T>) {
startDirect(completion) { actualCompletion ->
withCoroutineContext(completion.context, null) {
startCoroutineUninterceptedOrReturn(actualCompletion)
}
}
}

大家可以自己点击去看一下,大概就是会立即执行携程体,而不是将任务放入队列。



但是GlobalScope.launch却不是按照这样的逻辑,这是因为GlobalScope.launch启动的全局携程,是一个独立的携程体了,并不是runBlocking(父携程体)子携程。看一下通过GlobalScope.launch有什么不同。





  • GlobalScope.launch执行流程



    1. 启动全局携程


    GlobalScope.launch

    newCoroutineContext(context)返回Dispatchers.Default对象。而DefaultScheduler继承了ExperimentalCoroutineDispatcher类。看一下ExperimentalCoroutineDispatcher中的dispatch代码:


     override fun dispatch(context: CoroutineContext, block: Runnable): Unit =
    ...
    coroutineScheduler.dispatch(block)
    ...


    看一下coroutineScheduler初始化


    private fun createScheduler() = CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)

    CoroutineScheduler实现了Executor接口,里面还有两个全局队列和线程池相关的参数。


    @JvmField
    val globalCpuQueue = GlobalQueue()
    @JvmField
    val globalBlockingQueue = GlobalQueue()


    继续调用CoroutineScheduler中的dispatch方法


      fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
    trackTask() // this is needed for virtual time support
    val task = createTask(block, taskContext)
    // try to submit the task to the local queue and act depending on the result
    val currentWorker = currentWorker()
    val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)
    if (notAdded != null) {
    if (!addToGlobalQueue(notAdded)) {
    // Global queue is closed in the last step of close/shutdown -- no more tasks should be accepted
    throw RejectedExecutionException("$schedulerName was terminated")
    }
    }
    val skipUnpark = tailDispatch && currentWorker != null
    // Checking 'task' instead of 'notAdded' is completely okay
    if (task.mode == TASK_NON_BLOCKING) {
    if (skipUnpark) return
    signalCpuWork()
    } else {
    // Increment blocking tasks anyway
    signalBlockingWork(skipUnpark = skipUnpark)
    }
    }




    1. val task = createTask(block, taskContext)包装成TaskImpl对象。




    2. val currentWorker = currentWorker()当前是主线程,运行程序时由进程创建,肯定不是Worker对象,Worker是一个继承了Thread的类 ,并且在初始化时都指定为守护线程


      Worker存在5种状态:
      CPU_ACQUIRED 获取到cpu权限
      BLOCKING 正在执行IO阻塞任务
      PARKING 已处理完所有任务,线程挂起
      DORMANT 初始态
      TERMINATED 终止态







  1. val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)由于currentWorker是null,直接返回task对象。

  2. addToGlobalQueue(notAdded)根据任务是否是阻塞任务,将task添加到全局任务队列中。这里被添加到globalCpuQueue中。

  3. 执行signalCpuWork()来唤醒一个线程或者启动一个新的线程。


    fun signalCpuWork() {
if (tryUnpark()) return
if (tryCreateWorker()) return
tryUnpark()
}


 private fun tryCreateWorker(state: Long = controlState.value): Boolean {  
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 处理阻塞任务的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0)//得到非阻塞任务的线程数量
if (cpuWorkers < corePoolSize) {// 小于核心线程数量,进行线程的创建
val newCpuWorkers = createNewWorker()
if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()// 当前非阻塞型线程数量为1,同时核心线程数量大于1时,再进行一个线程的创建,
if (newCpuWorkers > 0) return true
}
return false
}

// 创建线程
private fun createNewWorker(): Int {
synchronized(workers) {
...
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 阻塞的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0) // 得到非阻塞线程数量
if (cpuWorkers >= corePoolSize) return 0//超过最大核心线程数,不能进行新线程创建
if (created >= maxPoolSize) return 0// 超过最大线程数限制,不能进行新线程创建
...
val worker = Worker(newIndex)
workers[newIndex] = worker
require(newIndex == incrementCreatedWorkers())
worker.start()// 线程启动
return cpuWorkers + 1
}
}


那么这里面的任务又是怎么调度的呢,当全局任务被执行的时候,看一下Worker中的run方法:


 override fun run() = runWorker()

执行runWorker方法,该方法会从队列中找到执行任务,然后开始执行。详细代码,可以自行翻阅。



所以GlobalScope.launch使用的就是线程池,没有所谓的性能好。




  • Dispatchers调度器
    Dispatchers是协程中提供的线程调度器,用来切换线程,指定协程所运行的线程。,上面用的是默认调度器Dispatchers.Default



Dispatchers中提供了4种类型调度器:
Default 默认调度器:适合CPU密集型任务调度器 比如逻辑计算;
Main UI调度器
Unconfined 无限制调度器:对协程执行的线程不做限制,协程恢复时可以在任意线程;
IO调度器:适合IO密集型任务调度器 比如读写文件,网络请求等。





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

使用 Kotlin 重写 AOSP 日历应用

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良...
继续阅读 »

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良好范例。Kotlin 之所以具有强大的吸引力,原因之一是其简洁的语法,很多情况下用 Kotlin 编写的代码块的代码数量相比于功能相同的 Java 代码块要更少一些。此外,Kotlin 这种具有丰富表现力的编程语言还具有其他各种优点,例如:




  • 空安全: 这一概念可以说是根植于 Kotlin 之中,从而帮助避免破坏性的空指针异常;




  • 并发: 正如 Google I/O 2019 中关于 Android 的描述,结构化并发 (structured concurrency) 能够允许使用协程简化后台的任务管理;




  • 兼容 Java: 尤其是在这次的重构项目中,Kotlin 与 Java 语言的兼容性能够让我们一个文件一个文件地进行 Kotlin 转换。




AOSP 团队在去年夏天发表了一篇文章,详细介绍了 AOSP 桌面时钟应用的转换过程。而今年,我们将 AOSP 日历应用从 Java 转换成了 Kotlin。在这次转换之前,应用的代码行数超过 18,000 行,在转换后代码库减少了约 300 行。在这次的转换中,我们沿袭了同 AOSP 桌面时钟转换过程中类似的技术,充分利用了 Kotlin 与 Java 语言的互操作性,对代码文件一一进行了转换,并在过程中使用独立的构建目标将 Java 代码文件替换为对应的 Kotlin 代码文件。因为团队中有两个人在进行此项工作,所以我们在 Android.bp 文件中为每个人创建了一个 exclude_srcs 属性,这样两个人就可以在减少代码合并冲突的前提下,都能够同时进行重构并推送代码。此外,这样还能允许我们进行增量测试,快速定位错误出现在哪些文件。


在转换任意给定的文件时,我们一开始先使用 Android Studio Kotlin 插件中提供的 从 Java 到 Kotlin 的自动转换工具。虽然该插件成功帮助我们转换了大部份的代码,但是还是会遇到一些问题,需要开发者手动解决。需要手动更改的部分,我们将会在本文接下来的章节中列出。


在将每个文件转换为 Kotlin 之后,我们手动测试了日历应用的 UI 界面,运行了单元测试,并运行了 Compatibility Test Suite (CTS) 的子集来进行功能验证,以确保不需要再进行任何的回归测试。


自动转换之后的步骤


上面提到,在使用自动转换工具之后,有一些反复出现的问题需要手动定位解决。在 AOSP 桌面时钟文章中,详细介绍了其中遇到的一些问题以及解决方法。如下列出了一些在进行 AOSP 日历转换过程中遇到的问题。


用 open 关键词标记父类


我们遇到的问题之一是 Kotlin 父类和子类之间的相互调用。在 Kotlin 中,要将一个类标记为可继承,必须得在类的声明中添加 open 关键字,对于父类中被子类覆盖的方法也要这样做。但是在 Java 中的继承是不需要使用到 open 关键字的。由于 Kotlin 和 Java 能够相互调用,这个问题直到大部分代码文件转换到了 Kotlin 才出现。


例如,在下面的代码片段中,声明了一个继承于 SimpleWeeksAdapter 的类:


class MonthByWeekAdapter(context: Context?, params:
    HashMap<String?, Int?>) : SimpleWeeksAdapter(context as Context, params) {//方法体}

由于代码文件的转换过程是一次一个文件进行的,即使是完全将 SimpleWeeksAdapter.kt 文件转换成 Kotlin,也不会在其类的声明中出现 open 关键词,这样就会导致一个错误。所以之后需要手动进行 open 关键词的添加,以便让 SimpleWeeksAdapter 类可以被继承。这个特殊的类声明如下所示:


open class SimpleWeeksAdapter(context: Context, params: HashMap<String?, Int?>?) {//方法体}

override 修饰符


同样地,子类中覆盖父类的方法也必须使用 override 修饰符来进行标记。在 Java 中,这是通过 @Override 注解来实现的。然而,虽然在 Java 中有相应的注解实现版本,但是自动转换过程中并没有为 Kotlin 方法声明中添加 override 修饰符。解决的办法是在所有适当的地方手动添加 override 修饰符。


覆写父类中的属性


在重构过程中,我们还遇到了一个属性覆写的异常问题,当一个子类声明了一个变量,而在父类中存在一个非私有的同名变量时,我们需要添加一个 override 修饰符。然而,即使子类的变量同父类变量的类型不同,也仍然要添加 override 修饰符。在某些情况下,添加 override 仍不能解决问题,尤其是当子类的类型完全不同的时候。事实上,如果类型不匹配,在子类的变量前添加 override 修饰符,并在父类的变量前添加 open 关键字,会导致一个错误:


type of *property name* doesn’t match the type of the overridden var-property

这个报错很让人疑惑,因为在 Java 中,以下代码可以正常编译:


public class Parent {
int num = 0;
}

class Child extends Parent {
String num = "num";
}

而在 Kotlin 中相应的代码就会报上面提到的错误:


class Parent {
var num: Int = 0
}

class Child : Parent() {
var num: String = "num"
}


这个问题很有意思,目前我们通过在子类中对变量重命名来规避了这个冲突。上面的 Java 代码会被 Android Studio 目前提供的代码转换器转换为有问题的 Kotlin 代码,这甚至 被报告为是一个 bug 了。


import 语句


在我们转换的所有文件中,自动转换工具都倾向于将 Java 代码中的所有 import 语句截断为 Kotlin 文件中的第一行。最开始这导致了一些很让人抓狂的错误,编译器会在整个代码中报 "unknown references" 的错误。在意识到这个问题后,我们开始手动地将 Java 中的 import 语句粘贴到 Kotlin 代码文件中,并单独对其进行转换。


暴露成员变量


默认情况下,Kotlin 会自动地为类中的实例变量生成 getter 和 setter 方法。然而,有些时候我们希望一个变量仅仅只是一个简单的 Java 成员变量,这可以通过使用 @JvmField 注解来实现。


@JvmField 注解 的作用是 "指示 Kotlin 编译器不要为这个属性生成 getter 和 setter 方法,并将其作为一个成员变量允许其被公开访问"。这个注解在 CalendarData 类 中特别有用,它包含了两个 static final 变量。通过对使用 val 声明的只读变量使用 @JvmField 注解,我们确保了这些变量可以作为成员变量被其他类访问,从而实现了 Java 和 Kotlin 之间的兼容性。


对象中的静态方法


在 Kotlin 对象中定义的函数必须使用 @JvmStatic 进行标记,以允许在 Java 代码中通过方法名,而非实例化来对它们进行调用。也就是说,这个注解使其具有了类似 Java 的方法行为,即能够通过类名调用方法。根据 Kotlin 的文档,"编译器会为对象的外部类生成一个静态方法,而对于对象本身会生成一个实例方法。"我们在 Utils 文件 中遇到了这个问题,当完成转换后,Java 类就变成了 Kotlin 对象。随后,所有在对象中定义的方法都必须使用 @JvmStatic 标记,这样就允许在其他文件中使用 Utils.method() 这样的语法来进行调用。值得一提的是,在类名和方法名之间使用 .INSTANCE (即 Utils.INSTANCE.method()) 也是一种选择,但是这不太符合常见的 Java 语法,需要改变所有对 Java 静态方法的调用。


性能评估分析


所有的基准测试都是在一台 96 核、176 GiB 内存的机器上进行的。本项目中分析用到的主要指标有所减少的代码行数、目标 APK 的文件大小、构建时间和首屏从启动到显示的时间。在对上述每个因素进行分析的同时,我们还收集了每个参数的数据并以表格的方式进行了展示。


减少的代码行数



从 Java 完全转换到 Kotlin 后,代码行数从 18,004 减少到了 17,729。这比原来的 Java 代码量 减少了大约 1.5%。虽然减少的代码量并不可观,但对于一些大型应用来说,这种转换对于减少代码行数的效果可能更为显著,可参阅 AOSP 桌面时钟 文中所举的例子。


目标 APK 大小



使用 Kotlin 编写的应用 APK 大小是 2.7 MB,而使用 Java 编写的应用 APK 大小是 2.6 MB。可以说这个差异基本可以忽略不计了,由于包含了一些额外的 Kotlin 库,所以 APK 体积上的增加,实际上是可以预期的。这种大小的增加可以通过使用 ProguardR8 来进行优化。


编译时间



Kotlin 和 Java 应用的构建时间是通过取 10 次从零进行完整构建的时间的平均值来计算的 (不包含异常值),Kotlin 应用的平均构建时间为 13 分 27 秒,而 Java 应用的平均构建时间为 12 分 6 秒。据一些资料 (如 "Java 和 Kotlin 的区别" 以及 "Kotlin 和 Java 在编译时间上的对比") 显示,Kotlin 的编译时间事实上比 Java 要更耗时,特别是对于从零开始的构建。一些分析断言,Java 的编译速度会快 10-15%,又有一些分析称这一数据为 15-20%。拿我们的例子进行从零开始完整构建所花费的时间来说,Java 的编译速度比 Kotlin 快 11.2%,尽管这个微小的差异并不在上述范围内,但这有可能是因为 AOSP 日历是一个相对较小的应用,仅有 43 个类。尽管从零开始的完整构建比较慢,但是 Kotlin 仍然在其他方面占有优势,这些优势更应该被考虑到。例如,Kotlin 相对于 Java,更简洁的语法通常可以保证较少的代码量,这使得 Kotlin 代码库更易维护。此外,由于 Kotlin 是一种更为安全有效的编程语言,我们可以认为完整构建时间较慢的问题可以忽略不计。


首屏显示的时间



我们使用了这种 方法 来测试应用从启动到完全显示首屏所需要的时间,经过 10 次试验后我们发现,使用 Kotlin 应用的平均时间约为 197.7 毫秒,而 Java 的则为 194.9 毫秒。这些测试都是在 Pixel 3a XL 设备上进行的。从这个测试结果可以得出结论,与 Kotlin 应用相比,Java 应用可能具有微小的优势;然而,由于平均时间非常接近,这个差异几乎可以忽略不计。因此,可以说 AOSP 日历应用转换到 Kotlin,并没有对应用的初始启动时间产生负面影响。


结论


将 AOSP 日历应用转换为 Kotlin 大约花了 1.5 个月 (6 周) 的时间,由 2 名实习生负责该项目的实施。一旦我们对代码库更加熟悉并更加善于解决反复出现的编译时、运行时和语法问题时,效率肯定会变得更高。总的来说,这个特殊的项目成功地展示了 Kotlin 如何影响现有的 Android 应用,并在对 AOSP 应用进行转换的路途中迈出了坚实的一步。


欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!


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

中秋快乐!来看看满眼都是中秋气息的app页面吧~

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快! 不妨点个赞啦,看到这篇文章的帅哥~ app中秋的引导界面:(完整效果截图在最后哦...
继续阅读 »

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快!


不妨点个赞啦,看到这篇文章的帅哥~


app中秋的引导界面:(完整效果截图在最后哦~)


效果图.gif


功能解析:


1.状态变化:背景和展示出来的诗篇与日期有关,日期不同,背景和诗篇不同


2.文字特效:中秋祝福的诗篇会一字一字慢慢浮现


3.倒计时处理:人性化,用户不想看直接跳过


1.状态变化:


我们定义一个变量date来控制状态,获取当前的日期来进行判断:


int _date = 1; //控制状态
DateTime _dateTime = DateTime.now(); //获取当前时间

然后在初始化时进行判断:


@override
 void initState() {
   super.initState();
   if (_dateTime.day <= 19) {
     ///19号之前,人们都在回家的路上
     _date = 1;
  } else if (_dateTime.day == 20) {
     ///20号,人们回到家中,吃上团圆饭
     _date = 2;
  } else if (_dateTime.day == 21) {
     ///21号,中秋快乐
     _date = 3;
  } else {
     ///中秋过后,亲人回到忙碌的生活,期盼着下一次团聚
     _date = 4;
  }
}

关于flutter如何获取时间,我给大家列出来了(送给新人,大神看了就图一乐~)


DateTime dateTime= DateTime.now();
dateTime.day 今天是几号,int类型
dateTime.month
dateTime.year
dateTime.hour
dateTime.minute
dateTime.second
dateTime.millisecond
dateTime.millisecondsSinceEpoch

2.文字特效


就像开始的gif图显示的一样,文字一个个浮现出来,其实这个很简单,我们可以自己diy,但是,广大热心程序猿给我们提供了插件:animated_text_kit


使用起来也很简单:


AnimatedTextKit(
 animatedTexts: [
   TyperAnimatedText(
     "Test文字",
     textStyle: TextStyle(fontSize: 22),
     speed: const Duration(milliseconds: 200),
  ),
],
 isRepeatingAnimation: false,//不循环播放
)

而且还有很多很多的效果,这里给大家列了出来,需要的可以查看文章最下方的项目源码


当然,在这里也是有难点的,因为flutter的文字无法竖排,网上有改源码的(我觉得复杂了)问了下朋友,说使用RotatedBox这个widget,但是我这看个der啊,你这竖的一个妙啊!


屏幕截图 2021-09-14 190155.jpg


所以最后我选择使用给每个文字后面加上/n 我直接手动换行,求求大神来告诉我解决方法(要不我自己写个插件哈哈)


3.倒计时处理


我们搞前端的必须要做一个人性化的东西给客户是不是


手动跳转加上:


int _countdown = 5;//五秒倒计时
Timer _countdownTimer;//控制倒计时

当然我们需要一个方法来控制倒计时,以及倒计时结束跳转:


void _startRecordTime() {
 _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
   setState(() {
     if (_countdown <= 1) {
       ///此处编写你需要跳转的界面
        _countdownTimer.cancel();
        _countdownTimer = null;
    } else {
       _countdown -= 1;
    }
  });
});
}

当然,在倒计时结束或者跳转时,记得把界面销毁~


@override
void dispose() {
 super.dispose();
 print('启动页面结束');
 if (_countdownTimer != null && _countdownTimer.isActive) {
   _countdownTimer.cancel();
   _countdownTimer = null;
}
}

onTap: () {
 ///点击跳过,在此处可以写跳转
 print("点击跳过,在此处可以写跳转代码,记得销毁界面哦");
},

完整效果:
屏幕截图 2021-09-14 195121.jpg


屏幕截图 2021-09-14 195203.jpg


屏幕截图 2021-09-14 195320.jpg


屏幕截图 2021-09-14 195345.jpg


源码地址:gitee.com/Xiao-Ti/aut…


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

React下一代状态管理库——recoil

引言 对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要...
继续阅读 »

引言


对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要开发者自己去保证,所以不得不引入例如immer这类的库;另外,redux本身是框架无关的库,他需要和redux-react结合才能在react中使用。使用我们不得不借助redux toolkit或者rematch这种内置了很多最佳实践的库以及重新设计接口的库,但与此同时也增加了开发者的学习成本。
所以react的状态管理的轮子层出不穷,下面将会介绍面向未来设计的react状态管理库——recoil。


简介


recoil 的 slogan 十分简单:一个react状态管理库(A state management library for React)。它不是一个框架无关的状态库,它是专门为react而生的。


和react一样,recoil也是facebook的开源的库。官方宣称有三个主要的特性:




  1. Minimal andReactish:最小化和react风格的api。




  2. Data-Flow Graph:数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。




  3. Cross-App Observation: 跨应用监听,能够实现整体状态监听。




基本设计思想


假如有这么一个场景,相应状态改变我们 仅仅需要 更新list中的第二个节点和canvas的第二个节点。

如果没有使用第三外部状态管理库,使用context API可能是这样的:



我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是 耦合 的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selector浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。


recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。

同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。

不仅支持同步的selector,recoil也支持异步的selector,recoil对selector的唯一要求就是他们必须是一个纯函数。

Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selector派生出更多状态,最后React的组件树订阅自己需要的状态,当有原子状态更新,只有改变的原子及其下游节点有订阅他们的组件才会更新。也就是说,recoil其实构建了一个 有向无环图 ,这个图和react组件树正交,他的状态和react组件树是完全 解耦 的。


简单用法


吹了这么多先来看看简单的用法吧。
区别于redux是与框架无关的状态管理库,既然Recoil是专门为React设计的状态管理库,那么他的API满满的“react风格”。 Recoil 只支持hooks API,在使用上来说可以说十分简洁了。
下面看看 Demo


import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue
} from "recoil";

export default function App() {
return (
<RecoilRoot>
<Demo />
</RecoilRoot>
);
}

const textState = atom({
key: "textState",
default: ""
});

const charCountState = selector({
key:'charCountState',
get: ({get}) => {
// 要求是纯函数
const text = get(textState)
return text.length
}
})

function Demo() {
const [text, setText] = useRecoilState(textState);
const count = useRecoilValue(charCountState)
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<br />
Echo: {text}
<br />
charCount: {count}
</>
);
}



  • 类似于React Redux,recoil也有一个Provider——RecoilRoot,用于全局共享一些方法和状态。




  • atom(原子)是recoil中最小的状态单元,atom表示一个值可以被读、写、订阅,它必须有一个区别于其他atom保持 唯一性和不变性 的key。通过atom可以定义一个数据。




  • Selector 有点像React-Redux中的selector,同样是用来“派生”状态的,不过和React-Redux中不同是:




    • React-redux的selector是一个纯函数,当全局唯一的状态改变,它总是会运行,从全局唯一的状态运算出新的状态。




    • 而在recoil中,selector的 options.的get也要求是一个纯函数,其中传入其中的get方法用来获取其他atom。 当且仅当依赖的 atom 发生改变且有组件订阅selector ,它其实才会重新运算,这意味着计算的值是会被缓存下来的,当依赖没有发生改变,其实直接会从缓存中读取并返回。而selector返回的也是一个atom,这意味着派生状态其实也是一个原子,其实也可以作为其他selector的依赖。






很明显,recoil是通过get函数中的get入参来收集依赖的,recoil支持动态收集依赖,也就是说get可以在条件中调用:


const toggleState = atom({key: 'Toggle', default: false});

const mySelector = selector({
key: 'MySelector',
get: ({get}) => {
const toggle = get(toggleState);
if (toggle) {
return get(selectorA);
} else {
return get(selectorB);
}
},
});

异步


recoil天然支持异步,用法也十分简单,也不需要配置什么异步插件,看看 Demo


const asyncDataState = selector({
key: "asyncData",
get: async ({get}) => {
// 要求是纯函数
return await getAsyncData();
}
});

function AsyncComp() {
const asyncData = useRecoilValue(asyncDataState);
return <>{asyncData}</>;
}
function Demo() {
return (
<React.Suspense fallback={<>loading...</>}>
<AsyncComp />
</React.Suspense>
);
}

由于recoil天然支持react suspense的特性,所以使用useRecoilValue获取数据的时候,如果异步状态pending,那么默认将会抛出该promise,使用时需要外层使用React.Suspense,那么react就会显示fallback里面的内容;如果报错,也会抛出里面的内容,被外层的ErrorBoundary捕获。
如果你不想使用该特性,可以使用useRecoilValueLoadable直接获取异步状态, demo


function AsyncComp() {
const asyncState = useRecoilValueLoadable(asyncDataState);
if (asyncState.state === "loading") {
return <>loading...</>;
}
if (asyncState.state === "hasError") {
return <>has error....</>;
}
if (asyncState.state === "hasValue") {
return <>{asyncState.contents}</>;
}
return null;
}

另外注意默认异步的结果是会被缓存下来,其实所有的selector上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调selector配置项get是纯函数的原因。


依赖外部变量


我们常常会遇到状态不纯粹的问题,如果状态其实是依赖外部的变量,recoil有selectorFamily支持:


const getUserInfoState = selectorFamily({
key: "userInfo",
get: (userId) => ({ get }) => {
return queryUserState({userId: id, xxx: get(xxx) });
},
});

function MyComponent({ userID }) {

const number = useRecoilValue(getUserInfoState(userID));
//...
}

这里外部的参数和key,会同时生成一个全局唯一的key,用于标识状态,也就是说如果外部变量没有变化或者依赖没有发生变化,不会重新计算状态,而是直接返回缓存值。


源码解析


如果说看到这里,仅仅实现上面那些简单例子的话,大家可能会说“就这”?实现起来应该不太难,这里有一个简单的 实现的版本 ,虽然功能差不多,但是架构完全不一样,recoil的源码继承了react源码的优良传统,就是十分难读。。。


其源码核心功能分为几个部分:




  • Graph 图相关的逻辑




  • Nodeatom和selector在内部统一抽象为node




  • RecoilRoot 主要是就是外部用的一些recoilRoot,




  • RecoilValue 对外部暴露的类型。也就说atom、selector的返回值。




  • hooks 使用的hooks相关的。




  • Snapshot 状态快照,提供状态记录和回滚。




  • 一些其他读不懂的代码。。。




下面就谈谈自己这几天看源码粗浅的认识,欢迎大佬们指正。


Concurrent mode 支持


为了防止把大家绕晕,先讲讲我最关心的问题,recoil是如何支持conccurent的思路,可能不太正确(网上没有资料参考,欢迎讨论)。


Cocurrent mode


先讲一讲什么是react的Cocurrent mode,官网的介绍是,一系列新的特性帮助ract应用保持响应式和并优雅的使用用户设备能力和网络速度。
react迁移到fiber架构就是为了concurrent mode的实现,React 在新的架构下实际上有两个阶段:




  • 渲染(rendering)阶段




  • 提交(commit)阶段




在渲染阶段,react 可以根据任务优先级对组件树进行渲染,所以当前渲染任务可能会因为优先级不够或者当前帧没有剩余时间而被中断。后续调度会重新执行当前任务渲染。


ui和state不一致的问题


因为react现在会放弃控制流,在渲染开始到渲染结束,任何事情都可能发生,一些的钩子被取消就是因为这个原因。而对于第三方状态库来说,比如说有一个异步请求在这段时间把外部的状态改变了,react会继续上一次打断的地方重新渲染,就会读到新的状态值。 就会发生 状态和 UI 不一致 的情况。


recoil的解决办法


整体数据结构



atom


atom实际上是调用baseAtom,baseAtom内部有闭包变量defaultLoadable一个用于记录当前的默认值。声明了getAtom函数和setAtom函数等,最后传给registerNode,完成注册。


function baseAtom(options){
// 默认值
let defaultLoadable = isPromise(options.default) ? xxxx : options.default

function getAtom(store,state){
if(state.atomValues.has(key)){
// 如果当前state里有这个key的值,直接返回。
return state.atomValues.get(key)
}else if(state.novalidtedAtoms.has(key)){
//.. 一些逻辑
}else{
return defaultLoadable;
}
}

function setAtom(store, state, newValue){
if (state.atomValues.has(key)) {
const existing = nullthrows(state.atomValues.get(key));
if (existing.state === 'hasValue' && newValue === existing.contents) {
// 如果相等就返回空map
return new Map();
}
}
//...
// 返回的的是key --> 新的loadableValue的Map
return new Map().set(key, loadableWithValue(newValue));
}

function invalidateAtom(){
//...
}



const node = registerNode(
({
key,
nodeType: 'atom',
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
// 忽略其他配置。。。
}),
);
return node;
}

function registerNode(){
if (nodes.has(node.key)) {
//...
}
nodes.set(node.key, node);

const recoilValue =
node.set == null
? new RecoilValueClasses.RecoilValueReadOnly(node.key)
: new RecoilValueClasses.RecoilState(node.key);

recoilValues.set(node.key, recoilValue);
return recoilValue;
}

selector


由于selector也可以传入set配置项,这里就不分析了。


function selector(options){
const {key, get} = options
const deps = new Set();
function selectorGet(){
// 检测是否有循环依赖
return detectCircularDependencies(() =>
getSelectorValAndUpdatedDeps(store, state),
);
}

function getSelectorValAndUpdatedDeps(){
const cachedVal = getValFromCacheAndUpdatedDownstreamDeps(store, state);
if (cachedVal != null) {
setExecutionInfo(cachedVal, store);
// 如果有缓存值直接返回
return cachedVal;
}
// 解析getter
const [loadable, newDepValues] = evaluateSelectorGetter(
store,
state,
newExecutionId,
);
// 缓存结果
maybeSetCacheWithLoadable(
state,
depValuesToDepRoute(newDepValues),
loadable,
);
//...
return lodable
}

function evaluateSelectorGetter(){
function getRecoilValue(recoilValue){
const { key: depKey } = recoilValue
dpes.add(key);
// 存入graph
setDepsInStore(store, state, deps, executionId);
const depLoadable = getCachedNodeLoadable(store, state, depKey);
if (depLoadable.state === 'hasValue') {
return depLoadable.contents;
}
throw depLoadable.contents;
}
const result = get({get: getRecoilValue});
const lodable = getLodable(result);
//...

return [loadable, depValues];
}

return registerNode<T>({
key,
nodeType: 'selector',
peek: selectorPeek,
get: selectorGet,
init: selectorInit,
invalidate: invalidateSelector,
//...
});
}
}

hooks


useRecoilValue && useRecoilValueLoadable




  • useRecoilValue底层实际上就是依赖useRecoilValueLoadable,如果useRecoilValueLoadable的返回值是promise,那么就把他抛出来。




  • useRecoilValueLoadable 首先是在useEffect里订阅RecoilValue的变化,如果发现变化不太一样,调用forceupdate重新渲染。返回值则是通过调用node的get方法拿到值为lodable类型的,返回出来。




function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
const storeRef = useStoreRef();
const loadable = useRecoilValueLoadable(recoilValue);
// 如果是promise就是throw出去。
return handleLoadable(loadable, recoilValue, storeRef);
}

function useRecoilValueLoadable_LEGACY(recoilValue){
const storeRef = useStoreRef();
const [_, forceUpdate] = useState([]);

const componentName = useComponentName();

useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
// 实际上就是在storeState.nodeToComponentSubscriptions里面建立 node --> 订阅函数的映射
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
// 在代码里通过gkx开启一些特性,方便单元测试和代码迭代。
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getRecoilValueAsLoadable(
store,
recoilValue,
store.getState().currentTree,
);
// 小小的优化
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
//...
// release
return subscription.release;
})

// 实际上就是调用node.get方法。然后做一些其他处理
const loadable = getRecoilValueAsLoadable(storeRef.current, recoilValue);

const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

这里一个有意思的点是useComponentName的实现有一点点hack:由于我们通常会约定hooks的命名是use开头,所以可以通过调用栈去找第一个调用函数不是use开头的函数名,就是组件的名称。当然生产环境,由于代码混淆是不可用的。


function useComponentName(): string {
const nameRef = useRef();
if (__DEV__) {
if (nameRef.current === undefined) {
const frames = stackTraceParser(new Error().stack);
for (const {methodName} of frames) {
if (!methodName.match(/\buse[^\b]+$/)) {
return (nameRef.current = methodName);
}
}
nameRef.current = null;
}
return nameRef.current ?? '<unable to determine component name>';
}
return '<component name not available>';
}

useRecoilValueLoadable_MUTABLESOURCE基本上是一样的,除了订阅函数里我们从手动调用foceupdate变成了调用参数callback。


function useRecoilValueLoadable_MUTABLESOURCE(){
//...

const getLoadable = useCallback(() => {
const store = storeRef.current;
const storeState = store.getState();
//...
const treeState = storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);

const subscribe = useCallback(
(_storeState, callback) => {
const store = storeRef.current;
const subscription = subscribeToRecoilValue(
store,
recoilValue,
() => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return callback();
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current.is(newLoadable)) {
callback();
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
return subscription.release;
},
[storeRef, recoilValue, componentName, getLoadable],
);
const source = useRecoilMutableSource();
const loadable = useMutableSource(source, getLoadableWithTesting, subscribe);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

useSetRecoilState & setRecoilValue


useSetRecoilState最终其实就是调用queueOrPerformStateUpdate,把更新放入更新队列里面等待时机调用


function useSetRecoilState(recoilState){
const storeRef = useStoreRef();
return useCallback(
(newValueOrUpdater) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState],
);
}

function setRecoilValue<T>(
store,
recoilValue,
valueOrUpdater,
) {
queueOrPerformStateUpdate(store, {
type: 'set',
recoilValue,
valueOrUpdater,
});
}

queueOrPerformStateUpdate,之后的操作比较复杂这里做简化为三步,如下;


function queueOrPerformStateUpdate(){
//...
//atomValues中设置值
state.atomValues.set(key, loadable);
// dirtyAtoms 中添加key。
state.dirtyAtoms.add(key);
//通过storeRef拿到。
notifyBatcherOfChange.current()
}

Batcher


recoil内部自己实现了一个批量更新的机制。


function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();

const [_, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));

useEffect(() => {
endBatch(storeRef);
});

return null;
}


function endBatch(storeRef) {
const storeState = storeRef.current.getState();
const {nextTree} = storeState;
if (nextTree === null) {
return;
}
// 树交换
storeState.previousTree = storeState.currentTree;
storeState.currentTree = nextTree;
storeState.nextTree = null;

sendEndOfBatchNotifications(storeRef.current);
}

function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
const dirtyAtoms = treeState.dirtyAtoms;
// 拿到所有下游的节点。
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentSubscriptions.get(key);

if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
//...
}

总结


虽然关于react的状态管理库很多,但是recoil的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前githubstar14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。


链接:https://juejin.cn/post/7006253866610229256

收起阅读 »

css做‘展开收起’功能,借鉴大佬思路

开局一张图 上图所示,多行文本的展开收起是一个很常见的交互效果。 实现这一类布局和交互难点主要一下几点: 位于多行文本右下角的“展开收起”按钮 “展开”和“收起”两种状态的切换 当文本不超过指定行数时,不显示“展开收起”按钮 在此之前,单独看这个布局,即...
继续阅读 »

开局一张图


more.gif


上图所示,多行文本的展开收起是一个很常见的交互效果。


实现这一类布局和交互难点主要一下几点:



  • 位于多行文本右下角的“展开收起”按钮

  • “展开”和“收起”两种状态的切换

  • 当文本不超过指定行数时,不显示“展开收起”按钮


在此之前,单独看这个布局,即便是配合JavaScript也不那么容易做出好看的交互效果。经过各方学习,发现纯CSS也能完美实现。


第一步,"展开收起"按钮


多行文本截断

假设有如下的一段html结构


<div class='more-text'>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

多行文本超出展示省略号的方式,大家平常也用得蛮多吧,关键代码如下


.more-text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

image.png


按钮右下角环绕效果

<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

.more-btn{
float: left;
/*其他装饰样式*/
}

image.png


换为右浮动


.more-btn{
float: right;
/*其他装饰样式*/
}


image.png


再移到右下角


.more-btn{
float: right;
margin-top: 50px;
/*其他装饰样式*/
}

image.png


不难看出,按钮确实到了右下角,但按钮上方空白空间太大了。并不是我们希望的效果。


此时,借鉴伪元素配合多个浮动元素来完成。


.more-text::before {
content: '';
float: right;
width: 10px;
height: 50px;
background: red;
}
.more-btn{
float: right;
clear: both;
/*其他装饰样式*/
}


image.png


如上图,当按钮和伪元素before都浮动,并且按钮clear: both,此时,伪元素before成功将按钮顶到了右下角。让伪元素before的宽度去掉便出现如下效果。


.more-text::before {
content: '';
float: right;
width: 0;
height: 50px;
background: red;
}

image.png


如你所见,按钮环绕效果非~常完美符合预期。


但是before高度是固定的50px,不一定会满足场景所需。还需修改为calc动态计算。


.more-text::before {
content: '';
float: right;
width: 0;
height: calc(100% - 20px);
/*100%减去一个按钮的高度即可*/
background: red;
}

image.png


很可惜,calc并没有达到理想的效果。


为什么呢?打开控制台可以发现,calc计算所得高度为0。怎么会这样呢?原因其实是因为父级元素没有设置高度,calc里面的 100% 便失效了。但问题在于,这里所需要的高度是动态变化的,不可能给父级定下一个固定高度。


至此,我们需要对布局进行修改。利用flex布局。大概的方法就是在 flex 布局 的子项中,可以通过百分比来计算变化高度。


修改如下,给.more-text再包裹一层,再设置 display: flex


<div class='more-wrapper'>
<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

.more-wrapper{
display: flex;
}

这样修改之后,calc的计算高度便能够生效。如下图所示。


image.png


至此,按钮右下角环绕效果就基本完成了。配上一个按钮点击事件就大功告成了。


浏览器兼容性处理


上面的实现是最完美的处理方式。但是,在Firefox浏览器却出现了兼容性问题。


image.png


哦豁。如此就非常尴尬。祸不单行,Safari浏览器也出现了兼容问题。


经过多番查证,发现是display: -webkit-box;属性存在兼容问题。


问题就在于,如果没有display: -webkit-box;怎么实现多行截断呢?如果在知道行数的情况下设置一个最大高度,理论上也能实现多行截断。由此我们通过行高属性line-height去入手。如果需要设置成 3 行,那就将高度设置成为 line-height * 3。


.more-text {
/*
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
*/
overflow: hidden;
line-height: 1.5;
max-height: 4.5em;
}

image.png


此时呢还缺少省略号...。可以利用伪元素实现。


.more-btn::before{
content: '…';
color: #333;
font-size: 14px;
position: absolute;
left: -10px;
transform: translateX(-100%);
}

image.png


大功告成,接下来加上点击切换即可。


点击切换“展开“ 与 ”收起“。


咱们目标是纯CSS完成。那么CSS状态切换就必不可少了,完全可以用input type = "checkbox"这个特性来完成。


要用到input特性就得对html代码进行一些修改。


<div class="more-wrapper">
<input type="checkbox" id="exp" />
<div class="more-text">
<!-- <div>展开</div> -->
<label class="more-btn" for="exp">展开</label>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

#exp:checked + .more-text {
-webkit-line-clamp: 999;
max-height: none;
}

more.gif


接下来,就是变换按钮文字,以及展开之后省略号隐藏。此时都可以利用伪元素处理。


<label class="more-btn" for="exp"></label>
<!-- 去掉按钮文字 -->

.more-btn::after {
content: '更多';
}

在:checked状态中


#exp:checked + .more-text .more-btn::after {
content: '收起';
}

省略号隐藏处理。


#exp:checked + .more-text .more-btn::before {
visibility: hidden;
}

more.gif


至此,我们需要的效果便成了。


当然咱们还可以添加一些过渡动画让展开收起效果更加美观。在此就不演示了。


最后,文本行数判断


此前的步骤已经能够满足使用需求。但是还是存在问题。比如当文本内容较少时,此时不会发生截断,便不需要省略号...以及展开收起按钮。


image.png


此时当然可以选择js方式去做判断。但我们的目标是纯CSS。


那CSS没有逻辑判断,咱们只能另辟蹊径,视觉欺骗。或者叫做障眼法


比如在上图中的场景,没有发生截断,那就不需要省略号...展开按钮。这时,如果在文本的最后加上一个元素。并且为了不影响布局,给此元素设置绝对定位。


.more-text::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: red;
}

同时,我们把父级的overflow: hidden;先去掉。得到效果如下


image.png


如图可见,红色部分的元素非常完美的挡住了按钮部分。


那我们把红色改成父级一样的背景色,并且恢复父级的overflow: hidden;


more.gif


上图可见,发现展开之后呢,伪元素盖住了收起按钮。所以必须再做一些修改。


#exp:checked + .more-text::after {
visibility: hidden;
}

more.gif


如你所见,非~常的好用。



注:IE10以下就不考虑了哈~




链接:https://juejin.cn/post/7007632958622269471

收起阅读 »

浅谈前端的状态管理

前言 提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在...
继续阅读 »

前言


提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在下方留言提出。


什么是前端状态管理?


举个例子:图书馆里所有人都可以随意进书库借书还书,如果人数不多,这种方式可以提高效率减少流程,一旦人数多起来就容易混乱,书的走向不明确,甚至丢失。所以需要一个图书管理员来专门记录借书的记录,也就是你要委托图书管理员给你借书及还书。


实际上,大多数状态管理方案都是如上思想,通过管理员(比如 Vuex)去规范书库里书本的借还(项目中需要存储的数据)


Vuex


在国内业务使用中 Vuex 的比例应该是最高的,Vuex 也是基于 Flux 思想的产品,Vuex 中的 state 是可以被修改的。原因和 Vue 的运行机制有关系,Vue 基于 ES5 中的 getter/setter 来实现视图和数据的双向绑定,因此 Vuex 中 state 的变更可以通过 setter 通知到视图中对应的指令来实现视图更新。更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。我们以图书馆来作为例子:


const state = {
book: 0
}

const mutations = {
borrow_book(state) {
state.book ++
}
}

//调用时
store.commit('borrow_book')

那还有action呢? 在 mutation 中混合异步调用会导致你的程序很难调试。你怎么知道是哪个先执行完呢?
aciton 可以包含任意异步操作,用法跟上面基本类似,不再叙述。


其实我只是拿 Vuex 来浅入一下相关用法大家应该是都熟悉了,那 Vuex 解决了什么问题呢?



  • 管理多个组件共享状态。

  • 全局状态管理。

  • 状态变更跟踪。

  • 让状态管理形成一种规范,使代码结构更清晰。


实际上大部分程序员都比较懒(狗头保命),只是为了能多个组件共享状态,至于其他的都是事后了。最典型的就是加入购物车的数量,加入一个就通过 Vuex 记录保存最终的总数显示在下栏。


那问题来了,既然你的目的只是共享多个状态,那何不直接用 Bus 总线好了?


Bus 总线


Bus 总线实际上他是一个公共的 Vue 实例,专门处理 emit 和 on 事件。


实际上 Bus 总线十分轻便,他并不存在 Dom 结构,他仅仅只是具有实例方法而已。


Vue.prototype.$Bus = new Vue()

然后,你可以通过 emit 来发送事件, on 来接收事件。


// 发送事件
this.$Bus.$emit('borrow_book', 1)

// 任意组件中接收
this.$Bus.$on('borrow_book', (book) => {
console.log(`借了${book}本书`)
})

当然还有 off(移除)、once(监听一次)等操作感兴趣可以自行搜索引擎。


怎么样?上面对于满足共享一个状态是不是比 Vuex 要简单多了?实际上确实是简单多了,但这也代表他比较适合中小型项目。多于大型项目来说 Bus 只会让你追述更改源时一脸懵逼甚至你都不知道他在哪里改变了。


他的工作原理就是发布订阅者的思想,虽然非常优雅简单,但实际 Vue 并不提倡这种写法,并在3.0版本中移除了大部分相关Api(emit、on等),其实不然,发布订阅模式你也可以自己手写一个去实现:


class Bus {
constructor() {
// 收集订阅信息,调度中心
this.list = {};
}

// 订阅
$on(name, fn) {
this.list[name] = this.list[name] || [];
this.list[name].push(fn);
}

// 发布
$emit(name, data) {
if (this.list[name]) {
this.list[name].forEach((fn) => {
fn(data);
});
}
}

// 取消订阅
$off(name) {
if (this.list[name]) {
delete this.list[name];
}
}
}
export default Bus;

简单吧?你只需要跟用 Vue Bus 一样去实例化然后用就可以了。什么?你想共享两三个甚至更少的状态(一个),那封装一个 Bus 是不是有点没必要了? 行吧,那你用 web storage 吧。


web storage


其实说到这,storage只是数据存储方式,跟状态管理其实没有太大关系,只是共享数据。但是既然都提到了那就顺带说一下(狗头)


web storage 有这三种:cookie、local storage、session storage。


无论这三种的哪种都强烈建议不要将敏感信息放入其中,这里应该是加密或一些不那么重要的数据在里面。


先简单复习一下三者:































类别生命周期存储容量存储位置
cookie默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效)4KB保存在客户端,每次请求时都会带上
localStorage理论上永久有效的,除非主动清除。4.98MB(不同浏览器情况不同,safari 2.49M)保存在客户端,不与服务端交互。节省网络流量
sessionStorage仅在当前网页会话下有效,关闭页面或浏览器后会被清除。4.98MB(部分浏览器没有限制)同上

cookie 不必多说,大家发起请求时经常会携带cookie请求一些个人数据等,与我们要探讨的内容没有太大关系。


loaclStorage 可以存储理论上永久有效的数据,如果你要存储状态一般推荐是放在 sessionStorage,localStorage 也有以下局限:



  • 浏览器的大小不统一,并且在 IE8 以上的 IE 版本才支持 localStorage 这个属性。

  • 目前所有的浏览器中都会把localStorage的值类型限定为string类型,这个在对我们日常比较常见的JSON对象类型需要一些转换。

  • localStorage在浏览器的隐私模式下面是不可读取的。

  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡。

  • localStorage不能被爬虫抓取到。


localStorage 与 sessionStorage 的唯一一点区别就是 localStorage 属于永久性存储,而 sessionStorage 属于当会话结束的时候,sessionStorage 中的键值对会被清空。


localStorage 本身只支持字符串形式存储,所以你存整数类型,拿出来的会是字符串类型。


sessionStorage 与 localStorage 基本差不多,只是回话关闭时,数据就会清空。


总结


不论哪种方案选择合适自己项目的方案才是最佳实践。没有最好的方案,只有合适自己的方案。


以上只是略微浅谈,也可能不够全面,欢迎下方留言~


链接:https://juejin.cn/post/7007306391836688415

收起阅读 »

关注 ? ? ? 前端仔也需要懂的nginx内容

tips 如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件和使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景 前言 作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容...
继续阅读 »


tips


如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景


前言


作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容易上手的Nginx可以满足你的一切幻想。学习nginx可以让我们更加清晰前端项目上线的整个流程

作为一个前端,或多或少都会对Nginx有一些经验,那为什么还要学习那? 不系统:以前可能你只会配置某项功能(网上搜集),都是碎片化的知识,不没有形成系统化。这样就导致你服务出现问题时,根本不知道从哪里下手来解决这些问题。


一、Nginx是什么?


nginx官方介绍:



"Nginx是一款轻量级的HTTP服务器,采用事件驱动的异步非阻塞处理方式框架,这让其具有极好的IO性能,时常用于服务端的反向代理和负载均衡。"



nginx的优点



  • 支持海量高并发:采用IO多路复用epoll。官方测试Nginx能够支持5万并发链接,实际生产环境中可以支撑2-4万并发连接数。

  • 内存消耗少

  • 可商业化

  • 配置文件简单


除了这些优点还有很多,比如反向代理功能,灰度发布,负载均衡功能等


二、安装


这里的文章不着重介绍怎么安装nginx,但是也给大家留下了安装的教程地址,自取



如果是centos大家也可以直接用yum安装也是很方便的


yum -y install nginx


nginx.conf 文件是nginx总配置文件也是nginx读取配置的入口。


三、nginx文件介绍


nginx我们最常用到的文件,其实就是nginx的配置文件,其他的文件我们就带过了,当你能熟练编写nginx文件,其实就等于熟练使用nginx了


[wujianrong@localhost ~]# tree /usr/local/nginx
/usr/local/nginx
├── client_body_temp
├── conf # Nginx所有配置文件的目录
│ ├── fastcgi.conf # fastcgi相关参数的配置文件
│ ├── fastcgi.conf.default # fastcgi.conf的原始备份文件
│ ├── fastcgi_params # fastcgi的参数文件
│ ├── fastcgi_params.default
│ ├── koi-utf
│ ├── koi-win
│ ├── mime.types # 媒体类型
│ ├── mime.types.default
│ ├── nginx.conf # Nginx主配置文件
│ ├── nginx.conf.default
│ ├── scgi_params # scgi相关参数文件
│ ├── scgi_params.default
│ ├── uwsgi_params # uwsgi相关参数文件
│ ├── uwsgi_params.default
│ └── win-utf
├── fastcgi_temp # fastcgi临时数据目录
├── html # Nginx默认站点目录
│ ├── 50x.html # 错误页面优雅替代显示文件,例如当出现502错误时会调用此页面
│ └── index.html # 默认的首页文件
├── logs # Nginx日志目录
│ ├── access.log # 访问日志文件
│ ├── error.log # 错误日志文件
│ └── nginx.pid # pid文件,Nginx进程启动后,会把所有进程的ID号写到此文件
├── proxy_temp # 临时目录
├── sbin # Nginx命令目录
│ └── nginx # Nginx的启动命令
├── scgi_temp # 临时目录
└── uwsgi_temp # 临时目录


1. 配置文件(重点)


conf //nginx所有配置文件目录   
nginx.conf //这个是Nginx的核心配置文件,这个文件非常重要,也是我们即将要学习的重点
nginx.conf.default //nginx.conf的备份文件

2. 日志


logs: 记录入门的文件,当nginx服务器启动后
这里面会有 access.log error.log 和nginx.pid三个文件出现。

3. 资源目录


html //存放nginx自带的两个静态的html页面   
50x.html //访问失败后的失败页面
index.html //成功访问的默认首页

4. 备份文件


fastcgi.conf:fastcgi  //相关配置文件
fastcgi.conf.default //fastcgi.conf的备份文件
fastcgi_params //fastcgi的参数文件
fastcgi_params.default //fastcgi的参数备份文件
scgi_params //scgi的参数文件
scgi_params.default //scgi的参数备份文件
uwsgi_params //uwsgi的参数文件
uwsgi_params.default //uwsgi的参数备份文件
mime.types //记录的是HTTP协议中的Content-Type的值和文件后缀名的对应关系
mime.types.default //mime.types的备份文件

5.编码文件


koi-utf、koi-win、win-utf这三个文件都是与编码转换映射相关的配置文件,
用来将一种编码转换成另一种编码

6. 执行文件


sbin: 是存放执行程序文件nginx

7. 命令


nginx: 是用来控制Nginx的启动和停止等相关的命令。

四、nginx常用命令



  1. 常见2种启动命令


> nginx //直接nginx启动,前提是配好nginx环境变量
> systemctl start nginx.service //使用systemctl命令启动


  1. 常见的4种停止命令


> nginx  -s stop //立即停止服务
> nginx -s quit // 从容停止服务 需要进程完成当前工作后再停止
> killall nginx //直接杀死nginx进程
> systemctl stop nginx.service //systemctl停止


  1. 常见的2种重启命令


> nginx -s reload //重启nginx
> systemctl reload nginx.service //systemctl重启nginx


  1. 验证nginx配置文件是否正确


> nginx -t //输出nginx.conf syntax is ok即表示nginx的配置文件正确


五、nginx配置详细介绍


1. 配置文件的结构介绍


为了让大家有个简单的轮廓,这里先对配置文件做一个简单的描述:


worker_processes  1;                			# worker进程的数量
events { # 事件区块开始
worker_connections 1024; # 每个worker进程支持的最大连接数
} # 事件区块结束
http { # HTTP区块开始
include mime.types; # Nginx支持的媒体类型库文件
default_type application/octet-stream; # 默认的媒体类型
sendfile on; # 开启高效传输模式
keepalive_timeout 65; # 连接超时
server { # 第一个Server区块开始,表示一个独立的虚拟主机站点
listen 80; # 提供服务的端口,默认80
server_name localhost; # 提供服务的域名主机名
location / { # 第一个location区块开始
root html; # 站点的根目录,相当于Nginx的安装目录
index index.html index.htm; # 默认的首页文件,多个用空格分开
} # 第一个location区块结果
error_page 500502503504 /50x.html; # 出现对应的http状态码时,使用50x.html回应客户
location = /50x.html { # location区块开始,访问50x.html
root html; # 指定对应的站点目录为html
}
}
......



  1. ngxin.conf 相当于是入口文件,nginx启动后会先从nginx.conf里面读取基础配置

  2. conf 目录下面的各种xxx.conf文件呢,一般就是每一个应用的配置,比如a网站的nginx配置叫a.conf,b网站的叫b.conf,可以方便我们去便于管理

  3. 加载conf目录下的配置,在主配置文件nginx.conf中,一般会有这么一行代码


2. nginx.conf主配置文件详细介绍


image.png


3. xx.conf 子配置文件详细介绍


我们最常改动nginx的,就是子配置文件


image.png


4. 关于location匹配


    #优先级1,精确匹配,根路径
location =/ {
return 400;
}

#优先级2,以某个字符串开头,以av开头的,优先匹配这里,区分大小写
location ^~ /av {
root /data/av/;
}

#优先级3,区分大小写的正则匹配,匹配/media*****路径
location ~ /media {
alias /data/static/;
}

#优先级4 ,不区分大小写的正则匹配,所有的****.jpg|gif|png 都走这里
location ~* .*\.(jpg|gif|png|js|css)$ {
root /data/av/;
}

#优先7,通用匹配
location / {
return 403;
}

更多配置


六、nginx反向代理、负载均衡 简单介绍


1. 反向代理


在聊反向代理之前,我们先看看正向代理,正向代理也是大家最常接触的到的代理模式,我们会从两个方面来说关于正向代理的处理模式,分别从软件方面和生活方面来解释一下什么叫正向代理,也说说正反向代理的区别


正向代理


正向代理,"它代理的是客户端",是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。客户端必须要进行一些特别的设置才能使用正向代理
正向代理的用途:



  • 访问原来无法访问的资源,如Google

  • 可以做缓存,加速访问资源

  • 对客户端访问授权,上网进行认证

  • 代理可以记录用户访问记录(上网行为管理),对外隐藏用户信息


反向代理


反向代理,"它代理的是服务端",主要用于服务器集群分布式部署的情况下,反向代理隐藏了服务器的信息
反向代理的作用:



  • 保证内网的安全,通常将反向代理作为公网访问地址,Web服务器是内网

  • 负载均衡,通过反向代理服务器来优化网站的负载


image.png


2. 负载均衡


服务器接收不同客户端发送的、Nginx反向代理服务器接收到的请求数量,就是我们说的负载量。
这些请求数量按照一定的规则进行分发到不同的服务器处理的规则,就是一种均衡规则。
所以,将服务器接收到的请求按照规则分发的过程,称为负载均衡

负载均衡也分硬件负载均衡和软件负载均衡两种,我们来讲的是软件负载均衡,关于硬件负载均衡的有兴趣的靓仔可以去了解下
负载均衡的算法:



  • 轮询(默认、加权轮询、ip_hash)

  • 插件(fair、url_hash),url_hash和ip_hash大同小异,一个基于ip一个基于url,就不过多介绍了


默认轮询


每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某个服务器宕机,能自动剔除故障系统。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001;
server localhost:10002;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

加权轮询


通过设置weight,值越大分配率越大
到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。其次是为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

权重越大,被访问的概率越大,比如上面就是33.33%和百分66.66%的访问概率
访问的效果:

localhost:10001、localhost:10002、localhost:10002、localhost:10001、localhost:10002、localhost:10002


ip_hash


每个请求都根据访问ip的hash结果分配,经过这样的处理,每个访客固定访问一个后端服务,如下配置(ip_hash可以和weight配合使用),并且可以有效解决动态网页存在的session共享问题


upstream constPolling {
ip_hash;
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}

fair


个人比较喜欢用的一种负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,响应时间短的优先分配。



  1. 安装upstream_fair模块 附上fair安装教程

  2. 哪个服务器的响应速度快,就将请求分配到那个服务器上


upstream constPolling { 
server localhost:10001;
server localhost:10002;
fair;
}

七、nginx错误页面配置、开启Gzip压缩配置


1. nginx错误页面配置


当我们访问的地址不存在的时候,我们可以根据http状态码来做对应的处理,我们以404为例


image.png
当然除了404以为我们还可以根据其他的状态码显示的,比如500、502等,熊猫的公司项目中,因为多个项目的错误页面都是统一的,所以我们有单独维护的一套错误码页面放到了我们公司的中台项目中,然后根据客户端是PC/移动端,跳转到对应的错误页面


2.Gzip压缩


Gzip是网页的一种网页压缩技术,经过gzip压缩后,页面大小可以变为原来的30%甚至更小。更小的网页会让用户浏览的体验更好,速度更快。gzip网页压缩的实现需要浏览器和服务器的支持

gzip是需要服务器和浏览器同时支持的。当浏览器支持gzip压缩时,会在请求消息中包含Accept-Encoding:gzip,这样Nginx就会向浏览器发送听过gzip后的内容,同时在相应信息头中加入Content-Encoding:gzip,声明这是gzip后的内容,告知浏览器要先解压后才能解析输出。
如果项目是在ie或者一些兼容性比较低浏览器上运行的,需要去查阅确定是否浏览器支持gzip


server {

listen 12089;

index index.php index.html;

error_log /var/log/nginx/error.log;

access_log /var/log/nginx/access.log;

root /var/www/html/gzip;
# 开启gzip压缩

gzip on;

# http请求版本

gzip_http_version 1.0;

# 设置什么类型的文件需要压缩

gzip_types text/css text/javascript application/javascript image/png image/jpeg image/gif;

location / {

index index.html index.htm index.php;

autoindex off;

}

}

gzip_types对应需要什么格式,可以去查看content-Type


image.png


Content-Type: text/css

# 成功开启gzip
Content-Encoding: gzip

八、常用全局变量
































































































变量含义
$args这个变量等于请求行中的参数,同$query_string
$content length请求头中的Content-length字段。
$content_type请求头中的Content-Type字段。
$document_root当前请求在root指令中指定的值。
$host请求主机头字段,否则为服务器名称。
$http_user_agent客户端agent信息
$http_cookie客户端cookie信息
$limit_rate这个变量可以限制连接速率。
$request_method客户端请求的动作,通常为GET或POST。
$remote_addr客户端的IP地址。
$remote_port客户端的端口。
$remote_user已经经过Auth Basic Module验证的用户名。
$request_filename当前请求的文件路径,由root或alias指令与URI请求生成。
$schemeHTTP方法(如http,https)。
$server_protocol请求使用的协议,通常是HTTP/1.0或HTTP/1.1。
$server_addr服务器地址,在完成一次系统调用后可以确定这个值。
$server_name服务器名称。
$server_port请求到达服务器的端口号。
$request_uri包含请求参数的原始URI,不包含主机名,如”/foo/bar.php?arg=baz”。
$uri不带请求参数的当前URI,$uri不包含主机名,如”/foo/bar.html”。
$document_uri与$uri相同。



九、nginx使用综合场景(在github里面会持续更新和补充)


1. 同一个域名通过不同目录指定不同项目目录


在开发过程中,有一种场景,比如有项目有多个子系统需要通过同一个域名通过不同目录去访问
在A/B Test 灰度发布等场景也会用上

比如:

访问 a.com/a/*** 访问的是a系统

访问 a.com/b/*** 访问的是b系统


image.png


2. 自动适配PC/移动端页面


image.png


3. 限制只能通过谷歌浏览器访问


image.png


4. 前端单页面应用刷新404问题


image.png


更多:包括防盗链、动静分离、权限控制



链接:https://juejin.cn/post/7007346707767754765

收起阅读 »