Date
Sep. 8th, 2024
 
2024年 8月 6日

Post: iOS 入门 017: 多线程

iOS 入门 017: 多线程

Published 12:04 Apr 18, 2012.

Created by @ezra. Categorized in #Programming, and tagged as #iOS.

Source format: Markdown

Table of Content

多线程

进程

* 进程对应一块内存空间

线程

* 一个进程可以分为多个线程,一个iOS程序运行后,默认会开启一条线程,称为主线程或UI线程 * 网络开发一般使用多线程

时间片

* 人的感知时有延迟的,CPU将时间分为人无法感知的碎片,称为时间片

串行与并行

* 线程是串行的,通过时间片实现伪并行   * 线程调度时无序的,线程启动后的调度顺序是由CPU决定的,程序员无法参与

线程的缺点

* 开启线程需要占用一定的内存空间,在iOS中,主线程栈区占用1MB,子线程占用512K * 线程太多,会占用大量的内存空间,降低程序性能,CPU在调度线程上的开销越大,负荷越大

线程的主要作用

* 显示和刷新UI界面

  • 处理UI事件(比如点击事件、滚动事件、拖拽事件等)

线程的使用注意

* 不要将耗时的操作放到主线程中,以避免主线程被阻塞,影响UI流畅度和用户体验  

  • 不要相信单次运行的结果

线程安全

* 多个线程同时进行操作同一块内存时,读写的数据可能会出现问题

互斥量(mutex)

* 为了避免多个线程同时进行操作同一块内存带来的问题,产生了互斥量的概念,也叫互斥锁、同步锁  

  • 使用互斥量会阻塞线程

  • 加锁的代码范围要尽可能小,只要锁住资源读写部分代码即可

信号量(semaphore)

* 信号量是一个计数器,用来控制访问共享资源的最大并行线程总数,这里的信号量也支持进程(Linux系统中目前不能用于进程)

* 当信号量的计数为1时,效果等价于互斥量

条件变量

线程间通信

XSI IPC

* 共享内存、消息队列和信号量集统称为XSI IPC,遵循相同的POSIX规范

* XSI IPC的同性

* 创建时 都需要提供一个外部key,类型`key_t`(整型)

* key被用来参与 XSI IPC结构(共享内存、消息队列和信号量集)的创建,创建成功后每个IPC结构都有自己唯一的标识(非负整数)ID

* 在创建IPC结构时,都需要提供结构的访问权限

* IPC结构由内核管理,如果不手工删除,重启机器后依然占据空间

* key的创建方式有三种

    * 宏 `IPC_PRIVATE`,但基本不用,因为私有别的进程无法获取

    * 可以把所有的key定义到一个头文件中,不会发生重复

    * 可以用 `ftok()`生成key,`ftok()`使用真实存在的路径 + 项目ID 生成key

    * IPC结构的创建/获取函数一般都是

        * `xxxget()` 参数中都有key,新建时一般都需要写上 `IPC_CREAT|权限`,返回值就是ID

* IPC结构一般都提供了一个控制函数

    * `xxxctl()`,都提供了以下功能

    * `IPC_STAT` - 查询

    * `IPC_Setter`  - 修改

    * `IPC_RMID` - 删除

* IPC结构可以用命令查看、删除

    * `ipcs` - 查询当前有哪些IPC结构

    * `ipcrm` - 删除当前的IPC结构,需要提供ID

    * 选项

        * `-a` 所有

        * `-m` 共享内存

        * `-q` 消息队列

        * `-s` 信号量集

多线程方法编写建议

* 首先保证一条线程工作正常

* 多线程方法需要保证自己能够独立完成所有任务

* 一般开发不建议使用任何锁

pthread

* Unix/Linux/Windows通用,遵循PROSIX规范,在头文件中均已定义

创建进程

pthread_create(pthread_t, NULL, , )

* 成功返回0,返回错误编号

线程状态

join状态

* join状态的线程会在函数返回时回收资源

detach状态

* detach状态的线程会在线程结束时回收资源

* 一个线程最好处于joindetach之一,否则无法确定其回收资源的时间

取消线程

pthread_cancel()

* 要取消的线程必须支持取消操作

设置线程状态

pthread_setcancelstate()

设置取消方式

pthread_setcanceltype()

结束线程

return valuepthread_exit()

等待线程

pthread_join()

pthread.c

# include \<pthread.h\>
void* task(void* par){
    int *pi = (int *)par;
    printf("");
    if
    //pthread_exit();
    return 0;
}
int main(){
    pthread_t pt;
    pthread_create(&pt, NULL, task, );//成功返回0,返回错误编号
    pthread_join(pt);
    //pthread_cancel(pt);
    //pthread_setcancelstate();
    //pthread_setcanceltype();
}

