大师学SwiftUI第9章 – 异步与并发任务

Coding Alan 1年前 (2023-11-10) 1176次浏览 0个评论 扫描二维码

其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记

苹果系统借助现代处理器的多核可同步执行多条代码,提升同一时间内程序所能执行的任务。例如,一段代码从网上下载文件,另一段代码可以在屏幕上显示进度。此时,我们不能等待第一个执行完后再执行第二个,而必须要同步执行这两个任务。

要并行处理代码,系统将代码单元分组成任务。在Swift中,任务可以通过异步和并发编程实现。异步编程是一种编程模式,代码在完成任务前等待处理完成。这样系统可以在不同进程间共享计算资源。等待期间,系统可使用资源执行其它任务。而并发编程实现的代码可以利用多核同步执行多个任务。

大师学SwiftUI第9章 - 异步与并发任务

图9-1:异步和并发编程

因很多应用可以同时运行,系统并不会对每个应用分配指定的核数。系统会创建一些执行线程,将任务分配给这些线程,然后根据可用资源决定哪个核执行哪些线程。在图9-1的示例中,左边是一个异步任务,从网上加载图片然后在屏幕上显示。在等待服务响应时,线程处于空闲状态可以执行其它任务,因此系统可以使用它执行更新进度条的任务。右图中创建了并发任务,因此在不同进程中同步执行。

任务

异步和并发的代码由任务定义。Swift标准库中包含有Task结构体用于创建和管理这些任务。下面是结构体的初始化方法:

  • Task(priority: TaskPriority?, operation: Closure):这个初始化方法创建并运行新任务。priority参数是一个辅助系统决定何时执行任务的结构体。这一结构体中包含类型属性定义标准优先级。当前有backgroundhighlowmediumuserInitiatedutilityoperation参数是一个闭包,内含任务执行的语句。

Task结构带有如下属性用于取消任务。

  • isCancelled:该属性返回一个表示任务是否被取消的布尔值。
  • cancel():取消任务的方法。

还有一些类型属性和方法,可用于从当前任务获取信息或创建执行指定处理的任务。以下是最常用的。

  • currentPriority:该属性返回当前任务的优先级。这是一个TaskPriority结构体,有属性backgroundhighlowmediumuserInitiatedutility
  • isCancelled:该属性返回一个表示当前任务是否取消的布尔值。
  • sleep(nanoseconds: UInt64):本方法按照nanoseconds参数指定的时间挂起当前任务。

虽然可以在代码的任意地方创建Task结构体初始化异步任务,SwiftUI自带了如下的修饰符在视图出现时进行创建。

  • task(priority: TaskPriority, Closure):此修饰符在视图出现时执行第二个参数所指定的任务。priority参数是一个结构体,辅助系统决定何时执行任务。值有backgroundhighlowmediumuserInitiatedutility
  • task(id: Value, priority: TaskPriority, Closure):此修饰符在视图出现时执行第三个参数所指定的任务。id参数是用于标识任务的值。每当这个值发生改变时,任务就会重启。priority参数是一个结构体,辅助系统决定何时执行任务。值有backgroundhighlowmediumuserInitiatedutility

Async和Await

异步和并发任务在Swift中通过asyncawait关键字定义。例如要创建异步任务,我们使用async标注方法,然后使用await等待该方法执行完成。这表示在另一异步方法内只能通过await关键字调用一个异步方法,创建一个无限循环。开启这一循环,我们使用task()修饰符在视图出现时初始化异步任务,如下所示。

示例9-1:初始化异步任务

本例中使用background优先级创建任务,表示它相对其它并行任务不具备优先级。在闭包中,我们调用loadImage()方法,然后在控制台中打印返回值。我们定义的这个方法模拟从网上下载图片。稍后我们会学习如何下载数据及连接网络,但这里我们使用了sleep()方法让任务暂停3秒,假装在下载图片(方法接收值的单位是纳秒)。停顿结束后,方法返回带文件名的字符串。要以异步定义方法,我们在参数的后面添加async关键字,然后使用await关键字调用它,表示任务必须等待处理完成。

task()修饰符创建任务并添加到线程中。在视图加载后,会执行赋值给修饰符的闭包。在闭包中,我们调用loadImage()方法,等待其完成。方法停顿3秒、返回字符串。此后,任务继续执行语句,在控制台上打印消息。

