工程师如何“神还原”用户问题?闲鱼回放技术揭秘

发布于 2018-11-01  约 34 分钟

我们透过系统底层来捕获ui事件流和业务数据的流动,并利用捕获到的这些数据通过事件回放机制来复现线上的问题。本文先介绍录制和回放的整体框架,接着介绍里面涉及到的3个关键技术点,也是这里最复杂的技术(模拟触摸事件,统一拦截器实现,统一hook block)。

背景

现在的app基本都会提供用户反馈问题的入口,然而提供给用户反馈问题一般有两种方式:

● 直接用文字输入表达,或者截图
● 直接录制视频反馈
这两种反馈方式常常带来以下抱怨:

● 用户:输入文字好费时费力
● 开发1:看不懂用户反馈说的是什么意思?
● 开发2:大概看懂用户说的是什么意思了,但是我线下没办法复现哈
● 开发3:看了用户录制的视频,但是我线下没办法重现,也定位不到问题
所以,为了解决以上问题,我们用一套全新的思路来设计线上问题回放体系。

线上问题回放体系的意义
● 用户不需要输入文字反馈问题,只需要重新操作一下app重现问题步骤即可。
● 开发者拿到用户反馈的问题脚本后,通过线下回放对问题一目了然,跟录制视频效果一样,是的,你没看错,就是跟看视频一样。
● 通过脚本的回放实时获取到app运行时相关数据(本地数据,网络数据,堆栈等等), 以便排查问题。
● 为后续自动测试提供想象空间——你懂的。
效果视频

技术原理

1.app与外部环境的关系

从上面的关系图可以看出,整个app的运行无非是用户ui操作,然后触发app从外界获取数据,包括网络数据,gps数据等等,也包括从手机本地获取数据,比如相册数据,机器数据,系统等数据。 所以我们要实现问题回放只需要记录用户的UI操作和外界数据,app自身数据即可。

app录制 = 用户的UI操作 + 外界数据(手机内和手机外) + app自身数据

2.线上问题回放架构由两部分组成:录制和回放

录制是为回放服务,录制的信息越详细,回放成功率就越高,定位问题就越容易

录制其实就是把ui和数据记录下来,回放其实就是app自动驱动UI操作并把录制时的数据塞回相应的地方。

3.录制架构图

录制流程:

4.回放架构图

回放跟录制框架图基本一样,实际上录制和回放的代码是在一起,逻辑也是统一的,为了便于表达,我人为划分成两个架构图出来。

回放的流程: 

1.启动app,点击回放按钮。

2.引擎加载回放脚本。

3.从脚本中解析出需要注册的运行时事件并注册,在回放里不需要业务上层来注册事件,这里跟录制是不一样的。

4.从脚本中解析出需要注册的静态数据事件并注册。

5.从脚本中解析出需要播放的事件数据,并组成消费队列。

6.启动播放器,从消费队列里读取一个个事件来播放,如果是ui事件则直接播放,如果是静态数据事件则直接按照指令要求替换数据值,如果是非ui运行时事件则通过事件指令规则来确定是主动播放还是等待拦截对应的事件,如果需要等待拦截对应的事件,则播放器会一直等待此事件直到此事件被app消费掉为止。只有此事件被消费了,播放器才能播放下一个事件。

7.当拦截到被注册的事件后,根据此事件指令要求把相应的数据塞到相应的字段里。

8.跳回6继续运行,直到消费队列里的事件被消费完。

注意:回放每个事件时会实时自动打印出相应的堆栈信息和事件数据,有利于排查问题

关键技术介绍

1.模拟触摸事件

从ui事件数据解中析出被触摸的view,以及此view所在的视图树中的层级关系,并在当前回放界面上查找到对应的view,然后往该view上发送ui操作事件(点击,双击等等),并带上触摸事件的坐标信息,其实这里是模拟触摸事件。我们先来介绍触摸事件的处理流程:

等待触摸阶段

 ●  手机屏幕处于待机状态,等待触摸事件发生
 ●  手指开始触摸屏幕

系统反应阶段

 ●  屏幕感应器接收到触摸,并将触摸数据传给系统IOKit(IOKit是苹果的硬件驱动框架)
 ●  系统IOKit封装该触摸事件为IOHIDEvent对象
 ●  接着系统IOKit把IOHIDEvent对象转发给SpringBoard进程

