V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
iOS 开发实用技术导航
NSHipster 中文版
http://nshipster.cn/
cocos2d 开源 2D 游戏引擎
http://www.cocos2d-iphone.org/
CocoaPods
http://cocoapods.org/
Google Analytics for Mobile 统计解决方案
http://code.google.com/mobile/analytics/
WWDC
https://developer.apple.com/wwdc/
Design Guides and Resources
https://developer.apple.com/design/
Transcripts of WWDC sessions
http://asciiwwdc.com
Cocoa with Love
http://cocoawithlove.com/
Cocoa Dev Central
http://cocoadevcentral.com/
NSHipster
http://nshipster.com/
Style Guides
Google Objective-C Style Guide
NYTimes Objective-C Style Guide
Useful Tools and Services
Charles Web Debugging Proxy
Smore
stevechen1010
V2EX  ›  iDev

Parse-iOS-SDK 源码浅析系列(一)---Parse 的底层多线程处理思路: GCD 高级用法

  •  1
     
  •   stevechen1010 ·
    ChenYilong · 2015-11-02 00:15:56 +08:00 · 3460 次点击
    这是一个创建于 3344 天前的主题,其中的信息可能已经有所发展或是发生改变。

    下篇预告: Parse 的网络缓存与离线存储,敬请 star 持续关注

    Parse 源码浅析系列(一)---Parse 的底层多线程处理思路: GCD 高级用法

    [前言] 从 iOS7 升到 iOS8 后, GCD 出现了一个重大的变化:在 iOS7 时,使用 GCD 的并行队列, dispatch_async 最大开启的线程一直能控制在 6 、 7 条,线程数都是个位数,然而 iOS8 后,最大线程数一度可以达到 40 条、 50 条。然而在文档上并没有对这一做法的目的进行介绍。

    笔者推测 Apple 的目的是想借此让开发者使用 NSOperationQueue : GCD 中 Apple 并没有提供控制并发数量的接口,而 NSOperationQueue 有。 GCD 没有提供暂停、恢复、取消队列任务的接口,而 NSOperationQueue 有,如果想让 GCD 支持 NSOperationQueue 原生就支持的功能,需要使用许多 GCD 的高级功能,大大提高了使用的难度。

    Apple 始终有一个观念:尽可能选用高层 API ,只在确有必要时才求助于底层。然而开发者并不买账,在我进行的一次 调查 中发现了一个有趣的现象:

    大概 80%的 iOS 开发者会支持使用 GCD 来完成操作队列的实现,而且有 60% 的开发已经在项目中使用。

    enter image description here

    更是有人这样表态:

    假如不让他用 GCD :

    enter image description here

    这种现象一直存在,包括 ARC 与 MRC 、 SB 建 UI 与纯代码建 UI 、 CoreData 与 SQL 的争论。

    但是因为是源码解析的文章,而 Parse 的 SDK 没有用一句的 NSOperation 的代码, GCD 一路用到底,让我也十分震惊。只能说明,写 Parse 的这位开发者是艺高人胆大。而且既然 GCD 的支持者如此之多,那么就谈一谈如何让 GCD 能支持 NSOperationQueue 原生就支持的功能。

    今天虽然谈了 NSOperation 原生功能的 GCD 版本实现,但并不代表我支持像 Parse 这样 GCD 一路用到底。 业内一般的看法是这样的:

    GCD 虽然能够实现暂停和终止,但开发还是灵活些好,那些 NSOperation 用起来方便的就直接用 NSOperation 的方式,不然苹果多包那一层不是蛋疼,包括文章里提到的 iOS8 后控制线程数的问题,不一定项目就一定要 GCD 一路到底。有时候需要支持一些高层级封装功能比如: KVONSOperation 还是有它的优势的。 GCD 反而是处理些比较简单的操作或者是较系统级的比如:监视进程或者监视文件夹内文件的变化之类的比较合适。

    ( iOS 开发学习交流群: 512437027 )

    第一篇的目的是通过解读 Parse 源码来展示 GCD 两个高级用法: Dispatch Source (派发源)和 Dispatch Semaphore (信号量)。首先通过 Parse 的“离线存储对象”操作,来介绍 Dispatch Source (派发源);然后通过 Parse 的单元测试中使用的技巧“强制把异步任务转换为同步任务来方便进行单元测试”来介绍Dispatch Semaphore (信号量)。我已将思路浓缩为可运行的 7 个 Demo 中,详见仓库里的 Demo1 到 Demo7 。

    如果对 GCD 不太熟悉,请先读下《 GCD 扫盲篇》

    1. Dispatch Source 分派源

      1. Parse-iOS-SDK 介绍
      2. Parse 的“离线存储对象”操作介绍
      3. Parse 的“离线存储对象”实现介绍
      4. Dispatch Source 的使用步骤
        1. 第一步:创建一个 Dispatch Source
        2. 第二步:创建 Dispatch Source 的事件处理方法
        3. 第三步:处理 Dispatch Source 的暂停与恢复操作
        4. 第四步:向 Dispatch Source 发送事件
      5. GCD 真的不能像 OperationQueue 那样终止任务?
      6. 完整例子 Demo1 :让 Dispatch Source “帮” Dispatch Queue 实现暂停和恢复功能
      7. DispatchSource 能通过合并事件的方式确保在高负载下正常工作
      8. Dispatch Source 与 Dispatch Queue 两者在线程执行上的关系
      9. 让 Dispatch Source 与 Dispatch Queue 同时实现暂停和恢复
      10. Parse “离线存储对象”操作的代码摘录
    2. Dispatch Semaphore 信号量

      1. 在项目中的应用:强制把异步任务转换为同步任务来方便进行单元测试
      2. 使用 Dispatch Semaphore 控制并发线程数量

    Parse-iOS-SDK 介绍

    《 iOS 开发周报: iOS 8.4.1 发布, iOS 8 时代谢幕》 对 Facebook 旗下的 Parse 有这样一段介绍:

    Parse-SDK-iOS-OSX :著名的 BaaS 公司 Parse 最近开源了它们的 iOS/OSX SDK 。 Parse 的服务虽然在国内可能访问速度不是很理想,但是它们在服务的稳定性和 SDK 质量上一直有非常优异的表现。此次开源的 SDK 对于日常工作是 SDK 开发的开发者来说,是一个难得的学习机会。 Parse 的存取操作涉及到很多多线程的问题,从 Parse SDK 的源代码中可以看出,这个 SDK 的开发者对 iOS 开发多线程有着非常深厚的理解和功底,让人叹服。我个人推荐对此感兴趣的朋友可以尝试从阅读 internal 文件夹下的两个 EventuallyQueue 文件开始着手,研究下 Parse 的底层多线程处理思路。

    类似的服务:
    Apple 的 Cloud ​ Kit 、 国内的 LeanCloud (原名 AVOS

    Parse 的“离线存储对象”操作介绍

    大多数保存功能可以立刻执行,并通知应用“保存完毕”。不过若不需要知道保存完成的时间,则可使用“离线存储对象”操作( saveEventually 或 deleteEventually ) 来代替,也就是:

    如果用户目前尚未接入网络,“离线存储对象”操作( saveEventually 或 deleteEventually ) 会缓存设备中的数据,并在网络连接恢复后上传。如果应用在网络恢复之前就被关闭了,那么当它下一次打开时, SDK 会自动再次尝试保存操作。

    所有 saveEventually (或 deleteEventually )的相关调用,将按照调用的顺序依次执行。因此,多次对某一对象使用 saveEventually 是安全的。

    国内的 LeanCloud (原名 AVOS 也提供了相同的功能,所以以上《 Parse 的“离线存储对象”操作介绍》部分完全摘录自 LeanCloud 的文档。详见《 LeanCloud 官方文档-iOS / OS X 数据存储开发指南--离线存储对象》

    (利益相关声明:本人目前就职于 LeanCloud (原名 AVOS

    Parse 的“离线存储对象”实现介绍

    Parse 的“离线存储对象”操作( saveEventually 或 deleteEventually ) 是通过 GCD 的 Dispatch Source (信号源)来实现的。下面对 Dispatch Source (信号源)进行一下介绍:

    GCD 中除了主要的 Dispatch Queue 外,还有不太引人注目的 Dispatch Source .它是 BSD 系内核惯有功能 kqueue 的包装。 kqueue 是在 XNU 内核中发生各种事件时,在应用程序编程方执行处理的技术。其 CPU 负荷非常小,尽量不占用资源。 kqueue 可以说是应用程序处理 XNU 内核中发生的各种事件的方法中最优秀的一种。

    Dispatch Source 也使用在了 Core Foundation 框架的用于异步网络的 API CFSocket 中。因为 Foundation 框架的异步网络 API 是通过 CFSocket 实现的,所以可享受到仅使用 Foundation 框架的 Dispatch Source 带来的好处。

    那么优势何在?使用的 Dispatch Source 而不使用 dispatch_async 的唯一原因就是利用联结的优势。

    联结的大致流程:在任一线程上调用它的的一个函数 dispatch_source_merge_data 后,会执行 Dispatch Source 事先定义好的句柄(可以把句柄简单理解为一个 block )。

    这个过程叫 Custom event ,用户事件。是 dispatch source 支持处理的一种事件。

    简单地说,这种事件是由你调用 dispatch_source_merge_data 函数来向自己发出的信号。

    下面介绍下使用步骤:

    Dispatch Source 的使用步骤

    第一步:创建一个Dispatch Source

    // 详见 Demo1 、 Demo2
        // 指定 DISPATCH_SOURCE_TYPE_DATA_ADD ,做成 Dispatch Source(分派源)。设定 Main Dispatch Queue 为追加处理的 Dispatch Queue
        _processingQueueSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0,
                                                        dispatch_get_main_queue());
    

    下面对参数进行下解释:

    其中自定义源累积事件中传递过来的值,累积的方式可以是相加的,正如上面代码中的 DISPATCH_SOURCE_TYPE_DATA_ADD ,也可以是逻辑或 DISPATCH_SOURCE_TYPE_DATA_OR 。这是最常见的两个 Dispatch Source 可以处理的事件。

    Dispatch Source 可处理的所有事件。如下表所示:

    名称 内容
    DISPATCH_SOURCE_TYPE_DATA_ADD 变量增加
    DISPATCH_SOURCE_TYPE_DATA_OR 变量 OR
    DISPATCH_SOURCE_TYPE_MACH_SEND MACH 端口发送
    DISPATCH_SOURCE_TYPE_MACH_RECV MACH 端口接收
    DISPATCH_SOURCE_TYPE_PROC 监测到与进程相关的事件
    DISPATCH_SOURCE_TYPE_READ 可读取文件映像
    DISPATCH_SOURCE_TYPE_SIGNAL 接收信号
    DISPATCH_SOURCE_TYPE_TIMER 定时器
    DISPATCH_SOURCE_TYPE_VNODE 文件系统有变更
    DISPATCH_SOURCE_TYPE_WRITE 可写入文件映像

    自定义源也需要一个队列,用来处理所有的响应句柄( block )。那么岂不是有两个队列了?没错,至于 Dispatch Queue 这个队列的线程执行与 Dispatch Source这个队列的线程执行的关系,下文会结合 Demo1 和 Demo2 进行详细论述。

    第二步:创建Dispatch Source的事件处理方法

    分派源提供了高效的方式来处理事件。首先注册事件处理程序,事件发生时会收到通知。如果在系统还没有来得及通知你之前事件就发生了多次,那么这些事件会被合并为一个事件。这对于底层的高性能代码很有用,但是 OS 应用开发者很少会用到这样的功能。类似地,分派源可以响应 UNIX 信号、文件系统的变化、其他进程的变化以及 Mach Port 事件。它们中很多都在 Mac 系统上很有用,但是 iOS 开发者通常不会用到。

    不过,自定义源在 iOS 中很有用,尤其是在性能至关重要的场合进行进度反馈。如下所示,首先创建一个源:自定义源累积事件中传递过来的值。累积方式可以是相加( DISPATCH_SOURCE_TYPE_DATA_ADD ),
    也可以是逻辑或( DISPATCH_SOURCE_DATA_OR )。自定义源也需要一个队列,用来处理所有的响应处理块。

    创建源后,需要提供相应的处理方法。当源生效时会分派注册处理方法;当事件发生时会分派事件处理方法;当源被取消时会分派取消处理方法。自定义源通常只需要一个事件处理方法,可以像这样创建:

    /*
      *省略部分: 
        指定 DISPATCH_SOURCE_TYPE_DATA_ADD ,做成 Dispatch Source(分派源)。设定 Main Dispatch Queue 为追加处理的 Dispatch Queue
        详见 Demo1 、 Demo2
      *
      */
        __block NSUInteger totalComplete = 0;
        dispatch_source_set_event_handler(_processingQueueSource, ^{
            //当处理事件被最终执行时,计算后的数据可以通过 dispatch_source_get_data 来获取。这个数据的值在每次响应事件执行后会被重置,所以 totalComplete 的值是最终累积的值。
            NSUInteger value = dispatch_source_get_data(_processingQueueSource);
            totalComplete += value;
            NSLog(@"进度:%@", @((CGFloat)totalComplete/100));
        });
    

    在同一时间,只有一个处理方法块的实例被分派。如果这个处理方法还没有执行完毕,另一个事件就发生了,事件会以指定方式(ADD 或者 OR)进行累积。通过合并事件的方式,系统即使在高负
    载情况下也能正常工作。当处理事件件被最终执行时,计算后的数据可以通过 dispatch_source_get_data 来获取。这个数据的值在每次响应事件执行后会被重置,所以上面例子中 totalComplete 的值是最终累积的值。

    第三步:处理Dispatch Source的暂停与恢复操作

    当追加大量处理到 Dispatch Queue 时,在追加处理的过程中,有时希望不执行已追加的处理。例如演算结果被 Block 截获时,一些处理会对这个演算结果造成影响。

    在这种情况下,只要挂起 Dispatch Queue 即可。当可以执行时再恢复。

    dispatch_suspend(queue);
    

    dispatch_resume 函数恢复指定的 Dispatch Queue .
    这些函数对已经执行的处理没有影响。挂起后,追加到 Dispatch Queue 中但尚未执行的处理在此之后停止执行。而恢复则使得这些处理能够继续执行。

    分派源创建时默认处于暂停状态,在分派源分派处理程序之前必须先恢复。因为忘记恢复分派源的状态而产生 bug 是常见的事儿。恢复的方法是调用 dispatch_resume :

    dispatch_resume (source);
    

    第四步:向Dispatch Source发送事件

    恢复源后,就可以像下面的代码片段这样,通过 dispatch_source_merge_data 向分派源发送事件:

    //2.
        //恢复源后,就可以通过 dispatch_source_merge_data 向 Dispatch Source(分派源)发送事件:
        //详见 Demo1 、 Demo2
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
            for (NSUInteger index = 0; index < 100; index++) {
                dispatch_async(queue, ^{
                dispatch_source_merge_data(_processingQueueSource, 1);
                usleep(20000);//0.02 秒
                });
            }
    

    上面代码在每次循环中执行加 1 操作。也可以传递已处理记录的数目或已写入的字节数。在任何线程中都可以调用 dispatch_source_merge_data 。需要注意的是,不可以传递 0 值(事件不会被触发),同样也不可以传递负数。

    GCD 真的不能像 OperationQueue 那样终止任务?

    完整例子 Demo1 :让 Dispatch Source “帮” Dispatch Queue 实现暂停和恢复功能

    本节配套代码在 Demo1 中( Demo_01_对 DispatchSource 实现取消恢复操作_main 队列版)。

    先写一段代码演示下 DispatchSource 的基本用法:

    //
    //  .m
    //  CYLDispatchSourceTest
    //
    //  Created by 微博 @iOS 程序犭袁( http://weibo.com/luohanchenyilong/) on 15/9/1.
    //  Copyright (c) 2015 年 https://github.com/ChenYilong . All rights reserved.
    //
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        //1.
        // 指定 DISPATCH_SOURCE_TYPE_DATA_ADD ,做成 Dispatch Source(分派源)。设定 Main Dispatch Queue 为追加处理的 Dispatch Queue
        _processingQueueSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0,
                                                        dispatch_get_main_queue());
        __block NSUInteger totalComplete = 0;
        dispatch_source_set_event_handler(_processingQueueSource, ^{
            //当处理事件被最终执行时,计算后的数据可以通过 dispatch_source_get_data 来获取。这个数据的值在每次响应事件执行后会被重置,所以 totalComplete 的值是最终累积的值。
            NSUInteger value = dispatch_source_get_data(_processingQueueSource);
            totalComplete += value;
            NSLog(@"进度:%@", @((CGFloat)totalComplete/100));
            NSLog(@"🔵线程号:%@", [NSThread currentThread]);
        });
        //分派源创建时默认处于暂停状态,在分派源分派处理程序之前必须先恢复。
        [self resume];
    
        //2.
        //恢复源后,就可以通过 dispatch_source_merge_data 向 Dispatch Source(分派源)发送事件:
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        dispatch_async(queue, ^{
            for (NSUInteger index = 0; index < 100; index++) {
                dispatch_source_merge_data(_processingQueueSource, 1);
                NSLog(@"♻️线程号:%@", [NSThread currentThread]);
                usleep(20000);//0.02 秒
            }
        });
    }
    

    则输出日志:

    2015-09-03 16:50:48.346 CYLDispatchSourceTest[8331:874681] ✅恢复 Dispatch Source(分派源)
    2015-09-03 16:50:48.348 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.372 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.401 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.424 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.444 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.473 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.493 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.515 CYLDispatchSourceTest[8331:874681] 进度: 0.07000000000000001
    2015-09-03 16:50:48.515 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:48.516 CYLDispatchSourceTest[8331:874681] 进度: 0.08
    2015-09-03 16:50:48.516 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:48.535 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:48.556 CYLDispatchSourceTest[8331:874681] 进度: 0.09
    /*================省略中间====================*/
    2015-09-03 16:50:50.630 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:50.630 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:50.654 CYLDispatchSourceTest[8331:874681] 进度: 0.97
    2015-09-03 16:50:50.654 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:50.654 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:50.676 CYLDispatchSourceTest[8331:874681] 进度: 0.98
    2015-09-03 16:50:50.676 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:50.676 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:50.699 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    2015-09-03 16:50:50.708 CYLDispatchSourceTest[8331:874681] 进度: 0.99
    2015-09-03 16:50:50.708 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:50.722 CYLDispatchSourceTest[8331:874681] 进度: 1
    2015-09-03 16:50:50.722 CYLDispatchSourceTest[8331:874681] 🔵线程号:<NSThread: 0x7ff373428140>{number = 1, name = main}
    2015-09-03 16:50:50.722 CYLDispatchSourceTest[8331:874889] ♻️线程号:<NSThread: 0x7ff3735abe40>{number = 2, name = (null)}
    

    耗时: 2.376

    这段代码还可以进行如下优化:

    将创建异步的操作放在 for 循环内部:

    - (void)viewDidLoad {
        [super viewDidLoad];
        //1.
        // 指定 DISPATCH_SOURCE_TYPE_DATA_ADD ,做成 Dispatch Source(分派源)。设定 Main Dispatch Queue 为追加处理的 Dispatch Queue
        _processingQueueSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0,
                                                          dispatch_get_main_queue());
        __block NSUInteger totalComplete = 0;
        dispatch_source_set_event_handler(_processingQueueSource, ^{
            //当处理事件被最终执行时,计算后的数据可以通过 dispatch_source_get_data 来获取。这个数据的值在每次响应事件执行后会被重置,所以 totalComplete 的值是最终累积的值。
            NSUInteger value = dispatch_source_get_data(_processingQueueSource);
            totalComplete += value;
            NSLog(@"进度:%@", @((CGFloat)totalComplete/100));
            NSLog(@"🔵线程号:%@", [NSThread currentThread]);
    
        });
        //分派源创建时默认处于暂停状态,在分派源分派处理程序之前必须先恢复。
        [self resume];
    
        //2.
        //恢复源后,就可以通过 dispatch_source_merge_data 向 Dispatch Source(分派源)发送事件:
        dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
            for (NSUInteger index = 0; index < 100; index++) {
                dispatch_async(queue, ^{
                dispatch_source_merge_data(_processingQueueSource, 1);
                NSLog(@"♻️线程号:%@", [NSThread currentThread]);
                usleep(20000);//0.02 秒
                });
            }
    }
    

    由于 V2EX 文章字数有限:更多文章请见: 《 Parse-iOS-SDK 源码浅析系列(一)---Parse 的底层多线程处理思路: GCD 高级用法》

    下篇预告: Parse 的网络缓存与离线存储,敬请 star 持续关注


    Posted by 微博 @iOS 程序犭袁

    原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

    3 条回复    2015-12-11 18:50:15 +08:00
    hustlzp
        1
    hustlzp  
       2015-11-02 00:51:22 +08:00
    收藏了。
    tigerZhang
        2
    tigerZhang  
       2015-11-03 13:36:55 +08:00
    @stevechen1010 直接挂外链不好吗?
    wxmowen
        3
    wxmowen  
       2015-12-11 18:50:15 +08:00
    东西很不错,值得推荐。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2808 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 02:40 · PVG 10:40 · LAX 18:40 · JFK 21:40
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.