✍️跟我一起做:创建一个多平台项目。使用示例9-1中的代码更新ContentView结构体。在模拟器中运行应用。3秒后会看到控制中打印的消息。

一个任务可执行多个异步处理。例如,下例中调用了loadImage()3次来下载3张图片。

示例9-2:运行多异步处理

这些处理逐条按顺序执行。任务会等待上一条处理结束再处理下一条。本例中,整个任务耗时9秒完成(每个处理3秒)。

✍️跟我一起做:使用示例9-2中的代码更新ContentView结构体。在模拟器中运行应用。9秒后会看到控制台中打印的消息。

只需在视图加载后运行异步任务使用task()修饰符很有用,但大多数时候任务和视图的生命周期无依赖关系,必须使用Task初始化方法显式地创建。比如可以通过onAppear()方法和Task结构体来重现上例。

示例9-3:显式定义任务

这一视图和之前一样执行了3条处理,但这里显式地定义了任务,我们有了更多的控制权。比如,现在可以将任务赋值给变量,然后调用cancel()方法取消任务。

cancel()方法用于取消任务,便处理不会自动取消,我们必须使用isCancelled属性监测任务是否被取消并自行停止任务,如下例所示。

示例9-4:取消任务

本例中将前面的任务赋值给了一个常量,然后创建一个定时器在2秒后调用任务的cancel()方法。在loadImage()方法中,我们读取isCancelled属性进行相对应的响应。如果取消了任务,返回Task Cancelled,否则和之前一样返回名称。注意本例我们是在任务执行的处理内操作,因此使用了类型属性,而不是实例属性(我们从数据类型而不是实例中读取isCancelled属性)。该属性根据当前任务的状态返回truefalse。任务在完成后就被取消了。

任务可接收并返回值。Task结构体包含一个value属性提供对任务返回值的访问。当然,我们需等待任务完成才能读取值,如下例如下。

示例9-5:读取任务的返回值

因为我们需要等待任务完成才能使用值,所以定义了第二个任务。处理和之前一样启动,通过任务调用loadImage()方法,但现在创建了第二个返回字符串的任务。该任务执行另一个异步方法,等待3秒、返回数字50000。在处理结束后,任务使用名称和返回的数字创建字符串。然后通过value属性获取字符串,将其返回给原始任务,打印到控制台。

至此,我们使用了异步方法,但还可以定义异步属性。只需要使用async关键字定义getter。

示例9-6:定义异步属性

这次把调用方法改成由任务读取属性。属性在挂起任务3秒后返回字符串。这里做挂起只是为了进行演示,但在这个属性中可以执行任意需要的任务,比如处理或下载数据。

错误

异步任务不一定都能成功,所以必须准备好处理返回的错误。如果在创建自己的任务,可以通过实现Error协议的枚举来定义错误,在第3章中进行过讲解(见示例3-189)。下例中定义了一个含两个错误的结构体,一个在未找到服务端元数据(noData)时返回,另一个在图片不存在(noImage)时返回。

示例9-7:响应错误

上例中的loadImage()在测试代码时总是会抛出noImage错误。其中的任务通过do catch语句检测错误并在控制台打印消息报告错误。注意在异步方法可能会抛出错误时,必须在async后使用关键字throws进行声明。

并发

异步任务对于希望释放资源让系统可以执行其它任务的场景非常有用,比如更新界面,但在希望同步执行两个任务时,就需要用到并发。为此,Swift标准库定义了async let语句。将异步任务变成多个并发任务,我们只需要使用async let语句声明处理,如下所示。

示例9-8:定义并发任务

每次完成async let所声明的处理,系统会创建一个并发任务与其它任务一起并行运行。在示例9-8中,我们创建了三个并发任务(imageName1imageName2imageName3)。过程与之前相同,它们调用loadImage()方法,方向会暂停任务3秒钟,返回一个字符串。但因为这次它们并行运行,完成任务所花费的时间大约为3秒(而不是前例中的9秒)。

✍️跟我一起做:使用示例9-8中的代码更新ContentView结构体。在模拟器中运行代码。几秒后,会在控制台中打印出处理所耗费的时间。

Actor

