阅读视图

发现新文章,点击刷新页面。
☑️ ☆

kotlin修炼指南9-Sequence的秘密

kotlin修炼指南9-Sequence的秘密

人们经常忽略Iterable和Sequence之间的区别。这是可以理解的,因为即使它们的定义也几乎是相同的。

interface Iterable<out T> {
    operator fun iterator(): Iterator<T>
}

interface Sequence<out T> {
    operator fun iterator(): Iterator<T>
}

你可以说它们之间唯一的正式区别就是名字。尽管Iterable和Sequence有着完全不同的用途(有不同的契约),它们的处理函数几乎都以不同的方式工作。Sequence是Lazy的,所以Sequence处理的中间函数不做任何计算。相反,它们返回一个新的Sequence,用新的操作来装饰以前的Sequence。所有这些计算在终端操作(如toList或count)中被处理。而另一方面,Iterable的处理在每一步都会返回一个类似List的集合。

public inline fun <T> Iterable<T>.filter(
   predicate: (T) -> Boolean
): List<T> {
   return filterTo(ArrayList<T>(), predicate)
}

public fun <T> Sequence<T>.filter(
   predicate: (T) -> Boolean
): Sequence<T> {
   return FilteringSequence(this, true, predicate)
}
Sequence过滤器是一个中间操作,所以它不做任何计算,而是用新的处理步骤来装饰Sequence。计算是在终端操作中完成的,比如toList。

因此,集合处理操作一旦被使用就会被调用。Sequence处理函数直到终端操作(一个返回其他东西而不是Sequence的操作)才会被调用。例如,对于Sequence来说,filter是一个中间操作,所以它不做任何计算,而是用新的处理步骤来装饰Sequence。计算是在toList这样的终端操作中完成的。

kotlin修炼指南9-Sequence的秘密
val seq = sequenceOf(1,2,3)
val filtered = seq.filter { print("f$it "); it % 2 == 1 }
println(filtered)  // FilteringSequence@...val asList = filtered.toList() // f1 f2 f3
println(asList) // [1, 3]val list = listOf(1,2,3)
val listFiltered = list
  .filter { print("f$it "); it % 2 == 1 } // f1 f2 f3
println(listFiltered) // [1, 3]

在Kotlin中,Sequence是Lazy的,这有几个重要的优点。

它们保持了操作的自然顺序

它们只做最少的操作

它们可以是无限的

它们不需要在每个步骤中都创建集合

让我们来逐一讨论这些优点。

Order is important

由于iterable和Sequence处理的实现方式,它们的操作顺序是不同的。在Sequence处理中,我们取第一个元素并应用所有的操作,然后我们取下一个元素,以此类推。我们将其称为逐个元素或Lazy的顺序。在可迭代处理中,我们取第一个操作,并将其应用于整个集合,然后转到下一个操作。他们是一步一步被执行的。

sequenceOf(1,2,3)
       .filter { print("F$it, "); it % 2 == 1 }
       .map { print("M$it, "); it * 2 }
       .forEach { print("E$it, ") } // Prints: F1, M1, E2, F2, F3, M3, E6,listOf(1,2,3)
       .filter { print("F$it, "); it % 2 == 1 }
       .map { print("M$it, "); it * 2 }
       .forEach { print("E$it, ") } // Prints: F1, F2, F3, M1, M3, E2, E6,
kotlin修炼指南9-Sequence的秘密

请注意,如果我们不使用任何集合处理函数来实现这些操作,而是使用经典的循环和条件,我们就会像在Sequence处理中一样是逐个元素的顺序。

for (e in listOf(1,2,3)) {
   print("F$e, ")
   if(e % 2 == 1) {
       print("M$e, ")
       val mapped = e * 2
       print("E$mapped, ")
   }
}
// Prints: F1, M1, E2, F2, F3, M3, E6,

因此,在Sequence处理中使用的逐个元素的顺序是比较自然的。它还为低级别的编译器优化打开了大门--Sequence处理可以被优化为基本的循环和条件。也许在未来,它将是这样。

Sequences do the minimal number of operations

通常我们不需要在每一步都处理整个集合来产生结果。比方说,我们有一个有数百万个元素的集合,在处理之后,我们只需要取前10个。为什么要处理其他所有的元素呢?Iterable处理没有中间操作的概念,所以整个集合的处理就像在每个操作上都要返回一样。Sequence不需要这样,所以它们会做最小数量的操作来获得结果。

kotlin修炼指南9-Sequence的秘密

看一下这个例子,我们有几个处理步骤,我们用find来结束我们的处理。

(1..10).asSequence()
   .filter { print("F$it, "); it % 2 == 1 }
   .map { print("M$it, "); it * 2 }
   .find { it > 5 }
// Prints: F1, M1, F2, F3, M3,(1..10)
   .filter { print("F$it, "); it % 2 == 1 }
   .map { print("M$it, "); it * 2 }
   .find { it > 5 }
// Prints: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, M1, M3, M5, M7, M9,

出于这个原因,当我们有一些中间处理步骤,并且我们的终端操作不一定需要遍历所有的元素时,使用一个Sequence很可能对你的处理性能更好。所有这些,同时看起来与标准的集合处理几乎一样。这类操作的例子有first, find, take, any, all, none或indexOf等。

Sequences can be infinite

由于Sequence是按需进行处理的,我们可以有无限的Sequence。创建一个无限Sequence的典型方法是使用Sequence生成器,如generateSequence或sequence。第一个生成器需要第一个元素和一个指定如何计算下一个元素的函数。

generateSequence(1) { it + 1 }
       .map { it * 2 }
       .take(10)
       .forEach { print("$it, ") }
// Prints: 2, 4, 6, 8, 10, 12, 14, 16, 18, 20,

第二个提到的Sequence生成器--sequence--使用一个suspend函数(coroutine),按要求生成下一个数字。每当我们要求下一个数字时,Sequence生成器就会运行,直到使用yield产生一个值。然后停止执行,直到我们要求得到另一个数字。下面是一个无限的下一个斐波那契数字的列表。

val fibonacci = sequence {
   yield(1)
   var current = 1
   var prev = 1
   while (true) {
       yield(current)
       val temp = prev
       prev = current
       current += temp
   }
}
print(fibonacci.take(10).toList()) 
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

请注意,无限Sequence在某些时候需要有有限的元素数量。我们不能在无穷大上迭代。

print(fibonacci.toList()) // Runs forever

因此,我们要么需要使用像take这样的操作来限制它们,要么需要使用一个不需要所有元素的终端操作,比如first、find、any、all、none或indexOf。基本上,这些都是Sequence更有效的操作,因为它们不需要处理所有元素。尽管注意到对于大多数这些操作来说,很容易陷入无限循环。any操作符只能返回true或者永远运行。同样,all和none操作符在一个无限的集合上也只能返回false。因此,我们通常要么通过take来限制元素的数量,要么就用first来要求第一个元素。

Sequences do not create collections at every processing step

标准的集合处理函数在每一步都会返回一个新的集合。大多数情况下,它是一个列表。这可能是一个优势--在每一个点之后,我们都有一些准备好的东西可以使用或存储。但它也是有代价的。这样的集合在每一步都需要被创建并填充数据。

numbers
   .filter { it % 10 == 0 } // 1 collection here
   .map { it * 2 } // 1 collection here
   .sum() 
// In total, 2 collections created under the hoodnumbers
   .asSequence()
   .filter { it % 10 == 0 }
   .map { it * 2 }
   .sum() 
// No collections created

这是个问题,特别是当我们处理大的或重的集合时。让我们从一个极端但又常见的案例开始:文件读取。文件可以达到数千兆字节。在每个处理步骤中分配一个集合中的所有数据将是对内存的巨大浪费。这就是为什么我们默认使用Sequence来处理文件。

作为一个例子,让我们分析一下芝加哥市的犯罪。这个城市和其他许多城市一样,在互联网上分享了自2001年以来发生在那里的全部犯罪数据库(你可以在www.data.cityofchicago.org找到这些记录)。这个数据集目前的Size超过1.53GB。比方说,我们的任务是找出有多少犯罪行为的描述中有大麻。下面就是一个使用集合处理的天真解决方案的样子(readLines返回List)。

// BAD SOLUTION, DO NOT USE COLLECTIONS FOR 
// POSSIBLY BIG FILES
File("ChicagoCrimes.csv").readLines()
   .drop(1) // Drop descriptions of the columns
   .mapNotNull { it.split(",").getOrNull(6) } 
    // Find description
   .filter { "CANNABIS" in it } 
   .count()
   .let(::println)

我的电脑上的结果是OutOfMemoryError。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

不难理解为什么。我们创建了一个集合,然后我们有3个中间处理步骤,加起来有4个集合。其中3个包含了这个数据文件的大部分,需要1.53GB,所以它们都需要消耗超过4.59GB。这是对内存的巨大浪费。正确的实现应该是使用一个Sequence,我们使用函数useLines来实现,它总是在一个单行上操作。

