Vas Sonic的源码分析

最近在研读Vas Sonic的源码,Sonic是一款轻量级的高性能Hybrid框架,由腾讯QQ会员团队开发,专注于提升H5页面首屏加载速度。

首屏就是指用户在没有滚动时候看到的内容渲染完成并且可以交互的时间。至于加载时间,则是整个页面滚动到底部,所有内容加载完毕并可交互的时间。

H5以其开发和维护的成本较低,开发周期较短的天然优势满足了APP快速迭代的需求。目前很多APP或多或少接入了H5页面,但H5存在的缺点是加载速度慢,造成不好的用户体验。因此,如何优化H5的加载速度可以有效提升用户的满意度。

话不多说,接下来我们看看Sonic这个开源库到底是一个什么样的实现原理,首先给大家奉上Sonic的GitHub地址 点我

看一个开源库,我通常会摸清楚其类层次关系,从整体把握其组件,然后在抽茧剥丝。不然会像走入一个迷宫,有种“只在此山中,云深不知处”的感觉。

下面是我绘制Sonic iOS库的UML类图:


可以梳理出其包含SonicURLProtocol, SonicEngine, SonicSession, SonicSever和SonicConnection五个组件及其相互之间的联系。下面我们从源码中来分析这几个组件的角色和发挥的作用。

1. SonicURLProtocol

看到SonicURLProtocol这个类,我们立刻就能联想到Foundation库的NSURLProtocol类,用户可以通过子类化NSURLProtocol类来对上层的URLRequest请求做拦截,并根据自己的需求场景做定制化响应处理。具体介绍详见iOS 开发中使用 NSURLProtocol 拦截 HTTP 请求。SonicURLProtocol利用这个原理来对UIWebView的请求进行拦截,实现自定义页面数据加载和缓存。

SonicURLProtocol有三个重要的方法:

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{    
 NSString *value = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyLoadType];
 if (value.length != 0 && [value isEqualToString:SonicHeaderValueWebviewLoad]) {
     NSString * delegateId = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyDelegateId];
     if (delegateId.length != 0) {
         NSString * sessionID = sonicSessionID(request.URL.absoluteString);
         SonicSession *session = [[SonicEngine sharedEngine] sessionWithDelegateId:delegateId];
         if (session && [sessionID isEqualToString:session.sessionID]) {
             return YES;
         }
       ...
     }
 }
 return NO;
}

这个方法重写了NSURLProtocol类的方法,主要过滤需要拦截的请求,只有这个方法返回YES我们才能够继续后续的处理。通过这个方法的实现里面进行请求的过滤,筛选出webView的网络请求进行处理的请求。也就是请求头中含有key值SonicHeaderKeyLoadType对应的值为SonicHeaderValueWebviewLoad的NSURLRequest需要被拦截。

接着会根据请求头中的delegate去SonicEngine中寻找SonicSession,如果找到了对应的SonicSession,接下来会对这个request进行拦截。 那么SonicSession是什么时候被初始化并注册到SonicEngine中的呢?后面我们会进行讲解。

下面我们继续看第2个方法,代码如下:


- (void)startLoading 
{    
 NSThread *currentThread = [NSThread currentThread];

 NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
 
 __weak typeof(self) weakSelf = self;
 
 [[SonicEngine sharedEngine] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
     
     [weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
     
 }];
}

这个方法是在请求开始的时候,会被执行。这里做的主要操作是注册了回调,也就是请求结束返回结果后作出对应的操作。核心操作是回调了callClientActionWithParams,也就是我们将要呈现的第三个方法,代码如下:

 - (void)callClientActionWithParams:(NSDictionary *)params
{
   SonicURLProtocolAction action = [params[kSonicProtocolAction]integerValue];
   switch (action) {
       case SonicURLProtocolActionRecvResponse:
       {
           NSHTTPURLResponse *resp = params[kSonicProtocolData];
           [self.client URLProtocol:self didReceiveResponse:resp cacheStoragePolicy:NSURLCacheStorageNotAllowed];
       }
           break;
       case SonicURLProtocolActionLoadData:
       {
           NSData *recvData = params[kSonicProtocolData];
           if (recvData.length > 0) {
               [self.client URLProtocol:self didLoadData:recvData];
           }
       }
           break;
       case SonicURLProtocolActionDidSuccess:
       {
           [self.client URLProtocolDidFinishLoading:self];
       }
           break;
       case SonicURLProtocolActionDidFaild:
       {
           NSError *err = params[kSonicProtocolData];
           [self.client URLProtocol:self didFailWithError:err];
       }
           break;
   }
}
 