SpringBoard进程就是iOS的系统桌面,它存在于iDevice的进程中,不可清除,它的运行原理与Windows中的explorer.exe系统进程相类似。它主要负责界面管理,所以只有它才知道当前触摸到底有谁来响应。

SpringBoard接收阶段

 ●  SpringBoard收到IOHIDEvent消息后,触发runloop中的Source1回调__IOHIDEventSystemClientQueueCallback()方法。
 ●  SpringBoard开始查询前台是否存在正在运行的app,如果存在,则SpringBoard通过进程通信方式把此触摸事件转发给前台当前app,如果不存在,则SpringBoard进入其自己内部响应过程。

app处理阶段

 ●  前台app主线程Runloop收到SpringBoard转发来的消息,并触发对应runloop 中的Source1回调_UIApplicationHandleEventQueue()。
 ●  _UIApplicationHandleEventQueue()把IOHIDEvent处理包装成UIEvent进行处理分发。
 ●  Soucre0回调内部UIApplication的sendEvent:方法,将UIEvent传给UIWindow。
 ●  在UIWindow为根节点的整棵视图树上通过hitTest(_:with:)和point(inside:with:)这两个方法递归查找到合适响应这个触摸事件的视图。
 ●  找到最终的叶子节点视图后,就开始触发此视图绑定的相应事件,比如跳转页面等等。

从上面触摸事件处理过程中我们可以看出要录制ui事件只需要在app处理阶段中的UIApplication sendEvent方法处截获触摸数据,回放时也是在这里把触摸模拟回去。

下面是触摸事件录制的代码,就是把UITouch相应的数据保存下来即可 这里有一个关键点,需要把touch.timestamp的时间戳记录下来,以及把当前touch事件距离上一个touch事件的时间间隔记录下来,因为这个涉及到触摸引起惯性加速度问题。比如我们平时滑动列表视图时,手指离开屏幕后,列表视图还要惯性地滑动一小段时间。

 (void)handleUIEvent:(UIEvent *)event

{

if (!self.isEnabled) return;

if (event.type != UIEventTypeTouches) return;

NSSet *allTouches = [event allTouches];

UITouch *touch = (UITouch *)[allTouches anyObject];

if (touch.view) {

if (self.filter && !self.filter(touch.view)) {

return;

}

}

switch (touch.phase) {

case UITouchPhaseBegan:

{

self.machAbsoluteTime = mach_absolute_time();

self.systemStartUptime = touch.timestamp;

self.tuochArray = [NSMutableArray array];

[self recordTouch:touch click:self.machAbsoluteTime];

break;

}

case UITouchPhaseStationary:

{

[self recordTouch:touch click:mach_absolute_time()];

break;

}

case UITouchPhaseCancelled:

{

[self recordTouch:touch click:mach_absolute_time()];

[[NSNotificationCenter defaultCenter] postNotificationName:@"notice_ui_test" object:self.tuochArray];

break;

}

case UITouchPhaseEnded:

{

[self recordTouch:touch click:mach_absolute_time()];

[[NSNotificationCenter defaultCenter] postNotificationName:@"notice_ui_test" object:self.tuochArray];

break;

}

case UITouchPhaseMoved:

{

[self recordTouch:touch click:mach_absolute_time()];

}

default:

break;

}

}

我们来看一下代码怎么模拟单击触摸事件(为了容易理解,我把有些不是关键,复杂的代码已经去掉),接着我们来看一下模拟触摸事件代码 一个基本的触摸事件一般由三部分组成:

● UITouch对象 - 将用于触摸
● 第一个UIEvent Began触摸
● 第二个UIEvent Ended触摸
实现步骤:

1.代码的前面部分都是一些UITouch和UIEvent私有接口,私有变量字段,由于苹果并不公开它们,为了让其编译不报错,所以我们需要把这些字段包含进来,回放是在线下,所以不必担心私有接口被拒的事情。

2.构造触摸对象:UITouch和UIEvent,把记录对应的字段值塞回相应的字段。塞回去就是用私有接口和私有字段。

3.触摸的view位置转换为Window坐标,然后往app里发送事件 [[UIApplication sharedApplication] sendEvent:event];

4.要回放这些触摸事件,我们需要把他丢到CADisplayLink里面来执行。

//

