iOS源码剖析 | SVPullToRefresh

阅读源码之路终于开启了, 小白一枚, 大神们要多多照顾啊, 有什么建议可以评论或私信, 在此多谢了!!!!


概要

文件结构
文章目录
  • 前言
  • API说明
  • 原理解析
  • 总结

1.前言

作为一个刷新框架, SVPullToRefresh以其简洁, 通俗易懂为大家所推崇. 对于刚开始读源码的我来说, 再合适不过了, 而且最近正在做一个刷新demo, 用到, 顺便整理一下, 学习学习.
SV是个熟悉的前缀, 就算没听过SVPullToRefresh, 也听过SVProgressHUD吧. 除了这些, 作者Sam还有其他优秀的开源代码, 大家感兴趣可以看看.


2.API说明

2.1 下拉刷新

下拉刷新ScrollView

@class SVPullToRefreshView;
@interface UIScrollView (SVPullToRefresh)

typedef NS_ENUM(NSUInteger, SVPullToRefreshPosition) {
    SVPullToRefreshPositionTop = 0,
    SVPullToRefreshPositionBottom,
};

//默认添加方法, position为top
- (void)addPullToRefreshWithActionHandler:(void (^)(void))actionHandler;

/*
  自定义添加下拉刷新的方法, 可以改变刷新方式; 
  top为下拉刷新, bottom为上拉刷新;
*/
- (void)addPullToRefreshWithActionHandler:(void (^)(void))actionHandler position:(SVPullToRefreshPosition)position;

//触发一次刷新, 会执行handler这个block里面的方法
- (void)triggerPullToRefresh;

//下拉刷新视图
@property (nonatomic, strong, readonly) SVPullToRefreshView *pullToRefreshView;

//是否展示下拉刷新视图(须在addPullToRefreshWithActionHandler:方法后面)
@property (nonatomic, assign) BOOL showsPullToRefresh;

@end

下拉刷新View

//只保留可更改选项

@interface SVPullToRefreshView : UIView
//下拉刷新箭头颜色
@property (nonatomic, strong) UIColor *arrowColor;
//文本颜色
@property (nonatomic, strong) UIColor *textColor;
//指示器view颜色
@property (nonatomic, strong, readwrite) UIColor *activityIndicatorViewColor NS_AVAILABLE_IOS(5_0);
//指示器类型
@property (nonatomic, readwrite) UIActivityIndicatorViewStyle activityIndicatorViewStyle;

//根据刷新状态设置标题
- (void)setTitle:(NSString *)title forState:(SVPullToRefreshState)state;
//根据刷新状态设置副标题
- (void)setSubtitle:(NSString *)subtitle forState:(SVPullToRefreshState)state;
//根据刷新状态设置自定义View
- (void)setCustomView:(UIView *)view forState:(SVPullToRefreshState)state;

//开始动画
- (void)startAnimating;
//结束动画
- (void)stopAnimating;

//最后更新日期(NSDate)
@property (nonatomic, strong) NSDate *lastUpdatedDate DEPRECATED_ATTRIBUTE;
//日期格式(NSDateFormatter)
@property (nonatomic, strong) NSDateFormatter *dateFormatter DEPRECATED_ATTRIBUTE;

@end 

👇是我自己测试的, 把所有属性玩了一遍, 亲测好用, O(∩_∩)O哈哈~


下拉测试效果图
 [self.tableView addPullToRefreshWithActionHandler:^{
       //下拉刷新数据
    }];
    self.tableView.pullToRefreshView.backgroundColor = RedColor;
    self.tableView.pullToRefreshView.arrowColor = [UIColor whiteColor];
    self.tableView.pullToRefreshView.textColor = [UIColor whiteColor];
    [self.tableView.pullToRefreshView setSubtitle:@"火之玉" forState:SVInfiniteScrollingStateLoading];
    [self.tableView.pullToRefreshView setTitle:@"正在加载..waiting.." forState:SVInfiniteScrollingStateLoading];

    self.tableView.pullToRefreshView.activityIndicatorViewColor = BlueColor;
    self.tableView.pullToRefreshView.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge;
    