互斥量

使用步骤

* 包含头文件

* 声明

* 初始化

* 加锁

* 执行可能造成数据冲突的代码

* 解锁

* 释放互斥量的资源 (销毁)

包含头文件

# include \<pthread.h\>

声明

pthread_mutex_t lock;

初始化

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);
pthread_mutex_init(&lock,0);
// 或在声明时赋值:
// pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_lock(&lock);

解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_unlock(&lock);

释放互斥量的资源 (销毁)

int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_destroy(&lock);

信号量

使用步骤

* 包含头文件

* 定义信号量

* 初始化信号量  

* 获取信号量 (计数器减1)

* 访问资源  

* 释放信号量 (计数器加1)

* 回收信号量资源 (销毁)

包含头文件

# include \<semaphore.h\>

定义信号量

sem_t sem;

初始化信号量

sem_init(&sem,0,5);
sem_init(信号量指针,0代表用于线程非0用于进程,计数器最大值);

获取信号量 (计数器减1)

sem_wait(&sem);

访问资源

释放信号量 (计数器加1)

sem_post(&sem);

回收信号量资源 (销毁)

sem_destroy(&sem);

条件变量

NSThread

原生方法

* 需要start方法

number属性

* 1为主线程,否则为其他线程

name属性

* 标识线程

threadPriority属性

* 即线程优先级,仅表示CPU对该任务的调度会更加积极  

* 范围0.0 \~ 1.0

* 默认值0.5  * 通常开发时,不要处理优先级

* 优先级反转

* 低优先级任务阻塞CPU无法调度高优先级任务

alloc_init

NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(run:) object:id];
thread.name = @"Thread A";
[thread start];

隐式方法

* 放在后台执行

performSelectorInBackGround:withObject:

[obj performSelectorInBackGround:@selector() withObject:nil];

派发任务

* 类方法指定selector方法在后台线程执行,方法会直接调度  

[NSThread detachNewThreadSelector:@selector() toTarget:self withObject:nil]

获取当前线程

[NSThread currentThread]

线程状态

* 新建(new)  

* start => 就绪状态(Runnable)

* CPU调度 => 运行(Running)  

* 休眠/同步锁 => 阻塞(Blocked)

* 线程任务结束/异常/强行退出 => 死亡(Dead)

线程休眠

[NSThread sleepForTimeInterval:1.0f]  

[NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0f]]

结束线程

* 任务执行完毕,线程自然结束  

* 调用exit/return方法,手动强行结束

* 一旦结束线程,后续代码都不会执行

* `NSThread exit]`

互斥量

* 要求是一个能够加锁的NSObject,所有的线程都能够访问到对象  

* 在实际开发中,如果只抢夺一个资源,就只需要一把锁,通常使用self

synchronized

@synchronized (self){
    // code here...
}

信号量

条件变量

原子锁

* 类似于互斥量,但性能略高  

* 在Obj-CC中定义属性时默认都是atomic

* 如果定义了原子属性,就不要重写gettersetter方法  

* 原子锁本质上是128位自旋锁,能够实现单写多读,保证只有一个线程写入,但允许多个线程读取

* 每一种技术,只要有额外的代码,就需要CPU付出额外的代价,原子属性同样代价很高,性能不好,耗电  

* 建议所有属性都声明为nonatomic

线程安全

* UIKit中几乎所有对象都不是线程安全的  

* 苹果约定,所有UI控件更新均在主线程中执行,从而保证UI正常显示

* 这也是为什么主线程也称为UI线程

  • 有些情况下在其他线程更新UI也是正确的,但一定不要尝试这样的做法

线程间通信

[obj performSelectorOnMainThread:@selector() : waitUntilDone:BOOL]

GCD

* GCD(Grand Central Dispatch,强大中枢调度器)  

* GCD是C语言框架,所有GCD函数均以dispatch开头

* GCD开发时,程序员不需要管线程,而是面对队列开发,将需要的任务添加到不同的队列中

* GCD的优势

  • GCD是苹果公司为多核的并⾏行运算提出的解决⽅方案

  • GCD会⾃自动利⽤用更多的CPU内核(⽐比如双核、四核)

  • GCD会⾃自动管理线程的⽣生命周期(创建线程、调度任务、销毁线程)

  • 程序员只需要告诉GCD想要执⾏行什么任务,不需要编写任何线程管理代码

  • MRC的GCD开发中,遇到createcopyretain时需要dispatch_release(dispatch_queue_t p)函数释放

  • ARC的GCD开发中,不需要释放

任务

