IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> kotlin进阶 -> 正文阅读

[移动开发]kotlin进阶

一、高阶函数

Kotlin 函数是头等函数First-class Function,这意味着它们可以存储在变量和数据结构中。函数可以和其他的变量一样的对待。可以赋值给变量,可以作为函数的返回值返回,可以作为参数传递给其他函数

为了促进这一点,Kotlin 作为一种静态类型编程语言,使用一系列函数类型来表示函数,并提供一组专用语言构造,例如 lambda 表达式。

高阶函数是将函数作为参数或返回函数的函数

高阶函数的一个很好的例子是集合的函数式编程习惯用法。 它接受一个初始累加器值和一个组合函数,并通过将当前累加器值与每个集合元素连续组合来构建其返回值,每次替换累加器值:

fun <T, R> Collection<T>.fold(
    initial: R,
    combine: (acc: R, nextElement: T) -> R
): R {
    var accumulator: R = initial
    for (element: T in this) {
        accumulator = combine(accumulator, element)
    }
    return accumulator
}

在上面的代码中,combine 参数的函数类型为 (R, T) -> R,因此它接受一个函数,该函数接受两个类型为 R 和 T 的参数并返回一个类型为 R 的值。它在 for 循环中被调用 ,然后将返回值分配给累加器。要调用 fold,需要将函数类型的实例作为参数传递给它,而在高阶函数调用处,lambda 表达 式广泛用于此。

val items = listOf(1, 2, 3, 4, 5)

// Lambdas 表达式是花括号括起来的代码块。
items.fold(0, { 
    // 如果一个 lambda 表达式有参数,前面是参数,后跟“->”
    acc: Int, i: Int -> 
    print("acc = $acc, i = $i, ") 
    val result = acc + i
    println("result = $result")
    // lambda 表达式中的最后一个表达式是返回值:
    result
})

// lambda 表达式的参数类型是可选的,如果能够推断出来的话:
val joinedToString = items.fold("Elements:", { acc, i -> acc + " " + i })

// 函数引用也可以用于高阶函数调用:
val product = items.fold(1, Int::times)

函数类型

Kotlin 使用类似 (Int) -> String 的一系列函数类型来处理函数的声明: val onClick: () -> Unit = ……。

这些类型具有与函数签名相对应的特殊符号 - 它们的参数和返回值:

  • 所有函数类型都有一个带括号的参数类型列表和一个返回类型: (A, B) -> C 表示一个类型,该类型表示一个函数,它接受两个类型为 A 和 B 的参数并返回一个类型为 C 的值。参数列表 types 可以为空,如 () -> A。Unit 返回类型不能省略。
  • 函数类型可以有一个额外的接收者类型,它在表示法中的点之前指定: 类型 A.(B) -> C 表示可以在 A 的接收者对象上以一个 B 类型参数来调用并返回一个 C 类型值的函数。 带有接收者的函数字面值通常与这些类型一起使用。
  • 挂起函数属于特殊种类的函数类型,它的表示法中有一个 suspend 修饰符 ,例如 suspend () -> Unit 或者 suspend A.(B) -> C。

函数类型表示法可以选择包含函数参数的名称:(x: Int, y: Int) -> Point。 这些名称可用于记录参数的含义。

  • 要指定函数类型可以为空,请使用如下括号:((Int, Int) -> Int)?。
  • 函数类型也可以使用括号组合:(Int) -> ((Int) -> Unit)。
  • 箭头符号是右结合的,(Int) -> (Int) -> Unit 等价于前面的例子,但不等价于 ((Int) -> (Int)) -> Unit。

函数类型实例化

有几种方法可以获得函数类型的实例:

  • 使用函数字面值的代码块,采用以下形式之一:带有接收者的函数字面值可用作带有接收者的函数类型的值。
    • lambda 表达式:
fun getInt(block: (String) -> Int) {
    //...
}
getInt { it.toIntOrNull() ?: 0 }
    • 匿名函数:
fun getInt(block: (String) -> Int) {
    //...
}
getInt(fun(s: String): Int { return s.toIntOrNull() ?: 0 })
  • 使用已有声明的可调用引用:这包括指向特定实例成员的绑定的可调用引用:foo::toString。
    • 顶层、局部、成员、扩展函数:::isOdd、 String::toInt,
    • 顶层、成员、扩展属性:List<Int>::size,
    • 构造函数:::Regex
