底层原理iOSiOS开发

[iOS] 跟着大神们学习代码(1)

2019-11-26  本文已影响0人  木小易Ying

因为之前的公司裁了一波员以及自己的能力太差了,衍生了想换家公司的想法,也挺神奇的,其实面试真的是相当顺利,但选择实在是太难了,遇到了超级nice的小哥哥大哥哥们,以及hr小姐姐们~ 很幸运被大家value,也能成为彼此生命中的过客/朋友,虽然我实在是太渣了。。

anyway终于尘埃落定,以后要跟着超级厉害的超级温柔的奶爸mentor小哥哥混啦~ 以及要跟着同组的超级厉害的大神们学习写代码了,真的超级慌的,他们怎么能写的这么好。。于是开启一个新的collection记录日常的小知识吧~


目录:
  1. 内存对齐
  2. git fork & cherry-pick & rebase
  3. 继承中的@synthesize
  4. DI - Dependency Injection
  5. mach-O
  6. index做了什么
  7. architecture
  8. app启动加载类过程
  9. 静态库动态库
  10. Xcode快捷键 command+shift+o

1. 内存对齐

可参考:https://www.cnblogs.com/xylc/p/3780907.html 以及 https://www.jianshu.com/p/3294668e2d8c

之前面试的时候有个小哥哥问过我struct占用多少内存,我当时真的一脸懵逼,入职以后的小哥哥讲了一下swift的内存,于是我又想起了这个topic。

struct StructOne {
    char a;         //1字节
    double b;       //8字节
    int c;          //4字节
    short d;        //2字节
} MyStruct1;

struct StructTwo {
    double b;       //8字节
    char a;         //1字节
    short d;        //2字节
    int c;         //4字节
} MyStruct2;
NSLog(@"%lu---%lu--", sizeof(MyStruct1), sizeof(MyStruct2));

打印出来是24---16--

为什么同样的struct只是变量顺序不一样就会有不同的内存呢?这里就涉及了C语言中的内存对齐啦。

比如对于int x;(这里假设sizeof(int)==4),因为cpu对内存的读取操作是对齐的,如果x的地址不是4的倍数,那么读取这个x,需要读取两次共8个字节,然后还要将其拼接成一个int,这比存取对齐过的x要麻烦很多。

每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。程序员可以通过预编译命令#pragma pack(n),n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的“对齐系数”。

#pragma pack(x)
//...
#pragma pack()

如果改成下面酱紫,打印出来的内存则为15---15--

#pragma pack(1)
struct StructOne {
    char a;         //1字节
    double b;       //8字节
    int c;          //4字节
    short d;        //2字节
} MyStruct1;