* 用block来定义  

* 任务决定是否开启新线程,同步不开启新线程,异步会开启新线程

同步任务

* 顺序执行任务  

* 同步任务只有一个用处,即阻塞后面的所有任务,等待同步任务完成后再并发执行

dispatch_sync(dispatch_queue_t queque,^{})

异步任务

* 要在别的线程并发执行,会开启新的线程  

* 异步是多线程的代名词

dispatch_async(dispatch_queue_t queque,^{})

队列

* 队列专门用来调度任务,安排不同的任务去不同的线程工作  

* 队列决定能够开启线程的数量,串行最多一条,并发最多线程数量由GCD决定

* 队列也可以不开启线程  

* 队列在调度任务时,如果正在执行的是同步任务,会等待同步任务执行完成后再调度后续的任务

串行队列

* 顺序的队列,所有任务顺序执行,最多开启一条线程

并发队列

* 能够开启多条线程

主队列

* 专门用来做线程间通讯,后台线程完成工作后,通知主线程更新UI  

* 主队列只能获取,不能创建

* 主队列调度任务在主线程工作,不能开启线程  

* 队列调度任务是先进先出,主队列不能开启线程,那么只能顺序执行任务

* 主队列中只能使用异步任务,否则会造成死锁

全局队列

* 全局队列在iOS 7和iOS 8中有区别  

* 全局队列是方便程序员使用,可以直接使用的整个系统全局共享的队列

* 全局队列只能获取,不能创建

全局队列与手动并发队列

* 全局队列本质上就是并发队列,系统全局的调度队列,可以方便程序员使用  

* 创建并发队列有一个好处,在大型商业应用中,通常需要跟踪具体的任务是由哪个队列调度的,如果程序闪退,会产生日志,发送给开发者,便于开发者解决问题,如果能够记录崩溃所在队列的名称,方便排错

dispatch_get_global_queue(long identifier,unsigned long flags)  

* 参数

* `long identifier`

    * iOS 8中为标识符,指定队列的服务质量,QOS与XPC框架联合使用,XPC是OS X开发中用于进程间通讯的框架

        * `QOS_CLASS_UNSPECIFIED`,即0,表示没有使用QOS

    * iOS 7中为优先级

        * `DISPATCH_QUEUE_PRIORITY_HIGH`,即2,高优先级

        * `DISPATCH_QUEUE_PRIORITY_DEFAULT`,即0,默认

        * `DISPATCH_QUEUE_PRIORITY_LOW`,即-2,低优先级

        * `DISPATCH_QUEUE_PRIORITY_BACKGROUND`,即INT16_MIN,后台优先级

        * 如果使用后台优先级,工作性能会慢的令人发指

    * 传入0就可以保证iOS 7与iOS 8全局队列的适配

* `unsigned long flags`,为未来使用保留,应该始终传入0

基本使用

使用步骤

* 定制任务

  • 确定想做的事情

  • 将任务添加到队列中

    • GCD会⾃自动将队列中的任务取出,放到对应的线程中执⾏行

    • 任务的取出遵循队列的FIFO原则,即先进先出,后进后出

串行队列+同步任务

* 不会开启新线程,顺序执行所有任务  

* 任务顺序执行的原因是同步任务

* 在开发中几乎不用

queue_serial_sync