fun Person.getNextYearAge2(): Int {
    return age + 1
}
 
fun getNextYearAge(person: Person): Int {
    return person.age + 1
}
 
fun getAge(block: (Person) -> Int): Int {
    return block(Person(12, "A"))
}
 
fun main(args: Array<String>) {
    print("A next year age = ${getAge(::getNextYearAge)}")
    val age = getAge(Person::getNextYearAge2)
}
  • 使用实现函数类型接口的自定义类的实例:
class IntTransformer: (Int) -> Int {
    override operator fun invoke(x: Int): Int = TODO()
}

val intFunction: (Int) -> Int = IntTransformer()

如果有足够信息,编译器可以推断变量的函数类型

val a = { i: Int -> i + 1 } // The inferred type is (Int) -> Int

成员引用

Kotlin 里函数可以作为参数这件事的本质,是函数在 Kotlin 里可以作为对象存在——因为只有对象才能被作为参数传递。赋值也是一样道理,只有对象才能被赋值给变量。但 Kotlin 的函数本身的性质又决定了它没办法被当做一个对象。那怎么办呢?Kotlin 的选择是,那就创建一个和函数具有相同功能的对象。怎么创建?使用双冒号。

成员引用的一个用途就是:如果要当做参数传递的代码块已经被定义成了函数,此时不必专门创建一个调用该函数的 Lambda 表达式,可以直接通过成员引用的方式来传递该函数(也可以传递属性)。此外,成员引用对扩展函数一样适用

data class Person(val name: String, val age: Int) {

    val myAge = age

    fun getPersonAge() = age
}

fun Person.filterAge() = age

fun main() {
    val people = listOf(Person("leavesC", 24), Person("Ye", 22))
    println(people.maxBy { it.age })    //Person(name=leavesC, age=24)
    println(people.maxBy(Person::age))  //Person(name=leavesC, age=24)
    println(people.maxBy(Person::myAge))  //Person(name=leavesC, age=24)
    println(people.maxBy(Person::getPersonAge))  //Person(name=leavesC, age=24)
    println(people.maxBy(Person::filterAge))  //Person(name=leavesC, age=24)
}

在 Kotlin 里,一个函数名的左边加上双冒号,它就不表示这个函数本身了,而表示一个对象,或者说一个指向对象的引用,但,这个对象可不是函数本身,而是一个和这个函数具有相同功能的对象。

就是把函数作为参数传给方法

引用顶层函数

fun test() {
    println("test")
}

fun main() {
    val t = ::test
}

也可以用构造方法引用存储或者延期执行创建类实例的动作

data class Person(val name: String, val age: Int)

fun main() {
    val createPerson = ::Person
    val person = createPerson("leavesC", 24)
    println(person)
}

函数类型实例调用

函数类型的值可以通过其 invoke(……)操作符调用:f.invoke(x) 或者直接 f(x)。

如果该值具有接收者类型,那么应该将接收者对象作为第一个参数传递。 调用带有接收者的函数类型值的另一个方式是在其前面加上接收者对象, 就好比该值是一个扩展函数:1.foo(2),

val stringPlus: (String, String) -> String = String::plus
val intPlus: Int.(Int) -> Int = Int::plus

println(stringPlus.invoke("<-", "->"))
println(stringPlus("Hello, ", "world!")) 

println(intPlus.invoke(1, 1))
println(intPlus(1, 2))
println(2.intPlus(3)) // 类扩展调用

<-->
Hello, world!
2
3
5

二、Lambda 表达式与匿名函数

lambda 表达式与匿名函数是“函数字面值”,即未声明的函数, 但立即做为表达式传递

max(strings, { a, b -> a.length < b.length })

函数 max 是一个高阶函数,它接受一个函数作为第二个参数。 其第二个参数是一个表达式,它本身是一个函数,即函数字面值,它等价于以下具名函数:

fun compare(a: String, b: String): Boolean = a.length < b.length

Lambda 表达式语法

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

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

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

如果我们把所有可选标注都留下,看起来如下:

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

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

val product = items.fold(1) { acc, e -> acc * e }

如果该 lambda 表达式是调用时唯一的参数,那么圆括号可以完全省略:

run { println("...") }

一个 lambda 表达式只有一个参数是很常见的。

如果编译器自己可以识别出签名,也可以不用声明唯一的参数并忽略 ->。 该参数会隐式声明为 it:

ints.filter { it > 0 } // 这个字面值是“(it: Int) -> Boolean”类型的