可以看到根据返回的不同的Action作出相应的处理。这里主要是把数据传回请求发起者client(这里就是UIWebView),帮助其正确渲染。

可以看到这个类和我们平时使用一样,主要就是拦截浏览器的请求,然后自定义请求得到数据返回给浏览器,进行渲染。

2. SonicEngine

顺藤摸瓜,我们看到第二个组件类SonicEngine,这是个单例对象类,通过上面的类图可以看到它主要作用是用来创建和管理SonicSession类,其对外暴露了两个重要的接口:

通过url和delegate来创建SonicSession

- (void)createSessionWithUrl:(NSString *)url withWebDelegate:(id<SonicSessionDelegate>)aWebDelegate withConfiguration:(SonicSessionConfiguration *)configuration

外部请求发起者注册结果回调处理

- (void)registerURLProtocolCallBackWithSessionID:(NSString *)sessionID completion:(SonicURLProtocolCallBack)protocolCallBack

这个类主要充当中转站的作用,同时管理SonicSession类,根据请求者和请求的URL分配Session来完成网络请求。比较简单,下面我们具体看下核心处理类SonicSession。

3. SonicSession

SonicSession由请求的url和delegate(WebView的持有者)唯一确定。首先我们看SonicSession何时后会被初始化。

在Sonic iOS提供的官方例子中可以看到,他们推荐在WebView初始化的时候,创建的SonicSession。Session的创建由SonicEngine来完成,代码如下:

- (void)createSessionWithUrl:(NSString *)url withWebDelegate:(id<SonicSessionDelegate>)aWebDelegate withConfiguration:(SonicSessionConfiguration *)configuration
{
...    
 
 [self.lock lock];
 SonicSession *existSession = self.tasks[sonicSessionID(url)];
 if (existSession && existSession.delegate != nil) {
     //session can only owned by one delegate
     [self.lock unlock];
     return;
 }
 
 if (!existSession) {
     //创建Session
     existSession = [[SonicSession alloc] initWithUrl:url withWebDelegate:aWebDelegate Configuration:configuration];
     
     NSURL *cUrl = [NSURL URLWithString:url];
     existSession.serverIP = [self.ipDomains objectForKey:cUrl.host];
     
     __weak typeof(self) weakSelf = self;
     __weak typeof(existSession)weakSession = existSession;
     [existSession setCompletionCallback:^(NSString *sessionID){
         [weakSession cancel];
         [weakSelf.tasks removeObjectForKey:sessionID];
     }];
     
     [self.tasks setObject:existSession forKey:existSession.sessionID];
     [existSession start]; //启动Session
     [existSession release];

 } else {
     
     if (existSession.delegate == nil) {
         existSession.delegate = aWebDelegate;
     }
 }
 
 [self.lock unlock];
}

可以看到SonicSession是由url唯一确定,并一次只能绑定到WebView上。

Sonic在初始化的时候会尝试从本地缓存中读取数据,如果数据存在,则直接将数据返回给请求着,否则,会等待请求结束后,将数据返回过去,并且将这次请求数据缓存下来。如果服务器最新的数据到达后,会根据返回码来选择性对浏览器已经渲染的视图进行修正。

这里我们需要介绍下Sonic的缓存和更新思路,Sonic将Html代码人为分为模板(Template)和数据(Data)。通过代码注释的方式,增加了“sonicdiff-xxx”来标注一个数据块的开始与结束。模板就是将数据块抠掉之后的Html,然后通过{albums}来表示这个是一个数据块占位。数据就是JSON格式,直接Key-Value。如图是官方一张图

由于我们HTML页面模板更新的频率比较低,而HTML需要展示的数据则更新频繁。通过这个思路,就可以实现HTML页面的增量更新。具体思想可以前往VasSonic:手Q开源Hybrid框架介绍

