MJRefresh 源码解析

MJRefresh

已经很久没有写技术文章了,之前一段时间确实也是很忙,当然这也是一个借口,自己不思进取的成分也有不少。 不过这段时间确实也得到了很多的锻炼,有些东西也没法直接用语言表达,或许,最大的收获在于我渐渐的沉下心来了,不再惧怕那么多行代码了,更了解写程序这回事了。😂

关于MJRefresh之前也想研究的,但是奈何能力不够,代码看不进去。现在做的东西多了,知道要做出一个能通用于整个项目乃至开源给很多用户的控件是需要一点点的去搭建和优化的,所以到了一定程度,控件是会大到一定量级的,看不下去看不懂也是很正常的,因为很多代码确实不知道作者是为什么这么写的。

到现在写程序两年了,我并没有自己或者说参与过的开源项目。顶多也就是在公司里写一些项目中用的到的控件,更多的精力用在写业务逻辑,因为公司是做建筑行业协同软件的,要把建筑行业那一套繁琐的线下东西做成线上应用。控件写多了,用是能用,但是真的写到位了吗,能够经得起大量用户的使用吗?我不知道。

带着这样一份疑惑,我回过头来想想自己能否再次去看一些开源项目了,能否好好去体会一个控件该怎么写了,还有没有哪些我没有考虑到的东西。所以,这篇文章我决定写一下 MJRefresh 的解读和理解,它很常用,我现在做过的项目都是用的它,它是一个控件,没有太多高大上的理论,我对它的理解是朴实可靠。但我也不认为我能在短时间内就理解透它,我已经做好了一点点填坑的准备了,打持久战。

唠叨了很多,其实算是讲给自己听的,创建一份仪式感,给自己树立点信心。废话不多说,直接进入正文。

MJRefreshComponent

MJRefresh中的基类,里面抽象了子类共用的方法, 有些方法是供子类调用的,如:

- (void)beginRefreshing;
- (void)beginRefreshingWithCompletionBlock:(void (^)(void))completionBlock;

有些方法是供子类复写的,如:

- (void)prepare;
- (void)placeSubviews;

MJRefresh继承链上的类较多,层级比较复杂,所以查看执行顺序的时候还是比较吃力的。但是只要搞清楚每一层的类的作用以及UIView那些钩子方法的执行先后顺序,可以为理解这个控件提供很大的帮助。

关于执行先后问题,可以参考 https://www.jianshu.com/p/4a62c10a36f1

这篇文章详细的讲述了 APP 的生命周期方法,以及UIViewControllerUIView的一些钩子方法的执行先后顺序。体现在MJRefresh中,是这样的:

alloc(prepare) -> setState -> willMoveToSuperView -> layoutSubViews(placeSubviews) -> drawRect

其中,prepare setState placeSubviews 是子类会复写的,这些方法子类实现时都会调用super来继承父类的实现,所以这里面的关键是要清晰每一个子类该干的事情,更抽象的行为应该出现在更顶层的类的实现中,反之就越应该出现在较低层的子类中。

willMoveToSuperView drawRect则并没有明确的说明子类要去复写,主要的代码还是在MJRefreshComponent基类中。它们并不是特别关键,但也有需要复写的原因,下文中会谈到他们的作用。

接下来我打算就这几个关键的方法来做一下分析,而不去讲结构层次图了,因为MJRefresh的结构层次还是蛮多的,github 上也有 mj 本人画的层次图,我没办法顾及全局的去考虑所有细节,但会尽可能的先去理解这几个关键方法以及下拉刷新的机制,所以下文我也只会围绕 header 的几个类来进行分析。即:

MJRefreshComponent -> MJRefreshHeader -> MJRefreshStateHeader -> MJRefreshNormalHeader

prepare

我们按照执行先后顺序来看这些方法,首先就要看prepare了。在prepare方法中,MJRrefresh会做一些初始化操作。

保证在横竖屏切换的时候能够保证自身相对于父视图的左右边距保持不变,这个方法是每个子类都必须的,所以放在了基类MJRefreshComponent中。

self.autoresizingMask = UIViewAutoresizingFlexibleWidth;

设置背景色

self.backgroundColor = [UIColor clearColor];

设置key,用于NSUserDefaults存储最后刷新时间

self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey;

设置自身高度,子类的高度可能不同(header 和 footer 就不一样),所以这个操作应该放在子类中去设置。