//    UIView *pullView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)];
//    pullView.backgroundColor = [UIColor cyanColor];
//    [self.tableView.pullToRefreshView setCustomView:pullView forState:SVPullToRefreshStateAll];

2.2 上拉刷新

上拉刷新ScrollView

@class SVInfiniteScrollingView;

@interface UIScrollView (SVInfiniteScrolling)
//默认添加上拉刷新视图
- (void)addInfiniteScrollingWithActionHandler:(void (^)(void))actionHandler;
////触发一次刷新, 会执行handler这个block里面的方法
- (void)triggerInfiniteScrolling;
//上拉刷新视图
@property (nonatomic, strong, readonly) SVInfiniteScrollingView *infiniteScrollingView;
//是否展示上拉刷新视图
@property (nonatomic, assign) BOOL showsInfiniteScrolling;

@end

上拉刷新View

@interface SVInfiniteScrollingView : UIView
//指示器类型
@property (nonatomic, readwrite) UIActivityIndicatorViewStyle activityIndicatorViewStyle;
//刷新状态
@property (nonatomic, readonly) SVInfiniteScrollingState state;
//是否取消上拉加载
@property (nonatomic, readwrite) BOOL enabled;
//根据刷新状态设置自定义View
- (void)setCustomView:(UIView *)view forState:(SVInfiniteScrollingState)state;
//开始动画
- (void)startAnimating;
//结束动画
- (void)stopAnimating;

@end

附上测试效果:


上拉测试效果图
    // setup infinite scrolling
    [self.tableView addInfiniteScrollingWithActionHandler:^{
        //上拉刷新数据
    }];
    
    self.tableView.infiniteScrollingView.backgroundColor = BlueColor;
    self.tableView.infiniteScrollingView.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhite;

//    UIImageView *pullImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)];
//    pullImageView.image = [UIImage imageNamed:@"avatar"];
//    pullImageView.layer.cornerRadius = 25;
//    pullImageView.layer.masksToBounds = YES;
//    [self.tableView.infiniteScrollingView setCustomView:pullImageView forState:SVPullToRefreshStateAll];
//
//    self.tableView.infiniteScrollingView.enabled = NO;

3.原理解析

3.1 下拉刷新

下拉刷新流程图

以上是下拉刷新的主要流程图, 接下来我们就来扣扣细节;

3.1.1 -(void)triggerPullToRefresh触发了一次刷新:

以下是方法的内部实现;

- (void)triggerPullToRefresh {
    self.pullToRefreshView.state = SVPullToRefreshStateTriggered;
    [self.pullToRefreshView startAnimating];
}

看了一眼, 当时就懵了; 怎么就这点儿代码, 完全看不出来啊, 别着急, 接着一个个点进去看. 发现state属性的setter方法里面做了处理;

- (void)setState:(SVPullToRefreshState)newState {
    
    if(_state == newState)
        return;
    
    SVPullToRefreshState previousState = _state;
    _state = newState;
    
    [self setNeedsLayout];
    [self layoutIfNeeded];
    
    switch (newState) {
        case SVPullToRefreshStateAll:
        case SVPullToRefreshStateStopped:
            [self resetScrollViewContentInset];
            break;
            
        case SVPullToRefreshStateTriggered:
            break;
            
        case SVPullToRefreshStateLoading:
            [self setScrollViewContentInsetForLoading];
            
            if(previousState == SVPullToRefreshStateTriggered && pullToRefreshActionHandler)
                pullToRefreshActionHandler();
            break;
    }
}

接下来拆分一下:

    self.pullToRefreshView.state = SVPullToRefreshStateTriggered;

执行完这步代码, 执行一次-(void)setState:, 之后break跳出;

  [self.pullToRefreshView startAnimating];

这步pullToRefreshView执行-(void)startAnimating, 方法内部实现如下:

- (void)startAnimating{
    ...
    self.state = SVPullToRefreshStateLoading;
}

可以看出之后又执行了一次-(void)setState:, 这时previousState == SVPullToRefreshStateTriggered条件满足, 执行infiniteScrollingHandler(), 也就执行了block里面刷新数据的方法;

3.1.2 利用runtime+KVO添加成员变量

