2021年6月面试小记

6月离开公司,开启了找工作之旅,记录一下遇到的面试题,未完,持续更新

SDWebImageView

  • 流程
    • sd_setImageWithUrl
    • sd_internalSetImageWithUrl
    • loadImageWithUrl
    • quryCacheForKey
      • diskResult
      • downloadImage
        • storImage
  • SDWebImageCache
    • 内存磁盘双缓存
    • 内存缓存SDMemoryCache
      • shouldUseWeakMemoryCache
        • 存在NSCache,不可控,容易被清理
        • 存在NSCache并存在MemoryCache,可以保证NSCache被清理之后,从MemoryCache中获取
    • 磁盘缓存
      • 创建缓存路径
      • 内存缓存查找
      • 磁盘缓存查找,存入内存缓存

内存泄露

  • 产生原因
    • 循环引用造成
  • 检查方式
    • Product -> Analyze
    • Instruments -> Leaks
  • 具体产生场景
    • Timer没有释放,在合适的时机,调用[timer invalidate]
    • blcok持有了对象,不能释放。使用weak属性
    • Network结束后,取消task
    • delegate的传递,尽量使用weak

消息转发机制

快速查找

  • 是否支持tagged pointer对象
    • 为空则直接返回Zero
    • 获取isa
  • 获取isa, 然后获取class isa & iSA_Mask
  • 开启缓存查找
  • 通过isa平移16获取到cache
  • 通过cache&掩码获取到buckets
  • 通过平移获取到mask(arm64为中为右移48位)
  • 在通过mask & sel获取到方法下标index
  • 在通过平移在buckets中获取到查找的buket,取到imp
  • 比较通过index获取到的bucket中的sel与我们查找的sel是否为同一个
    • 缓存命中,返回imp
    • 如果不相等,则判断此bucket是否为第buckets的第一个元素
      • 如果是第一个元素,则将bucket设置为buckets的最后一个元素,进行第二次递归查找
      • 如果不是第一个元素,则从最后一个元素递归向前查找
  • 如果一直没有找到则退出递归,进入__objc_msgSend_uncached慢速查找流程

慢速查找 (lookUpImpOrForward)

  • 排除一些干扰因素,是否是已知类呀,是否完成初始化
  • 确认继承链,因为这里会存在类方法和实例方法的区别,所以需要确定其继承链
  • 然后进去死循环查找流程
    • 当前查找的类是否是不断优化的类

      • 查找其缓存
        • 找到了就直接返回imp

        • 没找到循环继续

    • 通过二分查找,在方法列表中查找

      • 找到了,写入cache,返回imp
      • 查找其父类curClass = curClass -> superClass
        • 现在父类缓存中找
          • 找到了是否为forward_imp
            • 结束循环,进入动态方法决议
            • 存入查找类的cache,返回imp
        • 一直没有找到,则imp为forward_imp,进入动态方法决议
  • imp = forward_imp 动态方法决议

动态方法决议

  • 判断是否是元类
    • resolveInstanceMethod
+ (BOOL)resolveInstanceMethod:(SEL)sel{
    if (sel == @selector(say666)) {
        NSLog(@"%@ 来了", NSStringFromSelector(sel));
        //获取sayMaster方法的imp
        IMP imp = class_getMethodImplementation(self, @selector(sayMaster));
        //获取sayMaster的实例方法
        Method sayMethod  = class_getInstanceMethod(self, @selector(sayMaster));
        //获取sayMaster的签名
        const char *type = method_getTypeEncoding(sayMethod);
        //将sel的实现指向sayMaster
        return class_addMethod(self, sel, imp, type);
    }
    
    return [super resolveInstanceMethod:sel];
}
复制代码
  • resolveClassMethod