self.mj_h = MJRefreshHeaderHeight;

MJRefreshStateHeader中开始出现文字设置,用这个类以及其子类可以出现文字了。

// 初始化间距
self.labelLeftInset = MJRefreshLabelLeftInset;
    
// 初始化文字
self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderIdleText] forState:MJRefreshStateIdle];
[self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderPullingText] forState:MJRefreshStatePulling];
[self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderRefreshingText] forState:MJRefreshStateRefreshing];

MJRefreshNormalHeader 是我们用的比较多的一个类,这个类又多了一个控件UIActivityIndicatorView以及会旋转的箭头。在这个类中会去初始化UIActivityIndicatorView的样式。

self.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray;

setState

先来看基类中的实现。这边值得注意的地方是setNeedsLayout方法的处理。MJ 告诉我们这样做的目的是为了先执行完整个继承链上的setState,然后再去重新布局。这里之所以要调用setNeedsLayout去触发layoutSubviews是因为文字的变化会引起左侧箭头位置的变化,这时候需要刷新来重制位置。

- (void)setState:(MJRefreshState)state
{
    _state = state;
    
    // 加入主队列的目的是等setState:方法调用完毕、设置完文字后再去布局子控件
    dispatch_async(dispatch_get_main_queue(), ^{
        [self setNeedsLayout];
    });
}

然后看看子类中的实现:

这里做到事情 mj 已经标注的很清楚了,不再过多的说明。不考虑 header 中的子视图,那这个空白的 header 需要去完成那些事情,这块代码给出了答案。

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 根据状态做事情
    if (state == MJRefreshStateIdle) {
        if (oldState != MJRefreshStateRefreshing) return;
        
        // 保存刷新时间
        [[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:self.lastUpdatedTimeKey];
        [[NSUserDefaults standardUserDefaults] synchronize];
        
        // 恢复inset和offset
        [UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
            self.scrollView.mj_insetT += self.insetTDelta;
            
            // 自动调整透明度
            if (self.isAutomaticallyChangeAlpha) self.alpha = 0.0;
        } completion:^(BOOL finished) {
            self.pullingPercent = 0.0;
            
            if (self.endRefreshingCompletionBlock) {
                self.endRefreshingCompletionBlock();
            }
        }];
    } else if (state == MJRefreshStateRefreshing) {
         dispatch_async(dispatch_get_main_queue(), ^{
            [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
                CGFloat top = self.scrollViewOriginalInset.top + self.mj_h;
                // 增加滚动区域top
                self.scrollView.mj_insetT = top;
                // 设置滚动位置
                CGPoint offset = self.scrollView.contentOffset;
                offset.y = -top;
                [self.scrollView setContentOffset:offset animated:NO];
            } completion:^(BOOL finished) {
                [self executeRefreshingCallback];
            }];
         });
    }
}

这里的话主要是设置了文字以及去调用属性lastUpdatedTimeKey的 setter 方法,这里面不单单是设置了label的文字,还做了一些其他的处理。

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 设置状态文字
    self.stateLabel.text = self.stateTitles[@(state)];
    
    // 重新设置key(重新显示时间)
    self.lastUpdatedTimeKey = self.lastUpdatedTimeKey;
}

这里面主要处理了一下箭头和圈圈。

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 根据状态做事情
    if (state == MJRefreshStateIdle) {
        if (oldState == MJRefreshStateRefreshing) {
            self.arrowView.transform = CGAffineTransformIdentity;
            
            [UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
                self.loadingView.alpha = 0.0;
            } completion:^(BOOL finished) {
                // 如果执行完动画发现不是idle状态,就直接返回,进入其他状态
                if (self.state != MJRefreshStateIdle) return;
                
                self.loadingView.alpha = 1.0;
                [self.loadingView stopAnimating];
                self.arrowView.hidden = NO;
            }];
        } else {
            [self.loadingView stopAnimating];
            self.arrowView.hidden = NO;
            [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
                self.arrowView.transform = CGAffineTransformIdentity;
            }];
        }
    } else if (state == MJRefreshStatePulling) {
        [self.loadingView stopAnimating];
        self.arrowView.hidden = NO;
        [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
            self.arrowView.transform = CGAffineTransformMakeRotation(0.000001 - M_PI);
        }];
    } else if (state == MJRefreshStateRefreshing) {
        self.loadingView.alpha = 1.0; // 防止refreshing -> idle的动画完毕动作没有被执行
        [self.loadingView startAnimating];
        self.arrowView.hidden = YES;
    }
}