如果 lambda 表达式的参数未使用,那么可以用下划线取代其名称:

map.forEach { _, value -> println("$value!") }

从 lambda 表达式中返回一个值

可以使用限定的返回语法从 lambda 显式返回一个值。 否则,将隐式返回最后一个表达式的值。

因此,以下两个片段是等价的:

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

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

匿名函数

lambda 表达式语法缺少的一个东西是指定函数的返回类型的能力。在大多数情况下,这是不必要的。因为返回类型可以自动推断出来。然而,如果确实需要显式指定,可以使用另一种语法: 匿名函数

其函数体可以是表达式或代码块:

fun(x: Int, y: Int): Int = x + y

fun(x: Int, y: Int): Int {
    return x + y
}

匿名函数的返回类型推断机制与正常函数一样:对于具有表达式函数体的匿名函数将自动推断返回类型,而具有代码块函数体的返回类型必须显式指定(或者已假定为 Unit)

匿名函数参数总是在括号内传递。 允许将函数留在圆括号外的简写语法仅适用于 lambda 表达式

Lambda表达式与匿名函数之间的另一个区别是非局部返回的行为。一个不带标签的 return 语句总是在用 fun 关键字声明的函数中返回。这意味着 lambda 表达式中的 return 将从包含它的函数返回,而匿名函数中的 return 将从匿名函数自身返回。

如果我们要在Java方法中传入一个回调函数,需要定义一个接口,并使用new关键字实例化匿名类实现该方法

public interface OnClickListener {
  void onClick(View v);
}
public void setOnClickListener(OnClickListener listener) {
  this.listener = listener;
}
view.setOnClickListener(new OnClickListener() {
  @Override
  void onClick(View v) {
    switchToNextPage();
  }
});

kotlin是如何实例化匿名函数的

fun setOnClickListener(onClick: (View) -> Unit) {
  this.onClick = onClick
}
view.setOnClickListener(fun(v: View): Unit) {
  switchToNextPage()
})

写成 Lambda 表达式的形式

view.setOnClickListener({ v: View ->
  switchToNextPage()
})

对比 Java 的 Lambda

Java 从 8 开始引入了对 Lambda 的支持,对于单抽象方法的接口——简称 SAM 接口,Single Abstract Method 接口——对于这类接口,Java 8 允许你用 Lambda 表达式来创建匿名类对象,但它本质上还是在创建一个匿名类对象,只是一种简化写法而已,所以 Java 的 Lambda 只靠代码自动补全就基本上能写了。而 Kotlin 里的 Lambda 和 Java 本质上就是不同的,因为 Kotlin 的 Lambda 是实实在在的函数类型的对象,功能更强,写法更多更灵活

Java 匿名内部类在编译时会创建一个 class ,增加类的加载开销,运行时该内部类无论是否用到外部参数每次都会生成该类的实例。jdk 1.8 后 lambda 的实现是在当前类增加一个私有静态方法,减少了类的开销

Kotlin 匿名内部类的实现和 Java 一致也是在编译期生成一个 class,lambda 的实现也是同样创建一个 class,但是该 class 继承 Lambda 类并实现了 Function 接口。编译时匿名内部类会转化为具体的类类型,而 lamdba 则是转化为 Function 类型传递进去

三、Kotlin 协程

协程可以称为 轻量级线程。Kotlin 协程在 CoroutineScope 的上下文中通过 launch、async 等 协程构造器(CoroutineBuilder)来声明并启动

fun main() {
    GlobalScope.launch(context = Dispatchers.IO) {
        //延时一秒
        delay(1000)
        log("launch")
    }
}

private fun log(msg: Any?) = println("[${Thread.currentThread().name}] $msg")

[DefaultDispatcher-worker-1] launch
[main] end

通过 GlobalScope(全局作用域)启动了一个协程,在延迟一秒后输出一行日志。从输出结果可以看出来,启动的协程是运行在协程内部的线程池中。虽然从表现结果上来看,启动一个协程类似于我们直接使用 Thread 来执行耗时任务,但实际上协程和线程有着本质上的区别。通过使用协程,可以极大的提高线程的并发效率,避免以往的嵌套回调地狱,极大提高了代码的可读性