// 队列
dispatch_queue_t que = dispatch_queue_create("meniny",DISPATCH_QUEUE_SERIAL);
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_sync(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

串行队列+异步任务

* 开启一条新线程,顺序执行所有任务  

* 任务顺序执行的原因是串行队列

queue_serial_async

// 队列
dispatch_queue_t que = dispatch_queue_create("meniny",DISPATCH_QUEUE_SERIAL);
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_async(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

并发队列+异步任务

* 会开启多条线程,任务不是顺序执行

queue_concurrent_async

// 队列
dispatch_queue_t que = dispatch_queue_create("meniny",DISPATCH_QUEUE_CONCURRENT);
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_async(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

并发队列+同步任务

* 不会开启新线程,顺序执行所有任务  

* 任务顺序执行的原因是没有开启新线程

queue_concurrent_sync

// 队列
dispatch_queue_t que = dispatch_queue_create("meniny",DISPATCH_QUEUE_CONCURRENT);
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_sync(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

主队列+异步任务

* 不会开启新线程,顺序执行所有任务  

* 任务顺序执行的原因是没有开启新线程

main_queue_async

// 队列
dispatch_queue_t que = dispatch_get_main_queue();
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_async(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

主队列+同步任务

* 不会开启新线程,会造成死锁 

main_queue_sync

// 队列
dispatch_queue_t que = dispatch_get_main_queue();
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_sync(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

全局队列+异步任务

  • 会开启多条线程,任务并发执行

global_queue_async

// 队列
dispatch_queue_t que = dispatch_get_global_queue(0, 0);
// 让队列调度任务
for (int i = 0;i \< 10,i++){
    dispatch_async(que, ^{
        // code here...
        NSLog(@"%@-%d",[NSThread currentThread],i);
    });
}

应用场景

同步任务

例:在线小说网站,需要用户登录,然后再下载小说

dispatch_queue_t que = dispatch_queue_create("meniny",DISPATCH_QUEUE_CONCURRENT);
// 要保证用户登录最先执行
dispatch_async(que, ^{
    dispatch_sync(que, ^{
        NSLog(@"用户登录-%@",[NSThread currentThread]);
    }
    dispatch_async(que, ^{
        NSLog(@"下载小说 A -%@",[NSThread currentThread]);
    }
    dispatch_async(que, ^{
        NSLog(@"下载小说 B -%@",[NSThread currentThread]);
    }
}

队列的选择取舍

* 串行队列最多开启一条线程,任务顺序执行,效率低,执行慢,但省电

* 对执行顺序要求高,对并发要求不高,执行性能要求不高,兼顾电量,可以选择串行队列

* 并发队列可以开启多条线程,任务并发执行,效率高,执行快,但费电

* 对执行效率要求高,对执行顺序要求不高,可以选择并发队列

常用GCD组合

最常见的GCD代码

// GCD是用来处理耗时的任务
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    // 耗时的任务
    // code here...
    // 与主线程通讯,更新UI
    dispatch_async(dispatch_get_main_queue(), ^{
        // 更新UI
        // code here...
    });
});

其他GCD用法

延时处理

dispatch_after(dispatch_time_t when,queue , ^{})  

when 延时

* 从`DISPATCH_TIME_NOW`起经过多少纳秒后在`queue`队列上执行`block`

* dispatch_time(DISPATCH_TIME_NOW,延迟时间_纳秒)

queue 队列  

block 异步任务

dispatch_after

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    NSLog(@"%@",[NSThread currentThread]);
});
// 全局队列会延时多少时间后新建线程执行block
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64)(1.0 * NSEC_PER_SEC)), dispatch_get_global_queue(0, 0), ^{
    NSLog(@"%@",[NSThread currentThread]);
});
// 串行队列会延时多少时间后新建线程执行block
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64)(1.0 * NSEC_PER_SEC)), dispatch_queue_create("meniny", NULL), ^{
    NSLog(@"%@",[NSThread currentThread]);
});

成组工作

dispatch_group_t gup = dispatch_group_create();
dispatch_queue_t que = dispatch_get_global_queue(0, 0);
// 派发任务
dispatch_group_async(gup, que, ^{
    NSLog(@"任务一%@",[NSThread currentThread]);
});
dispatch_group_async(gup, que, ^{
    NSLog(@"任务二%@",[NSThread currentThread]);
});
dispatch_group_async(gup, que, ^{
    NSLog(@"任务三%@",[NSThread currentThread]);
});

dispatch_group_notify(gup, dispatch_get_main_queue(), ^{
    NSLog(@"任务全部完成%@",[NSThread currentThread]);
});

单次执行

* 有些代码只希望执行一次,应用最广泛的是单例设计模式  

dispatch_once()是线程安全的,在多线程执行的时候仍可以保证只执行一次,它同样使用了锁,而性能比互斥锁高很多,这是苹果推荐的技术,但目前已经达到了滥用的程度

dispatch_once

static dispatch_once_t onceToken;
NSLog(@"%ld",onceToken);
dispatch_once(&onceToken, ^{
    // 只执行一次的代码
    // code here...
});

NSOperation

  • NSOperation是抽象类,并不具备封装操作的能⼒力,必须使⽤用它的⼦子类

    * 使⽤用NSOperation子类的方式有3种

    • NSInvocationOperation

    • NSBlockOperation

    • 自定义⼦子类继承NSOperation,实现内部相应的⽅方法

  • 配合NSOperation和NSOperationQueue也可以实现多线程

  • NSOperation & GCD属于并发开发

  • 程序猿只需要面对队列,将操作添加到队列上即可,不用关心线程,也不用关心线程状态

  • NSOperation & GCD一般都不需要我们添加自动释放池

  • NSOperation是对GCD的封装

  • 历史

    • NSOperation是iOS 2出现,GCD在iOS 4出现

    • 苹果对NSOperation基于GCD面向对象封装

NSOperation和NSOperationQueue实现多线程的具体步骤