willMoveToSuperview

现在来看看这个方法里做了什么事情?它会去判断父视图的类型,会去设置mj_xmj_w 两个属性,还会记录scrollView的一些基本的属性值。

这个方法在UIView的整个生命周期中是会调用两次的,一次是子视图即将添加到父视图上的时候,还有一次是子视图即将从父视图移除的时候。可能有的小伙伴会对这个地方产生疑惑,为什么要把这些初始化操作放在这个里面?不能直接放在初始化方法中吗?其实只要想一下MJRerfesh的服务对象就知道了😌,这里是判断父视图是不是scrollView以及其子类的最佳位置,放在初始化方法中没法判断父视图,放在layoutSubViews中则太晚了,而且会调用多次。

- (void)willMoveToSuperview:(UIView *)newSuperview
{
    [super willMoveToSuperview:newSuperview];
    
    // 如果不是UIScrollView,不做任何事情
    if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return;
    
    // 旧的父控件移除监听
    [self removeObservers];
    
    if (newSuperview) { // 新的父控件
        // 设置宽度
        self.mj_w = newSuperview.mj_w;
        // 设置位置
        self.mj_x = -_scrollView.mj_insetL;
        
        // 记录UIScrollView
        _scrollView = (UIScrollView *)newSuperview;
        // 设置永远支持垂直弹簧效果
        _scrollView.alwaysBounceVertical = YES;
        // 记录UIScrollView最开始的contentInset
        _scrollViewOriginalInset = _scrollView.mj_inset;
        
        // 添加监听
        [self addObservers];
    }
}

在这个方法里,还会去设置对scrollView一些属性的监听,到这不看具体的代码也能猜得到绝对要监听的那个属性了:scrollViewcontentOffset。这也是MJRefresh下拉刷新发挥作用的真正关键算在。所以接下来我们需要看一下,MJRefresh是如何针对该值的变化作出响应调整的。

这个方法主要是在MJRefreshComponent中实现的,那几个 header 子类都没有实现,但其实 footer 是进行了复写的,这个我们先不做讨论了,但有一点是明确的,不同层级的类做不同的事情,不去做超出自身职责范围的事情。

placeSubViews

这个方法在基类中并没有做什么,因为基类中没有子视图😂

由于在prepare中初始化了MJRefresh的高度,所以需要改变自身的垂直位置,在这里,就可以看出属性ignoredScrollViewContentInsetTop的作用,他可以决定MJRefresh的垂直位置,从而改变滑动scrollview达到临界刷新点所需要的距离。

self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop;

MJRefreshStateHeader中开始出现子视图,所以在这个方法中会布局子视图。这里出现了两个子视图:stateLabellastUpdatedTimeLabel,分别代表了显示刷新状态和显示上一次刷新时间的Label。下面的代码主要是对其进行布局。如果看了这两个子视图的懒加载方法,你会发现它们也设置了autoresizingMask,同样是为了应对横竖屏切换。

if (self.stateLabel.hidden) return;

BOOL noConstrainsOnStatusLabel = self.stateLabel.constraints.count == 0;

if (self.lastUpdatedTimeLabel.hidden) {
    // 状态
    if (noConstrainsOnStatusLabel) self.stateLabel.frame = self.bounds;
} else {
    CGFloat stateLabelH = self.mj_h * 0.5;
    // 状态
    if (noConstrainsOnStatusLabel) {
       self.stateLabel.mj_x = 0;
       self.stateLabel.mj_y = 0;
       self.stateLabel.mj_w = self.mj_w;
       self.stateLabel.mj_h = stateLabelH;
    }
    
    // 更新时间
    if (self.lastUpdatedTimeLabel.constraints.count == 0) {
        self.lastUpdatedTimeLabel.mj_x = 0;
        self.lastUpdatedTimeLabel.mj_y = stateLabelH;
        self.lastUpdatedTimeLabel.mj_w = self.mj_w;
        self.lastUpdatedTimeLabel.mj_h = self.mj_h - self.lastUpdatedTimeLabel.mj_y;
    }     
} 

这里会调整箭头和圈圈的位置