struct StructTwo {
    double b;       //8字节
    char a;         //1字节
    short d;        //2字节
    int c;         //4字节
} MyStruct2;
#pragma pack()
  1. 数据成员对齐规则:
    每个数据成员的偏移应为(#pragma pack(指定的数n) 与该数据成员的自身长度中较小那个数的整数倍,不够整数倍的补齐。也就是相对于头部的偏移应该是这个数据的align(x) = min ( sizeof(x) , packalign)的整数倍。(如果是数组的alignx的计算,以元素为准)

  2. 数据成员为结构体:
    如果结构体的数据成员还为结构体,则该数据成员的“自身长度”为其内部最大元素的大小。(struct a 里存有 struct b,b 里有char&int&double等元素,那 b的自身长度为 8)

  3. 结构体的整体对齐规则:
    在数据成员按照上述第一步完成各自对齐之后,结构体本身也要进行对齐。对齐会将结构体的大小调整为(#pragma pack(指定的数n) 与结构体中的最大长度的数据成员中较小那个的整数倍,不够的补齐。也就是整体的size为min(pack, max(成员长度))的整数倍。

所以当我们修改pack时,整体的内存布局都会被改变。但是如果修改pack其实会导致内存读取的时候的效率变低,仍旧会有可能读一个int需要读8个字节,所以这个又是空间还是时间的问题了。


struct与class的区别是神马呢?

首先在C里面认为数据和数据操作是分开的,所以其实struct只是一个数据结构,但是C++对struct进行了拓展:

  1. struct可以包括成员函数
  2. struct可以实现继承
  3. struct可以实现多态

那么在C++中,struct与class有神马区别呢?

虽然感觉struct是多余的,但考虑到“对c兼容”就将struct保留了下来,并做了一些扩展使其更适合面向对象,所以c++中的struct再也不是c中的那个了。

两者最大的区别就在于思想上,c语言编程单位是函数,语句是程序的基本单元。而C++语言的编程单位是类。从c到c++的设计有过程设计为中心向以数据组织为中心转移。


class的内存结构是神马呢?ARC指针存在哪里?
class内存

也就是说其实class的struct里面是有用于保存引用计数的部分的哦。


Tagged Pointer

在2013年9月,苹果推出了iPhone5s,与此同时,iPhone5s配备了首个采用64位架构的A7双核处理器,为了节省内存和提高执行效率,苹果提出了Tagged Pointer的概念。

先看看原有的对象为什么会浪费内存。假设要存储一个NSNumber对象,其值是一个整数。正常情况下,如果这个整数只是一个NSInteger的普通变量,那么它所占用的内存是与CPU的位数有关,在32位CPU下占4个字节,在64位CPU下是占8个字节的。而指针类型的大小通常也是与CPU位数相关,一个指针所占用的内存在32位CPU下为4个字节,在64位CPU下也是8个字节。所以一个普通的iOS程序,如果没有Tagged Pointer对象,从32位机器迁移到64位机器中后,虽然逻辑没有任何变化,但这种NSNumber、NSDate一类的对象所占用的内存会翻倍。

32到64位的内存翻倍

为了存储和访问一个NSNumber对象,我们需要在堆上为其分配内存,另外还要维护它的引用计数,管理它的生命期。这些都给程序增加了额外的逻辑,造成运行效率上的损失。

为了改进上面提到的内存占用和效率问题,苹果提出了Tagged Pointer对象。由于NSNumber、NSDate一类的变量本身的值需要占用的内存大小常常不需要8个字节,拿整数来说,4个字节所能表示的有符号整数就可以达到20多亿。所以我们可以将一个对象的指针拆成两部分,一部分直接保存数据,另一部分作为特殊标记,表示这是一个特别的指针,不指向任何一个地址。

tagged pointer解决内存问题

简单来讲可以理解为把指针指向的内容直接放在了指针变量的内存地址中。

我们来尝试看下NSNumber的pointer:

NSNumber *number1 = @1;
NSNumber *number2 = @2;
NSNumber *number3 = @3;

NSLog(@"number1 pointer is %p", number1);
NSLog(@"number2 pointer is %p", number2);
NSLog(@"number3 pointer is %p", number3);

输出:
2019-11-24 15:09:59.728428+0800 Example1[27147:770431] number1 pointer is 0xfacae31dc706ba6f
2019-11-24 15:09:59.728551+0800 Example1[27147:770431] number2 pointer is 0xfacae31dc706ba5f
2019-11-24 15:09:59.728638+0800 Example1[27147:770431] number3 pointer is 0xfacae31dc706ba4f

一个比较神奇的事情是倒数第二位在数字依次增大,但是哦如果你把数字改为1&2&4会发现又不连续了。。所以其实这个还是蛮神奇的黑盒,anyway苹果据说是把值存到指针里面啦。

复习之前写的autorelease的时候惊讶的发现,当时觉得NSNumber和NSString无法被release其实是因为他们自己把值放到了指针里面了。

//autorelease方法
- (id)autorelease {
    return ((id)self)->rootAutorelease();
}

//rootAutorelease 方法
inline id objc_object::rootAutorelease()
{
    if (isTaggedPointer()) return (id)this;

    //检查是否可以优化
    if (prepareOptimizedReturn(ReturnAtPlus1)) return (id)this;
    //放到auto release pool中。
    return rootAutorelease2();

2. git fork & cherry-pick & rebase

※ fork

可参考https://www.jianshu.com/p/8200d4d90d77

比较大的产品都不能每个人都在公司真的project上面干活,然后每个人都直接推到公司的project,这样很容易出错,所以每个人都先fork一下公司的项目到自己的账号下,然后进行开发,最后提交Merge Request到真正的公司项目中,当review并修改过后才能提交测试,都过了以后可以merge到公司产品的branch里面。

如果想拉公司仓库的代码可以:

$ git remote add upstream  https://github.com/datura-lj/git-fork-demo.git
$ git pull upstream master

如果想拉别人仓库的代码:

git pull https://github.com/xiaolv/datura-lj/git-fork-demo.git master

这里其实可以用git pull -r也就是rebase~
可参考https://blog.csdn.net/jiajia4336/article/details/87855235

git pull = git fetch + git merge
git pull --rebase = git fetch + git rebase

※ rebase

可参考https://www.jianshu.com/p/4a8f4af4e803https://www.jianshu.com/p/dc367c8dca8e

也就是rebase其实是把merge会产生的分叉合成了一个节点,方便提交以及cherry-pick等,如果revert也只要一个节点会方便一点,但是丢失了分次commit的信息也,万一某个小commit有问题不是很容易回滚。

适用的场景包括合并多个commit为1个commit:git rebase -i [startpoint] [endpoint]
以及将几个节点移动到其他的branch:git rebase [startpoint] [endpoint] --onto [branchName]

昨天尝试的一个用法是删除某一个commit的时候也用到了rebase:https://www.jianshu.com/p/2fd2467c27bb

注意当rebase有冲突的时候需要先解冲突+git add .+git rebase --continue+push哦~


※ cherry-pick

可参考:https://blog.csdn.net/jxianxu/article/details/79240158

这个命令其实就是用于拣选某些节点从一个分支到另外一个分支的~

可以用:

git cherry-pick  65ad383c977acd6c7e

还可以一次拣选多个commit,用空格分隔commit id即可。但是每个commit被拣选都会生成一个新的commit,如果希望拣选多个commit合成一条,可以用-n来实现。


3. 继承中的@synthesize

@synthesize我之前一直觉得没有什么用处,毕竟实例变量在声明属性的时候是会自动生成的,但是如果有继承关系的时候不是的哦。

首先先来看下如果父类有个属性,子类是不能用实例变量下划线的方式去存取这个属性的,只能用self.的方式:

// .h文件
@interface Parent : NSObject

@property (nonatomic, strong) NSNumber * num;

@end

// .m文件
#import "Parent.h"

@implementation Parent

- (instancetype)init {
    if (self = [super init]) {
        _num = @(10);
    }
    return self;
}

- (void)setNum:(NSNumber *)num {
    _num = num;
    NSLog(@"parent set num");
}

==============================
// .h文件
#import <Foundation/Foundation.h>

#import "Parent.h"

NS_ASSUME_NONNULL_BEGIN

@interface Child : Parent

@end

NS_ASSUME_NONNULL_END

// .m文件
#import "Child.h"

@implementation Child

//@synthesize num = _num;

- (instancetype)init {
    if (self = [super init]) {
        self.num = @(20);
    }
    
    return self;
}

- (void)setNum:(NSNumber *)num {
    NSLog(@"child set num");
}

@end

如果按上面的代码init一个child对象,命令行将会打印child set num
并且打断点看child是这样子的:

child   Child * 0x60000343c740  0x000060000343c740
    |______ Parent  Parent  
                NSObject    NSObject    
                _num    __NSCFNumber *  (int)10 0xbb7c46e9adaa4cde

也就是说,由于child复写了setNum,所以没走父类的setNum,故而其实父类的_num仍旧是10,没有被改成20。

如果改为下面的样子:

- (void)setNum:(NSNumber *)num {
    [super setNum:num];
    NSLog(@"child set num");
}

那么这个时候child的父类Parent的_num就会被改成20了,并且命令行会有parent set num & child set num

用self.赋值的时候永远走的是setter方法哦

然后我们尝试把child覆写的setter注释掉,然后放开@synthesize num = _num;的注释,再来看一下child是什么样子的:

child   Child * 0x6000024e6ae0  0x00006000024e6ae0
    |______ Parent       Parent 
                _num         __NSCFNumber * (int)10 0xdd07221b04acaed4
_num    __NSCFNumber *  (int)20 0xdd07221b04acaf34

这个时候发现父类的_num仍旧是10,但是子类自己拥有了一个_num实例变量,已经被改成了20。

也就是说父类的property会自动生成实例变量,然后这个实例变量也是父类的,子类只能通过self.的方式来改变父类的实例变量。如果想让子类也拥有一个一样的实例变量,需要用synthesize给子类生成,此时相当于父类和子类都有一个属于自己的实例变量了,互不冲突。

当子类用self.来改的时候其实改的是子类的,如果父类用self.来改的时候改的是父类的实例变量。

4. DI - Dependency Injection

Spring的两个核心内容为控制反转(Ioc)和面向切面(AOP),依赖注入(DI)是控制反转(Ioc)的一种方式。

可参考造车过程:https://www.cnblogs.com/tinaluo/p/8353886.html

我理解的DI其实是当我们在class A中可能需要class B的时候,我们就自己在class A中new了或者使用了B的单例,这个创建过程是没有人管理的,非常的随意,假设当B的初始化方法需要修改的时候,所有创建B的class都需要修改。

但是其实A只要用B就可以了,它不需要关心B究竟怎么被初始化的,也就是不关心B的实现(表现上就是A不需要import B class)。

故而通过DI来实现在A和B中间加了一个容器,我们可以向容器注册B类对象,当A需要B的时候就向容器索要,容器就会将我们之前注册好的B给A。

当我们想要改B的初始化过程的时候,其实只要将注册给容器的对象改一下就可以了,甚至可以换一个B的实现类。


java中依赖注入的三种方式:
可参考https://blog.csdn.net/liu_shi_jun/article/details/79727838


应用实例

今天遇到了一个情景,和厉害小哥哥讨论了一下觉得这种就应该用DI来实现,具体是酱紫的:

view controller会持有很多小插件的视图view,然后也会有这些小插件的单例管理者,管理者会持有小插件们的对象。

如果小插件对象例如聊天插件,在接收到服务器的消息以后,要把别人发来的消息放到消息view上面,就需要操作他的插件视图,但这个视图在vc里面,这要怎么办呢。

如果将各个小插件回调给他们的管理者,由管理者再向上回调vc里面的方法,那么管理者要配备超多的方法,为了响应各种小插件的操作,而且每增加一个小插件,可能都要改管理者,这是非常不好的。

所以最好还是由插件可以直接获取到他们的视图view,而vc也要能方便地获取view。

这个时候可以考虑用一个类做视图管理,这个类可以通过DI依赖注入获取,也就是我们只要在开始的时候注册一下,在vc以及小插件里面都可以通过向DI容器请求来获取这个对象。

然后由这个视图管理类来持有各种插件view,这样其实很多插件都可以通过DI获取这个视图管理实例。DI的一个好处就是当这个东南是会被很多地方用到的时候,可以比较方便的解耦。这里正好会有很多plugin使共用图层管理,就非常合适抽出一个图层管理类。

5. mach-O

可参考:https://www.jianshu.com/p/6e566c4e2cee

Mach-O 为 Mach Object 文件格式的缩写,它是一种用于可执行文件、目标代码、动态库、内核转储的文件格式。作为 a.out 格式的替代,Mach-O 提供了更强的扩展性,并提升了 符号表 中信息的访问速度。

通用二进制代码有两种基本类型:一种类型就是简单提供两种独立的二进制代码,一个用来对应x86架构,一个用来对应PowerPC架构。

另外一种类型就是只编写一个架构的代码,当另外一种处理环境时让系统自动调用模拟器运行,这会导致运行速度下降。

在 NeXTSTEP ,OPENSTEP 和 Mac OS X 中,可以将多个Mach-O文件组合进一个多重架构二进制(胖二进制)文件中,以用一个单独的二进制文件支持多种架构的指令集。例如,一个Mac OS X中的多重架构二进制可以包含32位和64位的PowerPC代码,或PowerPC和x86的32位代码,甚至包含32位的PowerPC代码,64位PowerPC代码,32位x86代码和64位x86代码。


有一些可以用于查看文件的类型的命令,以及合并不同mach-o文件:(这部分好像主要是逆向工程会用到,将来再多学一下~)

//命令查看 Mach-O 文件类型
file Mach-O 

//命令查看文件架构
lifo -info <Mach-O> 

//拆分各种架构
lipo <Mach-O> -thin <架构名> -output <输出文件路径> 

//合并各种架构
lipo -create <Mach-O1> <Mach-O2> -output <输出文件路径> 

6. index做了什么

不知道你有木有发现,在build过程开始的时候,或者偶尔Xcode就会出现indexing

XCode的indexing系统消耗太大,这会导致在调试时经常会卡住。它带来的好处很多,比如自动补全、查找定义等,如果想关闭可以在terminal输入:

defaults write com.apple.dt.XCode IDEIndexDisable 1

还原的话就将1改为0即可。

那么indexing是在做什么嘞?你可以到~/Library/Developer/Xcode/DerivedData目录下会看的一个index文件夹,也就是indexing生成的。

index其实就是项目的索引,它会将我们代码中用到的类/消息和实际实现、声明关联起来,也就帮助了我们可以用xcode jump to definition。其实这个definition和我们代码中的消息直接的关联,是通过索引记录的。

7. architecture

iOS的App现在基本都是用llvm在编译,Xcode也提供了各种设置帮助你进行编译参数的设定。里面有一项就是设定编译的体系结构,涉及到的参数包 括:Architectures、Valid Architectures和Build Active Architecture Only。

这里,我们编译最终支持的指令集是Architectures和Valid Architectures两个参数的交集。

另外,列一下目前常见iOS设备的指令集:

ARMv8/ARM64: iPhone 6(Plus), iPhone 5s, iPad Air(2), Retina iPad Mini(2,3)
ARMv7s: iPhone 5, iPhone 5c, iPad 4 
ARMv7: iPhone 3GS, iPhone 4, iPhone 4S, iPod 3G/4G/5G, iPad, iPad 2, iPad 3, iPad Mini   
ARMv6: iPhone, iPhone 3G, iPod 1G/2G

ARM处理器,特点是体积小,低功耗,低成本,高性能,所以几乎所有手机处理器都基于ARM,在嵌入式系统中应用广泛。

armv6 | armv7 | armv7s | arm64都是ARM处理器的指令集,这些指令集都是向下兼容的,例如armv7指令集兼容armv6,只是armv6的时候无法发挥出其性能,无法使用armv7的新特性,从而会导致程序执行效率没那么高。

还有两个我们也很熟悉的指令集,i386 | x86_64是Mac处理器的指令集,i386是针对intel通用微处理器32架构的。x86_64是针对x86架构的64位处理器。所以当使用iOS模拟器的时候会遇到i386 | x86_64,iOS模拟器没有arm指令集。

我们用的一个SDK是不支持x86/i386的,所以不能在模拟器上使用那个库,可以通过宏定义在虚拟器上不引入该库。

#if TARGET_IPHONE_SIMULATOR

#else
//第三方的相关代码
#endif

支持的指令集越多,就会编译出包含多个指令集代码的数据包,对应生成二进制包就越大,也就是ipa包会变大。

例如,将Architectures支持arm指令集设置为:armv7,armv7s,对应的Valid Architectures的支持的指令集设置为:armv7s,arm64,那么此时,Xcode生成二进制包所支持的指令集只有armv7s。

Build Active Architecture Only 当其值设置为YES,是为了debug的时候编译速度更快,它只编译当前的architecture版本,而设置为NO时,会编译所有的版本。

编译出的版本是向下兼容的,连接的设备的指令集匹配是由高到低(arm64 > armv7s > armv7)依次匹配的。比如你设置此值为yes,用iPhone4编译出来的版本是armv7版本的,iPhone5也可以运行,但是armv6的设备就不能运行。所以,一般debug的时候可以选择设置为yes,release的时候要改为NO,以适应不同设备。

8. app启动加载类过程

可参考:https://www.cnblogs.com/dengzhuli/p/4443134.html & https://www.jianshu.com/p/3bccab8f17b3

app启动的时候会先加载各个类,执行他们的load方法,然后才是application的生命周期回调~

在一个程序开始运行之前(在main函数开始执行之前),类文件开始被程序加载,load方法就会开始被执行;因此load方法总是在main函数之前调用。

当父类和子类都实现load方法时,父类的load方法会被先执行。load方法是系统自动加载的,因此不需要使用[super load]方法调用父类的load方法,否则父类的load方法会多次执行。在Category中写load方法是不会替换原始类中的load方法的,原始类和Category中的load方法都会被执行,原始类的load方法会先被执行,再执行Category中的load方法。当有多个Category都实现了load方法,在Compile Sources中文件的排放顺序就是这几个load方法装载顺序。特别注意的是:如果一个类没有实现load方法,那么就不会调用它父类的load方法。

具体可以参考:http://www.cocoachina.com/articles/16273


※ load时机

我们都知道load是类加载的时候做的,我开始想的是每个类会挨个加载,然后立刻调用该类的load。

如果基于这个设想,如果一个类Class A专门用来swizzle method交换方法,然后它在load里面会交换Class B的两个方法,如果Class B还没有load的时候A已经load了交换会成功么?会有crash么?

#import <objc/runtime.h>

#import "ClassB.h"
#import "ClassA.h"

@implementation ClassA

+ (void)load {
    NSLog(@"class a loaded");
    
    Method originalMethod = class_getInstanceMethod([ClassB class], @selector(print1));
    Method swizzledMethod = class_getInstanceMethod([ClassB class], @selector(print2));
    
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

@end

========================
#import "ClassB.h"

@implementation ClassB

+ (void)load {
    NSLog(@"class b loaded");
}

- (void) print1 {
    NSLog(@"Print 1");
}

- (void) print2 {
    NSLog(@"Print 2");
}

@end

此时我在主VC里面初始化一个B对象,调用他的print1,打印的结果是:

2019-11-27 00:29:40.431287+0800 Example1[34414:339768] class a loaded
2019-11-27 00:29:47.682128+0800 Example1[34414:339768] class b loaded
2019-11-27 00:29:47.779207+0800 Example1[34414:339768] Print 2

也就是说虽然Class B的load方法在A之后,但是在A的load方法执行的时候,B的method们已经加载过了。

我还尝试了在A的load方法里面初始化一个B对象,结果也是正常的。虽然A的load方法先于B的load方法,但是实际上类都会先加载以后再执行他们的load方法,而非一个个挨个加载&执行load,是整体的加载后,挨个执行load。(大概对应上面文章里面的镜像加载以后执行load),但是还是不能确定在load的时候某个类一定存在,这个我理解可能是镜像不一样的情况之类的吧。

9. 静态库动态库

库(Library)说白了就是一段编译好的二进制代码,加上头文件就可以供别人使用。我们在和别人合作的时候,一种情况是某些代码需要给别人使用,但是我们不希望别人看到源码,就需要以库的形式进行封装,只暴露出头文件。另外一种情况是,对于某些不会进行大的改动的代码,我们想减少编译的时间,就可以把它打包成库,因为库是已经编译好的二进制了,编译的时候只需要 Link 一下,不会浪费编译时间。

这里说的是常用类型哦~ framework格式可以是静态也可是动态库哈~ 感谢楼下小哥哥提示~

※ 动态库放在哪里?

在其它大部分平台上,动态库都可以用于不同应用间共享, 共享可执行文件,这就大大节省了内存。

iOS平台在iOS8 之前,苹果不允许第三方框架使用动态方式加载,从 iOS8 开始允许开发者有条件地创建和使用动态框架,这种框架叫做 Cocoa Touch Framework。

虽然同样是动态框架,但是和系统 framework 不同,app 中使用 Cocoa Touch Framework 制作的动态库在打包和提交 app 时会被放到 app main bundle 的根目录中,运行在沙盒里,而不是系统中。也就是说,不同的 app 就算使用了同样的 framework,但还是会有多份的框架被分别签名,打包和加载。不过 iOS8 上开放了 App Extension 功能,可以为一个应用创建插件,这样主app和插件之间共享动态库还是可行的

苹果系统专属的framework 是共享的(如UIKit,会放在系统目录),但是我们自己使用 Cocoa Touch Framework 制作的动态库是放到 app bundle 中,运行在沙盒中的。

关于build到可执行文件经历了神马可以参考:https://www.jianshu.com/p/fda47fdc94de

程序从编译到被翻译成汇编语言,最后链接.o文件生成可执行文件

10. Xcode快捷键 command+shift+o

快速查找类~

参考:
struct: https://blog.csdn.net/alidada_blog/article/details/83419757
class: https://www.jianshu.com/p/5e72b05294f5
pointer: https://www.infoq.cn/article/deep-understanding-of-tagged-pointer/
arch: https://blog.csdn.net/illusion21/article/details/41821433
https://www.jianshu.com/p/369b84ee0fd6
静态库动态库:https://www.jianshu.com/p/4e0fd0214152

上一篇下一篇

猜你喜欢

热点阅读