代码如下:

static char UIScrollViewPullToRefreshView;
- (void)setPullToRefreshView:(SVPullToRefreshView *)pullToRefreshView {
    [self willChangeValueForKey:@"SVPullToRefreshView"];
    objc_setAssociatedObject(self, &UIScrollViewPullToRefreshView,
                             pullToRefreshView,
                             OBJC_ASSOCIATION_ASSIGN);
    [self didChangeValueForKey:@"SVPullToRefreshView"];
}

- (SVPullToRefreshView *)pullToRefreshView {
    return objc_getAssociatedObject(self, &UIScrollViewPullToRefreshView);
}

关于KVO:
从代码中可以看出willChangeValueForKey :didChangeValueForKey :是KVO的一部分, 源文件的代码为:

@interface NSObject(NSKeyValueObserverNotification)
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;
@end

用了这两个方法也就代表手动触发了KVO, 这也为了控制回调的调用时机, 在setPullToRefreshView:中触发. 而手动触发的场景一般是不使用属性,或重写了setter,需要手动通知系统.
一般我们是不需要用的, 比如@property写一个属性, 系统会以某种方式在中间插入 wilChangeValueForKey: 、 didChangeValueForKey: 和 observeValueForKeyPath:ofObject:change:context: 的调用.
想要了解更多, 可以看一下
KVO Programming Guide - Apple官方文档

关于runtime:
这里要知道这两个方法:

//set
objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,
                         id _Nullable value, objc_AssociationPolicy policy)
//get
objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)

//objc_AssociationPolicy类型说明
//关联时采用的协议,有assign,retain,copy等协议,一般使用OBJC_ASSOCIATION_RETAIN_NONATOMIC
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,           /**< Specifies a weak reference to the associated object. */
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,   /**< Specifies that the associated object is copied. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_RETAIN = 01401,       /**< Specifies a strong reference to the associated object.
                                            *   The association is made atomically. */
    OBJC_ASSOCIATION_COPY = 01403          /**< Specifies that the associated object is copied.
                                            *   The association is made atomically. */
};

看完所有API, 其实也了解的差不多, 这样就给scrollView增加了一个SVPullToRefreshView类型的属性;

3.1.3 监听探究

这里主要涉及scrollView的三个监听, contentOffset, contentSize, frame;
frame就不用说了, 说一下另外两个;
contentOffset是scrollview当前显示区域顶点相对于frame顶点的偏移量。可以理解为contentview的顶点相对于scrollerVIew的frame的偏移量;
contentSize是scrollview当前所有内容区域的大小;
顺便提下contentInset, 下面用到, 表示contentView.frame与scrollerView.frame的关系, 可以类比于css里的padding.
例如:

testScrollView.contentInset = UIEdgeInsetsMake(10, 10, 10, 10);

则testScrollView的top, left, bottom, right为10;

好了, 现在到重头戏了, 监听如何执行的, 代码如下:

#pragma mark - Observing
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if([keyPath isEqualToString:@"contentOffset"])
        [self scrollViewDidScroll:[[change valueForKey:NSKeyValueChangeNewKey] CGPointValue]];
    else if([keyPath isEqualToString:@"contentSize"]) {
        [self layoutSubviews];
        CGFloat yOrigin;
        ...
        self.frame = CGRectMake(0, yOrigin, self.bounds.size.width, SVPullToRefreshViewHeight);
    }
    else if([keyPath isEqualToString:@"frame"])
        [self layoutSubviews];

}

可以理解为当监听contentOffset改变时, scrollView滚动, 此时执行scrollViewDidScroll:方法得到此时的滚动state; 当为contentSizeframe时, scrollView视图发生变化, 此时执行layoutSubviews重新加载视图, 包括根据状态改变视图样式, 都在这里面执行;
scrollViewDidScroll:方法:

- (void)scrollViewDidScroll:(CGPoint)contentOffset {
    if(self.state != SVPullToRefreshStateLoading) {
        CGFloat scrollOffsetThreshold = 0;
        switch (self.position) {
            case SVPullToRefreshPositionTop:
                scrollOffsetThreshold = self.frame.origin.y - self.originalTopInset;
                break;
            case SVPullToRefreshPositionBottom:
                scrollOffsetThreshold = MAX(self.scrollView.contentSize.height - self.scrollView.bounds.size.height, 0.0f) + self.bounds.size.height + self.originalBottomInset;
                break;
        }
        
        if(!self.scrollView.isDragging && self.state == SVPullToRefreshStateTriggered)
            self.state = SVPullToRefreshStateLoading;
        else if(contentOffset.y < scrollOffsetThreshold && self.scrollView.isDragging && self.state == SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionTop)
            self.state = SVPullToRefreshStateTriggered;
        else if(contentOffset.y >= scrollOffsetThreshold && self.state != SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionTop)
            self.state = SVPullToRefreshStateStopped;
        else if(contentOffset.y > scrollOffsetThreshold && self.scrollView.isDragging && self.state == SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionBottom)
            self.state = SVPullToRefreshStateTriggered;
        else if(contentOffset.y <= scrollOffsetThreshold && self.state != SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionBottom)
            self.state = SVPullToRefreshStateStopped;
    } else {
        CGFloat offset;
        UIEdgeInsets contentInset;
        switch (self.position) {
            case SVPullToRefreshPositionTop:
                ...
                self.scrollView.contentInset = UIEdgeInsetsMake(offset, contentInset.left, contentInset.bottom, contentInset.right);
                break;
            case SVPullToRefreshPositionBottom:
                if (self.scrollView.contentSize.height >= self.scrollView.bounds.size.height) {
                    ...
                    self.scrollView.contentInset = UIEdgeInsetsMake(contentInset.top, contentInset.left, offset, contentInset.right);
                } else if (self.wasTriggeredByUser) {
                    ...
                    self.scrollView.contentInset = UIEdgeInsetsMake(-offset, contentInset.left, contentInset.bottom, contentInset.right);
                }
                break;
        }
    }
}

可以看出根据postion位置, state是SVPullToRefreshStateLoading状态的时候, 改变scrollView的contentInset;非该状态的时候, 根据contentOffset和postion设置state;


下拉刷新后, scrollView的内容高度下移60; 当然只是内容高度, 整个scrollView还是全屏的, 对比css中padding理解一下;
注:
SVPullToRefreshView向右移动一点儿距离, 方便看视图层级;

layoutSubviews方法:

- (void)layoutSubviews {
    
    for(id otherView in self.viewForState) {
        if([otherView isKindOfClass:[UIView class]])
           //从父视图剥离
            [otherView removeFromSuperview];
    }
    
    id customView = [self.viewForState objectAtIndex:self.state];
    BOOL hasCustomView = [customView isKindOfClass:[UIView class]];
    
    self.titleLabel.hidden = hasCustomView;
    self.subtitleLabel.hidden = hasCustomView;
    self.arrow.hidden = hasCustomView;
    
    if(hasCustomView) {
      //添加customView
        [self addSubview:customView];
        ...
        [customView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
    }
    else {
   //根据state旋转arrowView
        switch (self.state) {
            case SVPullToRefreshStateAll:
            case SVPullToRefreshStateStopped:
                self.arrow.alpha = 1;
                [self.activityIndicatorView stopAnimating];
                switch (self.position) {
                    case SVPullToRefreshPositionTop:
                        [self rotateArrow:0 hide:NO];
                        break;
                    case SVPullToRefreshPositionBottom:
                        [self rotateArrow:(float)M_PI hide:NO];
                        break;
                }
                break;
                
            ...
        }
        
        CGFloat leftViewWidth = MAX(self.arrow.bounds.size.width,self.activityIndicatorView.bounds.size.width);
        
        ...
        CGFloat labelX = (self.bounds.size.width / 2) - (totalMaxWidth / 2) + leftViewWidth + margin;
        
        if(subtitleSize.height > 0){
            ...
            self.titleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY, titleSize.width, titleSize.height));
            self.subtitleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY + titleSize.height + marginY, subtitleSize.width, subtitleSize.height));
        }else{
            ...
            self.titleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY, titleSize.width, titleSize.height));
            self.subtitleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY + titleSize.height + marginY, subtitleSize.width, subtitleSize.height));
        }
        
        CGFloat arrowX = (self.bounds.size.width / 2) - (totalMaxWidth / 2) + (leftViewWidth - self.arrow.bounds.size.width) / 2;
        self.arrow.frame = CGRectMake(arrowX,
                                      (self.bounds.size.height / 2) - (self.arrow.bounds.size.height / 2),
                                      self.arrow.bounds.size.width,
                                      self.arrow.bounds.size.height);
        self.activityIndicatorView.center = self.arrow.center;
    }
}