File("ChicagoCrimes.csv").useLines { lines ->
// The type of `lines` is Sequence<String>
   lines
       .drop(1) // Drop descriptions of the columns
       .mapNotNull { it.split(",").getOrNull(6) } 
       // Find description
       .filter { "CANNABIS" in it } 
       .count()
       .let { println(it) } // 318185

在我的电脑上,这需要8.3秒。为了比较这两种方法的效率,我又做了一个实验,我通过删除不需要的列来减少这个数据集的大小。这样我就得到了CrimeData.csv文件,其中包含了同样的罪行,但大小只有728MB。然后我做了同样的处理。在第一个实现中,使用集合处理,大约需要13秒;而第二个实现中,使用Sequence,大约需要4.5秒。正如你所看到的,对较大的文件使用Sequence,不仅是为了内存,也是为了性能。

虽然一个集合不需要很重。事实上,每一步我们都在创建一个新的集合,这本身也是一种成本,当我们处理具有较大数量元素的集合时,这种成本就会体现出来。差别并不是非常巨大的原因是--主要是因为经过许多步骤创建的集合被初始化为预期的大小,所以当我们添加元素时,只是把它们放在下一个位置。但这种差异仍然是不可忽视的,这也是为什么我们更愿意使用Sequence来处理超过一个处理步骤的大集合的主要原因。

我所说的 "大集合 "是指许多元素和真正的大集合。它可能是一个有几万个元素的整数列表。它也可能是一个只有几个字符串的列表,但每个字符串都很长,以至于它们都需要很多兆字节的数据。这些情况并不常见,但它们有时会发生。

我所说的一个处理步骤,是指超过一个函数的集合处理。因此,如果你比较这两个函数。

fun singleStepListProcessing(): List<Product> {
   return productsList.filter { it.bought }
}

fun singleStepSequenceProcessing(): List<Product> {
   return productsList.asSequence()
           .filter { it.bought }
           .toList()
}

你会注意到在性能上几乎没有差别(实际上简单的列表处理更快,因为它的过滤功能是内联的)。尽管当你比较有多个处理步骤的函数时,比如下面的函数,它使用了过滤器,然后是Map,对于更大的集合来说,差异将是可见的。为了看到区别,让我们比较一下5000个产品的典型处理,有两个和三个处理步骤。

fun twoStepListProcessing(): List<Double> {
   return productsList
           .filter { it.bought }
           .map { it.price }
}

fun twoStepSequenceProcessing(): List<Double> {
   return productsList.asSequence()
           .filter { it.bought }
           .map { it.price }
           .toList()
}

fun threeStepListProcessing(): Double {
   return productsList
           .filter { it.bought }
           .map { it.price }
           .average()
}

fun threeStepSequenceProcessing(): Double {
   return productsList.asSequence()
           .filter { it.bought }
           .map { it.price }
           .average()
}

下面你可以看到在MacBook Pro(处理器2.6 GHz Intel Core i7,内存16 GB 1600 MHz DDR3)上对产品清单中5000个产品的平均结果。

twoStepListProcessing                        81 095 ns
twoStepSequenceProcessing                    55 685 ns
twoStepListProcessingAndAcumulate            83 307 ns
twoStepSequenceProcessingAndAcumulate         6 928 ns

很难预测我们应该期待什么样的性能改进。根据我的观察,在一个典型的有多个步骤的集合处理中,对于至少几千个元素,我们可以期望有20-40%左右的性能改进。

When aren’t sequences faster?

有一些操作我们不能从这种Sequence的使用中获益,因为我们必须对整个集合进行操作,sorted是Kotlin stdlib中的一个例子(目前是唯一的例子)。sorted使用了最佳实现。它将Sequence累积到List中,然后使用Java stdlib中的sort。缺点是,如果我们将其与在一个集合上的相同处理进行比较,这个积累过程需要一些额外的时间(尽管如果Iterable不是一个集合或数组,那么区别并不明显,因为它也需要进行积累)。

Sequence是否应该有sorted这样的方法是有争议的,因为Sequence流式的操作符中,只是部分操作符是Lazy的(当我们需要得到第一个元素时才进行计算),并且在无限的Sequence上不起作用。添加它是因为它是一个流行的函数,而且这样使用它要容易得多。尽管Kotlin开发者应该记住它的缺陷,特别是它不能用于无限Sequence。

generateSequence(0) { it + 1 }.take(10).sorted().toList() 
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
generateSequence(0) { it + 1 }.sorted().take(10).toList() 
// Infinite time. Does not return.

sorted是一个罕见的处理例子,它在Collection上比在Sequence上快。尽管如此,当我们做一些处理步骤和单一的排序函数(或其他需要在整个集合上工作的函数)时,我们可以期望使用Sequence处理来提高性能。

// Benchmarking measurement result: 150 482 ns
fun productsSortAndProcessingList(): Double {
   return productsList
           .sortedBy { it.price }
           .filter { it.bought }
           .map { it.price }
           .average()
}

// Benchmarking measurement result: 96 811 ns
fun productsSortAndProcessingSequence(): Double {
   return productsList.asSequence()
           .sortedBy { it.price }
           .filter { it.bought }
           .map { it.price }
           .average()
}

What about Java stream?

Java 8引入了流,允许集合处理。它们的行为和代码外观类似于Kotlin的Sequence。

productsList.asSequence()
       .filter { it.bought }
       .map { it.price }
       .average()productsList.stream()
       .filter { it.bought }
       .mapToDouble { it.price }
       .average()
       .orElse(0.0)

Java 8的流是Lazy的,在最后一个(终端)处理步骤中开始计算。Java流和KotlinSequence的三大区别如下。

KotlinSequence有更多的处理函数(因为它们被定义为扩展函数),它们通常更容易使用(这是由于KotlinSequence是在Java streams已经被使用时设计的--例如,我们可以使用toList()来收集,而不是collectors.toList())。

Java流处理可以使用并行函数以并行模式启动。当我们的机器有多个经常未使用的内核时(这在现在很常见),这可以给我们带来巨大的性能提升。虽然要谨慎使用,因为这个功能有已知的隐患(问题来自于他们使用的常见的连接-分叉线程池。因为,一个Task可能会阻塞另一个Task。还有一个问题是单元素处理会阻塞其他元素。在此阅读更多信息:https://dzone.com/articles/think-twice-using-java-8)。

KotlinSequence可以在普通模块、Kotlin/JVM、Kotlin/JS和Kotlin/Native模块中使用。Java流只在Kotlin/JVM中使用,而且只在JVM版本至少为8时使用。

一般来说,当我们不使用并行模式时,很难给出一个简单的答案,Java流和KotlinSequence哪个更有效。我的建议是很少使用Java流,只在计算量大的处理中使用,这样可以从并行模式中获益。否则,使用Kotlin stdlib函数,以获得同质化的、干净的代码,可以在不同的平台上或共同的模块上使用。

Kotlin Sequence debugging

Kotlin Sequence和Java Stream都有支持,可以帮助我们在每一步调试元素流。对于Java Stream,它需要一个名为 "Java Stream Debugger "的插件。KotlinSequence也需要名为 "Kotlin Sequence Debugger "的插件,不过现在这个功能已经集成到Kotlin插件中了。下面是一个显示Sequence处理的每一步的屏幕。

kotlin修炼指南9-Sequence的秘密

Summary

Collection和Sequence的处理非常相似,都支持几乎相同的处理方法。然而这两者之间有重要的区别。Sequence处理更复杂,所以我们通常将元素保存在集合中,然后转换集合为Sequence,最后往往还需要回到所需的集合。但Sequence是Lazy的,这带来了一些重要的优势。

  • 它们保持操作的自然顺序
  • 它们只做最少的操作
  • 它们可以是无限的
  • 它们不需要在每一步都创建集合

因此,它们更适合于处理大尺寸的对象或具有多个处理步骤的大型集合。Sequence也得到了KotlinSequence调试器的支持,它可以帮助我们直观地看到元素的处理情况。Sequence不能取代经典的集合处理。你应该只在有充分理由的情况下使用它们,而且你会得到显著的性能优化的回报。

原文翻译自 https://blog.kotlin-academy.com/effective-kotlin-use-sequence-for-bigger-collections-with-more-than-one-processing-step-649a15bb4bf

☑️ ⭐

kotlin修炼指南8—集合中的高阶函数

kotlin修炼指南8—集合中的高阶函数

Kotlin对集合操作类新增了很多快捷的高阶函数操作,各种操作符让很多开发者傻傻分不清,特别是看一些Kotlin的源码或者是协程的源码,各种眼花缭乱的操作符,让代码完全读不下去,所以,本文将对Kotlin中的集合高阶函数,进行下讲解,降低大家阅读源码的难度,下面看几个用的比较多的高阶函数使用。

首先是sumOf,作为一个很方便的求和函数,它可以快速对集合内的某些参数进行sum操作,代码如下所示。

val list = mutableListOf(1, 2, 3, 4)
val sumOf = list.sumOf { it }

我们来看看它的源码。

public inline fun <T> Iterable<T>.sumOf(selector: (T) -> Int): Int {
    var sum: Int = 0.toInt()
    for (element in this) {
        sum += selector(element)
    }
    return sum
}

其实它内部就是对元素的累加,像这样的高阶函数,在Kotlin中有很多,这也是很多基础功能用Kotlin开发会更加方便的原因之一。

但是sumOf有个局限,那就是只能求和,毕竟它设计就是用来作求和的,所以对于更加一般的场景,我们可以将这个操作再进一步抽象出来,这就是reduce。

比如我们现在要实现一个乘法功能,代码如下所示。

val list = mutableListOf(1, 2, 3, 4)
val result = list.reduce { acc, i ->
    acc * i
}

reduce的操作参数有两个,当前的累积值和集合中的下一个元素。reduce的执行逻辑是,先取出集合的第一个元素,作为acc,并和第二个元素——i,执行block中的逻辑,返回值作为acc,继续上面的步骤。

如果集合为空,那么会导致异常。

但是reduce也有个局限问题,那就是它默认使用集合的第一个元素作为起始的acc,所以它就只能返回前面集合的泛型类型,假如是下面这样的结构,就无法使用了。

data class Test(val num: Int, val name: String)
val list = mutableListOf(
    Test(1, "x"),
    Test(2, "y"),
    Test(3, "z"),
    Test(4, "j")
)
val result = list.reduce { acc, i ->
    acc.num * i.num // Error
}

其问题,就是在于acc的类型不能指定,只能从集合中获取,所以,Kotlin还提供了更加通用的高阶函数——fold,代码如下所示。

data class Test(val num: Int, val name: String)
val list = mutableListOf(
    Test(1, "x"),
    Test(2, "y"),
    Test(3, "z"),
    Test(4, "j")
)
val result = list.fold(1) { acc, test ->
    acc * test.num
}

fold和reduce非常像,只不过fold增加了一个initial的参数,通过这个参数,可以设置acc的初始值,同时也指定了返回的类型,这样一来,就不像reduce一样需要和集合类型保持一致了。

由于初始值是initial参数指定的,所以即使集合为空也不会导致异常。

由此可见,在Kotlin中,reduce实际上是一个不完善的高阶函数,大部分时候,都应该避免使用它,而应该使用flod来代替,而且,要注意的是,在其它语言中,例如JavaScript中,它的reduce函数,实际上和Kotlin的fold函数的逻辑是一样的,而不是Kotlin中reduce的实现。

那么fold有什么使用场景呢?前面说的对集合进行遍历,然后对某些项目进行求和、求积、拼接字符串这些操作,就是一个非常常用的例子。

和大部分的集合高阶函数一样,fold也提供了foldRight、foldIndexed、foldRightIndexed这样的拓展,可以通过获取索引,或者是改变遍历的方向。

fold和reduce,实际上是一种对集合的规约操作,最后会返回一个「规约」之后的值,相当于对集合做提取并规约的操作。

除了对集合的规约,对集合的遍历,Kotlin也做了很多改善。

例如我们可以通过filter来过滤集合中满足某些规则的元素,代码如下所示。

val result = list.filter { it.num > 2 }

再例如对集合做排序,虽然之前也能做,但是绝对不像高阶函数这样一目了然,让我们看下下面的代码。

val sorted = lists.sorted()
val sortedDescending = lists.sortedDescending()
val sortedBy = lists.sortedBy { it.length }
val sortedByDescending = lists.sortedByDescending { it.length }
val sortedWith = lists.sortedWith(
    compareBy<String> { it.length }.thenBy { it }
)

这些都是常见的排序方法,基本可以涵盖我们大部分的使用场景。

除了排序,我们还可以对集合做Check,判断集合中是否有满足条件的元素,例如下面的代码。

val any = lists.any { it.length == 6 }
val all = lists.all { it.length == 6 }
val none = lists.none { it.length == 6 }
val count = lists.count { it.length == 6 }

类似的例如Search和take这样的高阶函数我们就不讲了,基本都可以望文生义。

最后我们来看下集合中的Transform。

最简单的,我们可以借助map函数来对一个集合做转换,例如下面的代码。

val result = list.map { it.num }

这样就形成了一个num组成的新集合。

map相对来说比较好理解,它实现的是一对一的转换,但是另一个——flatMap就不是这么好理解了。

所以我们先来了解另一个操作符——flatten。

假设我们有这样一个嵌套的List,如下所示。

val list = listOf(listOf("abc", "xyz", "hjk"), listOf("123", "789"), listOf("+-"))

我需要将这个二维List打平为一个一维List,那么就可以通过flatten来实现,代码如下所示。

val result = list.flatten()
// out
[abc, xyz, hjk, 123, 789, +-]

那么如果我在打平List之后,还要对数据做一些处理呢?很方便,我们可以链式调用其它的高阶函数,例如map,代码如下所示。

val result = list.flatten().map { it.first() }
// out
[a, x, h, 1, 7, +]

这样的操作其实很常见,所以Kotlin提供了一个复合的高阶函数——flatMap,我们使用flatMap来实现同样的功能。

val result = list.flatMap {
    it.map { item ->
        item.first()
    }
}

它实际上就是先使用flatten将数据打平,再对每个item进行map操作。所以,如果你只是需要打平数据,那么直接flatten就够了,如果需要再对数据做一些处理,那么就需要使用flatMap了。

flatMap的一个非常常用的场景,就是生成两个List的叉乘数据,我们来看下面这个例子。

val SUITS = setOf("♣" /* clubs*/, "♦" /* diamonds*/, "♥" /* hearts*/, "♠" /*spades*/)
val VALUES = setOf("2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A")

上面有两个list,分别是扑克牌中的花色和数字,那么我们如何通过这两个list来生成整副扑克牌呢?借助flatMap就可以很方便的实现,代码如下所示。

val DECK = SUITS.flatMap { suit ->
    VALUES.map { value -> Card(suit, value) }
}

这个例子和上面的例子还不太一样,可以说是一个互逆的过程,前面我们是通过一个嵌套List,然后打平处理数据,而这个例子,则是两个list进行叉乘,生成一个新的List。

综上,我们总结下flatMap的工作流程,首先,flatMap会遍历集合中的元素,然后将每个元素传入block中,经过block处理后返回一个list,最后将每个元素处理完后生成的list进行平铺,生成一个打平的list,这就是flatMap的完整执行流程。

由此可见,大部分场景下,我们甚至都不用再使用集合的遍历功能,通过这些辅助的高阶函数,就可以很方便的对集合进行操作,这也是Kotlin代码会比Java更加容易开发的原因,当然,Kotlin的函数式编程方式,会比Java的上手难度更高。

那么我们在使用Kotlin的高阶函数来对集合进行处理时,是否需要担心一些隐藏的性能开销呢?

首先,Kotlin默认的集合类高阶函数,都是inline函数,所以在编译时会进行替换,从而高阶函数的block不会生成新的内部类,造成代码膨胀,但是,由于高阶函数每次处理集合时,都会产生一个新的集合,所以确实会造成内存的增长,但是对于移动端来说,在数据量不大的场景下,这个影响是微乎其微的,所以,完全不用担心性能的开销,放心大胆的使用吧。

☑️ ☆

忙里偷闲IdleHandler

忙里偷闲IdleHandler

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。

/**
 * Callback interface for discovering when a thread is going to block
 * waiting for more messages.
 */
public static interface IdleHandler {
    /**
     * Called when the message queue has run out of messages and will now
     * wait for more.  Return true to keep your idle handler active, false
     * to have it removed.  This may be called if there are still messages
     * pending in the queue, but they are all scheduled to be dispatched
     * after the current time.
     */
    boolean queueIdle();
}

从注释我们就能发现,这是一个IdleHandler的静态接口,可以在消息队列没有消息时或是队列中的消息还没有到执行时间时才会执行的一个回调。

这个功能在某些重要但不紧急的场景下就非常有用了,比如我们要在主页上做一些处理,但是又不想影响原有的初始化逻辑,避免卡顿,那么我们就需要等系统闲下来的时候再来执行我们的操作,这个时候,我们就可以通过IdleHandler来进行回调。

它的使用也非常简单,代码示例如下。

Looper.myQueue().addIdleHandler {
    // Do something
    false
}

在Handler的消息循环中,一旦队列里面没有需要处理的消息,该接口就会回调,也就是Handler空闲的时候。

这个接口有返回值,代表是否需要持续执行,如果返回true,那么一旦Handler空闲,就会执行IdleHandler中的回调,而如果返回false,那么就只会执行一次。

当返回true时,可以通过removeIdleHandler的方式来移除循环的处理,如果是false,那么在处理完后,它自己会移除。

综上,IdleHandler的使用主要有下面这些场景。

  • 低优先级的任务处理:替换之前为了不在初始化的时候影响性能而使用的Handler.postDelayed方法,通过IdleHandler来自动获取空闲的时机。
  • Idle时循环处理任务:通过控制返回值,在系统空闲时,不断重复某个操作。

但是要注意的是,如果Handler过于繁忙,那么IdleHandler的执行时机是有可能被延迟很久的,所以,要注意一些比较重要的处理逻辑的处理时机。

在很多第三方库里面,都有IdleHandler的使用,例如LeakCanary,它对内存的dump分析过程,就是在IdleHandler中处理的,从而避免对主线程的影响。

☑️ ⭐

Android-Widget重装上阵

Android-Widget重装上阵

如果要在Android系统中找一个一直存在,但一直被人忽略,而且有十分好用的功能,那么Widget,一定算一个。这个从Android 1.x就已经存在的功能,经历了近10年的迭代,在遭到无数无视和白眼之后,又重新回到了大家的视线之内,当然,也有可能是App内部已经没东西好卷了,所以大家又把目光放到了App之外,但不管怎样,Widget在Android 12之后,都开始焕发一新,官网镇楼,让我们重新来了解下这个最熟悉的陌生人。

https://developer.android.com/develop/ui/views/appwidgets/overview

Widget使用的是RemoteView,这与Notification的使用如出一辙,RemoteView是继承自Parcelable的组件,可以跨进程使用。在Widget中,通过AppWidgetProvider来管理Widget的行为,通过RemoteView来对Widget进行布局,通过AppWidgetManager来对Widget进行刷新。基本的使用方式,我们可以通过一套模板代码来实现,在Android Studio中,直接New Widget即可。这样Android Studio就可以自动为你生成一个Widget的模板代码,详细代码我们就不贴了,我们来分析下代码的组成。

首先,每个Widget都包含一个AppWidgetProvider。这是Widget的逻辑管理类,它继承自BroadcastReceiver,然后,我们需要在清单中注册这个Receiver,并在meta-data中指定它的配置文件,它的配置文件是一个xml,这里描述的是添加Widget时展示的一些信息。

从这些地方来看,其实Widget的使用还是比较简单的,所以本文也不准备来讲解这些基础知识,下面我们针对开发中会遇到的一些实际需求来进行分析。

appwidget-provider配置文件

这个xml文件虽然简单,但还是有些有意思的东西的。

尺寸

在这里我们可以为Widget配置尺寸信息,通过maxResizeWidth、maxResizeHeight和minWidth、minHeight,我们可以大致将Widget的尺寸控制在MxN的格子内,这也是Widget在桌面上的展示方式,它并不是通过指定的宽高来展示的,而是桌面所占据的格子数。

官方设计文档中,对格子数和尺寸的转换标准,有一个表格,如下所示。

Android-Widget重装上阵

我们在设计的时候,也应该尽量遵循这个尺寸约束,避免在桌面上展示异常。在Android12之后,描述文件中,还增加了targetCellWidth和targetCellHeight两个参数,他们可以直接指定Widget所占据的格子数,这样更加方便,但由于它仅支持Android12+,所以,通常这些属性会一起设置。

有意思的是这个尺寸标准并不适用于所有的设备,因为ROM的碎片化问题,各个厂商的桌面都不一样,所以。。。只能参考参考。

updatePeriodMillis

这个参数用于指定Widget的被动刷新频率,它由系统控制,所以具有很强的不定性,而且它也不能随意设置,官网上对这个属性的限制如下所示。

Android-Widget重装上阵

updatePeriodMillis只支持设置30分钟以上的间隔,即1800000milliseconds,这也是为了保证后台能耗,即使你设置了小于30分钟的updatePeriodMillis,它也不会生效。

对于Widget来说,updatePeriodMillis控制的是系统被动刷新Widget的频率,如果当前App是活着的,那么随时可以通过广播来修改Widget。

而且这个值很有可能因为不同ROM而不同,所以,这是一个不怎么稳定的刷新机制。

其它

除了上面我们提到的一些属性,还有一些需要留意的。

  • resizeMode:拉伸的方向,可以设置为horizontal|vertical,表示两边都可以拉伸。
  • widgetCategory:对于现在的App来说,只能设置为home_screen了,5.0之前可以设置为锁屏,现在基本已经不用了。
  • widgetFeatures:这是Android12之后新加的属性,设置为reconfigurable之后,就可以直接调整Widget的尺寸,而不用像之前那样先删除旧的Widget再添加新的Widget了。

配置表

这个配置文件的主要作用,就是在添加Widget时,展示一个简要的描述信息,所以,一个App中是可以存在多个描述xml文件的,而且有几个描述文件,添加时,就会展示几个Widget的缩略图,通常我们会创建几个不同尺寸的Widget,例如2x2、4x2、4x1等,并创建多个xml面试文件,从而让用户可以选择添加哪一个Widget。

不过在Android12之后,设置一个Widget,通过拉动来改变尺寸,就可以动态改变Widget的不同展示效果了,但这仅限于Android12+,所以需要权衡使用利弊。

configure

通过configure属性可以配置添加Widget时的Configure Activity,这个在创建默认的Widget项目时就已经可以选择创建了,所以不多讲了,实际上就是一个简单的Activity,你可以配置一些参数,写入SP,然后在Widget中进行读取,从而实现自定义配置。

应用内唤起Widget的添加页面

大部分时候,我们都是通过在桌面上长按的方式来添加Widget,但是在Android API 26之后,系统提供了一直新的方式来在应用内唤起——requestPinAppWidget。

文档如下。

https://developer.android.com/reference/android/appwidget/AppWidgetManager#requestPinAppWidget(android.content.ComponentName, android.os.Bundle, android.app.PendingIntent)

代码如下所示。

fun requestToPinWidget(context: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val appWidgetManager: AppWidgetManager? = getSystemService(context, AppWidgetManager::class.java)
        appWidgetManager?.let {
            val myProvider = ComponentName(context, NewAppWidget::class.java)
            if (appWidgetManager.isRequestPinAppWidgetSupported) {
                val pinnedWidgetCallbackIntent = Intent(context, MainGroupActivity::class.java)
                val successCallback: PendingIntent = PendingIntent.getBroadcast(context, 0,
                    pinnedWidgetCallbackIntent, PendingIntent.FLAG_UPDATE_CURRENT)
                appWidgetManager.requestPinAppWidget(myProvider, null, successCallback)
            }
        }
    }
}

通过这种方式,就可以直接唤起Widget的添加入口,从而避免用户手动在桌面中进行添加。

应用内主动更新Widget

前面我们提到了,当App活着的时候,可以主动来更新Widget,而且有两种方式可以实现,一种是通过广播ACTION_APPWIDGET_UPDATE,触发Widget的update回调,从而进行更新,代码如下所示。

val manager = AppWidgetManager.getInstance(this)
val ids = manager.getAppWidgetIds(ComponentName(this, XXXWidget::class.java))
val updateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
sendBroadcast(updateIntent)

这种方式的本质就是发送更新的广播,除此之外,还可以使用AppWidgetManager来直接对Widget进行更新,代码如下。

val remoteViews = RemoteViews(context.packageName, R.layout.xxx)
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, XXXWidgetProvider::class.java)
appWidgetManager.updateAppWidget(componentName, remoteViews)

这种方式就是通过AppWidgetManager来对指定的Widget进行修改,使用新的RemoteViews来更新当前Widget。

这两种方式一种是主动替换,一种是被动刷新,具体的使用场景可以根据业务的不同来使用不同的方式。

应用外被动更新Widget

产品现在重新开始重视Widget的一个重要原因,实际上就是App内部卷不动了,Widget可以在不打开App的情况下,对App进行引流,所以,应用外的Widget更新,就是一个很重要的组成部分,Widget需要展示用户感兴趣的内容,才能触发用户的点击。

前面我们提到了通过设置updatePeriodMillis来进行Widget的更新,但是这种方式存在一些使用限制,如果你需要完全自主的控制Widget的刷新,那么可以使用AlarmManager或者WorkManager,类似的代码如下所示。

private fun scheduleUpdates(context: Context) {
        val activeWidgetIds = getActiveWidgetIds(context)
        if (activeWidgetIds.isNotEmpty()) {
            val nextUpdate = ZonedDateTime.now() + WIDGET_UPDATE_INTERVAL
            val pendingIntent = getUpdatePendingIntent(context)
            context.alarmManager.set(
                AlarmManager.RTC_WAKEUP,
                nextUpdate.toInstant().toEpochMilli(), // alarm time in millis since 1970-01-01 UTC
                pendingIntent
            )
        }
    }

当然,这种方式也同样会受到ROM的限制,所以说,不管是WorkManager还是AlarmManager,或者是updatePeriodMillis,都不是稳定可靠的,随它去吧,强扭的瓜不甜。

一般来说,使用updatePeriodMillis就够了,Widget的目的是为了引流,对内容的实时性其实并不是要求的那么严格,updatePeriodMillis在大部分场景下,都是够用的。

多布局动态适配

由于在Android12之后,用户可以在单个Widget上进行修改,从而修改Widget当前的配置,所以,用户在拖动修改Widget的尺寸时,就需要动态去调整Widget的布局,以自动适应不同的尺寸。我们可以通过下面的方式,来进行修改。

internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, widgetData: AppWidgetData) {
    val views41 = RemoteViews(context.packageName, R.layout.new_app_widget41).also { updateView(it, context, appWidgetId, widgetData) }
    val views42 = RemoteViews(context.packageName, R.layout.new_app_widget42).also { updateView(it, context, appWidgetId, widgetData) }
    val views21 = RemoteViews(context.packageName, R.layout.new_app_widget21).also { updateView(it, context, appWidgetId, widgetData) }
    val viewMapping: Map<SizeF, RemoteViews> = mapOf(
        SizeF(180f, 110f) to views21,
        SizeF(270f, 110f) to views41,
        SizeF(270f, 280f) to views42
    )
    appWidgetManager.updateAppWidget(appWidgetId, RemoteViews(viewMapping))
}

private fun updateView(remoteViews: RemoteViews, context: Context, appWidgetId: Int, widgetData: AppWidgetData) {
    remoteViews.setTextViewText(R.id.xxx, widgetData.xxx)
}

它的核心就是RemoteViews(viewMapping),通过这个就可以动态适配当前用户选择的尺寸。

那么如果是Android12之前呢?

我们需要重写onAppWidgetOptionsChanged回调来获取当前Widget的宽高,从而修改不同的布局,模板代码如下所示。

override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) {
    super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
    val options = appWidgetManager.getAppWidgetOptions(appWidgetId)

    val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
    val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)

    val rows: Int = getWidgetCellsM(minHeight)
    val columns: Int = getWidgetCellsN(minWidth)
    updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns)
}

fun getWidgetCellsN(size: Int): Int {
    var n = 2
    while (73 * n - 16 < size) {
        ++n
    }
    return n - 1
}

fun getWidgetCellsM(size: Int): Int {
    var m = 2
    while (118 * m - 16 < size) {
        ++m
    }
    return m - 1
}

其中的计算公式,n x m:(73n-16)x(118m-16)就是文档中提到的算法。

但是这种方案有一个致命的问题,那就是不同的ROM的计算方式完全不一样,有可能在Vivo上一个格子的高度只有80,但是在Pixel中,一个格子就是100,所以,在不同的设备上显示的n x m不一样,也是很正常的事。

也正是因为这样的问题,如果不是只在Android 12+的设备上使用,那么通常都是固定好Widget的大小,避免使用动态布局,这也是没办法的权衡之举。

RemoteViews行为

RemoteViews不像普通的View,所以我们不能像写普通布局的方式一样来操纵View,但RemoteViews提供了一些set方法来帮助我们对RemoteViews中的View进行修改,例如下面的代码。

remoteViews.setTextViewText(R.id.title, widgetData.xxx)

再比如点击后刷新Widget,实际上就是创建一个PendingIntent。

val intentUpdate = Intent(context, XXXAppWidget::class.java).also {
    it.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
    it.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
}
val pendingUpdate = PendingIntent.getBroadcast(
    context, appWidgetId, intentUpdate,
    PendingIntent.FLAG_UPDATE_CURRENT)
    views.setOnClickPendingIntent(R.id.btn, pendingUpdate)

原理

RemoteViews通常用在通知和Widget中,分别通过NotificationManager和AppWidgetManager来进行管理,它们则是通过Binder来和SystemServer进程中的NotificationManagerService以及AppWidgetService进行通信,所以,RemoteViews实际上是运行在SystemServer中的,我们在修改RemoteViews时,就需要进行跨进程通信了,而RemoteViews封装了一系列跨进程通信的方法,简化了我们的调用,这也是为什么RemoteViews不支持全部的View方法的原因,RemoteViews抽象了一系列的set方法,并将它们抽象为统一的Action接口,这样就可以提供跨进程通信的效率,同时精简核心的功能。

