MJRefresh源码分析

MJRefresh是李明杰老师的作品,对于iOS的开发者来说,一定非常熟悉这个简单实用,功能强大的下拉加载,上拉刷新的控件,有很好的可定制性,几乎能满足大部分刷新的需求,非常值得钻研学习。

这个框架的结构设计非常清晰,非常能够体现面向对象的设计原则,一般我总结为各行其是,基类MJRefreshComponent继承自UIView,并承载着整个框架的基础设置,然后MJRefreshHeader和MJRefreshFooter继承了MJRefreshComponent,扩展了下拉刷新和上拉加载的功能,下面的脑图很好的说明了整个框架的继承关系:

.

实现原理:

MJRefresh的实现原理是扩展了UIScrollView,添加了mj_header和mj_footer两个控件,通过KVO机制,监听了scrollView的contentOffset的变化,人为的将刷新这件事情划分成了五种不同的状态,当状态变化的时候,向外暴露出调用钩子,用户可以利用这些钩子来执行刷新和加载的动作。

说完了原理,来看一下怎么实现的。

MJRefreshComponent

这是所有刷新加载控件的基类,主要做的以下几件事:

  1. 声明控件的所有状态。
  2. 声明控件的回调函数。
  3. 添加监听机制。
  4. 提供了刷新,停止刷新的接口。
  5. 提供子类需要实现的方法。
1.声明控件的所有状态
/** 刷新控件的状态 */
typedef NS_ENUM(NSInteger, MJRefreshState) {
    /** 普通闲置状态 */
    MJRefreshStateIdle = 1,
    /** 松开就可以进行刷新的状态 */
    MJRefreshStatePulling,
    /** 正在刷新中的状态 */
    MJRefreshStateRefreshing,
    /** 即将刷新的状态 */
    MJRefreshStateWillRefresh,
    /** 所有数据加载完毕,没有更多的数据了 */
    MJRefreshStateNoMoreData
};
2.各种状态的回调Block
/** 进入刷新状态的回调 */
typedef void (^MJRefreshComponentRefreshingBlock)();
/** 开始刷新后的回调(进入刷新状态后的回调) */
typedef void (^MJRefreshComponentbeginRefreshingCompletionBlock)();
/** 结束刷新后的回调 */
typedef void (^MJRefreshComponentEndRefreshingCompletionBlock)();
3.添加监听
#pragma mark - KVO监听
- (void)addObservers
{
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentOffset options:options context:nil];
    [self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentSize options:options context:nil];
    self.pan = self.scrollView.panGestureRecognizer;
    [self.pan addObserver:self forKeyPath:MJRefreshKeyPathPanState options:options context:nil];
}

处理监听:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    // 遇到这些情况就直接返回
    if (!self.userInteractionEnabled) return;
    
    // 这个就算看不见也需要处理
    if ([keyPath isEqualToString:MJRefreshKeyPathContentSize]) {
        [self scrollViewContentSizeDidChange:change];
    }
    
    // 看不见
    if (self.hidden) return;
    if ([keyPath isEqualToString:MJRefreshKeyPathContentOffset]) {
        [self scrollViewContentOffsetDidChange:change];
    } else if ([keyPath isEqualToString:MJRefreshKeyPathPanState]) {
        [self scrollViewPanStateDidChange:change];
    }
}
4.刷新,停止刷新的接口
#pragma mark - 刷新状态控制
/** 进入刷新状态 */
- (void)beginRefreshing;
- (void)beginRefreshingWithCompletionBlock:(void (^)())completionBlock;
/** 结束刷新状态 */
- (void)endRefreshing;
- (void)endRefreshingWithCompletionBlock:(void (^)())completionBlock;
/** 是否正在刷新 */
- (BOOL)isRefreshing;
5.提供子类需要实现的方法
#pragma mark - 交给子类们去实现
/** 初始化 */
- (void)prepare NS_REQUIRES_SUPER;
/** 摆放子控件frame */
- (void)placeSubviews NS_REQUIRES_SUPER;
/** 当scrollView的contentOffset发生改变的时候调用 */
- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change NS_REQUIRES_SUPER;
/** 当scrollView的contentSize发生改变的时候调用 */
- (void)scrollViewContentSizeDidChange:(NSDictionary *)change NS_REQUIRES_SUPER;
/** 当scrollView的拖拽状态发生改变的时候调用 */
- (void)scrollViewPanStateDidChange:(NSDictionary *)change NS_REQUIRES_SUPER