在使用并发任务时,可能会碰到数据竞用的问题。数据竞用出现在两个或两个以上并行运行的任务尝试访问相同的数据时。比如,它们同时尝试修改某一个属性的值。这可能会导致错误或严重的bug。为解决这一问题,Swift标准库中引入了actor

actor是隔离并行任务的数据类型,因此任务在修改actor的值时,另一个任务会强制等待。actor是引用类型,定义类似类,但不是使用class关键字,而是通过actor关键字定义。它与类另一个重要的不同是属性和方法必须异步访问(我们必须使用await关键字等待)。这会确保代码等待actor释放(其它任务不能访问actor)。

下例演示了如何使用actor。这段代码声明了一个一家属性和方法的actor,创建了一个实例,然后在多个任务中调用其中的方法。

示例9-9:定义一个actor

界面中的按钮会启动两个无限重复的定时器,一个间隔0.1秒,另一个间隔0.2秒。定时器执行任务并发调用actor中的incrementCount()方法。这样不同线程中的不同任务会调用该方法,最终会同时调用,产生数据竞用。如果我们将ItemData声明为类,会报错、出现预期外的行为甚至出现崩溃,但因为我们将这个数据类型声明为actor,代码正确运行。每次在任务调用incrementCount()方法时,actor会接管并确保一次只有一个任务能访问该方法。

✍️跟我一起做:使用示例9-9中的代码更新ContentView.swift文件。在iPhone模拟器中运行代码、点击按钮。会看到incrementCount()方法所产生的值打印在控制台中。停止应用。将actor声明为类(将关键字actor替换成关键字class)。这时在方法同时由多个任务调用时会出现错误。

注意:默认Xcode不会在控制台显示异步错误。要监测异步操作的问题,必须激活Thread Sanitizer。点击Xcode工具栏的Scheme按钮(图5-2,2号图)。在菜单中选择Edit Scheme选项(图5-8)。在新窗口中,选择Run选项并打开Diagnostics标签。勾选复选框启用Thread Sanitizer。将actor声明为类,再次在iPhone模拟器中运行应用,点击按钮。在成功运行数次后,会在控制台中看到访问竞争的报错。

我们提到过,actor将属性和方法与其它的代码及线程隔离开。这表示我们只能异步访问actor(必须等待actor允许进行访问),但在某些场景下,不需要进行隔离。这时,我们可以使用如下的关键字反转隔离的状态。

  • nonisolated:该关键字打破属性或方法的隔离。

非隔离属性和方法可能遵循协议时要用到,也可以在actor中只需要访问不可变值时简化代码。例如,下例中我们对ItemData actor添加一个名为maximum的常量以及一个打印该值的方法。因为常量的值永不改变,我们可以将其声明为非隔离方法,调用时无需等待actor授权。

示例9-10:定义一个非隔离方法

在前面的例子中,我们操作了actor所定义的值,但通常值也会发送给actor进行处理。将值发送给actor中的方法非常危险。因为actor的任务是确保两个或多个异步任务不能同步修改同一个值。值类型,包括自定义类型和IntString这样的原生数据类型,是线程安全的,因为会进行值拷贝。在使用这些值调用actor中的方法时,系统创建一个拷贝并将拷贝发送给方法,所以不会修改原始值。但对象是引用类型,所以发送给actor的是对象的指针,也就意味着对象可能会在代码的其它地方被修改,存在数据竞用的可能。为确保我们发送给方法的值是安全的,Swift标准库定义了如下协议和属性。

  • Sendable:这一协议告诉系统该数据类型创建的值可安全地在异步线程间共享。
  • @Sendable:该属性向系统表明某个方法或闭包可安全地在异步线程间共享。

Sendable协议没做什么工作,只是告诉编译器某一数据类型是安全的。在数据类型遵循该协议时,其中包含不安全的值时编译器就会报错。比如,虽然结构体是安全的,但我们可以让其遵循这一协议来确保之后不会添加任何不安全的属性。在只包含不可变值时类也是安全的,但子类却有可能不安全,因此可以使用final关键字来标记类,这样没人可以创建其子类,如下所示。

示例9-11:定义非隔离方法

上例中定义了一个名为Product的final类(不能对其创建子类),其中包含一个不可变属性(let)。同时,这个属性的类型为String,默认为可发送类型。这表示通过该类创建的对象是线程安全的,可发送给actor