如何进行后台请求

Widget在后台进行更新时,通常会请求网络,然后根据返回数据来修改Widget的数据展示。

AppWidgetProvider本质是广播,所以它拥有和广播一致的生命周期,ROM通常会定制广播的生命周期时间,例如设置为5s、7s,如果超过这个时间,那么就会产生ANR或者其它异常。

所以,我们一般不会把网络请求直接写在AppWidgetProvider中,一个比较好的方式,就是通过Service来进行更新。

首先我们创建一个Service,用来进行后台请求。

class AppWidgetRequestService : Service() {

    override fun onBind(intent: Intent): IBinder? {
        return null
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val appWidgetManager = AppWidgetManager.getInstance(this)
        val allWidgetIds = intent?.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
        if (allWidgetIds != null) {
            for (appWidgetId in allWidgetIds) {
                BackgroundRequest.getWidgetData {
                    NewAppWidget.updateAppWidget(this, appWidgetManager, appWidgetId, AppWidgetData(book1Cover = it))
                }
            }
        }
        return super.onStartCommand(intent, flags, startId)
    }
}

在onStartCommand中,我们创建一个协程,来进行真正的网络请求。

object BackgroundRequest : CoroutineScope by MainScope() {
    fun getWidgetData(onSuccess: (result: String) -> Unit) {
        launch(Dispatchers.IO) {
            val response = RetrofitClient.getXXXApi().getXXXX()
            if (response.isSuccess) {
                onSuccess(response.data.toString())
            }
        }
    }
}

所以,在AppWidgetProvider的update里面,就需要进行下修改,将原有逻辑改为对Service的启动。

class NewAppWidget : AppWidgetProvider() {
    override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
        val intent = Intent(context.applicationContext, AppWidgetRequestService::class.java)
        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
        context.startService(intent)
    }
}

动画?

有必要这么卷吗,Widget里面还要加动画。由于RemoteViews里面不能实现正常的View动画,所以,Widget里面的动画基本都是通过类似「帧动画」的方式来实现的,即将动画抽成一帧一帧的图,然后通过Animator来进行切换,从而实现动画效果,群友给出了一篇比较好的实践,大家可以参考参考,我就不卷了。

https://juejin.cn/post/7048623673892143140

Widget的使用场景主要还是以实用功能为主,只有让用户觉得有用,才能锦上添花给App带来更多的活跃,否则只能是鸡肋。

🔲 ⭐

Android壁纸还是B站玩得花

Android壁纸还是B站玩得花

设置系统壁纸这个功能,对于应用层App来说,场景其实并不多,但在一些场景的周边活动中,确也是一种提升品牌粘性的方式,就好比某个活动中创建的角色的壁纸美图,这些就可以新增一个设置壁纸的功能。

从原始的Android开始,系统就支持设置两种方式的壁纸,一种是静态壁纸,另一种是动态壁纸。

静态壁纸

静态壁纸没什么好说的,通过系统提供的API一行代码就完事了。

最简单代码如下所示。

val wallpaperManager = WallpaperManager.getInstance(this)
try {
    val bitmap = ContextCompat.getDrawable(this, R.drawable.ic_launcher_background)?.toBitmap()
    wallpaperManager.setBitmap(bitmap)
} catch (e: Exception) {
    e.printStackTrace()
}

除了setBitmap之外,系统还提供了setResource、setStream,一共三种方式来设置静态壁纸。

三种方式殊途同归,都是设置一个Bitmap给系统API。

动态壁纸

动态壁纸就有点意思了,很多手机ROM也内置了一些动态壁纸,别以为这些是什么新功能,从Android 1.5开始,就已经支持这种方式了。只不过做的人比较少,为啥呢,主要是没有什么特别合适的场景,而且动态壁纸,会比静态壁纸更加耗电,所以大部分时候,我们都没用这种方式。

壁纸作为一个系统服务,在系统启动时,不管是动态壁纸还是静态壁纸,都会以一个Service的形式运行在后台——WallpaperService,它的Window类型为TYPE_WALLPAPER,WallpaperService提供了一个SurfaceHolder来暴露给外界来对画面进行渲染,这就是设置壁纸的基本原理。

创建一个动态壁纸,需要继承系统的WallpaperService,并提供一个WallpaperService.Engin来进行渲染,下面这个就是一个模板代码。

class MyWallpaperService : WallpaperService() {
    override fun onCreateEngine(): Engine = WallpaperEngine()