self.viewForState为一个可变数组, 里面是而且根据状态装入相应state的customView, 首先从通过- (void)setCustomView:forState:方法添加后removeFromSuperview从父视图剥离, 之后根据是否传入了customView决定是否添加自定义视图; 如果没有customView则改变里面arrowView的角度;最后都得改变titleLabel, subtitleLabel, arrow, activityIndicatorView的尺寸或位置;
值得注意的是里面的一个方法暴露了demo的年纪, O(∩_∩)O哈哈~

- (CGSize)sizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size lineBreakMode:(NSLineBreakMode)lineBreakMode NS_DEPRECATED_IOS(2_0, 7_0, "Use -boundingRectWithSize:options:attributes:context:") __TVOS_PROHIBITED;

7.0之后已取消, 难怪我感觉没见过这个方法. 哎, 还是太年轻~~~~~

3.2 上拉刷新

话不多说, 先看东西...(此话出自老罗语录😊)


上拉刷新流程图
- (void)setState:(SVInfiniteScrollingState)newState {
    
    if(_state == newState)
        return;
    
    SVInfiniteScrollingState previousState = _state;
    _state = newState;
    
    for(id otherView in self.viewForState) {
        if([otherView isKindOfClass:[UIView class]])
            [otherView removeFromSuperview];
    }
    
    id customView = [self.viewForState objectAtIndex:newState];
    BOOL hasCustomView = [customView isKindOfClass:[UIView class]];
    
    if(hasCustomView) {
        [self addSubview:customView];
        ...
        [customView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
    }
    else {
        ...
        [self.activityIndicatorView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
        //根据状态设置activityIndicatorView是否动画
        switch (newState) {
            case SVInfiniteScrollingStateStopped:
                [self.activityIndicatorView stopAnimating];
                break;
            ...
        }
    }
    
    if(previousState == SVInfiniteScrollingStateTriggered && newState == SVInfiniteScrollingStateLoading && self.infiniteScrollingHandler && self.enabled)
        self.infiniteScrollingHandler();
}

上拉刷新相对于下拉, 少了很多东西, 也就简单了一些, 重复性的就不多说了.值得注意的是, 对比下拉, 上拉把layoutSubviews里面东西放到setState:里面. 也是, 毕竟没多少东西, 对比着下拉刷新来看;

4.总结

这次阅读源码,可以说收获满满啊。以前觉得源码阅读是个比较枯燥的过程,可是当我把一个个问题解决了之后,成就感也慢慢累积,感觉就是越读越来劲儿。而且发现读一遍是远远不够的,每看了一遍都多少会有些收获。慢慢的从(这个方法是干什么的)-->(为什么写这个方法)-->(为什么写在这里),等等一些思考。在对作者称赞👍的同时也为自己认识了这种方法而感到高兴。
再接再厉!!!
加油💪2017!!!

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

推荐阅读更多精彩内容

  • 夜莺2517阅读 127,709评论 1 9
  • 版本:ios 1.2.1 亮点: 1.app角标可以实时更新天气温度或选择空气质量,建议处女座就不要选了,不然老想...
    我就是沉沉阅读 6,876评论 1 6
  • 我是一名过去式的高三狗,很可悲,在这三年里我没有恋爱,看着同龄的小伙伴们一对儿一对儿的,我的心不好受。怎么说呢,高...
    小娘纸阅读 3,375评论 4 7
  • 这些日子就像是一天一天在倒计时 一想到他走了 心里就是说不出的滋味 从几个月前认识他开始 就意识到终究会发生的 只...
    栗子a阅读 1,613评论 1 3