注意:带有可变值(var)的类也可以是可发送的,但我们需要负责保障它不会产生数据竞用。这一话题暂不在讨论范畴内。更多相关多信息,请参见本文的参考链接部分。

如果确实需要包含不安全的值,并且确定不会在其它线程中修改它,可以通过如下属性告诉编译器不做错误检查。

  • @unchecked:该属性要求编译器不检查指定数据类型是否遵循Sendable协议。

在使用不安全的数据类型或是向actor发送老框架所产生的值时这比较有用。例如,下例中我们将Product类转换成结构体,使用name属性存储NSString值。NSString数据类型是不可发送的,因此Product结构体不遵循Sendable协议的要求,但因为我们知道这个值不会在任何地方进行修改,所以通过@unchecked属性告诉编译器不要担心这个问题。

示例9-12:要求编译器不检查是否遵循Sendable协议

注意@unchecked属性通常实现用于在将不安全的值发送给Main Actor前封装它。我们会在下一节学习如何使用Main Actor,以及在稍后在实际场景中实现这一属性。

Main Actor

我们已经讲到,任务会分配给执行线程,然后系统将这些线程分发到处理器的多核,尽快尽可能平滑地执行任务。一个线程可管理多个任务,一个应用可创建多个线程。除了为处理异步、并发任务初始化的那些线程,系统还会创建一个称为主线程的线程,用于启动应用和运行非异步代码,包括创建和更新界面的代码。这表示如果尝试通过异步或并发任务修改界面,可能会导致数据竞用或是严重的bug。为避免这类冲突,Swift标准库定义了Main ActorMain Actor是由系统创建的actor,用于确保每个希望与主线程交互或是修改界面元素的任务等待其它任务完成。Swift提供了两种方式来保障代码运行于Main Actor(主线程):@MainActor修饰符和run()方法。通过@MainActor修饰符我们可以标记整个方法运行于主线程上,而run()方法在主线程上运行闭包。例如,下例中我们使用@MainActor标记loadImage()方法,来确保其中的代码在主线程中运行,并且我们修改Text视图的值时不会出现问题。

示例9-13:在Main Actor中执行方法

这段代码和之前一样创建了一个异步任务,但这里方法使用@MainActor进行标记,因此代码在主线程中运行,可以安全地更新myText属性及界面。

大多数时候,只有一部分代码处理界面,但其它代码可在当前线程中执行。这时,我们可以实现run()方法。这是一个由MainActor结构体(用于创建Main Actor)定义的类型方法。该方法接收一个包含需要在主线程中运行的语句的闭包。

示例9-14:在Main Actor中执行代码

loadImage()的最后包含了一条语句,在控制台打印出字符串,但只有将新值赋给myText属性的语句需要在主线程中运行,因此我们把它放到了run()方法中。注意这个方法使用await进行了标记。需要用await的原因是该方法需要等待主线程空闲才能执行该语句。

run()方法也可以返回值。这对于进行复杂运算后报告结果比较有用。我们只需要记住必须声明闭包所返回值的类型,如下所示。

示例9-15:通过Main Actor返回值

异步序列

有时信息以值的序列返回,但这些值并不是马上就绪。这时,我们可以创建一个异步序列。这种序列类似数组,但值是异步返回的,因此我们必须等待每次值都就绪。Swift标准库包含两个创建异步序列的协议:用于定义序列的AsyncSequence协议以及用于定义代码遍历序列以返回值的AsyncIteratorProtocol协议。AsyncSequence协议要求数据类型包含一个类型别名Element,表示序列返回的数据类型,以及下面这个方法。

  • makeAsyncIterator():该方法返回复杂生成值的迭代器实例。返回的值是遵循AsyncIteratorProtocol协议的数据类型的实例。

AsyncIteratorProtocol协议只要求数据类型实现如下方法。

  • next():该方法返回列表中的下一个元素。该方法被反复调用,直至返回的值是表示序列结束的nil

要创建异步序列,我们必须定义两个数据类型,一个遵循AsyncSequence,用于描述序列返回值的数据类型并初始化迭代器,另一个遵循AsyncIteratorProtocol协议,用于生成值。在下例中,我们定义了一个异步序列,逐一处理字符串数组并返回一个String序列。

示例9-16:定义一个异步序列