// SimulationTouch.m

//

// Created by 诗壮殷 on 2018/5/15.

//

#import "SimulationTouch.h"

#import <objc/runtime.h>

#include <mach/mach_time.h>


@implementation UITouch (replay)

- (id)initPoint:(CGPoint)point window:(UIWindow *)window

{

NSParameterAssert(window);

self = [super init];

if (self) {

[self setTapCount:1];

[self setIsTap:YES];

[self setPhase:UITouchPhaseBegan];

[self setWindow:window];

[self _setLocationInWindow:point resetPrevious:YES];

[self setView:[window hitTest:point withEvent:nil]];

[self _setIsFirstTouchForView:YES];

[self setTimestamp:[[NSProcessInfo processInfo] systemUptime]];

}

return self;

}

@end

@interface UIInternalEvent : UIEvent

- (void)_setHIDEvent:(IOHIDEventRef)event;

@end

@interface UITouchesEvent : UIInternalEvent

- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayedDelivery;

- (void)_clearTouches;

@end


typedef enum {

kIOHIDDigitizerEventRange = 0x00000001,

kIOHIDDigitizerEventTouch = 0x00000002,

kIOHIDDigitizerEventPosition = 0x00000004,

} IOHIDDigitizerEventMask;

IOHIDEventRef IOHIDEventCreateDigitizerFingerEvent(CFAllocatorRef allocator,

AbsoluteTime timeStamp,

uint32_t index,

uint32_t identity,

IOHIDDigitizerEventMask eventMask,

IOHIDFloat x,

IOHIDFloat y,

IOHIDFloat z,

IOHIDFloat tipPressure,

IOHIDFloat twist,

Boolean range,

Boolean touch,

IOOptionBits options);

@implementation SimulationTouch

- (void)performTouchInView:(UIView *)view start:(bool)start

{

UIWindow *_window = view.window;

CGRect fInWindow;

if ([view isKindOfClass:[UIWindow class]])

{

fInWindow = view.frame;

}

else

{

fInWindow = [_window convertRect:view.frame fromView:view.superview];

}

CGPoint point =

CGPointMake(fInWindow.origin.x + fInWindow.size.width/2,

fInWindow.origin.y + fInWindow.size.height/2);

if(start)

{

self.touch = [[UITouch alloc] initPoint:point window:_window];

[self.touch setPhase:UITouchPhaseBegan];

}

else

{

[self.touch _setLocationInWindow:point resetPrevious:NO];

[self.touch setPhase:UITouchPhaseEnded];

}

CGPoint currentTouchLocation = point;

UITouchesEvent *event = [[UIApplication sharedApplication] _touchesEvent];

[event _clearTouches];

uint64_t machAbsoluteTime = mach_absolute_time();

AbsoluteTime timeStamp;

timeStamp.hi = (UInt32)(machAbsoluteTime >> 32);

timeStamp.lo = (UInt32)(machAbsoluteTime);

[self.touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];

IOHIDDigitizerEventMask eventMask = (self.touch.phase == UITouchPhaseMoved)

? kIOHIDDigitizerEventPosition

: (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch);

Boolean isRangeAndTouch = (self.touch.phase != UITouchPhaseEnded);

IOHIDEventRef hidEvent = IOHIDEventCreateDigitizerFingerEvent(kCFAllocatorDefault,

timeStamp,

0,

2,

eventMask,

currentTouchLocation.x,

currentTouchLocation.y,

0,

0,

0,

isRangeAndTouch,

isRangeAndTouch,

0);

if ([self.touch respondsToSelector:@selector(_setHidEvent:)]) {

[self.touch _setHidEvent:hidEvent];

}

[event _setHIDEvent:hidEvent];

[event _addTouch:self.touch forDelayedDelivery:NO];

[[UIApplication sharedApplication] sendEvent:event];

}

@end

总的来说就下载苹果提供触摸事件的源码库,分析源码,然后设置断掉调试,甚至反汇编来理解触摸事件的原理。

2.统一拦截器

录制和回放都居于事件流来处理的,而数据的事件流其实就是对一些关键方法的hook,由于我们为了保证对业务代码无侵入和扩展性(随便注册事件),我们需要对所有方法统一hook,所有的方法由同一个钩子来响应处理。如下图所示