- (void)placeSubviews
{
    [super placeSubviews];
    
    // 箭头的中心点
    CGFloat arrowCenterX = self.mj_w * 0.5;
    if (!self.stateLabel.hidden) {
        CGFloat stateWidth = self.stateLabel.mj_textWith;
        CGFloat timeWidth = 0.0;
        if (!self.lastUpdatedTimeLabel.hidden) {
            timeWidth = self.lastUpdatedTimeLabel.mj_textWith;
        }
        CGFloat textWidth = MAX(stateWidth, timeWidth);
        arrowCenterX -= textWidth / 2 + self.labelLeftInset;
    }
    CGFloat arrowCenterY = self.mj_h * 0.5;
    CGPoint arrowCenter = CGPointMake(arrowCenterX, arrowCenterY);
    
    // 箭头
    if (self.arrowView.constraints.count == 0) {
        self.arrowView.mj_size = self.arrowView.image.size;
        self.arrowView.center = arrowCenter;
    }
        
    // 圈圈
    if (self.loadingView.constraints.count == 0) {
        self.loadingView.center = arrowCenter;
    }
    
    self.arrowView.tintColor = self.stateLabel.textColor;
}

回过头来,我们可以思考一下为什么mj_x mj_y mj_w mj_h 这四个属性的设置地方是不同的?

mj_x mj_w 的设置出现在了MJRefreshComponentwillMoveToSuperView方法中,因为这两个值始终是不会去变的。虽然可能会横竖屏切换,但是autoresizingMask的设置就解决了这个问题,MJRefresh的水平方向的布局始终是定下来了。

mj_h mj_y 这两个值的设置分别在MJRefreshHeaderprepareplaceSubviews方法中,前者是因为headerfooter的高度是不一样的,而后者是因为mj_h的值可能会发生改变,MJ 本人标注的注释表示:

"当自己的高度发生改变了,肯定要重新调整Y值,所以放到placeSubviews方法中设置y值"

scrollViewContentOffsetDidChange

首先我们来想一下,有哪些情况会引起contentOffset的变化?无非是这几种

  1. 手指进行拉拽的时候
  2. 手指下拉到一定程度松开后的回滚状态时
  3. 手指松开后的惯性滚动
  4. 调用setContentOffSet引起的滚动

这个方法首先会去看本身是否是在刷新状态中,如果是的话,那肯定不会去改变它的state状态。如果不是,那么就要分情况考虑了,如果手指正在拖拽中,那么就要根据是否超过临界值而决定是否将状态更改为pulling状态,一旦松手,scrollView回弹滚动,这时候已经标记为pulling状态,所以就要直接调用beginRefresh方法了。不过放手的时候,也可能没有超过临界值,这时候不会调用beginRefresh,而是会去标记它的pullingPercent,从而调整它的alpha值。这里专门做了一个渐变的效果,非常有心,赞一个。

- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change
{
    [super scrollViewContentOffsetDidChange:change];
    
    // 在刷新的refreshing状态
    if (self.state == MJRefreshStateRefreshing) {
        // 暂时保留
        if (self.window == nil) return;
        
        // sectionheader停留解决
        CGFloat insetT = - self.scrollView.mj_offsetY > _scrollViewOriginalInset.top ? - self.scrollView.mj_offsetY : _scrollViewOriginalInset.top;
        insetT = insetT > self.mj_h + _scrollViewOriginalInset.top ? self.mj_h + _scrollViewOriginalInset.top : insetT;
        self.scrollView.mj_insetT = insetT;
        
        self.insetTDelta = _scrollViewOriginalInset.top - insetT;
        return;
    }
    
    // 跳转到下一个控制器时,contentInset可能会变
     _scrollViewOriginalInset = self.scrollView.mj_inset;
    
    // 当前的contentOffset
    CGFloat offsetY = self.scrollView.mj_offsetY;
    // 头部控件刚好出现的offsetY
    CGFloat happenOffsetY = - self.scrollViewOriginalInset.top;
    
    // 如果是向上滚动到看不见头部控件,直接返回
    // >= -> >
    if (offsetY > happenOffsetY) return;
    
    // 普通 和 即将刷新 的临界点
    CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
    CGFloat pullingPercent = (happenOffsetY - offsetY) / self.mj_h;
    
    if (self.scrollView.isDragging) { // 如果正在拖拽
        self.pullingPercent = pullingPercent;
        if (self.state == MJRefreshStateIdle && offsetY < normal2pullingOffsetY) {
            // 转为即将刷新状态
            self.state = MJRefreshStatePulling;
        } else if (self.state == MJRefreshStatePulling && offsetY >= normal2pullingOffsetY) {
            // 转为普通状态
            self.state = MJRefreshStateIdle;
        }
    } else if (self.state == MJRefreshStatePulling) {// 即将刷新 && 手松开
        // 开始刷新
        [self beginRefreshing];
    } else if (pullingPercent < 1) {
        self.pullingPercent = pullingPercent;
    }
}