示例9-16中的代码模拟从网上异步下载图片。首先定义带next()方法的迭代器。在这个方法中,我们从list数组中读取字符串,并更新计数器来确定是否到达末尾(计算器的值等于或大于数组的元素数量时)。

接着由ImageLoader结构体定义异步序列。这个结构体包含一个类型别名Element,表示序列返回String值,还有一个makeAsyncIterator()方法用于初始化迭代器。

准备好读取序列中的值后,我们启动一个任务,创建ImageLoader序列的实例,然后使用for in循环遍历元素。注意for in循环要求用await关键字等待序列中的每个元素。循环一直运行到迭代器返回nil为止。

✍️跟我一起做:使用示例9-16中的代码更新ContentView.swift文件。在模拟器中运行应用。会看到每3秒在控制中打印list数组中的值。

任务组

任务组是一个动态生成任务的容器。组创建好后,我们可以通过代码按应用要求添加和管理任务。Swift标准库定义了如下用于创建组的全局方法。

  • withTaskGroup(of: Type, returning: Type, body: Closure):该方法创建一个任务组。of参数定义由任务返回的数据类型,returning参数定义由组返回的数据类型,body参数是一个定义任务的闭包。如果没有要返回的值,可以忽略这些参数。
  • withThrowingTaskGroup(of: Type, returning: Type, body: Closure):该方法定义一个可以抛出错误的组of参数定义由任务返回的数据类型,returning参数定义由组返回的数据类型,body参数是一个定义任务的闭包。如果没有要返回的值,可以忽略这些参数。

组由TaskGroup结构体的实例定义,包含在组中管理任务的属性和方法。以下是一些最常用的属性和方法。

  • isCancelled:该属性返回一个表示组是否被取消的布尔值。
  • isEmpty:该属性返回一个表示组是否还有任务的布尔值。
  • addTask(priority: TaskPriority?, operation: Closure):该方法向组添加任务。priority参数是帮助系统决定何时执行任务的结构体。该结构体包含预定义标准权重的类型属性。当前可以使用的有backgroundhighlowmediumuserInitiatedutilityoperation参数是一个包含任务所执行语句的闭包。
  • cancelAll():该方法取消组中的所有任务。

任务组是任务的异步序列。序列为泛型,也就是任务和组可以返回任意类型的值。这也是为什么创建任务组需要两个参数,一个用于指定任务返回的数据类型,另一个指定组返回的数据类型。

创建任务组的两个方法也一样。我们实现哪个取决于是否希望抛出错误。这些方法创建一个TaskGroup结构体,使用参数所指定的数据类型,并将实例返回给闭包。使用闭包中的值,我们可以向组添加任意需要添加的任务,如下所示。

示例9-17:定义任务组

本例中,我们创建了一个不抛出错误的任务组。这个组也不返回值,但任务返回字符串,所以我们将withTaskGroup()方法的of参数声明为String数据类型(String.self)。任务逐一添加到组中。每个任务执行与前面相同的处理。它们异步调用loadImage()方法,获取返回的字符串。

因为任务组是一个异步任务序列,我们可以使用for in循环遍历其中的值,这与前一节对所创建的异步序列操作相同。每次任务完成时,组返回由任务产生的值直至没有任务,这时会返回nil结束循环。

注意:任务组以序列存储任务。我们可以删除、过滤甚至是检查组中是否包含指定的任务。这一话题暂不做讨论,请参见本章的参考链接部分。

异步图像

虽然本章所介绍的工具可用于执行各种类型的异步或并发任务,Swift还是单独地提供了AsyncImage视图简化我们处理图像的操作。这个视图负责从服务端下载图像并在就绪时在屏幕上显示图像。以下是最常用的初始化方法。

  • AsyncImage(url: URL, scale: CGFloat):该视图从服务端下载图像并在屏幕上显示。url参数是一个带图像url的URL结构体,scale参数是我们希望对图像赋的放大比例(默认值是1)。
  • AsyncImage(url: URL, scale: CGFloat, content: Closure, placeholder: Closure):
  • 该视图从服务端下载图像并在屏幕上显示。url参数是一个带图像url的URL结构体,scale参数是我们希望对图像赋的放大比例(默认值是1),content参数是处理图像的闭包,placeholder是在等待图像下载返回在图像的地方显示视图的闭包。