这个钩子是用用汇编编写,由于汇编代码比较多,而且比较难读懂,所以这里暂时不附上源码,汇编层主要把硬件里面的一些数据统一读取出来,比如通用寄存器数据和浮点寄存器数据,堆栈信息等等,甚至前面的前面的方法参数都可以读取出来,最后转发给c语言层处理。

汇编层把硬件相关信息组装好后调用c层统一拦截接口,汇编层是为c层服务。c层无法读取硬件相关信息,所以这里只能用汇编来读取。c层接口通过硬件相关信息定位到当前的方法是属于哪个事件,知道了事件,也意味着知道了事件指令,知道了事件指令,也知道了哪些字段需要塞回去,也知道了被hook的原始方法。

c层代码介绍如下: 由于是统一调用这个拦截器,所以拦截器并不知道当前是哪个业务代码执行过来的,也不知道当前这个业务方法有多少个参数,每个参数类型是什么等等,这个接口代码处理过程大概如下:

 ●  通过寄存器获取对象self
 ●  通过寄存器获取方法sel
 ●  通过self和sel获取对应的事件指令
 ●  通过事件指令回调上层来决定是否往下执行
 ●  获取需要回放该事件的数据
 ●  把数据塞回去,比如塞到某个寄存器里,或者塞到某个寄存器所指向的对象的某个字段等等
 ●  如果需要立即回放则调用原来被hook的原始方法,如果不是立即回放,则需要把现场信息保存起来,并等待合适的时机由播放队列来播放(调用)

//xRegs 表示统一汇编器传入当前所有的通用寄存器数据,它们地址存在一个数组指针里

//dRegs 表示统一汇编器传入当前所有的浮点寄存器数据,它们地址也存在一个数组指针里

//dRegs 表示统一汇编器传入当前堆栈指针

//fp 表示调用栈帧指针

void replay_entry_start(void* xRegs, void* dRegs, void* spReg, CallBackRetIns *retIns,StackFrame *fp, void *con_stub_lp)

{

void *objAdr = (((void **)xRegs)[0]);//获取对象本身self或者block对象本身

EngineManager *manager = [EngineManager sharedInstance];

ReplayEventIns *node = [manager getEventInsWithBlock:objAdr];

id obj = (__bridge id)objAdr;

void *xrArg = ((void **)xRegs)+2;

if(nil == node)

{

SEL selecter = (SEL)(((void **)xRegs)[1]); //对应的对象调用的方法

Class tclass = [obj class];//object_getClass(obj);object_getClass方法只能通过对象获取它的类,不能传入class 返回class本身,

do

{

node = [manager getEventIns:tclass sel:selecter];//通过对象和方法获取对应的事件指令节点

}while(nil == node && (tclass = class_getSuperclass(tclass)));

}

else

{

xrArg = ((void **)xRegs)+1;

}

assert(node && "node is nil in replay_call_start");

//回调通知上层当前回放是否打断

if(node.BreakCurReplayExe && node.BreakCurReplayExe(obj,node,xrArg,dRegs))

{

retIns->nodeAddr = NULL;

retIns->recordOrReplayData = NULL;

retIns->return_address = NULL;

return;

}

bool needReplay = true;

//回调通知上层当前即将回放该事件

if(node.willReplay)

{

needReplay = (*(node.willReplay))(obj,node,xrArg,dRegs);

}

if(needReplay)

{

ReplayEventData *replayData = nil;

if(node.getReplayData)

{

//获取回放该事件对应的数据

replayData = (*(node.getReplayData))(obj,node,xrArg,dRegs);

}

else//默认获取方法

{

replayData = [manager getNextReplayEventData:node];

}

//以下就是真正的回放,即是把数据塞回去,并调用原来被hook的方法

if(replayData)

{

if(replay_type_intercept_call == node.replayType)

{

sstuffArg(xRegs,dRegs,spReg,node,replayData.orgDic);

NSArray *arglist = fetchAllArgInReplay(xRegs, dRegs, spReg, node);

ReplayInvocation *funobj = [[ReplayInvocation alloc] initWithFunPtr:node.callBack ? node.callBack : [node getOrgFun]

args:arglist

argType:[node getFunTypeStr]

retType:rf_return_type_v];

if([[EngineManager sharedInstance] setRepalyEventReady:replayData funObj:funobj])

{

//放到播放队列里播放,返回没调用地址,让其不往下走

retIns->return_address = NULL;

return ;

}

}

else

{

//塞数据

sstuffArg(xRegs,dRegs,spReg,node,replayData.orgDic);

}

}

retIns->nodeAddr = (__bridge void *)node;

retIns->recordOrReplayData = (__bridge void *)replayData;

retIns->return_address = node.callBack ? node.callBack : [node getOrgFun];

replayData.runStatus = relay_event_run_status_runFinish;

}

else

{

retIns->nodeAddr = NULL;

retIns->recordOrReplayData = NULL;

retIns->return_address = [node getOrgFun];

}

}