+ (BOOL)resolveClassMethod:(SEL)sel{
    
    if (sel == @selector(sayNB)) {
        NSLog(@"%@ 来了", NSStringFromSelector(sel));
        
        IMP imp = class_getMethodImplementation(objc_getMetaClass("LGPerson"), @selector(lgClassMethod));
        Method lgClassMethod  = class_getInstanceMethod(objc_getMetaClass("LGPerson"), @selector(lgClassMethod));
        const char *type = method_getTypeEncoding(lgClassMethod);
        return class_addMethod(objc_getMetaClass("LGPerson"), sel, imp, type);
    }
    
    return [super resolveClassMethod:sel];
}
复制代码
  • 如果动态方法决议处理了,则返回IMP,如果没有处理则进入消息转发

消息转发

  • 快速转发
    快速转发,可以转发给其它类或对象,其它的类会对象如果实现了查找方法的类方法或者对象方法,则不报错,如果没有则进入慢速转发
+ (id)forwardingTargetForSelector:(SEL)aSelector{
    NSLog(@"%s- %@", __func__, NSStringFromSelector(aSelector));
    return [Teacher alloc];
}
复制代码
  • 慢速转发
    这里在methodSignatureForSelector中返回方法签名,在forwardInvocation中可以处理,也可以不处理,处理方式
    这里也需要要在对应的类里面有对应的实现
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
    NSLog(@"%s - %@",__func__,NSStringFromSelector(aSelector));
    return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation{
    NSLog(@"%s - %@",__func__,anInvocation);
}
复制代码
/// 慢速转发
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
    NSLog(@"%s- %@", __func__, NSStringFromSelector(aSelector));
    return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation{
    NSLog(@"%s- %@", __func__, anInvocation);
    [anInvocation invokeWithTarget:[Student alloc]];
}
复制代码
[anInvocation invokeWithTarget:[Student alloc]];
复制代码

多线程的使用

NSThread的使用

  • 通过alloc来启用,需要手动开启
    NSThread *thread = [[NSThread alloc] initWithBlock:^{
        NSLog(@"%@", [NSThread currentThread]);
    }];
    
    NSThread *thread2 = [[NSThread alloc] initWithTarget:self selector:@selector(threadDo) object:NULL];
    [thread start];
    [thread2 start];
复制代码
  • 通过detachNewThread直接开启
[NSThread detachNewThreadSelector:@selector(threadDo) toTarget:self withObject:@"ThreadName1"];
[NSThread detachNewThreadWithBlock:^{
  NSLog(@"%@", [NSThread currentThread]);
}];
复制代码
  • 通过performSelector来开启
[self performSelectorInBackground:@selector(threadDo) withObject:@"ThreadName2"];
    
[self performSelectorOnMainThread:@selector(threadDo) withObject:@"ThreadName3" waitUntilDone:YES];
复制代码

NSThread支持KVO,可以监听到threa的执行状态

  • isExecuting是否正在执行
  • isCancelled是否被取消
  • isFinished是否完成
  • isMainThread是否是主线程
  • threadPriority优先级

GCD

  • dispatch_after
  • dispatch_once
  • dispatch_apply
  • dispatch_group_t
  • dispatch_semaphore_t
    控制并发数
  • dispatch_source_t
    可以实现timer不依赖于runloop,精度比timer高

NSOperation

Block

block类型

  • __NSGlobalBlock__:全局block,存储在全局区

此时的block没有访问外界变量,无参也无返回值

void(^block)(void) = ^{
	NSLog(@"hello world");
}
复制代码
  • __NSMallocBlock__:堆区block
int a = 10;
void(^block)(void) = ^{
	NSLog(@"hello world - %d", a);
}
NSlog(@"%@", block);
复制代码

此时的block会访问外界变量,即底层拷贝a,所以是堆区block

  • __NSStackBlock__:栈区block
int a = 10;

NSlog(@"%@", ^{
	NSLog(@"hello world - %d", a);
});
复制代码

在完成a的底层拷贝前,此时的block还是栈区block,拷贝完成之后,从上面的堆区block可以看出,就变成堆区block了

int a = 10;
void(^__weak block)(void) = ^{
	NSLog(@"hello world - %d", a);
}
NSlog(@"%@", block);
复制代码