图片的位置由URL结构体决定。这些结构体用于存储远程地址和本地文档、文件及资源。以下是创建访问文档和网络资源URL所需要的初始化方法。

  • URL(string: String):使用string参数指定URL创建URL结构体的初始化方法。
  • URL(string: String, relativeTo: URL?):通过参数指定URL创建URL结构体的初始化方法。将string参数值添加到relativeTo参数值来创建URL
  • URL(dataRepresentation: Data, relativeTo: URL?,  isAbsolute: Bool):通过参数指定URL创建URL结构体的初始化方法。将dataRepresentation参数的值添加到relativeTo参数值来创建URLisAbsolute是一个布尔值,指定URL是否是绝对链接(包含访问资源所需的所有信息)。

有两种类型的URL:安全的和不安全的。不安全的URL使用http协议(超文本传输协议)标识,安全的URL使用https协议(超文本安全传输协议)标识。默认允许安全URL,但如果需要打开不安全的URL,必须配置应用绕过Apple设备实现的称为ATS(App Transport Security)的安全系统。

配置ATS系统的配置项为App Transport Security Settings,通过Info面板添加至应用配置。我们介绍过这个面板(图5-13),使用它添加自定义字体(图5-34)。之前也讲过,新选项通过右侧的+按钮添加。

大师学SwiftUI第9章 - 异步与并发任务

图9-2:添加配置项的按钮

点击+按钮(图9-2圈出的部分)后,在选项下面会添加一个空的文本框。输入文字会在下拉框中显示可用的选项,可通过列表进行选择。

大师学SwiftUI第9章 - 异步与并发任务

图9-3:应用传输安全选项

App Transport Security Settings选项只是一个容器。要配置这个选项,我们必须添加子项。点击左侧的箭头添加子项(图9-3圈出的部分),然后再次点击+按钮。允许应用打开不安全的URL的选项为Allow Arbitrary Loads

大师学SwiftUI第9章 - 异步与并发任务

图9-4:配置应用传输安全允许访问不安全URL

Allow Arbitrary Loads接收由字符串YESNO(或是10)指定的布尔值。将其设置为YES1)允许打开任意URL。如果希望只允许指定域名,必须使用Exception Domains,添加希望包含的域名。这一子项又至少三个子项,键名分别为NSIncludesSubdomains(布尔值)、NSTemporaryExceptionAllowsInsecureHTTPLoads(布尔值)和NSTemporaryExceptionMinimumTLSVersion(字符串)。例如以下的配置允许打开alanhou.org域名下的文档。

大师学SwiftUI第9章 - 异步与并发任务

图9-5:配置应用传输安全允许打开来自alanhou.org的文档

配置应用传输安全系统是否必要取决于我们希望用户能够访问的URL类型。默认允许安全URL,但如果希望用户访问不安全的URL,必须添加应用配置选项,如上图所示。例如,下例加载了来自本站不安全版本的图片(http协议)。

示例9-18:异步加载图片

AsyncImage视图下载并显示图片只需要传一个URL。本例中,我们将URL存储在常量中,然后实现加载图像的视图。虽然有效加载并显示了图片,AsyncImage视图并不允许做任何配置,因此图片按原始大小进行显示。

大师学SwiftUI第9章 - 异步与并发任务

图9-6:异步加载图片

如果希望配置图片,必须为content参数提供一个闭包。这个闭包接收一个Image视图,可以像之前那样通过视图修饰符进行配置。

示例9-19:配置下载完成的图片

在提供了content参数后,AsyncImage视图会将图片显示的任务交给通过闭包所接收的Image视图,所以我们可以像之前一样配置该视图。本例中,我们使用resizable()修饰符重置图片的大小,通过scaleToFit()修饰符缩放图片适配在视图之内。注意我们还定义了placeholder参数在图片下载过程中作为临时显示图片。

大师学SwiftUI第9章 - 异步与并发任务

图9-7:图片配置

✍️跟我一起做:创建一个多平台项目。下载nopicture.png,添加至Asset Catalog。使用示例9-19中的代码更新ContentView视图。点击顶部的导航区打开应用的配置面板(图5-4,编号6)。打开info面板,按照图9-29-39-4所示的步骤操作。数秒后应该会看到nopicture.png被我们配置的图片替换掉。

参考链接

代码请见:GitHub仓库

喜欢 (1)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址