这个基类其实能够表明刷新加载的实现原理,下面的子类在这个基础上进行了各种情形下的扩展,下面沿着MJRfreshHeader这个分支向下面展开:

MJRefreshHeader

MJRefreshHeader继承自MJRefreshComponent,实现了下面几个功能:

  1. 初始化。
  2. 设置Header的高度。
  3. 重新调整Y值。
  4. 根据contentOffset的变化,来切换状态(默认状态,可以刷新的状态,正在刷新的状态),实现方法是:scrollViewContentOffsetDidChange:。
  5. 在切换状态时,执行相应的操作。实现方法是:setState:。
1.初始化方法

提供了两个便利初始化方法,通过refreshingBlock来初始化,通过target-action来初始化:

+ (instancetype)headerWithRefreshingBlock:(MJRefreshComponentRefreshingBlock)refreshingBlock
{
    MJRefreshHeader *cmp = [[self alloc] init];
    //传入block
    cmp.refreshingBlock = refreshingBlock;
    return cmp;
}
+ (instancetype)headerWithRefreshingTarget:(id)target refreshingAction:(SEL)action
{
    MJRefreshHeader *cmp = [[self alloc] init];
    //设置self.refreshingTarget 和 self.refreshingAction
    [cmp setRefreshingTarget:target refreshingAction:action];
    return cmp;
}
2.设置header的高度

重写prepare方法,来设置header的高度:

- (void)prepare
{
    [super prepare];
    
    // 设置用于在NSUserDefaults里存储时间的key
    self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey;
    
    // 设置header的高度
    self.mj_h = MJRefreshHeaderHeight;
}
3.重新调整y值

重写placeSubviews方法来调整y值:

- (void)placeSubviews
{
    [super placeSubviews];
    
    // 设置y值(当自己的高度发生改变了,肯定要重新调整Y值,所以放到placeSubviews方法中设置y值)
    self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop;
    //self.ignoredScrollViewContentInsetTop 如果是10,那么就向上移动10
}
4.状态切换
- (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.contentInset;
    
    // 当前的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;
    }
}

做三点说明:

  1. 三种状态:默认状态(MJRefreshStateIdle),可以刷新状态(MJRefreshStatePulling),正在刷新状态(MJRefreshStateRefreshing)。
  2. 两种因素:一个是下拉的距离是否超过临界值,另一个是 手指是否离开屏幕。这是状态切换的触发因素。
  3. 一点注意:可以刷新的状态和正在刷新的状态是不同的。因为在手指还贴在屏幕的时候是不能进行刷新的。所以即使在下拉的距离超过了临界距离(状态栏 + 导航栏 + header高度),如果手指没有离开屏幕,那么也不能马上进行刷新,而是将状态切换为:可以刷新。一旦手指离开了屏幕,马上将状态切换为正在刷新。
5.状态切换的相应操作
- (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;
                // 设置滚动位置
                [self.scrollView setContentOffset:CGPointMake(0, -top) animated:NO];
            } completion:^(BOOL finished) {
                [self executeRefreshingCallback];
            }];
         });
    }

两点说明:

  1. 这里是重写MJRefreshState的set方法,在状态变更成MJRefreshStateIdle和MJRefreshStateRefreshing时候,触发的相应的操作,也是针对开始刷新和结束刷新这两个状态切换点来进行相应的触发。
  2. 结束刷新的时候会记录下当前的系统时间,因为header里面有个磨人的label来显示上次刷新的时间。

MJRefreshStateHeader