3.怎样统一hook block
如果你只是想大概理解block的底层技术,你只需google一下即可。 如果你想全面深入的理解block底层技术,那网上的那些资料远远满足不了你的需求。 只能阅读苹果编译器clang源码和列出比较有代表性的block例子源码,然后转成c语言和汇编,通过c语言结合汇编研究底层细节。

何谓 oc block?
● block就是闭包,跟回调函数callback很类似,闭包也是对象。
● blcok的特点: 1.可有参数列表 2.可有返回值 3.有方法体 4.capture上下文变量 5.有对象引用计数的内存管理策略(block生命周期)。
● block的一般存储在内存中形态有三种 _NSConcretStackBlock(栈)_NSConcretGlobalBlock(全局)_NSConcretMallocBlock(堆)。
系统底层怎样表达block?
我们先来看一下block的例子:

void test()

{

__block int var1 = 8; //上下文变量

NSString *var2 = @"我是第二个变量”; //上下文变量

void (^block)(int) = ^(int arg)//参数列表

{

var1 = 6;

NSLog(@"arg = %d,var1 = %d, var2 = %@", arg, var1, var2);

};

block(1);//调用block语法

dispatch_async(dispatch_get_global_queue(0, 0), ^

{

block(2); //异步调用block

});

}

这段代码首先定义两个变量,接着定义一个block,最后调用block。

● 两个变量:这两个变量都是被block引用,第一个变量有关键字__block,表示可以在block里对该变量赋值,第二个变量没有__block关键字,在block里只能读,不能写。
● 两个调用block的语句:第一个直接在当前方法test()里调用,此时的block内存数据在栈上,第二个是异步调用,就是说当执行block(2)时test()可能已经运行完了,test()调用栈可能已经被销毁。那这种情况block的数据肯定不能在栈上,只能在堆上或者在全局区。
系统底层表达block比较重要的几种数据结构如下:

注意:虽然底层是用这些结构体来表达block,但是它们并不是源码,是二进制代码

enum

{

BLOCK_REFCOUNT_MASK = (0xffff),

BLOCK_NEEDS_FREE = (1 << 24),

BLOCK_HAS_COPY_DISPOSE = (1 << 25),

BLOCK_HAS_CTOR = (1 << 26),//todo == BLOCK_HAS_CXX_OBJ?

BLOCK_IS_GC = (1 << 27),

BLOCK_IS_GLOBAL = (1 << 28),

BLOCK_HAS_DESCRIPTOR = (1 << 29),//todo == BLOCK_USE_STRET?

BLOCK_HAS_SIGNATURE = (1 << 30),

OBLOCK_HAS_EXTENDED_LAYOUT = (1 << 31)

};

enum

{

BLOCK_FIELD_IS_OBJECT = 3,

BLOCK_FIELD_IS_BLOCK = 7,

BLOCK_FIELD_IS_BYREF = 8,

OBLOCK_FIELD_IS_WEAK = 16,

OBLOCK_BYREF_CALLER = 128

};

typedef struct block_descriptor_head

{

unsigned long int reserved;

unsigned long int size; //表示主体block结构体的内存大小

}block_descriptor_head;

typedef struct block_descriptor_has_help

{

unsigned long int reserved;

unsigned long int size; //表示主体block结构体的内存大小

void (*copy)(void *dst, void *src);//当block被retain时会执行此函数指针

void (*dispose)(void *);//block被销毁时调用

struct block_arg_var_descriptor *argVar;

}block_descriptor_has_help;

typedef struct block_descriptor_has_sig

{

unsigned long int reserved;

unsigned long int size;

const char *signature;//block的签名信息

struct block_arg_var_descriptor *argVar;

}block_descriptor_has_sig;