    inner class WallpaperEngine : WallpaperService.Engine() {
        lateinit var mediaPlayer: MediaPlayer

        override fun onSurfaceCreated(holder: SurfaceHolder?) {
            super.onSurfaceCreated(holder)
        }

        override fun onCommand(action: String?, x: Int, y: Int, z: Int, extras: Bundle?, resultRequested: Boolean): Bundle {
            try {
                Log.d("xys", "onCommand: $action----$x---$y---$z")
                if ("android.wallpaper.tap" == action) {
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
            return super.onCommand(action, x, y, z, extras, resultRequested)
        }

        override fun onVisibilityChanged(visible: Boolean) {
            if (visible) {
            } else {
            }
        }

        override fun onDestroy() {
            super.onDestroy()
        }
    }
}

然后在manifest中注册这个Service。

<service
    android:name=".MyWallpaperService"
    android:exported="true"
    android:label="Wallpaper"
    android:permission="android.permission.BIND_WALLPAPER">
    <intent-filter>
        <action android:name="android.service.wallpaper.WallpaperService" />
    </intent-filter>

    <meta-data
        android:name="android.service.wallpaper"
        android:resource="@xml/my_wallpaper" />
</service>

另外,还需要申请相应的权限。

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

最后,在xml文件夹中新增一个描述文件,对应上面resource标签的文件。

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_name"
    android:thumbnail="@mipmap/ic_launcher" />

动态壁纸只能通过系统的壁纸预览界面来进行设置。

val localIntent = Intent()
localIntent.action = WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER
localIntent.putExtra(
    WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
    ComponentName(applicationContext.packageName, MyWallpaperService::class.java.name))
startActivity(localIntent)

这样我们就可以设置一个动态壁纸了。

玩点花

既然是使用提供的SurfaceHolder来进行渲染,那么我们所有能够使用到SurfaceHolder的场景,都可以来进行动态壁纸的创建了。

一般来说,有三种比较常见的使用场景。

  • MediaPlayer
  • Camera
  • SurfaceView

这三种也是SurfaceHolder的常用使用场景。

首先来看下MediaPlayer,这是最简单的方式,可以设置一个视频,在桌面上循环播放。

inner class WallpaperEngine : WallpaperService.Engine() {
    lateinit var mediaPlayer: MediaPlayer

    override fun onSurfaceCreated(holder: SurfaceHolder?) {
        super.onSurfaceCreated(holder)
        mediaPlayer = MediaPlayer.create(applicationContext, R.raw.testwallpaper).also {
            it.setSurface(holder!!.surface)
            it.isLooping = true
        }
    }

    override fun onVisibilityChanged(visible: Boolean) {
        if (visible) {
            mediaPlayer.start()
        } else {
            mediaPlayer.pause()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        if (mediaPlayer.isPlaying) {
            mediaPlayer.stop()
        }
        mediaPlayer.release()
    }
}

接下来,再来看下使用Camera来刷新Surface的。

inner class WallpaperEngine : WallpaperService.Engine() {
    lateinit var camera: Camera

    override fun onVisibilityChanged(visible: Boolean) {
        if (visible) {
            startPreview()
        } else {
            stopPreview()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        stopPreview()
    }

    private fun startPreview() {
        camera = Camera.open()
        camera.setDisplayOrientation(90)
        try {
            camera.setPreviewDisplay(surfaceHolder)
            camera.startPreview()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

    private fun stopPreview() {
        try {
            camera.stopPreview()
            camera.release()
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

同时需要添加下Camera的权限。

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

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

由于这里偷懒,没有使用最新的CameraAPI,也没有动态申请权限,所以你需要自己手动去授权。

最后一种,通过Surface来进行自绘渲染。

val holder = surfaceHolder
var canvas: Canvas? = null
try {
    canvas = holder.lockCanvas()
    if (canvas != null) {
    		canvas.save()
        // Draw Something
    }
} finally {
    if (canvas != null) holder.unlockCanvasAndPost(canvas)
}

这里就可以完全使用Canvas的API来进行绘制了。

这里有一个比较复杂的绘制Demo,可以给大家参考。

https://www.developer.com/design/building-an-android-live-wallpaper/

有意思的方法

虽然WallpaperService是一个系统服务,但它也提供了一些比较有用的回调函数来帮助我们做一些有意思的东西。

onOffsetsChanged

当用户在手机桌面滑动时,有的壁纸图片会跟着左右移动,这个功能就是通过这个回调来实现的,在手势滑动的每一帧都会回调这个方法。

xOffset:x轴滑动的百分比

yOffset:y轴滑动百分比

xOffsetStep:x轴桌面Page数进度

yOffsetStep:y轴桌面Page数进度

xPixelOffset:x轴像素偏移量

通过这个函数,就可以拿到手势的移动惯量,从而对图片做出一些修改。

onTouchEvent、onCommand

这两个方法,都可以获取用户的点击行为,通过判断点击类型,就可以针对用户的特殊点击行为来做一些逻辑处理,例如点击某些特定的地方时,唤起App,或者打开某个界面等等。

class MyWallpaperService : WallpaperService() {
  override fun onCreateEngine(): Engine = WallpaperEngine()

  private inner class WallpaperEngine : WallpaperService.Engine() {

    override fun onTouchEvent(event: MotionEvent?) {
      // on finder press events
      if (event?.action == MotionEvent.ACTION_DOWN) {
        // get the canvas from the Engine or leave
        val canvas = surfaceHolder?.lockCanvas() ?: return
        // TODO
        // update the surface
        surfaceHolder.unlockCanvasAndPost(canvas)
      }
    }
  }
}

B站怎么玩的呢

不得不说,B站在这方面玩的是真的花,最近B站里面新加了一个异想少女系列,你可以设置一个动态壁纸,同时还带交互,有点意思。

Android壁纸还是B站玩得花

其实类似这样的交互,基本上都是通过OpenGL或者是RenderScript来实现的,通过GLSurfaceView来进行渲染,从而实现了一些复杂的交互,下面这些例子,就是一些实践。

https://github.com/PavelDoGreat/Unity-Android-Live-Wallpaper

https://github.com/jinkg/live-wallpaper

https://www.cnblogs.com/YFEYI/category/1425066.html

https://code.tutsplus.com/tutorials/creating-live-wallpapers-on-android--mobile-9516

但是B站的这个效果,显然比上面的方案更加成熟和完整,所以,通过调研可以发现,它们使用的是Live2D的方案。

https://www.live2d.com/

动态壁纸的Demo如下。

https://github.com/Live2D/CubismAndroidLiveWallpaper

这个东西是小日子的一个SDK,专业做2D可交互纸片人,这个东西已经出来很久了,前端之前用它来做网页的看板娘,现在客户端又拿来做动态壁纸,风水轮流换啊,想要使用的,可以参考它们官方的Demo。

但是官方的动态壁纸Demo在客户端是有Bug的,会存在各种闪的问题,由于我本身不懂OpenGL,所以也无法解决,通过回退Commit,发现可以直接使用这个CommitID : Merge pull request #2 from Live2D/create-new-function ,就没有闪的问题。
a9040ddbf99d9a130495e4a6190592068f2f7a77

好了,B站YYDS,但我觉得这东西的使用场景太有限了,而且特别卡,极端影响功耗,所以,要不要这么卷呢,你看着办吧。

🔲 ☆

Flutter混编工程之打通纹理之路

Flutter混编工程之打通纹理之路

Flutter的图片系统基于Image的一套架构,但是这东西的性能,实在不敢恭维,感觉还停留在Native开发至少5年前的水平,虽然使用上非常简单,一个Image.network走天下,但是不管是解码性能还是加载速度,抑或是内存占用和缓存逻辑,都远远不如Native的图片库,特别是Glide。虽然Google一直在有计划优化Flutter Image的性能,但现阶段,体验最佳的图片加载方式,还是通过插件,使用Glide来进行加载。

所以,在混编的大环境下,将Flutter的图片加载功能托管给原生,是最合理且性能最佳的方案。

那么对于桥接到原生的方案来说,主要有两个方向,一个是通过Channel来传递加载的图像的二进制数据流,然后在Flutter内解析二进制流后来解析图像,另一个则是通过外接纹理的方式,来共享图像内存,显然,第二种方案是更好的解决方案,不管从内存消耗还是传输性能上来说,外接纹理的方案,都是Flutter桥接Native图片架构的最佳选择。

虽然说外接纹理方案比较好,但是网络上对于这个方案的研究却不是很多,比较典型的是Flutter官方Plugins中的视频渲染的方案,地址如下所示。

https://github.com/flutter/plugins/tree/main/packages/video_player

这是我们研究外接纹理的第一手方案,除此之外,闲鱼开源的PowerImage,也是基于外接纹理的方案来实现的,同时他们也给出了基于外接纹理的一系列方案的预研和技术基础研究,这些也算是我们了解外接纹理的最佳途径,但是,基于阿里的一贯风格,我们不太敢直接大范围使用PowerImage,研究研究外接纹理,来实现一套自己的方案,其实是最好的。

https://www.infoq.cn/article/MLMK2bx8uaNb5xJm13SW

https://juejin.cn/post/6844903662548942855

外接纹理的基本概念

其实上面两篇闲鱼的文章,已经把外接纹理的概念讲解的比较清楚了,下面我们就简单的总结一下。

首先,Flutter的渲染机制与Native渲染完全隔离,这样的好处是Flutter可以完全控制Flutter页面的绘制和渲染,但坏处是,Flutter在获取一些Native的高内存数据时,通过Channel来进行传递就会导致浪费和性能压力,所以Flutter提供了外接纹理,来处理这种场景。

在Flutter中,系统提供了一个特殊的Widget——Texture Widget。Texture在Flutter的Widget Tree中是一个特殊的Layer,它不参与其它Layer的绘制,它的数据全部由Native提供,Native会将动态渲染数据,例如图片、视频等数据,写入到PixelBuffer,而Flutter Engine会从GPU中拿到相应的渲染数据,并渲染到对应的Texture中。

Texture实战

Texture方案来加载图片的过程实际上是比较长的,涉及到Flutter和Native的双端合作,所以,我们需要创建一个Flutter Plugin来完成这个功能的调用。

我们创建一个Flutter Plugin,Android Studio会自动帮我们生成对应的插件代码和Example代码。

整体流程

Flutter和Native之间,通过外接纹理的方式来共享内存数据,它们之间相互关联的纽带,就是一个TextureID,通过这个ID,我们可以分别关联到Native侧的内存数据,也可以关联到Flutter侧的Texture Widget,所以,一切的故事,都是从TextureID开始的。

Flutter加载图片的起点,从Texture Widget开始,Widget初始化的时候,会通过Channel请求Native,创建一个新的TextureID,并将这个TextureID返回给Flutter,将当前Texture Widget与这个ID进行绑定。

接下来,Flutter侧将要加载的图片Url通过Channel请求Native,Native侧通过TextureID找到对应的Texture,并在Native侧通过Glide,用传递的Url进行图片加载,将图片资源写入Texture,这个时候,Flutter侧的Texture Widget就可以实时获取到渲染信息了。

最后,在Flutter侧的Texture Widget回收时,需要对当前的Texture进行回收,从而将这部分内存释放。

以上就是整个外接纹理方案的实现过程。

Flutter侧

首先,我们需要创建一个Channel来注册上面提到的几个方法调用。

class MethodChannelTextureImage extends TextureImagePlatform {
  @visibleForTesting
  final methodChannel = const MethodChannel('texture_image');

  @override
  Future<int?> initTextureID() async {
    final result = await methodChannel.invokeMethod('initTextureID');
    return result['textureID'];
  }

  @override
  Future<Size> loadByTextureID(String url, int textureID) async {
    var params = {};
    params["textureID"] = textureID;
    params["url"] = url;
    final size = await methodChannel.invokeMethod('load', params);
    return Size(size['width']?.toDouble() ?? 0, size['height']?.toDouble() ?? 0);
  }

  @override
  Future<int?> disposeTextureID(int textureID) async {
    var params = {};
    params["textureID"] = textureID;
    final result = await methodChannel.invokeMethod('disposeTextureID', params);
    return result['textureID'];
  }
}

接下来,回到Flutter Widget中,封装一个Widget用来管理Texture。

在这个封装的Widget里面,你可以对尺寸作调整,或者是对生命周期进行管理,但核心只有一个,那就是创建一个Texture。

Texture(textureId: _textureID),

使用前面创建的Channel,来完成流程的加载。

@override
void initState() {
  initTextureID().then((value) {
    _textureID = value;
    _textureImagePlugin.loadByTextureID(widget.url, _textureID).then((value) {
      if (mounted) {
        setState(() => bitmapSize = value);
      }
    });
  });
  super.initState();
}

Future<int> initTextureID() async {
  int textureID;
  try {
    textureID = await _textureImagePlugin.initTextureID() ?? -1;
  } on PlatformException {
    textureID = -1;
  }
  return textureID;
}

@override
void dispose() {
  if (_textureID != -1) {
    _textureImagePlugin.disposeTextureID(_textureID);
  }
  super.dispose();
}

这样整个Flutter侧的流程就完成了——创建TextureID——>绑定TextureID和Url——>回收TextureID。

Native侧

Native侧的处理都集中在Plugin的注册类中,在注册时,我们需要创建TextureRegistry,这是系统提供给我们使用外接纹理的入口。

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "texture_image")
    channel.setMethodCallHandler(this)
    context = flutterPluginBinding.applicationContext
    textureRegistry = flutterPluginBinding.textureRegistry
}

接下来,我们需要对Channel进行处理,分别实现前面提到的三个方法。

"initTextureID" -> {
    val surfaceTextureEntry = textureRegistry?.createSurfaceTexture()
    val textureId = surfaceTextureEntry?.id() ?: -1
    val reply: MutableMap<String, Long> = HashMap()
    reply["textureID"] = textureId
    textureSurfaces[textureId] = surfaceTextureEntry
    result.success(reply)
}

initTextureID方法,核心功能就是从TextureRegistry中创建一个surfaceTextureEntry,textureId就是它的id属性。

"load" -> {
    val textureId: Int = call.argument("textureID") ?: -1
    val url: String = call.argument("url") ?: ""
    if (textureId >= 0 && url.isNotBlank()) {
        Glide.with(context).load(url).skipMemoryCache(true).into(object : CustomTarget<Drawable>() {
            override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
                if (resource is BitmapDrawable) {
                    val bitmap = resource.bitmap
                    val imageWidth: Int = bitmap.width
                    val imageHeight: Int = bitmap.height
                    val surfaceTextureEntry: SurfaceTextureEntry = textureSurfaces[textureId.toLong()]!!
                    surfaceTextureEntry.surfaceTexture().setDefaultBufferSize(imageWidth, imageHeight)
                    val surface =
                        if (surfaceMap.containsKey(textureId.toLong())) {
                            surfaceMap[textureId.toLong()]
                        } else {
                            val surface = Surface(surfaceTextureEntry.surfaceTexture())
                            surfaceMap[textureId.toLong()] = surface
                            surface
                        }
                    val canvas: Canvas = surface!!.lockCanvas(null)
                    canvas.drawBitmap(bitmap, 0F, 0F, null)
                    surface.unlockCanvasAndPost(canvas)
                    val reply: MutableMap<String, Int> = HashMap()
                    reply["width"] = bitmap.width
                    reply["height"] = bitmap.height
                    result.success(reply)
                }
            }

            override fun onLoadCleared(placeholder: Drawable?) {
            }
        })
    }
}

load方法,就是我们熟悉的Glide了,通过Glide来获取对应Url的图片数据,再通过SurfaceTextureEntry,来创建Surface对象,并将Glide返回的数据,写入到Surface中,最后,将图像的宽高回传给Flutter,做后续的一些处理。

"disposeTextureID" -> {
    val textureId: Int = call.argument("textureID") ?: -1
    val textureIdLong = textureId.toLong()
    if (surfaceMap.containsKey(textureIdLong) && textureSurfaces.containsKey(textureIdLong)) {
        val surfaceTextureEntry: SurfaceTextureEntry? = textureSurfaces[textureIdLong]
        val surface = surfaceMap[textureIdLong]
        surfaceTextureEntry?.release()
        surface?.release()
        textureSurfaces.remove(textureIdLong)
        surfaceMap.remove(textureIdLong)
    }
}

disposeTextureID方法,就是对dispose的Texture进行回收,否则的话,Texture一直在申请新的内存,就会导致Native内存一直上涨而不会被回收,所以,在Flutter侧调用dispose后,我们需要对相应TextureID对应的资源进行回收。

以上,我们就完成了Native的处理,通过和Flutter侧配合,借助Glide的高效加载能力,我们就完成就一次完美的图片加载过程。

总结

通过外接纹理来加载图片,我们可以有下面这些优点。

  • 复用Native的高效、稳定的图片加载机制,包括缓存、编解码、性能等
  • 降低多套方案的内存消耗,降低App的运行内存
  • 打通Native和Flutter,图片资源可以进行内存共享

但是,当前这个方案也并不是「完美的」,只能说,上面的方案是一个「可用」的方案,但还远远没有达到「好用」的级别,为了更好的实现外接纹理的方案,我们还需要处理一些细节。

  • 复用、复用,还是TMD复用,对于同Url的图片、加载过的图片,在Native端和Flutter端,都应该再做一套缓存机制
  • 对于Gif和Webp的支持,目前为止,我们都是处理的静态图片,还未添加动态内容的处理,当然这一定是可以的,只不过我们还没支持
  • Channel的Batch调用,对于一个列表来说,可能一帧中会同时产生大量的图片请求,虽然现在Channel的性能有了很大的提升,但是如果能对Channel的调用做一个缓冲区,那么对于特别频繁的调用来说,会优化一部分Channel的性能

所以这只是第一篇,后面我们会继续针对上面的问题进行优化,请各位拭目以待。

☑️ ☆

重走Flutter状态管理之路—Riverpod最终篇

重走Flutter状态管理之路—Riverpod最终篇

最后一篇文章,我们在掌握了如何读取状态值,并知道如何根据不同场景选择不同类型的Provider,以及如何对Provider进行搭配使用之后,再来了解一下它的一些其它特性,看看它们是如何帮助我们更好的进行状态管理的。

Provider Modifiers

所有的Provider都有一个内置的方法来为你的不同Provider添加额外的功能。

它们可以为 ref 对象添加新的功能,或者稍微改变Provider的consume方式。Modifiers可以在所有Provider上使用,其语法类似于命名的构造函数。

final myAutoDisposeProvider = StateProvider.autoDispose<int>((ref) => 0);
final myFamilyProvider = Provider.family<String, int>((ref, id) => '$id');

目前,有两个Modifiers可用。

  • .autoDispose,这将使Provider在不再被监听时自动销毁其状态
  • .family,它允许使用一个外部参数创建一个Provider

一个Provider可以同时使用多个Modifiers。

final userProvider = FutureProvider.autoDispose.family<User, int>((ref, userId) async {
  return fetchUser(userId);
});

.family

.family修饰符有一个目的:根据外部参数创建一个独特的Provider。family的一些常见用例是下面这些。

  • 将FutureProvider与.family结合起来,从其ID中获取一个Message对象
  • 将当前的Locale传递给Provider,这样我们就可以处理国际化

family的工作方式是通过向Provider添加一个额外的参数。然后,这个参数可以在我们的Provider中自由使用,从而创建一些状态。

例如,我们可以将family与FutureProvider结合起来,从其ID中获取一个Message。

final messagesFamily = FutureProvider.family<Message, String>((ref, id) async {
  return dio.get('http://my_api.dev/messages/$id');
});

当使用我们的 messagesFamily Provider时,语法会略有不同。

像下面这样的通常语法将不再起作用。

Widget build(BuildContext context, WidgetRef ref) {
  // Error – messagesFamily is not a provider
  final response = ref.watch(messagesFamily);
}

相反,我们需要向 messagesFamily 传递一个参数。

Widget build(BuildContext context, WidgetRef ref) {
  final response = ref.watch(messagesFamily('id'));
}
我们可以同时使用一个具有不同参数的变量。
例如,我们可以使用titleFamily来同时读取法语和英语的翻译。
@override
Widget build(BuildContext context, WidgetRef ref) {
final frenchTitle = ref.watch(titleFamily(const Locale('fr')));
final englishTitle = ref.watch(titleFamily(const Locale('en')));

return Text('fr: $frenchTitle en: $englishTitle');
}

参数限制

为了让families正确工作,传递给Provider的参数必须具有一致的hashCode和==。

理想情况下,参数应该是一个基础类型(bool/int/double/String),一个常数(Provider),或者一个重写==和hashCode的不可变的对象。

当参数不是常数时,更倾向于使用autoDispose

你可能想用family来传递一个搜索字段的输入,给你的Provider。但是这个值可能会经常改变,而且永远不会被重复使用。这可能导致内存泄漏,因为在默认情况下,即使不再使用,Provider也不会被销毁。

同时使用.family和.autoDispose就可以修复这种内存泄漏。

final characters = FutureProvider.autoDispose.family<List<Character>, String>((ref, filter) async {
  return fetchCharacters(filter: filter);
});

给family传递多重参数

family没有内置支持向一个Provider传递多个值的方法。另一方面,这个值可以是任何东西(只要它符合前面提到的限制)。

这包括下面这些类型。

下面是一个对多个参数使用Freezed或equatable的例子。

@freezed
abstract class MyParameter with _$MyParameter {
  factory MyParameter({
    required int userId,
    required Locale locale,
  }) = _MyParameter;
}

final exampleProvider = Provider.autoDispose.family<Something, MyParameter>((ref, myParameter) {
  print(myParameter.userId);
  print(myParameter.locale);
  // Do something with userId/locale
});

@override
Widget build(BuildContext context, WidgetRef ref) {
  int userId; // Read the user ID from somewhere
  final locale = Localizations.localeOf(context);

  final something = ref.watch(
    exampleProvider(MyParameter(userId: userId, locale: locale)),
  );

  ...
}

.autoDispose

它的一个常见的用例是,当一个Provider不再被使用时,要销毁它的状态。

这样做的原因有很多,比如下面这些场景。

  • 当使用Firebase时,要关闭连接并避免不必要的费用
  • 当用户离开一个屏幕并重新进入时,要重置状态

Provider通过.autoDisposeModifiers内置了对这种使用情况的支持。

要告诉Riverpod当它不再被使用时销毁一个Provider的状态,只需将.autoDispose附加到你的Provider上即可。

final userProvider = StreamProvider.autoDispose<User>((ref) {

});

就这样了。现在,userProvider的状态将在不再使用时自动被销毁。

注意通用参数是如何在autoDispose之后而不是之前传递的--autoDispose不是一个命名的构造函数。

如果需要,你可以将.autoDispose与其他Modifiers结合起来。

final userProvider = StreamProvider.autoDispose.family<User, String>((ref, id) {

});

ref.keepAlive

用autoDispose标记一个Provider时,也会在ref上增加了一个额外的方法:keepAlive。

keep函数是用来告诉Riverpod,即使不再被监听,Provider的状态也应该被保留下来。

它的一个用例是在一个HTTP请求完成后,将这个标志设置为true。

final myProvider = FutureProvider.autoDispose((ref) async {
  final response = await httpClient.get(...);
  ref.keepAlive();
  return response;
});

这样一来,如果请求失败,UI离开屏幕然后重新进入屏幕,那么请求将被再次执行。但如果请求成功完成,状态将被保留,重新进入屏幕将不会触发新的请求。

示例:当Http请求不再使用时自动取消

autoDisposeModifiers可以与FutureProvider和ref.onDispose相结合,以便在不再需要HTTP请求时轻松取消。

我们的目标是:

  • 当用户进入一个屏幕时启动一个HTTP请求
  • 如果用户在请求完成前离开屏幕,则取消HTTP请求
  • 如果请求成功,离开并重新进入屏幕不会启动一个新的请求

在代码中,这将是下面这样。

final myProvider = FutureProvider.autoDispose((ref) async {
  // An object from package:dio that allows cancelling http requests
  final cancelToken = CancelToken();
  // When the provider is destroyed, cancel the http request
  ref.onDispose(() => cancelToken.cancel());

  // Fetch our data and pass our `cancelToken` for cancellation to work
  final response = await dio.get('path', cancelToken: cancelToken);
  // If the request completed successfully, keep the state
  ref.keepAlive();
  return response;
});

异常

当使用.autoDispose时,你可能会发现自己的应用程序无法编译,出现类似下面的错误。

The argument type 'AutoDisposeProvider' can't be assigned to the parameter type 'AlwaysAliveProviderBase'

不要担心! 这个错误是正常的。它的发生是因为你很可能有一个bug。

例如,你试图在一个没有标记为.autoDispose的Provider中监听一个标记为.autoDispose的Provider,比如下面的代码。

final firstProvider = Provider.autoDispose((ref) => 0);

final secondProvider = Provider((ref) {
  // The argument type 'AutoDisposeProvider<int>' can't be assigned to the
  // parameter type 'AlwaysAliveProviderBase<Object, Null>'
  ref.watch(firstProvider);
});

这是不可取的,因为这将导致firstProvider永远不会被dispose。

为了解决这个问题,可以考虑用.autoDispose标记secondProvider。

final firstProvider = Provider.autoDispose((ref) => 0);

final secondProvider = Provider.autoDispose((ref) {
  ref.watch(firstProvider);
});

provider状态关联与整合

我们之前已经看到了如何创建一个简单的Provider。但实际情况是,在很多情况下,一个Provider会想要读取另一个Provider的状态。

要做到这一点,我们可以使用传递给我们Provider的回调的ref对象,并使用其watch方法。

作为一个例子,考虑下面的Provider。

final cityProvider = Provider((ref) => 'London');

我们现在可以创建另一个Provider,它将消费我们的cityProvider。

final weatherProvider = FutureProvider((ref) async {
  // We use `ref.watch` to listen to another provider, and we pass it the provider
  // that we want to consume. Here: cityProvider
  final city = ref.watch(cityProvider);

  // We can then use the result to do something based on the value of `cityProvider`.
  return fetchWeather(city: city);
});

这就是了。我们已经创建了一个依赖另一个Provider的Provider。

这个其实在前面的例子中已经讲到了,ref是可以连接多个不同的Provider的,这是Riverpod非常灵活的一个体现。

FAQ

What if the value being listened to changes over time?

根据你正在监听的Provider,获得的值可能会随着时间的推移而改变。例如,你可能正在监听一个StateNotifierProvider,或者被监听的Provider可能已经通过使用ProviderContainer.refresh/ref.refresh强制刷新。

当使用watch时,Riverpod能够检测到被监听的值发生了变化,并将在需要时自动重新执行Provider的创建回调。

这对计算的状态很有用。例如,考虑一个暴露了todo-list的StateNotifierProvider。

class TodoList extends StateNotifier<List<Todo>> {
  TodoList(): super(const []);
}

final todoListProvider = StateNotifierProvider((ref) => TodoList());

一个常见的用例是让用户界面过滤todos的列表,只显示已完成/未完成的todos。

实现这种情况的一个简单方法是。

  • 创建一个StateProvider,它暴露了当前选择的过滤方法。
enum Filter {
  none,
  completed,
  uncompleted,
}

final filterProvider = StateProvider((ref) => Filter.none);
  • 做一个单独的Provider,把过滤方法和todo-list结合起来,暴露出过滤后的todo-list。
final filteredTodoListProvider = Provider<List<Todo>>((ref) {
  final filter = ref.watch(filterProvider);
  final todos = ref.watch(todoListProvider);

  switch (filter) {
    case Filter.none:
      return todos;
    case Filter.completed:
      return todos.where((todo) => todo.completed).toList();
    case Filter.uncompleted:
      return todos.where((todo) => !todo.completed).toList();
  }
});

然后,我们的用户界面可以监听filteredTodoListProvider来监听过滤后的todo-list。使用这种方法,当过滤器或todo-list发生变化时,用户界面将自动更新。

要看到这种方法的作用,你可以看一下Todo List例子的源代码。

这种行为不是特定于Provider的,它适用于所有的Provider。
例如,你可以将watch与FutureProvider结合起来,实现一个支持实时配置变化的搜索功能。
// The current search filter
final searchProvider = StateProvider((ref) => '');

/// Configurations which can change over time
final configsProvider = StreamProvider<Configuration>(...);

final charactersProvider = FutureProvider<List<Character>>((ref) async {
  final search = ref.watch(searchProvider);
  final configs = await ref.watch(configsProvider.future);
  final response = await dio.get('${configs.host}/characters?search=$search');

  return response.data.map((json) => Character.fromJson(json)).toList();
});
这段代码将从服务中获取一个字符列表,并在配置改变或搜索查询改变时自动重新获取该列表。

Can I read a provider without listening to it?

有时,我们想读取一个Provider的内容,但在获得的值发生变化时不需要重新创建值。

一个例子是一个 Repository,它从另一个Provider那里读取用户token用于认证。

我们可以使用观察并在用户token改变时创建一个新的 Repository,但这样做几乎没有任何用处。

在这种情况下,我们可以使用read,这与listen类似,但不会导致Provider在获得的值改变时重新创建它的值。

在这种情况下,一个常见的做法是将ref.read传递给创建的对象。然后,创建的对象将能够随时读取Provider。

final userTokenProvider = StateProvider<String>((ref) => null);

final repositoryProvider = Provider((ref) => Repository(ref.read));

class Repository {
  Repository(this.read);

  /// The `ref.read` function
  final Reader read;

  Future<Catalog> fetchCatalog() async {
    String token = read(userTokenProvider);

    final response = await dio.get('/path', queryParameters: {
      'token': token,
    });

    return Catalog.fromJson(response.data);
  }
}
你也可以把ref而不是ref.read传给你的对象。
final repositoryProvider = Provider((ref) => Repository(ref));

class Repository {
  Repository(this.ref);

  final Ref ref;
}
传递ref.read带来的唯一区别是,它略微不那么冗长,并确保我们的对象永远不会使用ref.watch。

但是,永远不要像下面这样做。

final myProvider = Provider((ref) {
  // Bad practice to call `read` here
  final value = ref.read(anotherProvider);
});

如果你使用read作为尝试去避免太多的刷新重建,可以参考后面的FAQ

How to test an object that receives read as a parameter of its constructor?

如果你正在使用《我可以在不监听Provider的情况下读取它吗》中描述的模式,你可能想知道如何为你的对象编写测试。

在这种情况下,考虑直接测试Provider而不是原始对象。你可以通过使用ProviderContainer类来做到这一点。

final repositoryProvider = Provider((ref) => Repository(ref.read));

test('fetches catalog', () async {
  final container = ProviderContainer();
  addTearOff(container.dispose);

  Repository repository = container.read(repositoryProvider);

  await expectLater(
    repository.fetchCatalog(),
    completion(Catalog()),
  );
});

My provider updates too often, what can I do?

如果你的对象被重新创建得太频繁,你的Provider很可能在监听它不关心的对象。

例如,你可能在监听一个配置对象,但只使用host属性。

通过监听整个配置对象,如果host以外的属性发生变化,这仍然会导致你的Provider被重新评估--这可能是不希望的。

这个问题的解决方案是创建一个单独的Provider,只公开你在配置中需要的东西(所以是host)。

应当避免像下面的代码一样,对整个对象进行监听。

final configProvider = StreamProvider<Configuration>(...);

final productsProvider = FutureProvider<List<Product>>((ref) async {
  // Will cause productsProvider to re-fetch the products if anything in the
  // configurations changes
  final configs = await ref.watch(configProvider.future);

  return dio.get('${configs.host}/products');
});

当你只需要一个对象的单一属性时,更应该使用select。

final configProvider = StreamProvider<Configuration>(...);

final productsProvider = FutureProvider<List<Product>>((ref) async {
  // Listens only to the host. If something else in the configurations
  // changes, this will not pointlessly re-evaluate our provider.
  final host = await ref.watch(configProvider.selectAsync((config) => config.host));

  return dio.get('$host/products');
});

这将只在host发生变化时重建 productsProvider。

通过这三篇文章,相信大家已经能熟练的对Riverpod进行使用了,相比package:Provider,Riverpod的使用更加简单和灵活,这也是我推荐它的一个非常重要的原因,在入门之后,大家可以根据文档中作者提供的示例来进行学习,充分的了解Riverpod在实战中的使用技巧。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

重走Flutter状态管理之路—Riverpod最终篇
☑️ ⭐

我悟出了公众号取名的套路

我悟出了公众号取名的套路

疫情到现在,已经两个多月了,每天看着新闻的报道,看着各大厂的公众号,每天都很充实,好像看了很多东西,但又好像什么也没看,眼瞅着人家的公众号阅读量蹭蹭蹭的上涨,1w+、10w+,再看看自己的公众号零零碎碎的阅读量,我不禁陷入了沉思——为什么我的公众号没人看!

我觉得是时候来分析一下了,让我们来看看,如何打造一篇阅读量高的公众号文章标题。

作为一个技术公众号,我们来想一个最基本的名字,然后再一步步进行迭代。

例如,我准备写一篇文章——

《Kotlin协程基础学习》

平平淡淡的标题透露着作者菜菜的水平,假如我是一个自认为牛逼的开发者,我才不屑于看这种「基础」文章,仿佛拉低了我的水平,所以,我们修改下标题——

《Kotlin协程-核心原理与分析》

嗯,有点味道了,看上去有点「资深」的感觉了。但是,这样的标题又会让很多初学者望而却步,所以,为了能够让文章的受众更广,我又进行了迭代——

《Kotlin协程-常见面试题分析》

一篇文章,让你轻松应付面试,这受众一下子就打开了,看来这个标题也不错。

不过,这些标题和那些大厂,以及我发的「广告」相比,还是差了点,我们来看看它们是怎么写的。

《吐血推荐!Kotlin协程面试精选》

《万字长文!搞定Kotlin协程方方面面》

《Kotlin协程,你不得不知的真相》

《再不学就废了!Kotlin协程是找工作的敲门砖》

《一道Kotlin面试题引发的血案》

卧槽,果然有点东西,加了几个「浮夸性」修饰词,瞬间让逼格就起来了,让人很有点击的欲望。

类似的,还有——

《全网最干货-解读Kotlin协程核心原理》

《生动图解-Kotlin协程图文分析》

《全网最硬核Kotlin协程分享》

这类的文章,点击率和收藏可能非常多,但真正读的,可能没几个,不管文章干不干,写了就是干货,不管图生不生动,加了就生动。当然,有一些确实很有实力的大佬,可以写出这样的文章,但这样的文章,的确是可遇不可求,所以这类文章,通常都是两个极端,要么极好,要么极差。

再加上这几年各种自媒体对「35岁」的炒作,现在的广告普遍变成了下面的风格——

《搞定Kotlin协程,大厂随便进》

《今年三十五,找工作还在被问Kotlin协程》

《搞不懂Kotlin协程,要失业了》

各种贩卖焦虑的标题,让人真的很焦虑。也许你本来不焦虑,但焦虑的人多了,你也踏上了焦虑的路。

除了贩卖焦虑,还有一种贩卖「人设」的套路,通过一些名人效应,来吸引读者的点击,例如——

《阿里内部疯传的Kotlin协程学习资料》

《三星大牛AvLin老师带你手撕Kotlin协程》

《阿里P9面试官最喜欢的Kotlin协程面试题》

《看完这篇Kotlin协程分析,吊打腾讯面试官》

太牛逼了吧,这么多大佬带我学习,看完这篇文章,我应该可以升P10了,吊打P9。

除了这种「大牛系」人设,还有一种「女友系」人设——

《手把手教妹子Kotlin协程,结果……》

《睡前给妹子讲了Kotlin协程,没想到……》

这类的文章,真的是——一言难尽。


分析了这么多标题,看来要想提高阅读量,还是挺简单的,不就是:

  • 浮夸修饰词、震惊体
  • 大而全,全网、全公司、最硬核
  • 贩卖焦虑
  • 卖人设

原理很简单,但是看完自己的分析,我决定给这篇文章起名——

《再谈协程》

花里胡哨的鬼话学不来,一个好的标题,的确是一篇公众号文章阅读量的基石,但我觉得,更加真诚的标题,才是技术公众号最好的口碑。

我也会在公众号中取一些比较有意思的标题,就像现在的几个系列文章:

  • 「再谈协程」系列
  • 「FlutterComponent最佳实践」系列
  • 「Flutter混编之路」系列
  • 「Kotlin修炼指南」系列

希望能在尽可能的吸引读者的同时,最大限度的用贴近技术的词汇来取名。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

我悟出了公众号取名的套路
☑️ ☆

重走Flutter状态管理之路—Riverpod进阶篇

重走Flutter状态管理之路—Riverpod进阶篇

前面一篇文章,我们了解了如何正确的去读取状态值,这一篇,我们来了解下不同的Provider都有哪些使用场景。这篇文章,我们将真正的深入了解,如何在不同的场景下,选择合适的种类的Provider,以及这些不同类型的Provider,都有哪些作用。

不同类型的Provider

Provider有多种类型的变种,可以用于多种不同的使用场景。

在所有这些Provider中,有时很难理解何时使用一种Provider类型而不是另一种。使用下面的表格,选择一个适合你想提供给Widget树的Provider。

Provider Type Provider Create Function Example Use Case
Provider Returns any type A service class / computed property (filtered list)
StateProvider Returns any type A filter condition / simple state object
FutureProvider Returns a Future of any type A result from an API call
StreamProvider Returns a Stream of any type A stream of results from an API
StateNotifierProvider Returns a subclass of StateNotifier A complex state object that is immutable except through an interface
ChangeNotifierProvider Returns a subclass of ChangeNotifier A complex state object that requires mutability

虽然所有的Provider都有他们的目的,但ChangeNotifierProviders不被推荐用于可扩展的应用程序,因为它存在可变的状态问题。它存在于flutter_riverpod包中,以提供一个简单的从package:provider的迁移组件,并允许一些flutter特定的使用情况,如与一些Navigator 2包的集成。

Provider

Provider是所有Providers中最基本的。它返回了一个Value... 仅此而已。

Provider通常用于下面的场景。

  • 缓存计算后的值
  • 将一个值暴露给其他Provider(比如Repository/HttpClient)
  • 提供了一个可供测试的覆写Provider
  • 通过不使用select,来减少Provider/widget的重建

通过Provider来对计算值进行缓存

当与ref.watch结合时,Provider是一个强大的工具,用于缓存同步操作。

一个典型的例子是过滤一个todos的列表。由于过滤一个列表的成本较高,我们最好不要在我们的应用程序每次需要重新渲染的时候,就过滤一次我们的todos列表。在这种情况下,我们可以使用Provider来为我们做过滤工作。

为此,假设我们的应用程序有一个现有的StateNotifierProvider,它管理一个todos列表。

class Todo {
  Todo(this.description, this.isCompleted);
  final bool isCompleted;
  final String description;
}

class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier() : super([]);

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO add other methods, such as "removeTodo", ...
}

final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

在这里,我们可以使用Provider来管理一个过滤后的todos列表,只显示已完成的todos。

final completedTodosProvider = Provider<List<Todo>>((ref) {
  // We obtain the list of all todos from the todosProvider
  final todos = ref.watch(todosProvider);

  // we return only the completed todos
  return todos.where((todo) => todo.isCompleted).toList();
});

有了这段代码,我们的用户界面现在能够通过监听 completedTodosProvider来显示已完成的todos列表。

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO show the todos using a ListView/GridView/...
});

有趣的是,现在的过滤后的列表是被缓存的。这意味着在添加/删除/更新todos之前,已完成的todos列表不会被重新计算,即使我们多次读取已完成的todos列表。

请注意,当todos列表发生变化时,我们不需要手动使缓存失效。由于有了ref.watch,Provider能够自动知道何时必须重新计算结果。

通过Provider来减少provider/widget的重建

Provider的一个独特之处在于,即使Provider被重新计算(通常在使用ref.watch时),它也不会更新监听它的widgets/providers,除非其值发生了变化。

一个真实的例子是启用/禁用一个分页视图的上一个/下一个按钮。

重走Flutter状态管理之路—Riverpod进阶篇

在我们的案例中,我们将特别关注 "上一页 "按钮。这种按钮的一个普通的实现,是一个获得当前页面索引的Widget,如果该索引等于0,我们将禁用该按钮。

这段代码可以是这样。

final pageIndexProvider = StateProvider<int>((ref) => 0);

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // if not on first page, the previous button is active
    final canGoToPreviousPage = ref.watch(pageIndexProvider) != 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

这段代码的问题是,每当我们改变当前页面时,"上一页 "按钮就会重新Build。在理想的世界里,我们希望这个按钮只在激活和停用之间变化时才重新build。

这里问题的根源在于,我们正在计算用户是否被允许在 "上一页 "按钮中直接转到上一页。

解决这个问题的方法是把这个逻辑从widget中提取出来,放到一个Provider中。

final pageIndexProvider = StateProvider<int>((ref) => 0);

// A provider which computes whether the user is allowed to go to the previous page
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) != 0;
});