这个是MJRefreshHeader的子类,实现了两个功能:

  1. 简单布局了子控件stateLabel和lastUpdateTimeLabel。
  2. 根据刷新状态,实现了这两个label的显示状态的切换。
1.布局子控件

重写prepare方法,初始化间距和要显示的文字

    // 初始化间距
    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];

这个方法供调用来自定义设置不同状态下的显示文本。

#pragma mark - 公共方法
- (void)setTitle:(NSString *)title forState:(MJRefreshState)state
以刷新状态为Key,以显示的title为值,来储存不同状态的文本信息。

placeSubviews方法,负责对子控件进行布局,如果更新时间label是隐藏的,则让状态label撑满整个header,如果更新时间label不是隐藏的,根据约束设置更新时间label和状态label(高度各占一半)。

重写setState方法,根据传入的state不同,在stateLabel和lastUpdateTimeLabel里切换相应的文字,stateLabel里的文字直接从stateTitles字典里取出,lastUpdateTimeLabel里的文字需要通过一个方法来取出。

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

MJRefreshNormalHeader

MJRefreshNormalHeader 继承于 MJRefreshStateHeader,它主要做了两件事:

  1. 在MJRefreshStateHeader上添加arrowView和loadingView两个指示控件。
  2. 改变这两个控件显示的样式。
1.重写prepare方法

给indicatorView定义一个初始样式。

self.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray;
2.重写placeSubviews方法

要注意的点是,因为stateLabel和lastUpdatedTimeLabel是上下并排分布的,而arrowView或loadingView是在这二者的左边,所以为了避免这两组重合,在计算arrowView或loadingView的center的时候,需要获取stateLabel和lastUpdatedTimeLabel两个控件的宽度并比较大小,将较大的一个作为两个label的‘最宽距离’,再计算center,这样一来就不会重合了。

作者对于文本宽度计算的封装,也可以用在自己的项目中:

- (CGFloat)mj_textWith {
    CGFloat stringWidth = 0;
    CGSize size = CGSizeMake(MAXFLOAT, MAXFLOAT);
    if (self.text.length > 0) {
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 70000
        stringWidth =[self.text
                      boundingRectWithSize:size
                      options:NSStringDrawingUsesLineFragmentOrigin
                      attributes:@{NSFontAttributeName:self.font}
                      context:nil].size.width;
#else
        
        stringWidth = [self.text sizeWithFont:self.font
                             constrainedToSize:size
                                 lineBreakMode:NSLineBreakByCharWrapping].width;
#endif
    }
    return stringWidth;
}

主要是不同版本的api有变化。
setState方法

根据不同的状态修改箭头的transfrom属性,控制indicatorView的显示样式和是否要显示。

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 根据状态更新arrowView和loadingView的显示
    if (state == MJRefreshStateIdle) {
       
        //1. 设置为默认状态
        if (oldState == MJRefreshStateRefreshing) {
            
            //1.1 从正在刷新状态中切换过来
            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 {
            //1.2 从其他状态中切换过来
            [self.loadingView stopAnimating];
            //显示箭头并设置为初始状态
            self.arrowView.hidden = NO;
            [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
                self.arrowView.transform = CGAffineTransformIdentity;
            }];
        }
        
    } else if (state == MJRefreshStatePulling) {
        
        //2. 设置为可以刷新状态
        [self.loadingView stopAnimating];
        self.arrowView.hidden = NO;
        [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
            //箭头倒立
            self.arrowView.transform = CGAffineTransformMakeRotation(0.000001 - M_PI);
        }];
        
    } else if (state == MJRefreshStateRefreshing) {
        
        //3. 设置为正在刷新状态
        self.loadingView.alpha = 1.0; // 防止refreshing -> idle的动画完毕动作没有被执行
        //菊花旋转
        [self.loadingView startAnimating];
        //隐藏arrowView
        self.arrowView.hidden = YES;
    }
}

这样我们就从上到下,从基础api到完整功能的实现来看了一遍这个框架,注意到三个贯穿基类和子类的方法prepareplaceSubviewssetState,分别在各层实现自己这一层的功能。