可以通过__weak不进行强持有,block就还是栈区block

总结

  1. block直接存储在全局区
  2. 如果block访问外界变量,并进行block相应拷贝
    1. 如果此时的block是强引用,则block存储在堆区
    2. 如果此时的block通过__weak变成了弱引用,则block存储在栈区

block的循环引用

  • 造成循环引用的原因
    • 互相持有,导致释放不掉
  • 解决循环引用的方法
    • weak-strong-dance

如果block未嵌套block,直接使用__weak修饰的self即可,否则,需要搭配__strong来使用

    __weak typeof(self) weakSelf = self;
    self.dkblock = ^{
        NSLog(@"%@", weakSelf.name);
    };

    self.dkblock = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            NSLog(@"%@", weakSelf.name);
        });
    };
复制代码
  • __block修饰对象(需要注意在block内部使用完成之后置为nil,block必须调用)
__block ViewController *vc = self;
self.dkblock = ^{
	__strong typeof(weakSelf) strongSelf = weakSelf;
	dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", vc.name);
        vc = nil; 记得手动释放
    });
};
复制代码
  • 传递self作为block的参数,提供给block内部使用
self.myBlock = ^(ViewController *vc) {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", vc.name);
    });
}
复制代码

block为什么要用copy修饰,使用strong修饰会有什么问题吗?

  • block用strong和copy修饰都可以,对于block,编译器重写了strong底层逻辑,使其和copy是一样的原理,即把block从栈区复制到堆区
  • 使用copy是因为block初始化时位于栈区,copy可以把栈区的对象复制到堆区,而栈上的block对象在作用域结束后释放