class PreviousButton extends ConsumerWidget {
  const PreviousButton({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // We are now watching our new Provider
    // Our widget is no longer calculating whether we can go to the previous page.
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? null : goToPreviousPage,
      child: const Text('previous'),
    );
  }
}

通过这个小的重构,我们的PreviousButton Widget将不会在页面索引改变时重建,这都要归功于Provider的缓存作用。

从现在开始,当页面索引改变时,我们的canGoToPreviousPageProviderProvider将被重新计算。但是如果Provider暴露的值没有变化,那么PreviousButton将不会重建。

这个变化既提高了我们的按钮的性能,又有一个有趣的好处,就是把逻辑提取到我们的Widget之外。

StateProvider

我们再来看下StateProvider,它是一个公开了修改其状态的方法的Provider。它是StateNotifierProvider的简化版,旨在避免为非常简单的用例编写一个StateNotifier类。

StateProvider的存在主要是为了允许用户对简单的变量进行修改。一个StateProvider所维护的状态通常是下面几种。

  • 一个枚举,比如一个filter,用来做筛选
  • 一个字符串,通常是一些固定的文本,可以借助family关键字来做Switch
  • 一个布尔值,用于checkbox这类的状态切换
  • 一个数字,用于分页或者Pager的Index

而下面这些场景,就不适合使用StateProvider。

  • 你的状态中包含对校验逻辑
  • 你的状态是一个复杂的对象,比如一个自定义类,一个List、Map等
  • 状态的修改逻辑比较复杂

对于这些场景,你可以考虑使用StateNotifierProvider代替,并创建一个StateNotifier类。

虽然StateNotifierProvider的模板代码会多一些,但拥有一个自定义的StateNotifier类对于项目的长期可维护性至关重要--因为它将你的状态的业务逻辑集中在一个地方。

由此,我们可以了解,Riverpod最合适的场景,就是「单一状态值的管理」。例如,PageView的切换Index、ListView的切换Index,或者是CheckBox、dropdown的内容改变监听,这些是非常适合用StateProvider的。

一个filter的示例

官方给出了一个dropdown的例子,用来演示如何根据filter来修改列表的排序。

StateProvider在现实世界中的一个使用案例是管理简单表单组件的状态,如dropdown/text fields/checkboxes。特别是,我们将看到如何使用StateProvider来实现一个允许改变产品列表排序方式的dropdown。为了简单起见,我们将获得的产品列表将直接在应用程序中建立,其内容如下。

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

final productsProvider = Provider<List<Product>>((ref) {
  return _products;
});

在现实世界的应用中,这个列表通常是通过使用FutureProvider进行网络请求来获得的,然后,用户界面可以显示产品列表,就像下面这样。

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    body: ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price} \$'),
        );
      },
    ),
  );
}

由于这里是写死了products,所以使用Provider来作为数据Provider,是一个很好的选择。

现在我们已经完成了基础框架,我们可以添加一个dropdown,这将允许我们通过价格或名称来过滤产品。为此,我们将使用DropDownButton。

// An enum representing the filter type
enum ProductSortType {
  name,
  price,
}

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    appBar: AppBar(
      title: const Text('Products'),
      actions: [
        DropdownButton<ProductSortType>(
          value: ProductSortType.price,
          onChanged: (value) {},
          items: const [
            DropdownMenuItem(
              value: ProductSortType.name,
              child: Icon(Icons.sort_by_alpha),
            ),
            DropdownMenuItem(
              value: ProductSortType.price,
              child: Icon(Icons.sort),
            ),
          ],
        ),
      ],
    ),
    body: ListView.builder(
      // ... 
    ),
  );
}

现在我们有了一个dropdown,让我们创建一个StateProvider并将dropdown的状态与我们的StateProvider同步。首先,让我们创建StateProvider。

final productSortTypeProvider = StateProvider<ProductSortType>(
  // We return the default sort type, here name.
  (ref) => ProductSortType.name,
);

然后我们可以通过下面这个方式,将StateProvider和dropdown联系起来。

DropdownButton<ProductSortType>(
  // When the sort type changes, this will rebuild the dropdown
  // to update the icon shown.
  value: ref.watch(productSortTypeProvider),
  // When the user interacts with the dropdown, we update the provider state.
  onChanged: (value) =>
      ref.read(productSortTypeProvider.notifier).state = value!,
  items: [
    // ...
  ],
),

有了这个,我们现在应该能够改变排序类型。不过,这对产品列表还没有影响。现在是最后一个部分了。更新我们的productsProvider来对产品列表进行排序。

实现这一点的一个关键部分是使用ref.watch,让我们的productProvider获取排序类型,并在排序类型改变时重新计算产品列表。实现的方法如下。

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

这就是全部代码,这一改变足以让用户界面在排序类型改变时自动重新对产品列表进行排序。

更新状态的简化

参考下面的这个场景,有时候,我们需要根据前一个状态值,来修改后续的状态值,例如Flutter Demo中的加数器。

final counterProvider = StateProvider<int>((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // We're updating the state from the previous value, we ended-up reading
          // the provider twice!
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

这种更新State的方法,我们可以使用update函数来简化,简化之后,代码如下。

ref.read(counterProvider.notifier).update((state) => state + 1);

所以,如果是对StateProvider的state进行赋值,那么直接使用下面的代码即可。

ref.read(counterProvider.notifier).state = xxxx

那么如果是根据前置状态的值来修改状态值,则可以使用update来简化。

StateNotifierProvider

StateNotifierProvider是一个用于监听和管理StateNotifier的Provider。StateNotifierProvider和StateNotifier是Riverpod推荐的解决方案,用于管理可能因用户交互而改变的状态。

它通常被用于下面这些场景。

  • 暴露一个不可变的,跟随时间和行为而发生改变的状态
  • 将修改某些状态的逻辑(又称 "业务逻辑")集中在一个地方,提高长期的可维护性

作为一个使用例子,我们可以使用StateNotifierProvider来实现一个todo-list。这样做可以让我们暴露出诸如addTodo这样的方法,让UI在用户交互中修改todos列表。

// The state of our StateNotifier should be immutable.
// We could also use packages like Freezed to help with the implementation.
@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  // All properties should be `final` on our class.
  final String id;
  final String description;
  final bool completed;

  // Since Todo is immutable, we implement a method that allows cloning the
  // Todo with slightly different content.
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// The StateNotifier class that will be passed to our StateNotifierProvider.
// This class should not expose state outside of its "state" property, which means
// no public getters/properties!
// The public methods on this class will be what allow the UI to modify the state.
class TodosNotifier extends StateNotifier<List<Todo>> {
  // We initialize the list of todos to an empty list
  TodosNotifier(): super([]);

  // Let's allow the UI to add todos.
  void addTodo(Todo todo) {
    // Since our state is immutable, we are not allowed to do `state.add(todo)`.
    // Instead, we should create a new list of todos which contains the previous
    // items and the new one.
    // Using Dart's spread operator here is helpful!
    state = [...state, todo];
    // No need to call "notifyListeners" or anything similar. Calling "state ="
    // will automatically rebuild the UI when necessary.
  }

  // Let's allow removing todos
  void removeTodo(String todoId) {
    // Again, our state is immutable. So we're making a new list instead of
    // changing the existing list.
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  // Let's mark a todo as completed
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        // we're marking only the matching todo as completed
        if (todo.id == todoId)
          // Once more, since our state is immutable, we need to make a copy
          // of the todo. We're using our `copyWith` method implemented before
          // to help with that.
          todo.copyWith(completed: !todo.completed)
        else
          // other todos are not modified
          todo,
    ];
  }
}

// Finally, we are using StateNotifierProvider to allow the UI to interact with
// our TodosNotifier class.
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

现在我们已经定义了一个StateNotifierProvider,我们可以用它来与用户界面中的todos列表进行交互。

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // rebuild the widget when the todo list changes
    List<Todo> todos = ref.watch(todosProvider);

    // Let's render the todos in a scrollable list view
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // When tapping on the todo, change its completed status
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

FutureProvider

FutureProvider相当于Provider,但仅用于异步代码。

FutureProvider通常用于下面这些场景。

  • 执行和缓存异步操作(如网络请求)
  • 更好地处理异步操作的错误、加载状态
  • 将多个异步值合并为另一个值

FutureProvider在与ref.watch结合时收获颇丰。这种组合允许在一些变量发生变化时自动重新获取一些数据,确保我们始终拥有最新的值。

FutureProvider不提供在用户交互后直接修改计算的方法。它被设计用来解决简单的用例。
对于更高级的场景,可以考虑使用StateNotifierProvider。

示例:读取一个配置文件

FutureProvider可以作为一种方便的方式来管理一个通过读取JSON文件创建的配置对象。

创建配置将用典型的async/await语法完成,但在Provider内部。使用Flutter的asset,这将是下面的代码。

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

然后,用户界面可以像这样监听配置。

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

这将在Future完成后自动重建UI。同时,如果多个widget想要这些解析值,asset将只被解码一次。

正如你所看到的,监听Widget内的FutureProvider会返回一个AsyncValue - 它允许处理错误/加载状态。

StreamProvider

StreamProvider类似于FutureProvider,但用于Stream而不是Future。

StreamProvider通常被用于下面这些场景。

  • 监听Firebase或web-sockets
  • 每隔几秒钟重建另一个Provider

由于Streams自然地暴露了一种监听更新的方式,有些人可能认为使用StreamProvider的价值很低。特别是,你可能认为Flutter的StreamBuilder也能很好地用于监听Stream,但这是一个错误。

使用StreamProvider而不是StreamBuilder有许多好处。

  • 它允许其他Provider使用ref.watch来监听Stream
  • 由于AsyncValue的存在,它可以确保加载和错误情况得到正确处理
  • 它消除了区分broadcast streams和normal stream的需要
  • 它缓存了stream所发出的最新值,确保如果在事件发出后添加了监听器,监听器仍然可以立即访问最新的事件
  • 它允许在测试中通过覆盖StreamProvider的方式来mock stream

ChangeNotifierProvider

ChangeNotifierProvider是一个用来管理Flutter中的ChangeNotifier的Provider。

Riverpod不鼓励使用ChangeNotifierProvider,它的存在主要是为了下面这些场景。

  • 从package:provider的代码迁移到Riverpod时,替代原有的ChangeNotifierProvider
  • 支持可变的状态管理,但是,不可变的状态是首选推荐的
更倾向于使用StateNotifierProvider来代替。
只有当你绝对确定你想要可变的状态时,才考虑使用ChangeNotifierProvider。

使用可变的状态而不是不可变的状态有时会更有效率。但缺点是,它可能更难维护,并可能破坏各种功能。

例如,如果你的状态是可变的,使用provider.select来优化Widget的重建可能就会失效,因为select会认为值没有变化。

因此,使用不可变的数据结构有时会更快。而且,针对你的用例进行基准测试很重要,以确保你通过使用ChangeNotifierProvider真正获得了性能。

作为一个使用例子,我们可以使用ChangeNotifierProvider来实现一个todo-list。这样做将允许我们公开诸如addTodo的方法,让UI在用户交互中修改todos列表。

class Todo {
  Todo({
    required this.id,
    required this.description,
    required this.completed,
  });

  String id;
  String description;
  bool completed;
}

class TodosNotifier extends ChangeNotifier {
  final todos = <Todo>[];

  // Let's allow the UI to add todos.
  void addTodo(Todo todo) {
    todos.add(todo);
    notifyListeners();
  }

  // Let's allow removing todos
  void removeTodo(String todoId) {
    todos.remove(todos.firstWhere((element) => element.id == todoId));
    notifyListeners();
  }

  // Let's mark a todo as completed
  void toggle(String todoId) {
    for (final todo in todos) {
      if (todo.id == todoId) {
        todo.completed = !todo.completed;
        notifyListeners();
      }
    }
  }
}

// Finally, we are using StateNotifierProvider to allow the UI to interact with
// our TodosNotifier class.
final todosProvider = ChangeNotifierProvider<TodosNotifier>((ref) {
  return TodosNotifier();
});

现在我们已经定义了一个ChangeNotifierProvider,我们可以用它来与用户界面中的todos列表进行交互。

class TodoListView extends ConsumerWidget {
  const TodoListView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // rebuild the widget when the todo list changes
    List<Todo> todos = ref.watch(todosProvider).todos;

    // Let's render the todos in a scrollable list view
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // When tapping on the todo, change its completed status
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

这些不同类型的各种Provider,就是我们的军火库,我们需要根据不同的场景和它们的特性来选择不同的「武器」,通过文中给出的例子,相信大家能够很好的理解它们的作用了。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

重走Flutter状态管理之路—Riverpod进阶篇
☑️ ☆

重走Flutter状态管理之路—Riverpod入门篇

重走Flutter状态管理之路—Riverpod入门篇

熟悉我的朋友应该都知道,我好几年前写过一个「Flutter状态管理之路」系列,那个时候介绍的是Provider,这也是官方推荐的状态管理工具,但当时没有写完,因为写着写着,觉得有很多地方不尽人意,用着很别扭,所以在写了7篇文章之后,就暂时搁置了。

一晃时间过了这么久,Flutter内部依然没有一个能够碾压一切的状态管理框架,GetX可能是,但是我觉得不是,InheritedWidget系的状态管理,才应该是正统的状态管理。

最近在留意Provider的后续进展时,意外发现了一个新的库——Riverpod,号称是新一代的状态管理工具,仔细一看,嘿,居然还是Provider的作者,好家伙,这是搬起石头砸自己的脚啊。

就像作者所说,Riverpod就是对Provider的重写,可不是吗,字母都没变,就换了个顺序,这名字也是取的博大精深。

其实Provider在使用上已经非常不错了,只不过随着Flutter的更加深入,大家对它的需求也就越来越高,特别是对Provider中因为InheritedWidget层次问题导致的异常和BuildContext的使用这些问题诟病很多,而Riverpod,正是在Provider的基础上,探索出了一条心的状态管理之路。

大家可以先把官方文档看一看 https://riverpod.dev ,看完之后发现还是一脸懵逼,那就对了,Riverpod和Provider一样,有很多类型的Provider,分别用于不同的场景,所以,理清这些Provider的不同作用和使用场景,对于我们用好Riverpod是非常有帮助的。

官网的文档,虽然是作者精心编写的,但它的教程,站在的是一个创作者的角度,所以很多入门的初学者看上去会有点摸不清方向,所以,这才有了这个系列的文章。

我将在这个系列中,带领大家对文档进行一次精读,进行一次赏析,本文不全是对文档的翻译,而且讲解的顺序也不一样,所以,如果你想入门Riverpod进行状态管理,那么本文一定是你的最佳选择。

Provider第一眼

首先,我们为什么要进行状态管理,状态管理是解决申明式UI开发,关于数据状态的一个处理操作,例如Widget A依赖于同级的Widget B的数据,那么这个时候,就只能把数据状态上提到它们的父类,但是这样比较麻烦,Riverpod和Provider这样的状态管理框架,就是为了解决类似的问题而产生的。

将一个state包裹在一个Provider中可以有下面一些好处。

  • 允许在多个位置轻松访问该状态。Provider可以完全替代Singletons、Service Locators、依赖注入或InheritedWidgets等模式
  • 简化了这个状态与其他状态的结合,你有没有为,如何把多个对象合并成一个而苦恼过?这种场景可以直接在Provider内部实现
  • 实现了性能优化。无论是过滤Widget的重建,还是缓存昂贵的状态计算;Provider确保只有受状态变化影响的部分才被重新计算
  • 增加了你的应用程序的可测试性。使用Provider,你不需要复杂的setUp/tearDown步骤。此外,任何Provider都可以被重写,以便在测试期间有不同的行为,这可以轻松地测试一个非常具体的行为
  • 允许与高级功能轻松集成,如logging或pull-to-refresh

首先,我们通过一个简单的例子,来感受下,Riverpod是怎么进行状态管理的。

Provider是Riverpod应用程序中最重要的部分。Provider是一个对象,它封装了一个state并允许监听该state。Provider有很多变体形式,但它们的工作方式都是一样的。

最常见的用法是将它们声明为全局常量,例如下面这样。

final myProvider = Provider((ref) {
  return MyValue();
});
不要被Provider的全局变量所吓倒。Provider是完全final的。声明一个Provider与声明一个函数没有什么不同,而且Provider是可测试和可维护的。

这段代码由三个部分组成。

  • final myProvider,一个变量的声明。这个变量是我们将来用来读取我们Provider的状态的。Provider应该始终是final的
  • Provider,我们决定使用的Provider类型。Provider是所有Provider类型中最基本的。它暴露了一个永不改变的对象。我们可以用其他Provider如StreamProvider或StateNotifierProvider来替换Provider,以改变值的交互方式
  • 一个创建共享状态的函数。该函数将始终接收一个名为ref的对象作为参数。这个对象允许我们读取其他Provider,在我们Provider的状态将被销毁时执行一些操作,以及其它一些事情

传递给Provider的函数返回的对象的类型,取决于所使用的Provider。例如,一个Provider的函数可以创建任何对象。另一方面,StreamProvider的回调将被期望返回一个Stream。

你可以不受限制地声明你想要的多个Provider。与使用package:provider不同的是,Riverpod允许创建多个暴露相同 "类型 "的状态的provider。
final cityProvider = Provider((ref) => 'London');
final countryProvider = Provider((ref) => 'England');
两个Provider都创建了一个字符串,但这并没有任何问题。

为了使Provider发挥作用,您必须在Flutter应用程序的根部添加ProviderScope。

void main() {
  runApp(ProviderScope(child: MyApp()));
}

以上就是Riverpod最简单的使用,我们看下完整的示例代码。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// We create a "provider", which will store a value (here "Hello world").
// By using a provider, this allows us to mock/override the value exposed.
final helloWorldProvider = Provider((_) => 'Hello world');

void main() {
  runApp(
    // For widgets to be able to read providers, we need to wrap the entire
    // application in a "ProviderScope" widget.
    // This is where the state of our providers will be stored.
    ProviderScope(
      child: MyApp(),
    ),
  );
}

// Extend ConsumerWidget instead of StatelessWidget, which is exposed by Riverpod
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final String value = ref.watch(helloWorldProvider);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Example')),
        body: Center(
          child: Text(value),
        ),
      ),
    );
  }
}