四个基础概念:

  • suspend function。即挂起函数,delay() 就是协程库提供的一个用于实现非阻塞式延时的挂起函数
  • CoroutineScope。即协程作用域,GlobalScope 是 CoroutineScope 的一个实现类,用于指定协程的作用范围,可用于管理多个协程的生命周期,所有协程都需要通过 CoroutineScope 来启动
  • CoroutineContext。即协程上下文,包含多种类型的配置参数。Dispatchers.IO 就是 CoroutineContext 这个抽象概念的一种实现,用于指定协程的运行载体,即用于指定协程要运行在哪类线程上
  • CoroutineBuilder。即协程构建器,协程在 CoroutineScope 的上下文中通过 launch、async 等协程构建器来进行声明并启动。launch、async 均被声明为 CoroutineScope 的扩展方法

suspend

直接在 GlobalScope 外调用 delay() 函数的话,IDE 就会提示一个错误:Suspend function 'delay' should be called only from a coroutine or another suspend function。意思是:delay() 函数是一个挂起函数,只能由协程或者由其它挂起函数来调用

delay() 函数就使用了 suspend 进行修饰,用 suspend 修饰的函数就是挂起函数

public suspend fun delay(timeMillis: Long)

挂起函数不会阻塞其所在线程,而是会将协程挂起,在特定的时候才再恢复执行

delay() 函数是非阻塞的,是因为它和单纯的线程休眠有着本质的区别。例如,当在 ThreadA 上运行的 CoroutineA 调用了delay(1000L)函数指定延迟一秒后再运行,ThreadA 会转而去执行 CoroutineB,等到一秒后再来继续执行 CoroutineA。所以,ThreadA 并不会因为 CoroutineA 的延时而阻塞,而是能继续去执行其它任务,所以挂起函数并不会阻塞其所在线程,这样就极大地提高了线程的并发灵活度,最大化了线程的利用效率。而如果是使用Thread.sleep()的话,线程就只能干等着而不能去执行其它任务,降低了线程的利用效率

协程是运行于线程上的,一个线程可以运行多个(几千上万个)协程。线程的调度行为是由操作系统来管理的,而协程的调度行为是可以由开发者来指定并由编译器来实现的,协程能够细粒度地控制多个任务的执行时机和执行线程,当线程所执行的当前协程被 suspend 后,该线程也可以腾出资源去处理其他任务

suspend 挂起与恢复

协程在常规函数的基础上添加了两项操作用于处理长时间运行的任务,在invoke(或 call)和return之外,协程添加了suspend和 resume:

  • suspend 用于暂停执行当前协程,并保存所有局部变量
  • resume 用于让已暂停的协程从暂停处继续执行

suspend 函数只能由其它 suspend 函数调用,或者是由协程来调用

Kotlin 使用 堆栈帧 来管理要运行哪个函数以及所有局部变量。暂停协程时,系统会复制并保存当前的堆栈帧以供稍后使用。恢复时,会将堆栈帧从其保存的位置复制回来,然后函数再次开始运行。虽然代码可能看起来像普通的顺序阻塞请求,协程也能确保网络请求不会阻塞主线程

在主线程进行的 暂停协程恢复协程 的两个操作,既实现了将耗时任务交由后台线程完成,保障了主线程安全,又以同步代码的方式完成了实际上的多线程异步调用。

CoroutineScope

协程作用域,用于对协程进行追踪。如果我们启动了多个协程但是没有一个可以对其进行统一管理的途径的话,就会导致我们的代码臃肿杂乱,甚至发生内存泄露或者任务泄露。为了确保所有的协程都会被追踪,Kotlin 不允许在没有 CoroutineScope 的情况下启动协程。

所有的协程都需要通过 CoroutineScope 来启动,它会跟踪通过 launch 或 async 创建的所有协程,你可以随时调用 scope.cancel() 取消正在运行的协程。CoroutineScope 本身并不运行协程,它只是确保你不会失去对协程的追踪,即使协程被挂起也是如此。在 Android 中,某些 ktx 库为某些生命周期类提供了自己的 CoroutineScope,例如,ViewModel 有 viewModelScope,Lifecycle 有 lifecycleScope

CoroutineScope 大体上可以分为三种:

  • GlobalScope。即全局协程作用域,在这个范围内启动的协程可以一直运行直到应用停止运行。GlobalScope 本身不会阻塞当前线程,且启动的协程相当于守护线程,不会阻止 JVM 结束运行。
  • runBlocking。一个顶层函数,和 GlobalScope 不一样,它会阻塞当前线程直到其内部所有相同作用域的协程执行结束
  • 自定义 CoroutineScope。可用于实现主动控制协程的生命周期范围,对于 Android 开发来说最大意义之一就是可以在 Activity、Fragment、ViewModel 等具有生命周期的对象中按需取消所有协程任务,从而确保生命周期安全,避免内存泄露