* 先将需要执⾏行的操作封装到⼀个NSOperation对象中

  • 然后将NSOperation对象添加到NSOperationQueue中

  • 系统会⾃自动将NSOperationQueue中的NSOperation取出来

NSOperationQueue

* 实例化的NSOperationQueue本质上就是GCD的全局队列  

* 添加到队列中的操作都是异步执行的

NSOperationQueue

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperation:op];

NSOperationQueue mainQueue

[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // upd UI...
}];
  • 通常开发中,会使用一个全局队列来管理

NSOperationQueue

@property (nonatomic, strong) NSOperationQueue *opQueue;

- (NSOperationQueue *)opQueue {
    if (!_opQueue) {
        _opQueue = [[NSOperationQueue alloc] init];
    }
    return _opQueue;
    }

并发线程数

* 在iOS 7中,使用NSOperation或GCD一般只能开启10条左右的线程  

* 从iOS 8开始,如果任务很多,线程数量同样很大

* 开启线程是有消耗的,实际开发中有必要控制同时并发的线程数量,这个控制在GCD中较难实现  

maxConcurrentOperationCount:(NSInteger)最大并发线程数,设定最大并发线程数

* 在GCD底层为了提高程序并发性能,会维护一个线程池,供所有应用程序的多线程使用,每个线程上的任务执行后,会被GCD线程池回收,而不销毁  

* 在队列调度任务时,会出现任务执行完毕,但底层线程池还没有完全回收,如果在调度其他任务,系统会从线程池中取出一个空线程供程序使用

何时需要控制最大并发线程数?

* 下载网络资源,判断用户联网状态

* WI-FI:不花钱,能够随时充电,可以让线程数5-6,并发性能好,速度快

* 3G:花钱,不容易充电,可以让线程数少一些,2-3,速度慢

队列暂停与继续调度任务

op.suspended = YES挂起,设定YESNO  

operationCount操作数,返回当前操作数

* 如果队列已经调度了任务,挂起操作不会对任务造成影响,任务仍然会继续执行  

* 如果任务没有执行完毕,会包含在operationCount中

* 任务执行完成后,队列中的任务技术才会变化

应用场景

* TableView滚动时需要暂停队列的下载,而停止滚动,需要让队列继续下载,这样可以避免很多无谓的网络请求

依赖

* 在GCD中可以通过同步任务要求任务执行的顺序,而NSOperation中只有异步任务  

[op2 addDependency:op1]指定任务之间的依赖关系,op2需要等op1执行完毕后才可以执行

* 多个任务之间,可以跨队列依赖  * 注意,线程依赖关系不能循环,否则会造成死锁

应用场景

* 网络下载小说的压缩包  

* 解压缩保存到磁盘

* 通知用户可以阅读

NSInvocationOperation

[op start]

* 调用start方法后会自动执行target的selector方法

* 不会创建新线程,默认在当前线程同步执行操作

* 只有添加NSOperation到NSOperationQueue才会异步执行

NSInvocationOperation & start

NSInvocationOperation *op = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector() object:@"hello"];
[op start];

NSInvocationOperation & NSOperationQueue

NSInvocationOperation *op = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector() object:@"hello"];
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperation:op];

NSBlockOperation

- (void)blockOperationWithBlock:(void (^)(void))block初始化并添加操作  

- (void)addExecutionBlock:(void (^)(void))block添加更多操作

NSBlockOperation

NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
    // code here...
}];
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperation:op];

NSBlockOperation

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
[queue addOperationWithBlock:^{
    // code here...
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        // udp UI
    }];
}];

自定义子类继承NSOperation类,实现相应方法

GCD对比NSOperation

CGD

* GCD时C语言的  

* 两个队列,两种任务,需要排列组合

* 一次性、分组、延迟等功能时NSOperation不具备的

NSOperation

* NSOperation是Obj-C的  

* NSOperation是对GCD的封装

* 新创建的队列就是并发队列,添加到队列中的任务就是异步执行  

* 可以设置最大并发数、暂停与继续使用起来比GCD方便

* 可以指定队列之间的依赖关系,代码结构上直观

提示

* 国内大部分公司在使用GCD,很少使用NSOperation  

* 苹果强烈建议使用NSOperation,从iOS 8开始,GCD底层调度的线程数量从原有的10以内增加到70以上(测试结果)


Pinned Message
HOTODOGO
The Founder and CEO of Infeca Technology.
Developer, Designer, Blogger.
Big fan of Apple, Love of colour.
Feel free to contact me.
反曲点科技创始人和首席执行官。
程序猿、设计师、奇怪的博主。
苹果死忠、热爱色彩斑斓的世界。
如有意向请随时 与我联系