block的的底层结构

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_a_0 *a; // by ref
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_a_0 *_a, int flags=0) : a(_a->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
复制代码

block的底层结构是一个结构体,也可以说block其实是一个对象、函数

block为什么需要调用

在底层block的类型是__main_block_impl_0结构体,通过其同名构造函数创建,第一个传入的block的内部实现代码块,即_main_block_func_0,用fp来表示,然后赋值给impl的FuncPtr,然后再main中进行了调用,这也是block为什么需要调用的原因,如果不调用,内部实现的代码块将无法执行

  • 函数生命,block的内部实现声明成了一个函数__main_block_impl_0
  • 通过block的FuncPtr指针,调用block执行

block是如何捕获外界变量的

  • 对外界变量没有__weak修饰时,是进行了值拷贝
  • 对外界变量实现__weak修饰时,是进行了指针拷贝

__weak原理

  • block使用使用__weak修饰的变量时,会生成__Block_byref_x_0结构体
  • 结构体用来保存原始变量的指针和值
  • 将变量生成的结构体对象的指针地址传递给block,然后再block内部就可以对外界变量进行操作了

block的三重拷贝

  1. 通过__block_copy实现对象的自身拷贝,从栈区到堆区
  2. 通过__block_byref_copy方法,将对象拷贝为block_byref结构体类型
  3. 调用_block_object_assign方法,对_block修饰的当前变量进行拷贝

只有_block修饰的对象,block的copy才有三层

Extension类扩展

  • 类扩展在编译器,会作为类的一部分,和类一起编译进来
  • 类的扩展只是声明,依赖当前的主类,没有.m文件,可以理解为一个.h文件
  • 声明属性和成员变量,也可以声明方法
  • 在当前类.h中声明的属性和方法是共有的,在.m中声明的方法和属性是私有的
  • 通过Extention新建的声明的属性与方法是私有的

Category分类

  • 给类添加新的方法
  • 不能给类添加成员属性,添加了成员属性,也无法取到
  • 分类中使用@property定义的变量,只会生成Setter,Getter方法的声明,不会生成对应的实现
  • 可以通过runtime给分类添加属性

关联对象

  • Objc_setAssociatedObject
    • 创建一个AssociationsManager管理类
    • 获取全局静态Hasmap
    • 判断插入的关联值是否存在
    • 创建一个空的ObjctAssociationMap去取查询的简直对
    • 如果发现没有这个key就插入一个空的BucketT进去
    • 标记关联对象
    • 用当前的policy和value组成一个ObjcAssociation替代原来的BucketT中的空
    • 标记一下ObjctAssociationMap的第一次为false
  • Objec_getAssociatedObject
    • 创建一个AssociationsManager管理类
    • 获取全局静态Hasmap
    • 根据DisguisedPtr找到AssociationsHashMap中的iterator迭代查询器
    • 如果迭代查询器不是最后能获取
    • 找到ObjctAssociationMap的迭代查询器获取一个经过policy修饰的value
    • 返回value
  • AssociationsManager
    • AssociationsHashMap
      • ObjctAssociationMap
        • ObjcAssociation

KVO


kvo与NSNotificationCenter有什么区别

  • 相同点
    • 两者的实现都是观察者模式,都是用于监听
    • 都能实现一对多的操作
  • 不同点
    • KVO用于监听对象属性的变化,并且属性名都是通过NSString来查找,编译器不会检测对错与自动补全
    • NSNotification的发送监听的操作我们可以控制,KVO的由系统控制
    • KVO可以记录新旧值得变化

KVO对可变集合的监听

  • 通过[arr addObject:object]这种方式向数组添加元素,是不会触发KVO
  • 对可变集合需要调用对应的KVC方法,监听才能生效
    • [[self.person mutableArrayValueForkey:@"dataArray"] addObject:@"1"]

KVO的实现原理

  • 添加KVO之后,实例对象的isa指向了一个新的派生类NSKVONotifying_Class
    • 重写了原本类的观察属性的setter方法
    • 新增了_isKVO来判断当前是否是KVO类
  • 在观察移除之前对象的isa指向一直是派生类
  • 移除观察之后对象的呃isa指向原有类
  • 派生类一旦产生就会一直存在内存中,不会被销毁

KVC

API

  • valueForKey、setValueForKey
  • valueForKeyPath、setValue:ForKeyPath

KVC设值原理

  1. 查找是否有这三种setter方法
    1. set<Key>
    2. _set<Key>
    3. setIs<Key>

如果能找到上面三个中的任意一个,则直接设值属性的value

  1. 查找accessInstanceVariablesDirectly是否返回YES
    1. 如果返回YES,则查找间接访问的实例变量进行赋值,查找顺序为
      1. _<Key>
      2. _is<Key>
      3. <key>
      4. is<Key>
    2. 如果返回NO,则进入3

如果能找到任意一个实例变量i,ii,iii,iv,则直接赋值,否则进入3

  1. 如果setter方法或者实例变量都没有找到,系统会执行该对象的setValue:forUndefinedKey:方法,默认抛出NSUndefinedKeyException类型异常

KVC取值原理

  1. 首先查找getter方法,顺序为
    1. get<Key>
    2. <Key>
    3. is<Key>
    4. _<Key>

如果找到执行5

  1. 继续查找countOf<Key>objectIn<Key>AtInde<Key>AtIndex
    1. 如果找到countOf<Key>和其他两个中的一个,则会创建一个响应所有NSArray方法的集合代理对象,并返回该对象,即NSKeyValueArray,是NSArray的子类。代理对象随后将接收到的所有NSArray消息转换为countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:消息的某种组合,用来创建键值编码对象。如果原始对象还实现了一个名为get:range:之类的可选方法,则代理对象也将在适当时使用该方法(注意:方法名的命名规则要符合KVC的标准命名方法,包括方法签名。)
  2. 查找countOf <Key>enumeratorOf<Key>memberOf<Key>这三个方法
    1. 如果这三个方法都找到,则会创建一个响应所有NSSet方法的集合代理对象,并返回该对象,此代理对象随后将其收到的所有NSSet消息转换为countOf<Key>enumeratorOf<Key>memberOf<Key>:消息的某种组合,用于创建它的对象
  3. 检查类方法InstanceVariablesDirectly是否YES,依次搜索
    1. _<key>
    2. _is<Key>
    3. <key>
    4. is<Key>
  4. 根据属性的类型值,返回不同的结果
    1. 对象指针,直接返回结果
    2. 如果是NSNumber支持标量的类型,则将其存储在NSNumber实例中并返回
    3. 如果是NSNumber不支持的标量类型,请转换为NSValue对象
  5. 如果一直没有找到,系统会执行该对象的valueForUndefinedKey:方法,默认抛出NSUndefinedException异常

RunLoop

怎么保证子线程的数据回来更新UI操作不打断用户的滑动操作

将更新UI的事件,放到主线程的NSDefaultRunloopModel上执行,这样就会等用户不再滑动,主线程的RunLoop由UITrakingRunLoopModel切换到NSDefaultRunloopModel时再去更新UI

RunLoop有几种模式

  1. kCFRunloopDefultModel:默认模式,主线程也是在此model下执行
  2. UITrakingRunLoopModel:跟踪用户事件
  3. UIInitializetionRunLoopModel:在刚启动APP时第一个Model,启动完成后就不再使用
  4. GSEventReceiveRunloopModel:接受内部事件,一般用不到
  5. kCFRunloopCommonModels:伪model,是同步source/timer/observer到多个Model的一种解决方案

面向对象的三大特性

  • 封装
  • 继承
  • 多肽

网络

http与https有什么区别

  • Http协议 = Http协议 + SSL/TLS协议

SSL全程是Secure Sockets Layer,即安全套接层协议,是为网络通信提供安全及数据完整性的一种安全协议。TLS全称是Transport Layer Security,即安全传输层协议,即HTTPS是安全的HTTP

TCP

  • 三次握手
    • 发出链接请求
      • 客户端的TCP首先向服务端的TCP发送一条特殊的SYN报文
      • 发送SYN报文后,客户端进入SYN_SENT状态,等待服务端确认,并将SYN比特置为1的报文段
    • 授予链接
      • 收到SYN报文后,服务端会为该TCP链接分配TCP缓存和变量,服务端的TCP进入SYN_RCVD状态,等待客户端TCP发送确认报文
      • 向客户端发送允许链接的SYNACK报文段
    • 确认,并建立链接
      • 收到SYNACK报文段后,客户端也要为TCP分配缓存和变量,客户端的TCP进入ESTABLISHED状态
      • 向服务端TCP发送一个报文段,这最后一个报文段对服务端的允许连接的报文表示了确认(将 server_isn + 1 放到报文段首部的确认字段中)。因为连接已经建立了,所以该 SYN 比特被置为 0。 这个阶段,可以在报文段负载中携带应用层数据
      • 收到客户端该报文段后,服务端TCP也会进入ESTABLISHED状态,可以发送和接收包含有效载荷数 据的报文段。
  • 四次挥手
    • 客户端发出终止FIN=1报文段
      • 客户端向服务端发送FIN=1的报文段,并进入FIN_WAIT_1状态
    • 服务端向客户端发送确认报文ACK=1
      • 收到客户端发来的FIN=1的报文后,向客户端发送确认报文
      • 服务端TCP进入CLOSE_WAIT状态
      • 客户端送到确认报文后,进入FINAL_WAIT_2状态,等待服务端FIN=1的报文
    • 服务端向客户端发送FIN=1报文段
      • 服务端发送FIN=1的报文
      • 服务端进入LAST_ACK状态
    • 客户端向服务端发送ACK=1报文段
      • 收到服务端的终止报文后,向服务端发送一个确认报文,并进入TIME_WAIT状态
      • 如果ACK丢失,TIME_WAIT会使客户端TCP重传ACK报文。最后关闭,进入CLOSE状态,释放缓存和变量
      • 服务端收到之后,TCP也会进入CLOSE状态,释放资源
  • 为什么建立链接需要三次握手,而断开链接缺需要四次挥手
    • 因为,在服务端发送ACK信号后,还有可能数据传输没有完成
    • 数据传输完成才会发送FIN=1的信号
  • 在四次握手中,客户端为什么在TIME_WAIT后必须等待2MSL时间呢
    • 为了保证客户端发送的最后一个ACK报文段能够到达服务器
  • 为什么要三次握手,而不是二次或者三次
    • 两次握手会可能导致已失效的连接请求报文段突然又传送到了服务端产生错误,四次握手又太浪费资源

PUT和POST,POST与GET的区别

PUT VS POST

push和post都有更改指定URL的语义,但PUT被定义为idempotent的方法,post则不是。idempotent多个请求产生的效果是一样的

  • PUT请求,多个请求,后面的请求会覆盖掉前面的请求
  • POST请求,后一个请求不会覆盖掉前一个请求

GET VS POST

  • GET参数通过URL传递,POST放在Request body中
  • GET请求会被浏览器主动cache,而POST不会,除非手动设置
  • GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留
  • Get 请求中有非 ASCII 字符,会在请求之前进行转码,POST不用,因为POST在Request body中,通过 MIME,也就可以传输非 ASCII 字符
  • 一般我们在浏览器输入一个网址访问网站都是GET请求
  • HTTP的底层是TCP/IP。HTTP只是个行为准则,而TCP才是GET和POST怎么实现的基本。GET/POST都是TCP链接。GET和POST能做的事情是一样一样的。但是请求的数据量太大对浏览器和服务器都是很大负担。所以业界有了不成文规定,(大多数)浏览器通常都会限制url长度在2K个字节,而(大多数)服务器最多处理64K大小的url
  • GET产生一个TCP数据包;POST产生两个TCP数据包。对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)
  • 在网络环境好的情况下,发一次包的时间和发两次包的时间差别基本可以无视。而在网络环境差的情况下,两次包的TCP在验证数据包完整性上,有非常大的优点。但并不是所有浏览器都会在POST中发送两次包,Firefox就只发送一次。