typedef struct block_descriptor_has_all

{

unsigned long int reserved;

unsigned long int size;

void (*copy)(void *dst, void *src);

void (*dispose)(void *);

const char *signature;

struct block_arg_var_descriptor *argVar;

}block_descriptor_has_all;

typedef struct block_info_1

{

void *isa;//表示当前blcok是在堆上还是在栈上,或在全局区_NSConcreteGlobalBlock

int flags; //对应上面的enum值,这些枚举值是我从编译器源码拷贝过来的

int reserved;

void (*invoke)(void *, ...);//block对应的方法体(执行体,就是代码段)

void *descriptor;//此处指向上面几个结构体中的一个,具体哪一个根据flags值来定,它用来进一步来描述block信息

//从这个字段开始起,后面的字段表示的都是此block对外引用的变量。

NSString *var2;

byref_var1_1 var1;

} block_info_1;

这个例子中的block在底层表达大概如下图:

首先用block_info_1来表达block本身,然后用block_desc_1来具体描述block相关信息(比如block_info_1结构体大小,在堆上还是在栈上?copy或dispose时调用哪个方法等等),然而block_desc_1具体是哪个结构体是由block_info_1中flags字段来决定的,block_info_1里的invoke字段是指向block方法体,即是代码段。block的调用就是执行这个函数指针。由于var1是可写的,所以需要设计一个结构体(byref_var1_1)来表达var1,为什么var2直接用他原有的类型表达,而var1要用结构体来表达。篇幅有限,这个自己想想吧?

block小结

 ●  为了表达block,底层设计三种结构体:block_info_1,block_desc_1,byref_var1_1,三种函数指针: block invoke方法体,copy方法,dispose方法
 ●  其实表达block是非常复杂的,还涉及到block的生命周期,内存管理问题等等,我在这里只是简单的贯穿主流程来介绍的,很多细节都没介绍。

怎样统一 hook block?

通过上面的分析,得知oc里的block就是一个结构体指针,所以我在源码里可以直接把它转成结构体指针来处理。 统一hook block源码如下:

VoidfunBlock createNewBlock(VoidfunBlock orgblock, ReplayEventIns *blockEvent,bool isRecord)

{

if(orgblock && blockEvent)

{

VoidfunBlock newBlock = ^(void)

{

orgblock();

if(nil == blockEvent)

{

assert(0);

}

};

trace_block_layout *blockLayout = (__bridge trace_block_layout *)newBlock;

blockLayout->invoke = (void (*)(void *, ...))(isRecord?hook_var_block_callBack_record:hook_var_block_callBack_replay);

return newBlock;

}

return nil;

}

我们首先新建一个新的block newBlock,然后把原来的block orgblock 和 事件指令blockEvent包到新的blcok中,这样达到引用的效果。然后把

新的block转

成结构体指针,并把结构体指针中的字段invoke(方法体)指向统一回调方法。你可能诧异新的block是没有参数类型的,原来block是有参数类型,

外面调用原

来block传递参数时会不会引起crash?答案是否定的,因为这里构造新的block时 我们只用block数据结构,block的回调方法字段已经被阉割,回

调方法已经指

向统一方法了,这个统一方法可以接受任何类型的参数,包括没有参数类型。这个统一方法也是汇编实现,代码实现跟上面的汇编层代码类似,这

里就不附上源

码了。

那怎样在新的blcok里读取原来的block和事件指令对象呢? 代码如下:

void var_block_callback_start_record(trace_block_layout * blockLayout)

{

VoidfunBlock orgBlock = (__bridge VoidfunBlock)(*((void **)((char *)blockLayout + sizeof(trace_block_layout))));

ReplayEventIns *node = (__bridge ReplayEventIns *)(*((void **)((char *)blockLayout + 40)));

}

总结

本文大概介绍了问题回放框架,接着介绍三个关键技术。这三个技术相对比较深入,欢迎在留言区评论,我们期待与大家交流,共同探讨。

云服务器99元拼团购!拉新还可赢现金红包!300万等你瓜分!
马上一键开团赢红包: http://click.aliyun.com/m/100...



本文作者:镜空

阅读原文

本文来自云栖社区合作伙伴“阿里技术",如需转载请联系原作者。

阅读 797发布于 2018-11-01

推荐阅读
目录