这样客户端就可以根据服务器返回的请求头来增量更新HTML页面。具体代码如下:

- (void)updateDidSuccess
{
 switch (self.sonicServer.response.statusCode) {//获得Response响应头
     case 304: //完全使用缓存
     {
         self.sonicStatusCode = SonicStatusCodeAllCached;
         self.sonicStatusFinalCode = SonicStatusCodeAllCached;
         //update headers
         [[SonicCache shareCache] saveResponseHeaders:self.sonicServer.response.allHeaderFields withSessionID:self.sessionID];
     }
         break;
     case 200: // Only need to request dynamic data.
     {
         if (![self.sonicServer isSonicResponse]) {
             [[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
             NSLog(@"Clear cache because while not sonic repsonse!");
             break;
         }
         
         if ([self isTemplateChange]) {
             
             [self dealWithTemplateChange];
             
         }else{
             
             [self dealWithDataUpdate];
         }
         
         NSString *policy = [self.sonicServer responseHeaderForKey:SonicHeaderKeyCacheOffline];
         if ([policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
             [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
         }
         
         if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
             
             if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
                 
                 [[SonicCache shareCache]removeCacheBySessionID:self.sessionID];
             }
             
             if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
                 [[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];
             }
         }
         
     }
         break;
     default:
     {
         
     }
         break;
 }
 
 //use the call back to tell web page which mode used
 if (self.webviewCallBack) {
     NSDictionary *resultDict = [self sonicDiffResult];
     if (resultDict) {
         self.webviewCallBack(resultDict);
     }
 }
 ...
}

由以上代码可以看到,首页会判断服务器返回response的状态码,304表示HTML的模板和数据均没有更新,直接使用缓存的数据。这个时候客服端不需要进行任何操作。

如果是200,则需要判断是模板更新还是数据更新。接下来我们具体看下数据更新和模板更新会做什么操作:

  • 数据更新函数代码如下:
   - (void)dealWithDataUpdate
{
    NSString *htmlString = nil;
    if (self.sonicServer.isInLocalServerMode) {
        NSDictionary *serverResult = [self.sonicServer sonicItemForCache];
        htmlString = serverResult[kSonicHtmlFieldName];
    }
    
    SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData:self.sonicServer.responseData withHtmlString:htmlString withResponseHeaders:self.sonicServer.response.allHeaderFields withUrl:self.url];
    
    if (cacheItem) {
        
        self.sonicStatusCode = SonicStatusCodeDataUpdate;
        self.sonicStatusFinalCode = SonicStatusCodeDataUpdate;
        self.localRefreshTime = cacheItem.lastRefreshTime;
        self.cacheFileData = cacheItem.htmlData;
        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
        
        if (_diffData) {
            [_diffData release];
            _diffData = nil;
        }
        _diffData = [cacheItem.diffData copy];
        
        self.isDataFetchFinished = YES;
    }
}

这里主要工作是取出缓存的html模板,然后将更新后的数据和html模板进行合并。生成新的cacheItem,随后更新本地数据。这里更新数据的格式是JSON格式,key为elementTag Id,value为tag显示的数据。可能有人会有疑惑,最新的数据怎么更新浏览器的显示。我们看到上面updateDidSuccess函数末尾有一段代码。调用了webViewCallback回调,这个操作就完成了将更新的数据通过native调用js的方式来更新数据。

  • Html模板更新:
  - (void)dealWithTemplateChange
{
   NSDictionary *serverResult = [self.sonicServer sonicItemForCache];
   SonicCacheItem *cacheItem = [[SonicCache shareCache] saveHtmlString:serverResult[kSonicHtmlFieldName] templateString:serverResult[kSonicTemplateFieldName] dynamicData:serverResult[kSonicDataFieldName] responseHeaders:self.sonicServer.response.allHeaderFields withUrl:self.url];//更新缓存
   
   if (cacheItem) {
       
       self.sonicStatusCode = SonicStatusCodeTemplateUpdate;
       self.sonicStatusFinalCode = SonicStatusCodeTemplateUpdate;
       self.localRefreshTime = cacheItem.lastRefreshTime;
       self.cacheFileData = self.sonicServer.responseData;
       self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
       
       self.isDataFetchFinished = YES;
       
       if (!self.didFinishCacheRead) {
           return;
       }
       
       NSString *opIdentifier  =  dispatchToMain(^{
           NSString *policy = [self.sonicServer responseHeaderForKey:SonicHeaderKeyCacheOffline];
           if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
               if (self.delegate && [self.delegate respondsToSelector:@selector(session:requireWebViewReload:)]) { //通知浏览器重新加载页面
                   NSURLRequest *sonicRequest = [NSURLRequest requestWithURL:[NSURL URLWithString:self.url]];
                   [self.delegate session:self requireWebViewReload:[SonicUtil sonicWebRequestWithSession:self withOrigin:sonicRequest]];
               }
           }
       });
       [self.mainQueueOperationIdentifiers addObject:opIdentifier];
   }
}