可以发现,Riverpod的使用比package:Provider还要简单,申明一个全局变量来管理状态数据,然后就可以在任意地方获取数据了。

如何读取Provider的状态值

在有了一个简单的了解后,我们先来了解下关于状态中的「读」。

在Riverpod中,我们不像package:Provider那样需要依赖BuildContext,取而代之的是一个「ref」变量。这个东西,就是联系存取双方的纽带,这个对象允许我们与Provider互动,不管是来自一个Widget还是另一个Provider。

从Provider中获取ref

所有Provider都有一个 "ref "作为参数。

final provider = Provider((ref) {
  // use ref to obtain other providers
  final repository = ref.watch(repositoryProvider);

  return SomeValue(repository);
})

这个参数可以安全地传递给其它Provider或者类,来获取所需要的值。

例如,一个常见的用例是将Provider的 "ref "传递给一个StateNotifier。

final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  return Counter(ref);
});

class Counter extends StateNotifier<int> {
  Counter(this.ref): super(0);

  final Ref ref;

  void increment() {
    // Counter can use the "ref" to read other providers
    final repository = ref.read(repositoryProvider);
    repository.post('...');
  }
}

这样做,可以使我们的Counter类能够读取Provider。

这种方式是联系组件和Provider的一个重要方式。

从Widget中获取ref

Widgets自然没有一个ref参数。但是Riverpod提供了多种解决方案来从widget中获得这个参数。

扩展ConsumerWidget

在widget树中获得一个ref的最常见的方法是用ConsumerWidget代替StatelessWidget。

ConsumerWidget在使用上与StatelessWidget相同,唯一的区别是它的构建方法上有一个额外的参数:"ref "对象。

一个典型的ConsumerWidget看起来像这样。

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // use ref to listen to a provider
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}
扩展ConsumerStatefulWidget

与ConsumerWidget类似,ConsumerStatefulWidget和ConsumerState相当于一个带有状态的StatefulWidget,不同的是,state有一个 "ref "对象。

这一次,"ref "不是作为构建方法的参数传递,而是作为ConsumerState对象的一个属性。

class HomeView extends ConsumerStatefulWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  HomeViewState createState() => HomeViewState();
}

class HomeViewState extends ConsumerState<HomeView> {
  @override
  void initState() {
    super.initState();
    // "ref" can be used in all life-cycles of a StatefulWidget.
    ref.read(counterProvider);
  }

  @override
  Widget build(BuildContext context) {
    // We can also use "ref" to listen to a provider inside the build method
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

通过ref来获取状态

现在我们有了一个 "ref",我们可以开始使用它。

ref "有三个主要用途。

  • 获得一个Provider的值并监听变化,这样,当这个值发生变化时,这将重建订阅该值的Widget或Provider。这是通过ref.watch完成的
  • 在一个Provider上添加一个监听器,以执行一个action,如导航到一个新的页面或在该Provider发生变化时执行一些操作。这是通过 ref.listen 完成的
  • 获取一个Provider的值,同时忽略它的变化。当我们在一个事件中需要一个Provider的值时,这很有用,比如 "点击操作"。这是通过ref.read完成的
只要有可能,最好使用 ref.watch 而不是 ref.read 或 ref.listen 来实现一个功能。
通过依赖ref.watch,你的应用程序变得既是反应式的又是声明式的,这使得它更容易维护。

通过ref.watch观察Provider的状态

ref.watch在Widget的构建方法中使用,或者在Provider的主体中使用,以使得Widget/Provider可以监听另一个Provider。

例如,Provider可以使用 ref.watch 来将多个Provider合并成一个新的值。

一个例子是过滤一个todo-list,我们需要两个Provider。

  • filterTypeProvider,一个暴露当前过滤器类型的Provider(None,表示只显示已完成的任务)
  • todosProvider,一个暴露整个任务列表的Provider

通过使用ref.watch,我们可以制作第三个Provider,结合这两个Provider来创建一个过滤后的任务列表。

final filterTypeProvider = StateProvider<FilterType>((ref) => FilterType.none);
final todosProvider = StateNotifierProvider<TodoList, List<Todo>>((ref) => TodoList());

final filteredTodoListProvider = Provider((ref) {
  // obtains both the filter and the list of todos
  final FilterType filter = ref.watch(filterTypeProvider);
  final List<Todo> todos = ref.watch(todosProvider);

  switch (filter) {
    case FilterType.completed:
      // return the completed list of todos
      return todos.where((todo) => todo.isCompleted).toList();
    case FilterType.none:
      // returns the unfiltered list of todos
      return todos;
  }
});

有了这段代码,filteredTodoListProvider现在就可以管理过滤后的任务列表。

如果过滤器或任务列表发生变化,过滤后的列表也会自动更新。同时,如果过滤器和任务列表都没有改变,过滤后的列表将不会被重新计算。

类似地,一个Widget可以使用ref.watch来显示来自Provider的内容,并在该内容发生变化时更新用户界面。

final counterProvider = StateProvider((ref) => 0);

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // use ref to listen to a provider
    final counter = ref.watch(counterProvider);

    return Text('$counter');
  }
}

这段代码显示了一个Widget,它监听了一个存储计数的Provider。如果该计数发生变化,该Widget将重建,用户界面将更新以显示新的值。

ref.watch方法不应该被异步调用,比如在ElevatedButton的onPressed中。也不应该在initState和其他State的生命周期内使用它。在这些情况下,考虑使用 ref.read 来代替。

通过ref.listen监听Provider的变化

与ref.watch类似,可以使用ref.listen来观察一个Provider。

它们之间的主要区别是,如果被监听的Provider发生变化,使用ref.listen不会重建widget/provider,而是会调用一个自定义函数。

这对于在某个变化发生时执行某些操作是很有用的,比如在发生错误时显示一个snackbar。

ref.listen方法需要2个参数,第一个是Provider,第二个是当状态改变时我们要执行的回调函数。回调函数在被调用时将被传递2个值,即先前状态的值和新状态的值。

ref.listen方法也可以在Provider的体内使用。

final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

final anotherProvider = Provider((ref) {
  ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
    print('The counter changed $newCount');
  });
  // ...
});

或在一个Widget的Build方法中使用。

final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
      print('The counter changed $newCount');
    });
    
    return Container();
  }
}
ref.listen也不应该被异步调用,比如在ElevatedButton的onPressed中。也不应该在initState和其他State的生命周期内使用它。

通过ref.read来读取Provider的状态

ref.read方法是一种在不监听的情况下获取Provider的状态的方法。

它通常用于由用户交互触发的函数中。例如,当用户点击一个按钮时,我们可以使用ref.read来增加一个计数器的值。

final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}): super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Call `increment()` on the `Counter` class
          ref.read(counterProvider.notifier).increment();
        },
      ),
    );
  }
}
应该尽可能地避免使用ref.read,因为它不是响应式的。
它存在于使用watch或listen会导致问题的情况下。如果可以的话,使用watch/listen几乎总是更好的,尤其是watch。

关于ref.read到底什么时候用

首先,永远不要在Widget的build函数中直接使用ref.read。

你可能很想使用ref.read来优化一个Widget的性能,例如通过下面的代码来实现。

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  // use "read" to ignore updates on a provider
  final counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

但这是一种非常糟糕的做法,会导致难以追踪的错误。

以这种方式使用 ref.read 通常与这样的想法有关:"Provider所暴露的值永远不会改变,所以使用'ref.read'是安全的"。这个假设的问题是,虽然今天该Provider可能确实从未更新过它的值,但不能保证明天也是如此。

软件往往变化很大,而且很可能在未来,一个以前从未改变的值需要改变。

如果你使用ref.read,当这个值需要改变时,你必须翻阅整个代码库,将ref.read改为ref.watch--这很容易出错,而且你很可能会忘记一些情况。

如果你一开始就使用ref.watch,你在重构时就会减少问题。

但是如果我想用ref.read来减少我的widget重构的次数呢?

虽然这个目标值得称赞,但需要注意的是,你可以用ref.watch代替来达到完全相同的效果(减少构建的次数)。

Provider提供了各种方法来获得一个值,同时减少重建的次数,你可以用这些方法来代替。

例如下面的代码(bad)。

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

我们可以这样改。

final counterProvider = StateProvider((ref) => 0);

Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.watch(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}

这两个片段代码都达到了同样的效果:当计数器增加时,我们的按钮将不会重建。

另一方面,第二种方法支持计数器被重置的情况。例如,应用程序的另一部分可以调用。

ref.refresh(counterProvider);

这将重新创建StateController对象。

如果我们在这里使用ref.read,我们的按钮仍然会使用之前的StateController实例,而这个实例已经被弃置,不应该再被使用。

而使用ref.watch则可以正确地重建按钮,使用新的StateController。

关于ref.read可以读哪些值

根据你想监听的Provider,你可能有多个可能的值可以监听。

作为一个例子,考虑下面的StreamProvider。

final userProvider = StreamProvider<User>(...);

当读取这个userProvider时,你可以像下面这样。

  • 通过监听userProvider本身同步读取当前状态。
Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<User> user = ref.watch(userProvider);

  return user.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stack) => const Text('Oops'),
    data: (user) => Text(user.name),
  );
}
  • 通过监听userProvider.stream来获得相关的Stream。
Widget build(BuildContext context, WidgetRef ref) {
  Stream<User> user = ref.watch(userProvider.stream);
}
  • 通过监听userProvider.future获得一个Future,该Future以最新发出的值进行解析。
Widget build(BuildContext context, WidgetRef ref) {
  Future<User> user = ref.watch(userProvider.future);
}

其他Provider可能提供不同的替代值。

欲了解更多信息,请查阅API参考资料,参考每个Provider的API文档。

通过select来控制精确的读范围

最后要提到的一个与读取Provider有关的功能是,能够减少Widget/Provider从ref.watch重建的次数,或者ref.listen执行函数的频率的功能。

这一点很重要,因为默认情况下,监听一个Provider会监听整个对象的状态。但有时,一个Widget/Provider可能只关心一些属性的变化,而不是整个对象。

例如,一个Provider可能暴露了一个User对象。

abstract class User {
  String get name;
  int get age;
}

但一个Widget可能只使用用户名。

Widget build(BuildContext context, WidgetRef ref) {
  User user = ref.watch(userProvider);
  return Text(user.name);
}

如果我们简单地使用ref.watch,当用户的年龄发生变化时,这将重建widget。

解决方案是使用select来明确地告诉Riverpod我们只想监听用户的名字属性。

更新后的代码将是这样。

Widget build(BuildContext context, WidgetRef ref) {
  String name = ref.watch(userProvider.select((user) => user.name));
  return Text(name);
}

通过使用select,我们能够指定一个函数来返回我们关心的属性。

每当用户改变时,Riverpod将调用这个函数并比较之前和新的结果。如果它们是不同的(例如当名字改变时),Riverpod将重建Widget。然而,如果它们是相等的(例如当年龄改变时),Riverpod将不会重建Widget。

这个场景也可以使用select和ref.listen。
ref.listen<String>(
      userProvider.select((user) => user.name),
          (String? previousName, String newName) {
         print('The user name changed $newName');
      }
  );
这样做也将只在名称改变时调用listener。
另外,你不一定要返回对象的一个属性。任何覆盖==的值都可以使用。例如,你可以这样做。
final label = ref.watch(userProvider.select((user) => 'Mr ${user.name}'));

读取状态,是一个非常重要的部分,什么时候用什么样的方式来读,都会有不同的效果。

ProviderObserver

ProviderObserver可以监听一个ProviderContainer的变化。

要使用它,你可以扩展ProviderObserver类并覆盖你想使用的方法。ProviderObserver有三个方法。

  • didAddProvider:在每次初始化一个Provider时被调用
  • didDisposeProvider:在每次销毁Provider的时候被调用
  • didUpdateProvider:每次在Provider更新时都会被调用

ProviderObserver的一个简单用例是通过覆盖didUpdateProvider方法来记录Provider的变化。

// A Counter example implemented with riverpod with Logger

class Logger extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('''
{
  "provider": "${provider.name ?? provider.runtimeType}",
  "newValue": "$newValue"
}''');
  }
}

void main() {
  runApp(
    // Adding ProviderScope enables Riverpod for the entire project
    // Adding our Logger to the list of observers
    ProviderScope(observers: [Logger()], child: const MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: Home());
  }
}