GlobalScope

GlobalScope 属于 全局作用域,这意味着通过 GlobalScope 启动的协程的生命周期只受整个应用程序的生命周期的限制,只要整个应用程序还在运行且协程的任务还未结束,协程就可以一直运行,GlobalScope.launch 会创建一个顶级协程,尽管它很轻量级,但在运行时还是会消耗一些内存资源,且可以一直运行直到整个应用程序停止(只要任务还未结束),这可能会导致内存泄露,所以在日常开发中应该谨慎使用 GlobalScope

runBlocking

也可以使用 runBlocking 这个顶层函数来启动协程,runBlocking 函数的第二个参数即协程的执行体,该参数被声明为 CoroutineScope 的扩展函数,因此执行体就包含了一个隐式的 CoroutineScope,所以在 runBlocking 内部可以来直接启动协程

public fun <T> runBlocking(context: CoroutineContext = 
	EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T

runBlocking 的一个方便之处就是:只有当内部相同作用域的所有协程都运行结束后,声明在 runBlocking 之后的代码才能执行,即 runBlocking 会阻塞其所在线程,但runBlocking 内部启动的协程是非阻塞式的

CoroutineBuilder

launch

launch 是一个作用于 CoroutineScope 的扩展函数,用于在不阻塞当前线程的情况下启动一个协程,并返回对该协程任务的引用,即 Job 对象

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job

launch 函数共包含三个参数:

  1. context。用于指定协程的上下文
  2. start。用于指定协程的启动方式,默认值为 CoroutineStart.DEFAULT,即协程会在声明的同时就立即进入等待调度的状态,即可以立即执行的状态。可以通过将其设置为CoroutineStart.LAZY来实现延迟启动,即懒加载
  3. block。用于传递协程的执行体,即希望交由协程执行的任务

Job

Job 是协程的句柄。使用 launch 或 async 创建的每个协程都会返回一个 Job 实例,该实例唯一标识协程并管理其生命周期。Job 是一个接口类型,这里列举 Job 几个比较有用的属性和函数

//当 Job 处于活动状态时为 true
//如果 Job 未被取消或没有失败,则均处于 active 状态
public val isActive: Boolean

//当 Job 正常结束或者由于异常结束,均返回 true
public val isCompleted: Boolean

//当 Job 被主动取消或者由于异常结束,均返回 true
public val isCancelled: Boolean

//启动 Job
//如果此调用的确启动了 Job,则返回 true
//如果 Job 调用前就已处于 started 或者是 completed 状态,则返回 false 
public fun start(): Boolean

//用于取消 Job,可同时通过传入 Exception 来标明取消原因
public fun cancel(cause: CancellationException? = null)

//阻塞等待直到此 Job 结束运行
public suspend fun join()

//当 Job 结束运行时(不管由于什么原因)回调此方法,可用于接收可能存在的运行异常
public fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle

async

async 也是一个作用于 CoroutineScope 的扩展函数,和 launch 的区别主要就在于:async 可以返回协程的执行结果,而 launch 不行

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

通过await()方法可以拿到 async 协程的执行结果

fun main() {
    val time = measureTimeMillis {
        runBlocking {
            val asyncA = async {
                delay(3000)
                1
            }
            val asyncB = async {
                delay(4000)
                2
            }
            log(asyncA.await() + asyncB.await())
        }
    }
    log(time)
}

[main] 3
[main] 4070

async 并行

借助 Kotlin 中的结构化并发机制,可以定义用于启动一个或多个协程的 coroutineScope。然后,可以使用 await()(针对单个协程)或 awaitAll()(针对多个协程)保证这些协程在从函数返回结果之前完成

假设我们定义一个用于异步获取两个文档的 coroutineScope,通过对每个延迟引用调用 await(),我们可以保证这两项 async 操作在返回值之前完成:

suspend fun fetchTwoDocs() = coroutineScope {
    val deferredOne = async { fetchDoc(1) }
    val deferredTwo = async { fetchDoc(2) }
    deferredOne.await()
    deferredTwo.await()
}

?

若有收获,就点个赞吧

?

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-05-08 08:15:26  更:2022-05-08 08:16:31 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 23:40:09-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码