由于模板更新会更新整个网页结构,因此,避免不了需要重新刷新浏览器。首先会解析出返回htmlString中包含的模板和数据,然后分别进行存储,并更新本地变量。最后利用 [self.delegate session:self requireWebViewReload:[SonicUtil sonicWebRequestWithSession:self withOrigin:sonicRequest]]; 这句代码来通知浏览器刷新。

以上分析的是存在缓存,处理的结果,如果是第一次加载页面,那么就不会走这个策略。直接通知浏览器渲染并存储网页数据。比较简单,这里就不再赘述。

4. SonicServer

SonicServer是一个中间层,用来对HTTP连接的request和response进行预处理。SonicServer内部自己组装NSHTTPURLResponse对象,然后返回给上层。此外,SonicServer还支持本地服务模式。当设置enableLocalSever为true时,如果本地存在缓存,则直接将本地缓存数据传给浏览器进行渲染,等到网络数据接收完成时候,才通知数据的更新。

下面我们具体分析下在LocalServerMode模式下数据接收完成的处理操作。

- (void)connectionDidCompleteWithoutError:(SonicConnection *)connection
{
    self.isCompletion = YES;
    if (self.isInLocalServerMode) {//本地服务器模式,这个时候请求彻底完成,数据也接收完成,直接更新缓存了
        
        do {
            //if http status is 304, there is nothing changed
            if (self.response.statusCode == 304) {
                NSLog(@"response status 304!");
                break;
            }
            
            self.htmlString = [[[NSString alloc]initWithData:self.responseData encoding:[self encodingFromHeaders]] autorelease];
            NSDictionary *splitResult = [SonicUtil splitTemplateAndDataFromHtmlData:self.htmlString];
            if (splitResult) {
                self.templateString = splitResult[kSonicTemplateFieldName];
                self.data = splitResult[kSonicDataFieldName];
            }
            
            NSMutableDictionary *headers = [[_response.allHeaderFields mutableCopy]autorelease];
            
            if (![headers objectForKey:SonicHeaderKeyCacheOffline]) { // refresh this time
                [headers setValue:@"true" forKey:[SonicHeaderKeyCacheOffline lowercaseString]];
            }
            NSString *htmlSha1 = nil;
            NSString *responseEtag = [headers objectForKey:[SonicHeaderKeyETag lowercaseString]];
            if (!responseEtag) {
                responseEtag = htmlSha1 = getDataSha1([self.htmlString dataUsingEncoding:NSUTF8StringEncoding]);
                [headers setObject:responseEtag forKey:[SonicHeaderKeyETag lowercaseString]];
            }
            NSString *requestEtag = [self.request.allHTTPHeaderFields objectForKey:HTTPHeaderKeyIfNoneMatch];
            if ([responseEtag isEqualToString:requestEtag]) { // Case:hit 304
                [headers setValue:@"false" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
                NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:304 HTTPVersion:nil headerFields:headers]autorelease];
                // Update response data
                ...
                break;
            }
            
            NSString *responseTemplateTag = [headers objectForKey:[SonicHeaderKeyTemplate lowercaseString]];
            if (!responseTemplateTag) {
                responseTemplateTag = getDataSha1([self.templateString dataUsingEncoding:NSUTF8StringEncoding]);
                [headers setValue:responseTemplateTag forKey:[SonicHeaderKeyTemplate lowercaseString]];
            }
            NSString *requestTemplateTag = [self.request.allHTTPHeaderFields objectForKey:SonicHeaderKeyTemplate];
            if ([responseTemplateTag isEqualToString:requestTemplateTag]) { // Case:data update
                NSError *jsonError = nil;
                NSMutableDictionary *jsonDict = [NSMutableDictionary dictionaryWithDictionary:self.data];
                if (!htmlSha1) {
                    htmlSha1 = getDataSha1([self.htmlString dataUsingEncoding:NSUTF8StringEncoding]);
                }
                [jsonDict setObject:htmlSha1 forKey:SonicHeaderKeyHtmlSha1];
                NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonDict options:NSJSONWritingPrettyPrinted error:&jsonError];
                if (!jsonError) {
                    [headers setValue:@"false" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
                    NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:200 HTTPVersion:nil headerFields:headers]autorelease];
                    // Update response data
                  ...
                    break;
                }
            }
            
            // Case:template-change
            [headers setValue:@"true" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
            NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:200 HTTPVersion:nil headerFields:headers]autorelease];
            ...
            _response = [newResponse retain];
            break;
            
        } while (true);
        
        //First request need't to load again
        if (![self isFirstLoadRequest]) {
            [self.delegate server:self didRecieveResponse:self.response];
            [self.delegate server:self didReceiveData:self.responseData];
        }
    }
    [self.delegate serverDidCompleteWithoutError:self];
}