接下来我们来看看beginRefreshing方法做了什么?他其实主要就是把state标记为MJRefreshStateRefreshing。但是它还做了另外一层判断:window的有无。MJ 也做了备注,说明了为什么要有这个判断,主要是因为预防用户过早的调用了beginRefresh方法,然而这时候自身还并没有显示出来,所以巧妙的先将state标记为了MJRefreshStateWillRefresh

- (void)beginRefreshing
{
    [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
        self.alpha = 1.0;
    }];
    self.pullingPercent = 1.0;
    // 只要正在刷新,就完全显示
    if (self.window) {
        self.state = MJRefreshStateRefreshing;
    } else {
        // 预防正在刷新中时,调用本方法使得header inset回置失败
        if (self.state != MJRefreshStateRefreshing) {
            self.state = MJRefreshStateWillRefresh;
            // 刷新(预防从另一个控制器回到这个控制器的情况,回来要重新刷新一下)
            [self setNeedsDisplay];
        }
    }
}

到底这个标记会有什么样的影响?这时候就要去另外一个方法里看了:drawRect,由上文所说的调用顺序可知,drawRect是在最后才回去调用的,此时视图已经被添加到父视图了。通过这种方法,延缓了MJRefresh的刷新时间,从而保证了父视图的存在。

- (void)drawRect:(CGRect)rect
{
    [super drawRect:rect];
    
    if (self.state == MJRefreshStateWillRefresh) {
        // 预防view还没显示出来就调用了beginRefreshing
        self.state = MJRefreshStateRefreshing;
    }
}

小结

其实写着写着,发现MJRefresh中的东西比我想象的还要多,如果要抓细节把所有东西都理清,估计要写个几篇文章。这次的源代码阅读经历带给我的收获主要是如何进行层级设计以及理解UIView生命周期或者说是那几个方法的调用顺序。很多时候,我们会把代码一股脑的放在某一个方法里,这样既没法提高可读性,往往还会出现一些很难发现的错误。如果能够把这些代码打散在不同的层级中,不同的钩子方法中,这样在写那些特别复杂的业务逻辑时思路能够更清晰而少犯错。

MJRefresh也不乏一些耐人寻味的小知识点,比如说__unsafe_unretained的使用。__unsafe_unretained所修饰的指针在所指向对象被销毁时,不会置为nil。 在MJRefresh中,所有的子视图都是用这种形式去修饰的,因为子视图和父视图的生命周期是相同的,所以在子视图销毁的时候,指向该子视图的指针没必要再去置为nil了,子视图销毁后父视图也会销毁,内存回收,也就不会出现什么野指针的情况了。

这次的源代码探索只是开始,以后我还会再回来看MJRefresh,我也会去研究其他的开源项目,集思广益。正如牛顿所说,之所以能取得如此成就,就是因为站在了巨人的肩膀上。

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

推荐阅读更多精彩内容

  • 你出现在我的生命里,像一场及时雨。 初次见面,我正深陷泥潭,无法从痛苦中脱离,正如你对我的第一印象,这年头,还有女...
    onlywei阅读 478评论 0 0
  • 善良的人都有一颗仁慈的心 凡事都会点到为止 得饶人处且饶人 相聚不容易 分散很容易 当你有了善良 别人就会记住你的...
    福天思想阅读 765评论 0 0
  • 没时间用4a纸画,随手涂鸦也是可以满足一下的,嘻嘻……
    四丫xmh阅读 823评论 2 4
  • 刚才无意中打开信息,发现我给我爸发的短信竟只有拒接电话时的自动回复。 一瞬间,我才意识到自己活在没有家人的...
    Alilililily阅读 17,675评论 0 0