HTTP的请求方式有哪些

  • GET
  • POST
  • PUT
  • DELET
  • HEAD
  • OPTIONS

cookie和session的区别

  • 存放位置不同
    • cookie存放在客户的浏览器
    • session存在在服务器上
  • 安全程度不同
    • cookie不是很安全,其他人可以分析存放在本地的COOKIE并进行COOKIE欺骗,考虑到安全应当使用session
  • 性能使用程度不同
    • session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能,考虑到减轻服务器性能方面,应当使用cookie。
  • 数据存储大小不同
    • 单个cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个cookie,而session则存储与服务端,浏览器对其没有限制
  • 会话机制不同
    • session会话机制:session会话机制是一种服务器端机制,它使用类似于哈希表(可能还有哈希表)的结构来保存信息
    • cookies会话机制:cookie是服务器存储在本地计算机上的小块文本,并随每个请求发送到同一服务器。 Web服务器使用HTTP标头将cookie发送到客户端。在客户端终端,浏览器解析cookie并将其保存为本地文件,该文件自动将来自同一服务器的任何请求绑定到这些cookie。

Swift

swift类与结构体的区别

  • 关键字
    • class
    • struct
  • class定义的属性必须初始化, struct不用初始化
// class定义
class Person {
    final var name:String = ""  
}

// strcut定义
struct Person{
    var name:String
}

复制代码
  • 定义函数

class可以使用关键字static修饰,struct只能使用static修饰

  • 扩展下标

class和struct都可以使用扩展下标

  • 初始化

结构体有默认的初始化方法

  • 结构体不能继承
  • 类是引用类型,结构体是值类型
  • 类有deinit方法,结构体没有

什么时候使用结构体

  • 用于封装简单的数据结构类型
  • 结构在传递的时候是被赋值,而不是被引用
  • 不需要继承或者方法

swift怎么防止父类方法在子类被重写

class Person {
    final var name:String = ""
    final func personName() {
      
    }   
}
复制代码

final关键字

Guess you like

Origin juejin.im/post/6971808317282730015