上面代码流程如下:

  1. 判断服务器返回的网页HtmlString和本地缓存的HtmlString的hash值是否一致,如果一致,说明网页没有改变,命中304,客户端什么操作都不用做。
  2. 判断服务器返回的网页模板和本地缓存HTML模板的差异,如果没有变更,那么判断是data变更。这时候更新本地数据
  3. 最后只可能是网页模板更新,则在请求头标记模板更新,交由上层进行处理。也就是SonicSession

最后回调SonicSession的serverDidCompleteWithoutError方法。

5. SonicConnection

SonicConnection最接近网络层,直接操作的是NSURLSession,然后将NSURLSession返回的数据直接向上传递,比较简单。

思考

这样Sonic iOS的整体源码就分析完成了。如有不当之处,欢迎大家批评指正

总的来说,Sonic通过服务器和客户端约定的协议,可以实现网页的缓存和部分动态更新功能,为有效提升H5的加载速度提供了一种新的思路,可以有效提升用户体验。但是Sonic需要人为对网页结构进行划分,还需要服务器的协助,具有过高的侵入性,引入成本比较高。是否引入需要考虑项目对WebView的依赖程度。
值得学习的是Sonic在首次加载页面的时候在未创建UIWebView之前建立起网络链接,等待UIWebView发起主资源请求到NSURLProtocol层完成拦截,并且将提前发起的数据流通过NSURLProtocol返回给WebKit,实现网络提前的并行加载。

参考资源

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,440评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,814评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,427评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,710评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,625评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,014评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,511评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,162评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,311评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,262评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,278评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,989评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,583评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,664评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,904评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,274评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,856评论 2 339

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,259评论 25 707
  • 这些概念性的东西可以在这里查看,这篇文章我们主要来分析iOS端的表象然后再分析源码。咋们还是放一张使用Sonic前...
    Thebloodelves阅读 3,222评论 4 20
  • iOS网络架构讨论梳理整理中。。。 其实如果没有APIManager这一层是没法使用delegate的,毕竟多个单...
    yhtang阅读 5,155评论 1 23
  • 《庄子》解,每章一读。 文: 一雀适羿,羿必得之,威也;以天下为之笼,则雀无所逃。是故汤以胞人笼伊尹,秦穆公以五羊...
    千里飘蓬阅读 671评论 0 0
  • 小时候,爸爸妈妈出去打工,我一直是跟着奶奶生活。 一天,奶奶突然瘫痪了,前几天奶奶一直说腰疼,现在已经站不起来...
    糯米的天使阅读 210评论 0 0