final counterProvider = StateProvider((ref) => 0, name: 'counter');

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Text('$count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).state++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

现在,每当我们的Provider的值被更新时,logger将记录它。

I/flutter (16783): {
I/flutter (16783):   "provider": "counter",
I/flutter (16783):   "newValue": "1"
I/flutter (16783): }
对于诸如StateController(StateProvider.state的状态)和ChangeNotifier等可改变的状态,previousValue和newValue将是相同的。因为它们引用的是同一个StateController / ChangeNotifier。

这些是对Riverpod的最基本了解,但是却是很重要的部分,特别是如何对状态值进行读取,这是我们用好Riverpod的核心。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

重走Flutter状态管理之路—Riverpod入门篇
☑️ ☆

它来了!Flutter3.0新特性全接触

它来了!Flutter3.0新特性全接触

又到了Flutter稳定版发布的时候了--我们非常自豪地宣布Flutter 3! 仅仅三个月前,我们宣布Flutter支持Windows。今天,我们很高兴地宣布,除了Windows之外,Flutter现在在macOS和Linux上也是稳定的!

它来了!Flutter3.0新特性全接触

感谢我们的Flutter贡献者的辛勤工作,我们已经合并了5248个pull requests!

作为这个版本的一部分,我们有几件令人兴奋的事情要宣布,包括Flutter对macOS和Linux的支持的更新,显著的性能改进,移动和网络的更新--以及更多。此外,我们还有关于减少对旧版Windows的支持的消息,以及一个简短的breaking变化清单。所以,让我们开始谈正事吧!

Ready for production on all desktop platforms

Linux和macOS已经达到稳定,包括以下功能。

Cascading menus and support for the macOS system menu bar

你现在可以使用PlatformMenuBar部件在macOS上创建平台渲染的菜单栏,该部件支持插入平台专用的菜单,并控制macOS应用程序菜单中出现的内容。

gif太大,再见。

Full support for international text input on all desktop platforms

国际文本输入,包括使用文本输入法编辑器(IME)的语言,如中文、日文和韩文,在所有三个桌面平台上都得到完全支持,包括第三方输入法,如搜狗和谷歌日文输入。

Accessibility on all desktop platforms

用于Windows、macOS和Linux的Flutter支持无障碍服务,如读屏器、无障碍导航和颜色反转。

Universal binaries by default on macOS

从Flutter 3开始,Flutter macOS桌面应用程序被构建为通用二进制文件,对现有基于英特尔的Mac和苹果最新的Apple Silicon设备都有原生支持。

Deprecating Windows 7/8 for development

在这个版本中,我们将开发的推荐Windows版本提高到Windows 10。虽然我们没有阻止在旧版本(Windows 7、Windows 8、Windows 8.1)上的开发,但这些版本不再受到微软的支持,我们在这些版本上提供有限的测试。虽然我们将继续为旧版本提供 "最大努力 "的支持,但我们鼓励你升级。

注意:我们继续为在Windows 7和Windows 8上运行的Flutter应用程序提供支持;这一变化只影响到推荐的开发环境。

Mobile updates

我们对移动平台的更新包括以下内容。

Foldable phone support

Flutter 3版本支持可折叠移动设备。在微软带头的合作中,新的功能和部件允许你在可折叠设备上创建动态和令人愉快的体验。

作为这项工作的一部分,MediaQuery现在包含一个DisplayFeatures列表,描述了设备元素的边界和状态,如铰链、折叠和切口。此外,DisplayFeatureSubScreen小组件现在在定位其子小组件时不会与DisplayFeatures的边界重叠,并且已经与框架的默认对话框和弹出式窗口集成,使Flutter能够感知并响应这些元素的改变。

它来了!Flutter3.0新特性全接触

非常感谢微软团队,特别是@andreidiaconu,感谢他们的贡献!

试试Surface Duo模拟器的Sample:https://docs.microsoft.com/en-us/dual-screen/flutter/samples,包括一个带有Flutter图库的特殊分支的sample,看看Flutter的双显示器的运行情况。

iOS variable refresh rate support

Flutter现在支持带有ProMotion显示器的iOS设备上的可变刷新率,包括iPhone 13 Pro和iPad Pro。在这些设备上,Flutter应用程序可以在刷新率达到120 hz的情况下进行渲染,而以前则限制在60 hz。这使得在滚动等快速动画中的体验更加顺畅。更多细节见flutter.dev/go/variable-refresh-rate。

Simplified iOS releases

我们在flutter build ipa命令中添加了新的选项,以简化发布您的iOS应用。当您准备发布到TestFlight或App Store时,运行flutter build ipa来构建一个Xcode归档文件(.xcarchive文件)和一个应用包(.ipa文件)。你可以选择添加 --export-method ad-hoc, --export-method development, 或 --export-method enterprise。一旦应用程序捆绑完成,通过Apple Transport macOS应用程序或在命令行中使用xcrun altool(运行man altool获取App Store Connect API密钥验证说明)将其上传到苹果。上传后,您的应用程序可以发布到TestFlight或App Store。在设置了最初的Xcode项目设置,如显示名称和应用程序图标后,您不再需要打开Xcode来发布您的应用程序。

Gradle version update

如果你用Flutter工具创建一个新的项目,你可能会注意到,现在生成的文件使用最新版本的Gradle和Android Gradle插件。对于现有项目,你需要手动将Gradle的版本提升到7.4,将Android Gradle插件的版本提升到7.1.2。

Sunsetting 32-bit iOS/iOS 9/iOS 10

正如我们在2022年2月宣布的2.10稳定版,Flutter对32位iOS设备和iOS 9和10版本的支持即将结束。这一变化影响到iPhone 4S、iPhone 5、iPhone 5C以及第二代、第三代和第四代iPad设备。Flutter 3是支持这些iOS版本和设备的最后一个稳定版本。

要了解有关这一变化的更多信息,请看RFC:结束对32位iOS设备的支持。

Web updates

我们对网络应用的更新包括以下内容。

Image decoding

Flutter web现在能自动检测并在支持它的浏览器中使用ImageDecoder API。截至今天,大多数基于Chromium的浏览器(Chrome、Edge、Opera、Samsung Browser等)都增加了这个API。

新的API使用浏览器内置的图像编解码器在主线程外异步地解码图像。这使图像解码的速度提高了2倍,而且它从不阻塞主线程,消除了以前由图像引起的所有干扰。

Web app lifecycles

Flutter网络应用程序的新生命周期API使您能够灵活地从托管HTML页面控制Flutter应用的启动过程,并帮助Lighthouse分析您的应用的性能。这适用于许多用例,包括以下经常要求的场景。

  • A splash screen
  • A loading indicator
  • 在Flutter应用程序之前显示的普通HTML交互页面

欲了解更多信息,请查看docs.flutter.dev上的自定义Web应用初始化:https://docs.flutter.dev/development/platform-integration/web/initialization。

Tooling updates

我们对Flutter和Dart工具的更新包括。

Updated lint package

2.0版的lint软件包已经发布。

在Flutter 3中用flutter create生成的应用程序会自动启用v2.0版的lints包。我们鼓励现有的应用程序、软件包和插件通过运行 flutter pub upgrade --major-versions flutter_lints 迁移到 v2.0,以遵循 Flutter 世界中最新和最伟大的最佳实践。

在v2版中,大多数新增加的lint警告都有自动修复功能。因此,在你的应用程序的pubspec.yaml文件中升级到最新的软件包版本后,你可以在你的代码库中运行dart fix --apply来自动修复大多数lint警告(有些警告仍然需要一些手工操作)。还没有使用package:flutter_lints的应用程序、软件包或插件可以按照迁移指南进行迁移。

Performance improvements

感谢开源贡献者knopp,partial repaint已经在支持它的Android设备上启用。在我们的本地测试中,这一变化将Pixel 4 XL设备上backdrop_filter_perf基准的平均、第90个百分点和第99个百分点的帧栅格化时间缩短了5倍。在iOS和较新的安卓设备上,当存在单一rectangular dirty区域时,现在启用了partial repaint。

我们进一步提高了简单情况下Opacity动画的性能。特别是,当一个Opacity小组件只包含一个渲染基元时,通常由Opacity调用的saveLayer方法被省略。在为衡量这种优化的好处而构建的基准中,这种情况下的光栅化时间提高了一个数量级。在未来的版本中,我们计划将这种优化应用到更多的场景中。

由于开源贡献者JsouLiang的工作,引擎的光栅和UI线程现在在Android和iOS上的运行优先级高于其他线程;例如,Dart VM后台垃圾收集线程。在我们的基准测试中,这导致平均帧构建时间快了约20%。

在第3版发布之前,光栅缓存的接纳策略只看图片中的绘制操作数,假设任何超过几个操作数的图片都是缓存的好候选。不幸的是,这导致了引擎花费内存来缓存那些实际上渲染速度非常快的图片。这个版本引入了一种机制,根据它所包含的绘制操作的成本来估计图片的渲染复杂性。在我们的基准测试中,使用这种方法作为光栅缓存的接纳策略,在不降低性能的情况下减少了内存的使用。

感谢开源贡献者ColdPaleLight,他修复了帧调度中的一个bug,该bug导致iOS上少量的动画帧被丢弃。感谢所有报告这个问题并提供掉帧视频的人。

Impeller

该团队一直在努力工作,以解决iOS和其他平台上的早期jank问题。在Flutter 3版本中,你可以在iOS上预览一个名为Impeller的实验性渲染后端。Impeller在引擎构建时预编译一套更小、更简单的着色器,这样它们就不会在应用程序运行时被编译;这一直是Flutter中jank的一个主要来源。Impeller还没有为生产做好准备,也远未完成。并非所有的Flutter功能都已实现,但我们对其在flutter/gallery应用程序中的保真度和性能感到足够满意,因此我们正在分享我们的进展。特别是,画廊应用的过渡动画中最差的一帧速度大约是20倍。

Impeller在iOS的一个标志下可用。你可以向flutter运行传递--enable-impeller,或者将你的Info.plist文件中的FLTEnableImpeller标志设置为true,来尝试一下Impeller。Impeller的开发在Flutter的主频道继续进行,我们希望在未来的版本中提供进一步的更新。

Inline ads on android

当你使用google_mobile_ads软件包时,你应该看到在用户的关键互动方面有更好的表现,如滚动和页面之间的转换。这在新兴市场流行的设备上尤其明显。最重要的是,不需要修改代码!

在引擎盖下,Flutter现在以异步方式组成Android视图,通常称为平台视图。这意味着Flutter光栅线程不需要等待Android视图的渲染。相反,Flutter引擎使用其管理的OpenGL纹理将视图放在屏幕上。

More exciting updates

Flutter生态系统的其他更新包括以下内容。

Material 3

Flutter 3支持Material Design 3,即新一代的Material Design。Flutter 3提供了对Material 3的选择支持;这包括Material You功能,如动态颜色、更新的颜色系统和排版,对许多组件的更新,以及在Android 12中引入的新视觉效果,如新的触摸波纹设计和拉伸过卷效果。在新的Take your Flutter app from Boring to Beautiful codelab中尝试Material 3功能。有关如何选择使用这些新功能以及哪些组件支持Material 3的详细信息,请参见API文档。关注正在进行的Material 3 Umbrella问题的工作。

Theme extensions

Flutter现在可以向素材库的ThemeData添加任何东西,有一个概念叫做Theme extensions。你可以指定ThemeData.extensions,而不是扩展(Dart意义上的)ThemeData并重新实现其copyWith、lerp和其他方法。另外,作为一个包的开发者,你可以提供ThemeExtensions。更多细节请参见flutter.dev/go/theme-extensions,并查看GitHub上的这个例子。

Ads

我们知道,对于出版商来说,为个性化广告征求同意和处理苹果公司的应用跟踪透明度(ATT)要求是很重要的。

为了支持这些要求,谷歌提供了用户信息平台(UMP)SDK,它取代了以前的开源同意SDK。在即将发布的GMA SDK for Flutter中,我们正在增加对UMP SDK的支持,以使发布者能够获得用户同意。更多细节,请查看pub.dev上的google_mobile_ads页面。

Breaking changes

随着我们不断发展和改进Flutter,我们的目标是将破坏性变化的数量降到最低。随着Flutter 3的发布,我们有以下突破性变化。

具体的迁移过程,可以参考migration guide on Flutter.dev.

Summary

从谷歌的Flutter团队来说,我们要感谢社区所做的出色工作,帮助Flutter保持其作为最受欢迎的跨平台UI工具包的地位,正如Statista和SlashData等分析机构所衡量的那样。我们期待着作为一个社区一起工作,继续提供一个由社区驱动的工具,帮助为开发者和用户创造愉快的体验!

原文链接:https://medium.com/flutter/whats-new-in-flutter-3-8c74a5bc32d0

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

它来了!Flutter3.0新特性全接触
☑️ ⭐

它来了!Flutter3.0发布全解析

它来了!Flutter3.0发布全解析

我们在手机、桌面和网络上进行多平台UI开发的历程达到了顶峰。

我们很高兴地宣布,作为谷歌I/O主题演讲的一部分,我们今天推出了Flutter 3。Flutter 3完成了我们从以移动为中心到多平台框架的路线图,提供了对macOS和Linux桌面应用的支持,以及对Firebase集成的改进,新的生产力和性能特性,并支持Apple Silicon。

它来了!Flutter3.0发布全解析

The journey to Flutter 3

我们创办Flutter的初衷是试图彻底改变应用开发:将网络的迭代开发模式与硬件加速图形渲染和像素级控制相结合,而这在以前是游戏的专利。自Flutter 1.0测试版以来的四年里,我们逐渐在这些基础上发展,增加了新的框架功能和新的小工具,与底层平台更深入的整合,丰富的包库和许多性能和工具的改进。

它来了!Flutter3.0发布全解析

随着产品的成熟,越来越多的人开始用它构建应用程序。今天,有超过50万个应用程序是用Flutter建立的。来自data.ai等研究公司的分析,以及公众的评价,表明Flutter被许多细分领域的客户所使用:从微信等社交应用到Betterment和Nubank等金融和银行应用;从SHEIN和trip.com等商务应用到Fastic和Tabcorp等生活方式应用;从My BMW等伴侣应用到巴西政府等公共机构。

今天,有超过50万个应用程序使用Flutter构建。

开发人员告诉我们,Flutter有助于在更多的平台上更快地构建漂亮的应用程序。在我们最新的用户研究中。

  • 91% 的开发者认为 Flutter 缩短了构建和发布应用程序的时间。
  • 85%的开发者认为Flutter使他们的应用程序比以前更漂亮。
  • 85%的人认为Flutter使他们的应用比以前能在更多的平台上发布。

在Sonos最近的一篇博客文章中,讨论了他们改造后的设置体验,他们强调了其中的第二个问题。

"毫不夸张地说,[Flutter]释放了一种与我们团队之前交付的任何东西都不同的 "高级 "程度。对我们的设计师来说,最重要的是,可以轻松地构建新的UI,这意味着我们的团队花在对规格说 "不 "的时间更少,花在迭代上的时间更多。如果这听起来值得,我们会推荐你试一试Flutter--我们很高兴这样做。"

Introducing Flutter 3

今天,我们推出Flutter 3,这是我们填补Flutter所支持的平台的旅程的高潮。有了Flutter 3,您可以从一个代码库中为六个平台构建更好的体验,为开发者提供无与伦比的生产力,并使初创企业从第一天起就能将新的想法带到完整的可触达市场。

在以前的版本中,我们用网络和Windows支持来补充iOS和Android,现在Flutter 3增加了对macOS和Linux应用的稳定支持。增加平台支持需要的不仅仅是渲染像素:它包括新的输入和交互模型、编译和构建支持、可访问性和国际化,以及特定平台的整合。我们的目标是让你能够灵活地充分利用底层操作系统,同时尽可能多地分享你选择的用户界面和逻辑。

在macOS上,我们已经支持英特尔和苹果Silicon,并提供通用二进制支持,使应用程序能够打包可执行文件,在两种架构上原生运行。在Linux上,Canonical和谷歌已经合作为开发提供了一个高度集成的、最好的选择。

Superlist是Flutter如何实现美丽的桌面体验的一个很好的例子,它今天推出了测试版。Superlist提供了超强的协作,通过一个新的应用程序,将列表、任务和自由形式的内容结合在一起,成为待办事项和个人计划的新方式。Superlist团队选择Flutter是因为它能够提供快速、高度品牌化的桌面体验,我们认为他们迄今为止的进展证明了为什么它被证明是一个伟大的选择。

Flutter 3还对许多基本要素进行了改进,提高了性能,支持Material You,并更新了生产力。

除了上述工作外,在这个版本中,Flutter可以完全原生在苹果芯片上进行开发。虽然Flutter自发布以来一直与M1驱动的苹果设备兼容,但Flutter现在充分利用了Dart对苹果芯片的支持,在M1驱动的设备上实现了更快的编译,并支持macOS应用程序的通用二进制文件。

在这个版本中,我们为Material Design 3所做的工作基本完成,使开发者能够利用一个适应性强、跨平台的设计系统,提供动态的色彩方案和更新的视觉组件。

它来了!Flutter3.0发布全解析

我们详细的技术博文阐述了这些以及Flutter 3的许多其他新功能。

Flutter由Dart驱动,这是一种用于多平台开发的高生产力、可移植的语言。我们在这个周期中对Dart的工作包括减少模板和帮助可读性的新语言功能,实验性的RISC-V支持,升级的linter和新的文档。关于Dart 2.17中所有新改进的进一步细节,请查看专用博客:https://medium.com/dartlang。

Firebase and Flutter

当然,建立一个应用程序不仅仅是一个UI框架。应用程序发布者需要一套全面的工具来帮助你构建、发布和运营你的应用程序,包括认证、数据存储、云功能和设备测试等服务。有多种服务支持Flutter,包括Sentry、AppWrite和AWS Amplify。

谷歌提供的应用服务是Firebase,SlashData的开发者基准研究显示,62%的Flutter开发者在其应用中使用Firebase。因此,在过去的几个版本中,我们一直在与Firebase合作,以扩大和更好地将Flutter作为一个一流的集成。这包括将Flutter的Firebase插件提高到1.0,增加更好的文档和工具,以及像FlutterFire UI这样的新部件,为开发者提供可重用的auth和profile界面的UI。

今天,我们宣布Flutter/Firebase的整合将成为Firebase产品中完全支持的核心部分。我们将源代码和文档转移到Firebase的主仓库和网站中,你可以指望我们与Android和iOS同步发展Firebase对Flutter的支持。

此外,我们还进行了重大改进,以支持使用Crashlytics的Flutter应用程序,这是Firebase流行的实时崩溃报告服务。随着Flutter Crashlytics插件的更新,你可以实时跟踪致命的错误,为你提供与其他iOS和Android开发者相同的功能集。这包括重要的警报和指标,如 "无崩溃用户",帮助你保持你的应用程序的稳定性。Crashlytics分析管道已经升级,以改善Flutter崩溃的聚类,使其更快地分流、优先处理和修复问题。最后,我们简化了插件的设置过程,因此只需要几个步骤就可以使用Crashlytics,并从你的Dart代码中开始运行。

Flutter Casual Games Toolkit

对于大多数开发者来说,Flutter是一个应用程序框架。但是,围绕休闲游戏开发的社区也在不断壮大,利用Flutter提供的硬件加速图形支持和Flame等开源游戏引擎。我们希望让休闲游戏开发者更容易上手,所以在今天的I/O大会上,我们宣布了休闲游戏工具包,它提供了一个模板和最佳实践的入门套件,以及广告和云服务的良好体验。

它来了!Flutter3.0发布全解析

虽然Flutter并不是为高强度的3D动作游戏而设计的,但即使是一些游戏也转向Flutter的非游戏UI,包括像PUBG Mobile这样拥有数亿用户的流行游戏。而对于I/O,我们想看看我们能把技术推到什么程度,所以我们创造了一个有趣的弹球游戏,它由Firebase和Flutter的网络支持提供支持。I/O弹球游戏提供了一个围绕谷歌最喜欢的四个吉祥物设计的定制桌子。Flutter的Dash、Firebase的Sparky、Android机器人和Chrome的恐龙,并让你与他人竞争高分。我们认为这是一种展示Flutter多功能性的有趣方式。

它来了!Flutter3.0发布全解析

Sponsored by Google, powered by community

我们喜欢Flutter的一点是,它不仅仅是谷歌的产品--它是一个 "所有人 "的产品。开源意味着我们都可以参与其中,并与它的成功息息相关,无论是通过贡献新的代码或文档,创建赋予核心框架新的超能力的包,编写教导他人的书籍和培训课程,还是帮助组织活动和用户组。

为了展示社区的最佳状态,我们最近与DevPost合作赞助了一个Puzzle Hack挑战,为开发者提供了一个机会,通过用Flutter重新想象经典的滑动拼图来展示他们的技能。这证明了网络、桌面和移动的完美结合:现在我们都可以在线或通过商店玩这些游戏。

我们把这个视频放在一起,展示了我们最喜欢的一些作品和获奖者;我们认为你会喜欢它。

https://youtu.be/l6hw4o6_Wcs

谢谢您对Flutter的支持,欢迎来到Flutter 3!

原文链接:https://medium.com/flutter/introducing-flutter-3-5eb69151622f

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

它来了!Flutter3.0发布全解析
☑️ ☆

kotlin修炼指南7之泛型

kotlin修炼指南7之泛型

Kotlin在Java的基础上,同样对泛型语法进行了拓展,所以很多Kotlin开发者,看着源码中的一堆in、out和*,感觉非常不知所措。其实,只要了解了Java泛型,那么Kotlin泛型就迎刃而解了。

首先,我们来想想,我们为什么需要泛型。

泛型是面向对象编程的一个非常重要的方面,它的出现,是多态的核心实现,简单的说,就是可以在不同的对象类型之间,使用相同的代码逻辑,从而实现复用。

为了充分了解泛型,以及泛型的实例场景,我们下面来构建一个面向对象的例子。

abstract class Person(open val name: String) {
    abstract fun talk(): String
}

class Parent(override val name: String) : Person(name) {
    override fun talk(): String = name
}

class Son : Person("ryan") {
    override fun talk(): String = "hahaha"
}

fun doTalk(family: MutableList<Person>) {
    family.forEach { println(it.talk()) }
}

这里定义了一个Person类,作为基类,他的子类——Father、Son,就是具体的实例,新建一个方法doTalk,用来输出具体的实现。

fun main() {
    val family = arrayListOf<Person>()
    family.add(Parent("Father"))
    family.add(Son())

    doTalk(family)
}

这样,我们就可以构建一个family的List,指定List的类型为Person,这样Father、Son,这些子类就都可以加入到这个List。

上面就是一个比较简单的泛型的使用实例。

泛型不变性

Father和Son都可以作为子类,加入到Person的List中,这就是泛型,但是让我们再看下下面的代码。

val parents = mutableListOf<Parent>()
parents.add(Parent("Father"))
parents.add(Parent("Mother"))
doTalk(parents)

创建一个类型为Parent的List,再传入doTalk函数,这时候,编译器报错了。

为什么呢?从编译器来看,doTalk需要的是一个List类型的参数,但是传入的是List类型,确实类型不一致,但是,Parent是Person的子类,从语义上来说,doTalk函数也是可以接受Parent类型的List的。

这就是泛型的不变性。即使参数中的类型是父子关系,但是编译器依然不能识别,它只能识别具体的类型。

泛型的型变

正是由于存在泛型的不变性,所以我们在支持某些场景的泛型参数时,就需要通过「泛型的型变」来拓展「泛型的不变性」。

Kotlin,或者说Java的泛型,实际上是一种伪泛型,即泛型只在申明时检查泛型是否有效,在编译时,泛型类型会被擦除,这是因为Java的历史原因所导致的,由于它为了兼容没有泛型的老Java版本,从而做出的妥协。

不管是如何型变,它们的作用都是扩大泛型参数的类型范围。

协变

泛型的协变,是泛型型变的一种方式。

协变的使用很简单,我们给参数加上out前缀即可,代码如下。

fun doTalk(family: MutableList<out Person>) {
    family.forEach { println(it.talk()) }
}

加上out关键字之后,参数类型就变成了「Person类及其子类」,也就是说,只要是Person的子类,都可以作为参数传进来。

那么这样处理之后,上面的方法就可以执行了。但是,协变之后的泛型,就变成可读而不可写类型了。

例如我们在协变泛型参数上进行写操作,代码如下。

fun doTalk(family: MutableList<out Person>) {
    family.add(Son()) // Error
    family.forEach { println(it.talk()) }
}

这样就会报错,因为被out修饰之后,参数失去了写属性,变为只读属性了,这就是协变的副作用。

那么原因是什么呢?

我们来思考下,为什么它是可读的,通过out修饰之后,我们能保证,加入List的数据都是Person的子类,所以,List读取出来的实例类型,不管是哪个子类,都可以转为Person,也就是基类,所以可以通过它来调用基类的函数。

如果把参数写成Java的方式,可能更好理解一些。

void doTalk(List<? extends Person> family) {}

可以发现,泛型的协变,实际上是控制了类型的上限,但返回的具体类型,是不确定的(?代表未知类型),这就是为什么在协变后的参数中,无法执行写指令的原因,因为参数的类型,可能是List,也可能是List,所以无法确定是哪一种类型,自然无法写入。

逆变

逆变是泛型型变的第二种方式,与协变类似,逆变也是将某一个泛型类型,拓展了其父类类型,例如下面这个方法。

fun work(worker: MutableList<Son>) {
    worker.forEach { println(it.talk()) }
}

这个方法接收一个List类型的参数,那么假如我们要传递一个List类型的参数,就会报错,原因跟协变是一样的。

这个时候,就需要使用逆变关键字in,将参数类型拓展为「Son类及其父类」。

fun work(family: MutableList<in Son>) {
    family.forEach { println(it.talk()) }
}


val family = arrayListOf<Person>()
family.add(Parent("Father"))
family.add(Son())
work(family)

这样参数就可以传进去了,但是,逆变的副作用,是会导致泛型参数失去读属性,而只能使用写属性。

fun work(family: MutableList<in Son>) {
    family.add(Son())
    family.forEach { println(it.talk()) } // Error
}

同样的,我们将它转化为Java中的代码,这样更好理解一些。

void work(List<? super Son> family) {}

泛型的逆变,实际上是控制了类型的下限,即Son及其父类。对List进行add操作时,新实例son一定符合条件,但是get时,只会获取到Any或者Object类型,所以,拿到Object类型后,你可以根据业务来进行强转。

星型投影

星型投影,其实就是Java中的「?」通配符,用于在泛型的使用中,去除泛型的依赖,这么说有点抽象,简单的说,就是当你不关心具体的泛型类型时,就可以使用「?」或者「*」来忽略泛型的约束。下面举个例子。

class Push<T> {
    fun pushMsg(msg: String): T {}
}

fun <T> getPush(): Push<T> {}

这是泛型版本的方法,我们可以获取指定泛型的Push,同时,你也可以用out来做泛型协变,让它可以返回子类。

那么这个时候,如果我不关心泛型的类型呢?

fun getPush(): Push<*> {}

fun main() {
    val push = getPush()
    val pushMsg = push.pushMsg("xys")
}

通过「*」,我们就可以不用指定泛型的具体类型,因为我不关心泛型类型,不过要注意的是,星型投影之后返回的类型,就成了「Any?」或者「Object」,因为泛型类型已经没有了。

但是我们依然可以使用协变来限制投影的上限,当我们加上上限后,就可以限制返回数据的上限类型了——out T : CommonPush

实际使用

我们在设计泛型API时,通常会有两种使用方式,一种是将泛型作为参数,另一种是将泛型作为返回值,这两种模式,实际上就对应「生产者-消费者」模型。下面我们就借助这个模型,来完整的演示下。

官方文档中的说法是——Consumer in, Producer out !

生产者

首先,设计一个生产者。

class Producer<T> {
    fun produceSth(): T {
        // TODO
    }
}

fun main() {
    val producer: Producer<Person> = Producer()
    val sth: Person = producer.produceSth()
}

这是我们泛型最基本的使用,创建Person类型的生产者,它生产出来的东西,全是Person类型。

下面,我们来对泛型协变,这样就可以创建Son类型的生产者。

fun main() {
    val producer: Producer<out Person> = Producer<Son>()
    val sth: Person = producer.produceSth()
}

但是协变之后,生产出来的类型,依然是Person类型。

那么在Kotlin中,可以将这种在使用时的协变,变为申明时的协变,代码如下。

class Producer<out T> {
    fun produceSth(): T {
        // TODO
    }
}

fun main() {
    val producer = Producer<Son>()
    val sth: Person = producer.produceSth()
}

在申明时标记协变,这样后续在使用时,就不用再标记了,你可以创建子类的生产者,生产基类的对象。

在Kotlin中,集合类大量使用了协变,如下所示。

public interface List<out E> : Collection<E> {
    // Query Operations

    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>

    // Bulk Operations
    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean

    // Positional Access Operations
    /**
     * Returns the element at the specified index in the list.
     */
    public operator fun get(index: Int): E

这里泛型就是作为返回值传入。

消费者

同样的,我们创建一个消费者。

class Consumer<T> {
    fun consumeSth(t: T) {
        // TODO
    }
}

fun main() {
    val consumer: Consumer<Son> = Consumer<Person>()
    consumer.consumeSth(Son())
}

同理,创建一个Person类型的消费者,它只能消费Son类型的参数。

我们再给它增加逆变,让它可以接受Son的基类。

fun main() {
    val consumer: Consumer<in Son> = Consumer<Person>()
    consumer.consumeSth(Son())
}

但是逆变之后,同样只能接受Son类型的参数,但是可以创建Person类型的消费者。

类似的,逆变也可以在申明处标记。

fun main() {
    val consumer = Consumer<Person>()
    consumer.consumeSth(Son())
}

class Consumer<in T> {
    fun consumeSth(t: T) {
        // TODO
    }
}

那么逆变,在实际代码中的例子,我们可以参考下Comparable接口的设计。

public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

这里的泛型就是作为参数传递,所以使用了逆变。

泛型的实例化

由于Java会在编译期进行泛型擦除,所以我们无法对泛型来做类型判断,比如下面的代码。

fun <T> test(param: Int) {
    if (param is T) {// Error
    }
}

T是无法进行类型判断的,因为它已经被擦除了,这和在Java中使用instanceof判断是一样的,在Java中,我们通常会再传入一个Class类型的参数来处理这个问题。而在Kotlin中,有更简单的方法来处理,那就是通过inline配合reified关键字来处理。

inline fun <reified T> test(param: Int) {
    if (param is T) {
    }
}

这样T就可以当做正常的类型来处理了,不过这种实例化的方式是有限制的。

  • 函数必须是内联函数,因为只有内联函数才会在编译时进行替换
  • 加上reified关键字让编译器在该泛型使用时进行实例化

在实战中,我们就可以利用泛型来进一步简化代码,例如:

inline fun <reified T> startActivity(context: Context) {
    context.startActivity(Intent(context, T::class.java))
}

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

kotlin修炼指南7之泛型
☑️ ☆

为了看Flutter到底有没有人用我竟然

为了看Flutter到底有没有人用我竟然

Flutter这个东西出来这么久了,到底市场占有率怎么样呢?为了让大家了解这一真实数据,也为了让大家了解当前Flutter在各大App中的使用情况,我今天下载了几百个App,占了手机将近80G空间,就为了得出一个结论——Flutter,到底有没有人用。

首先,我在vivo应用市场中,下载了4月11日软件排行榜中的所有App,总计230个,再加上平时用的比较多的一些App,总共270个App,作为我们的统计基数。

检测方法,我使用LibChecker来查看App是否有使用Flutter相关的so。

https://github.com/zhaobozhen/LibChecker

除了使用LibChecker之外,还有其它方案也可以,例如使用shell指令——zipinfo。

https://github.com/sugood/apkanalyser

Apk本质上也是一种压缩包,所以,通过zipinfo指令并进行grep,就可以很方便的获取了,同时,如果配合一下爬虫来爬取应X宝的Apk下载地址,就可以成为一个全自动化的脚本分析工具,这里没这么强的需求,所以就不详细做了。

App列表

我们来看下,我都下载了多少App。

为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然

这些App基本上已经覆盖了应用商店各个排行榜里的Top软件,所以应该还是比较具有代表性和说服力的。

下面我们就用LibChecker来看下,这些App里面到底有多少使用了Flutter。

统计结果

为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然
为了看Flutter到底有没有人用我竟然 为了看Flutter到底有没有人用我竟然

已经使用Flutter的App共52个,占全体样本的19.2%,作为参考,统计了下RN相关的App,共有45个,占全体样本的16.6%,可以说,Flutter已经超过RN成为跨平台方案的首选。

在52个使用Flutter的App中:

  • 腾讯系:QQ邮箱、微信、QQ同步助手、蓝盾、腾讯课堂、QQ浏览器、微视、企业微信、腾讯会议
  • 百度系:百度网盘、百度输入法
  • 阿里系:优酷视频、哈啰出行、淘特、酷狗直播、阿里1688、学习强国、钉钉、淘宝、闲鱼
  • 其它大厂:链家、转转、智联招聘、拍拍贷、哔哩哔哩漫画、网易有道词典、爱奇艺、考拉海购、携程旅行、微博、Soul、艺龙旅行、唯品会、飞猪旅行

从上面的数据来看,各大厂都对Flutter有使用,头条系未列出的原因是,目前好像只有头条系大规模使用了Flutter的动态化加载方案,所以原始包内找不到Flutter相关的so,所以未检出(猜测是这样,具体可以请头条系的朋友指出,根据上次头条的分享,内部有90+App在使用Flutter)。

不过这里要注意的 ,这里并不是选取的大家常用的一些APP来做测试的,而是直接选取的排行榜,如果直接用常用APP来测试,那比例可能更高,大概统计了下,估计在60%左右。

不过大厂里面,京东没有使用Flutter我还是比较意外的,看了下京东的几个App,目前还是以RN为主作为跨平台的方案。这跟其它很多大厂一样,它们不仅使用了Flutter,RN也还可以检出,这也从侧面说明了,各个厂商,对跨平台的方案探索,从未停止。

所以,总结一下,目前使用Flutter的团队的几个特定:

  • 创业公司:快速试错、快速开发,像Blued、夸克这也的
  • 大厂:大厂的话题永远是效率,如何利用跨平台技术来提高开发效率,是它们引入Flutter的根本原因
  • 创新型业务:例如B漫、淘特、Soul这类没有太多历史包袱的新业务App,可以利用Flutter进行极为高效的开发

所以,整体在知乎上吵「Flutter被抛弃了」、「Flutter要崛起了」,有什么意义呢?所有的争论都抵不过数据来的真实。

嘴上说着不要,身体倒是很诚实。

希望这份数据能给你一些帮助。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

为了看Flutter到底有没有人用我竟然
☑️ ☆

闲言碎语——第七期

闲言碎语——第七期

一转眼,困在家里已经一个月了,这次的上海疫情真是出乎所有人的预料,作为一个技术公众号,我们也不去评判任何政治问题,只是作为一个亲身体验者,谈谈自己最近的感受吧。

最早知道疫情扩散时,其实大家都并没有太当回事,毕竟国外的疫情范围,只能用躺平来形容了,所以从在家办公开始,即使知道后面要进行封控,也并没有准备太多的物资,因为那个时候虽然警戒线拉起来了,但是你想出去,保安也不会管你,也就是说,最开始的防疫,实际上是比较形式主义的。

我们小区所在镇并不是疫情的重灾区,所以管理相对来说是比较松散的,直到铁丝网拉起来的那一天,所有人才意识到,这次疫情不是那么简单了,这才开始疯狂采购物资,我也是隔离几天后,才找到附件超市老板的电话,采购了一大批物资,这才让温饱有了最基本的保障,再到后来,小区团购的兴起,才让大家对基本生活,有了一定的保障,不至于饿殍满地。

作为一个经历者,我来聊聊这次疫情的一些感受。

首先是「居委-基层管理者」,面对疫情,除了政府以外,最大的工作承担者,实际上就是这些基层干部,也就是居委会、社区管理者这些人,他们的组织能力和管理能力,直接影响了整个小区的防疫工作,不过很可惜的是,大部分的居委工作人员,组织能力太差,从核酸采集的组织、信息的通报、居民情况的了解这些情况,就可以完全了解一个小区居委的工作能力,不过,没有功劳也有苦劳,小区居委和志愿者们还是很辛苦的,特别是有些居委要分管好几个小区,他们的工作量也是成倍的增长。希望经过这次疫情,居委们能有所警醒,平时就建立起一套完整的管理方案,不仅仅是疫情,因为居委可以说是人民群众和政府之间的桥梁,这个桥梁平时就没建好,怎么能在真正要用的时候承载所需的压力呢?

其次谈谈「物资」,对于我们来说,只要有物资保证,让我在家办公一年也没什么问题。所以,这次疫情反映出来的一个很大的问题,就是物资紧缺,大家的基本生活不能保证,也就让大家对封控产生了很大的抵触情绪,网络上很多流出的xx视频,很大一部分都是因为没有物资而产生的。

不过从客观上来说,如何保障上海近3000万人的基本生活物资,相信这换成其它任何一个城市,都是很大的挑战。但是挑战归挑战,放低数量级之后,也不是没有成功的案例,深圳就是一个,所以这些可能真的和决策层的领导思路有关,咱也不懂,咱也不敢问。就我所见到的,虽然物资紧,但年轻人基本上还是可以从各种渠道,找到采购的方式,不管是团购还是外卖,还是各种抢单抢菜,虽然生活苦一点,还是能活下去,最可怜的是小区内的老人,如果家里没有年轻人,还真是很难得到这些物资保障,这里其实完全靠居委来保障,为这些老人提供帮助,可真的有多少能做到呢?大部分的老人可能都不会使用微信,更不用说什么抢单了,所以说,这次疫情,家里没老人小孩,没生病,就是最大的幸运了。还好大部分的小区在这次疫情中都建了微信群,邻里之间虽然平时交流不多,但是在这种大是大非面前,都是比较照顾的,大家会分一些物资给那些需要帮助的人,有老人需要药或者物资,大家也会帮忙想办法,所以,人性本善。

再来谈谈「躺平 or 清零」。其实,真的,你应该感谢国家愿意无条件的花费巨资来抗击疫情,奥密克戎的致死率虽然不如之前高,但是其传染性和带来的症状,一定也不必其它大型流行性传染病差,所谓的「无症状感染者」,并不是完全没有任何异样,发热、咽痛、肺炎,当躺平的你真的遇到这些问题,再去看看平时没有疫情就已经人山人海的医院,你会感到绝望吗?

中国的人均医疗设施占有率,比起发达国家来说要低很多,人口基数大导致的就医难问题,在没有疫情时,就已经让很多人绝望了,一旦「躺平」,老人小孩的感染风险,会让原本就已经危若累卵的医疗机构更加难以运作,所以,如果你没有得「新冠」,那就不要轻言「躺平」。

但是,「清零」也绝不应该是对除「新冠」之外的其它疾病的无视,这一点在这次疫情中,被无限放大了,有太多的病人,并不是因为「新冠」而绝望,而是本身的疾病无法得到救治,这一点,在新闻中已经被完全解决了,各种患病的人,都可以联系警察、120来获得救治,但是在现实中,依然还是有很多无助、绝望的人,我们只能用「需要救助的人太多了,来不及处理」来麻痹自己,但这事如果真的发生在自己身边,谁又能独善其身呢?

这里最应该感谢的还是全国各地的医务工作者,这三年的抗疫时间,他们应该是最辛苦的人,很多时候,他们没有决策权,但是却服从命令,作出了最大的牺牲。

最后来说说「暴利」。这次疫情以来,可以说是「家蔬抵万金」,各种物资的价格,特别是蔬菜水果,几乎是天价,各种「大礼包」跟充值一样,99、128、299、998、1288,眼花缭乱的大礼包,收到才发现,也就是平时一半不到的价格,正如马克思所说,利润是资本金铤而走险的动力。各种有渠道的「人」,在国难面前大赚一笔,甚至在群里炫耀,这也让很多人不惜感染的风险、甚至已经感染而躲避,来获取高额的利润。其实,在这种灾难面前,物资适当涨价是可以接受的,毕竟运输渠道阻塞,但是,漫天要价,最终必然会接受历史的制裁。

经过这一场疫情,上海可以说是成功的在抗疫史上留下了浓墨重彩的一笔,这是好的一笔,还是差的一笔,只能交给历史来评判了。至此之后,你们小区疫情期间发菜了吗,发了什么菜,发了几次,居委组织得当吗,有人做信息通报吗,志愿者多吗,有多少感染了,可能成为了购房必问的几大问题了。

多年以后,我会夸张的对我孙子说,「想当年,爷爷在中国GDP第一的上海,都差点饿死,你现在居然还不好好吃饭。」

写于在家办公一个月的上海。希望这次疫情早日过去,所有人都能经历风雨,重见彩虹。

上海加油。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

闲言碎语——第七期
❌