MJRefreshHeader:负责header的高度设置和调整header在scrollView中的位置。
MJRefreshStateHeader:负责header内部stateLabel和lastUpdateTimeLabel的布局和不同状态下内部文字的显示。
MJRefreshNormalHeader:负责header内部子控件loadingView和arrowView的布局和不同状态下的显示。

这样就非常好的实现了软件架构思维中的分层解耦,各层互不影响,如果某一天我们要给MJRefresh添加一个新的样式,我们就只需要在一层上面做文章,而不用牵一发而动全身。例如框架里面提供的MJRefreshGifHeader和MJRefreshNormalHeader处于同一层,二者具有相同的stateLabel和lastUpdateTimeLabel,这是左侧的显示状态不同:

  • MJRefreshNormalHeader左侧是箭头和indicatorView;
  • MJRefreshGifHeader左侧是一个gif动画。

来看一下是怎么实现的:
MJRefreshGifHeader的左侧是一个imageView,并提供了两个设置图片数组对的接口:

/** 设置state状态下的动画图片images 动画持续时间duration*/
- (void)setImages:(NSArray *)images duration:(NSTimeInterval)duration forState:(MJRefreshState)state;
- (void)setImages:(NSArray *)images forState:(MJRefreshState)state;

然而MJRefreshGifHeader只需要和MJRefreshNormalHeader一样,重写基类提供的三个方法来实现显示gif图片的功能。

1. 初始化和右侧label的间距
- (void)prepare
{
    [super prepare];
    
    // 初始化间距
    self.labelLeftInset = 20;
}
2.设置承载gif的imageView的位置
- (void)placeSubviews
{
    [super placeSubviews];
    
    //如果约束存在,就立即返回
    if (self.gifView.constraints.count) return;
    
    self.gifView.frame = self.bounds;
    
    if (self.stateLabel.hidden && self.lastUpdatedTimeLabel.hidden) {
        
        //如果stateLabel和lastUpdatedTimeLabel都在隐藏状态,将gif剧中显示
        self.gifView.contentMode = UIViewContentModeCenter;
        
    } else {
        
        //如果stateLabel和lastUpdatedTimeLabel中至少一个存在,则根据label的宽度设置gif的位置
        self.gifView.contentMode = UIViewContentModeRight;
        
        CGFloat stateWidth = self.stateLabel.mj_textWith;
        CGFloat timeWidth = 0.0;
        if (!self.lastUpdatedTimeLabel.hidden) {
            timeWidth = self.lastUpdatedTimeLabel.mj_textWith;
        }
        CGFloat textWidth = MAX(stateWidth, timeWidth);
        self.gifView.mj_w = self.mj_w * 0.5 - textWidth * 0.5 - self.labelLeftInset;
    }
}
3.设置图片数组动画
- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    if (state == MJRefreshStatePulling || state == MJRefreshStateRefreshing) {
        
        //1. 如果传进来的状态是可以刷新和正在刷新
        NSArray *images = self.stateImages[@(state)];
        if (images.count == 0) return;
        
        [self.gifView stopAnimating];
        
        if (images.count == 1) {
            //1.1 单张图片
            self.gifView.image = [images lastObject];
        } else {
            //1.2 多张图片
            self.gifView.animationImages = images;
            self.gifView.animationDuration = [self.stateDurations[@(state)] doubleValue];
            [self.gifView startAnimating];
        }
    } else if (state == MJRefreshStateIdle) {
        //2.如果传进来的状态是默认状态
        [self.gifView stopAnimating];
    }
}

总结

这样就沿着一条线把MJRefresh的实现思路和方法解读了一遍,总之这个框架的设计非常优美,通过一个基类来定义一些状态和一些需要子类实现的接口。通过一层一层地继承,让每一层的子类各司其职,只完成真正属于自己的任务,提高了框架的可定制性,而且对于功能的扩展和bug的追踪也很有帮助,非常值得我们参考与借鉴。

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

推荐阅读更多精彩内容