iOS中定时器(Timer)的那点事

Timer

A timer that fires after a certain time interval has elapsed, sending a specified message to a target object.

简单来说就是在指定时间过去,定时器会被启动并发送消息给目标对象去执行对应的事件

定时器(Timer)的功能是与Runloop相关联的,Runloop会强引用Timer,所以当定时器被添加到Runloop之后,我们并没有必须强引用定时器(Timer

理解Run Loop概念

谈到定时器,首先需要了解的一个概念是 RunLoop。一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常的代码逻辑是这样的:

function  loop()  {
    initialize();
    do  {
        var  message  =  get_next_message();
        process_message(message);
    }  while  (message  !=  quit);
}

这种模型通常被称作 Event Loop。 Event Loop 在很多系统和框架里都有实现,比如Windows 程序的消息循环,再比如 OSX/iOS 里的 RunLoop。实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

所以,RunLoop实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面 Event Loop 的逻辑。线程执行了这个函数后,就会一直处于这个函数内部 “接受消息->等待->处理” 的循环中,直到这个循环结束(比如传入 quit 的消息),函数返回。

OSX/iOS 系统中,提供了两个这样的对象:RunLoop 和 CFRunLoopRef。更多详细的内容可以看深入理解RunLoop,也可以参考官方文档Threading Programming Guide

重复和非重复定时器

  • 重复定时

常用的target-action方式

func addRepeatedTimer() {
   let timer = Timer.scheduledTimer(timeInterval: 1.0,
                                          target: self,
                                        selector: #selector(fireTimer),
                                        userInfo: nil,
                                         repeats: true)
}

@objc func fireTimer() {
    print("fire timer")
}

参数介绍

  • timeInterval:延时时间,单位为秒,可以是小数。如果值小于等于0.0的话,系统会默认赋值0.1毫秒
  • target:目标对象,一般是self,但是注意timer会强引用target,直到调用invalidate方法
  • selector: 执行方法
  • userInfo: 传入信息
  • repeats:是否重复执行

使用block方式

func addRepeatedTimerWithClosure() {
    if #available(iOS 10.0, *) { // iOS10之后的API
        let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
            print("fire timer")
        }
     } else {
        // Fallback on earlier versions
    }
}

上面两种方式可以实现重复定时触发事件,但是target-action方式会存在一个问题?那就是对象之间的引用问题导致内存泄露,因为定时器强引用了self,而本身又被runloop强引用。所以timerself都得不到释放,所以定时器一直存在并触发事件,这样就会导致内存泄露。

为了避免内存泄露,所以需要在不使用定时器的时候,手动执行timer.invalidate()方法。而block方式虽然并不会存在循环引用情况,但是由于本身被runloop强引用,所以也需要执行timer.invalidate()方法,否则定时器还是会一直存在。

invalidate方法有2个功能:一是将timerrunloop中移除,二是timer本身也会释放它持有的资源

因此经常会对timer进行引用。

self.timer = timer

失效定时器

timer.invalidate()
timer = nil

具体的循环引用例子,后面会有

  • 非重复定时

非重复定时器只会执行一次,执行结束会自动调用invalidates方法,这样能够防止定时器再次启动。实现很简单将repeats设置为false即可

// target-action方式
func addNoRepeatedTimer() {
    let timer = Timer.scheduledTimer(timeInterval: 1.0,
                                           target: self,
                                         selector:  #selector(fireTimer),
                                         userInfo: nil,
                                         repeats: false)
}

// block方式
func addUnRepeatedTimerWithClosure() {
    if #available(iOS 10.0, *) { // iOS10之后的API
        let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { (timer) in
            print("fire timer")
        }
     } else {
        // Fallback on earlier versions
    }
}

@objc func fireTimer() {
     print("fire timer")
 }

定时容忍范围(Timer Tolerance)

iOS7之后,iOS允许我们为Timer指定Tolerance,这样会给你的timer添加一些时间宽容度可以降低它的电力消耗以及增加响应。好比如:“我希望1秒钟运行一次,但是晚个200毫秒我也不介意”。

当你指定了时间宽容度,就意味着系统可以在原有时间附加该宽容度内的任意时刻触发timer。例如,如果你要timer1秒后运行,并有0.5秒的时间宽容度,实际就可能是1秒,1.5秒或1.3秒等。

下面是每秒运行一次的timer,并有0.2秒的时间宽容度

 let timer = Timer.scheduledTimer(timeInterval: 1.0,
                                         target: self,
                                         selector: #selector(fireTimer),
                                         userInfo: nil,
                                         repeats: true)
 timer.tolerance = 0.2

默认的时间宽容度是0,如果一个重复性timer由于设定的时间宽容度推迟了一小会执行,这并不意味着后续的执行都会晚一会。iOS不允许timer总体上的漂移,也就是说下一次触发会快一些。

举例的话,如果一个timer每1秒运行一次,并有0.5秒的时间宽容度,那么实际可能是这样:

  • 1.0秒后timer触发
  • 2.4秒后timer再次触发,晚了0.4秒,但是在时间宽容度内
  • 3.1秒后timer第三次触发,和上一次仅差0.7秒,但每次触发的时间是按原始时间算的。
    等等…

使用userInfo获取额外信息

   func getTimerUserInfo() {
        let timer = Timer.scheduledTimer(timeInterval: 1.0,
                                         target: self,
                                         selector:  #selector(fireTimer),
                                         userInfo: ["score": 90],
                                         repeats: false)

    }

    @objc func fire(_ timer: Timer) {
        guard let userInfo = timer.userInfo as? [String: Int],
            let score = userInfo["score"] else {
            return
        }
        print("score: \(score)")
    }

与Run Loop协同工作

当使用下列方法创建timer,需要手动添加timerRun Loop并指定运行模型,上面使用的方法都是自动添加到当前的Run Loop并在默认模型(default mode)允许

public  init(timeInterval ti: TimeInterval,
             invocation: NSInvocation,
             repeats yesOrNo: Bool)

public init(timeInterval ti: TimeInterval,
            target aTarget: Any,
            selector aSelector: Selector,
            userInfo: Any?,
            repeats yesOrNo: Bool)

public init(fireAt date: Date,
            interval ti: TimeInterval,
            target t: Any,
            selector s: Selector,
            userInfo ui: Any?,
            repeats rep: Bool)

比如创建timer添加到当前的Run Loop

// 手动添加到runloop,指定模型
func addTimerToRunloop() {
    let timer = Timer(timeInterval: 1.0,
                            target: self,
                          selector: #selector(fireTimer),
                          userInfo: nil,
                           repeats: true)
        
   RunLoop.current.add(timer, forMode: .common)
}

iOS开发中经常遇到的场景,tableView上有定时器,当用户用手指触摸屏幕,定时器会停止执行,滚动停止才会恢复定时。但是这并不是我们所想要的?为什么会出现呢?

主线程的RunLoop里有两个预置的 ModekCFRunLoopDefaultModeUITrackingRunLoopMode

这两个Mode都已经被标记为”Common”属性。DefaultModeApp平时所处的状态,TrackingRunLoopMode是追踪 ScrollView滑动时的状态。当你创建一个 Timer并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop会将 mode 切换为 TrackingRunLoopMode,这时 Timer就不会被回调,并且也不会影响到滑动操作。

所以当你需要一个Timer,在两个 Mode 中都能得到回调,有如下方法

  • 1、将这个Timer分别加入这两个 Mode
  RunLoop.current.add(timer, forMode: .default)
  RunLoop.current.add(timer, forMode: .tracking)
  • 2、将 Timer加入到顶层的 RunLoopcommon模式中
RunLoop.current.add(timer, forMode: .common)
  • 3、在子线程中进行Timer的操作,再在主线程中修改UI界面

实际场景

1、利用Timer简单实现倒计时功能

class TimerViewController: BaseViewController {

    var timer: Timer?
    var timeLeft = 60
    lazy var timeLabel: UILabel = {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: 60, height: 60))
        label.backgroundColor = UIColor.orange
        label.textColor = UIColor.white
        label.text = "60 s"
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        addTimeLabel()
        countDownTimer()
    }

    func addTimeLabel() {
        view.addSubview(timeLabel)
        timeLabel.center = view.center
    }

    func countDownTimer() {
        timer = Timer.scheduledTimer(timeInterval: 1.0,
                                     target: self,
                                     selector: #selector(countTime),
                                     userInfo: nil,
                                     repeats: true)

    }

    @objc func countTime() {
        timeLeft -= 1
        timeLabel.text = "\(timeLeft) s"

        if timeLeft <= 0 {
            timer?.invalidate()
            timer = nil
        }
    }
}

2、定时器的循环引用

常见的场景:

有两个控制器ViewControllerAViewControllerBViewControllerA 跳转到ViewControllerB中,ViewControllerB开启定时器,但是当返回ViewControllerA界面时,定时器依然还在走,控制器也并没有执行deinit方法销毁掉

为何会出现循环引用的情况呢?原因是:定时器对控制器 (self) 进行了强引用,定时器被runloop引用,定时器得不到释放,所以控制器也不会被释放

具体代码

TimerViewController是第二个界面,实现很简单,也是初学者经常做的事情,仅仅是启动一个定时器,在TimerViewController被释放的时候,释放定时器

class TimerViewController: BaseViewController {

    var timer: Timer?

    override func viewDidLoad() {
        super.viewDidLoad()
        addRepeatedTimer()
    }

    func addRepeatedTimer() {
        let timer = Timer.scheduledTimer(timeInterval: 1.0,
                                         target: self,
                                         selector: #selector(fireTimer),
                                         userInfo: nil,
                                         repeats: true)
        self.timer = timer
    }

    @objc func fireTimer() {
        print("fire timer")
    }

    func cancelTimer() {
        timer?.invalidate()
        timer = nil
    }

    deinit {
        cancelTimer()
        print("deinit timerviewcontroller")
    }
}

运行程序之后,可以看到进入该视图控制页面,定时器正常执行,返回上级页面,定时器仍然执行,而且视图控制也没有得到释放。为了解决这个问题,有两种方法

方式1:

苹果官方为了给我们解决对象引用的问题,提供了一个新的定时器方法,利用block来解决与视图控制器的引用循环,但是只适用于iOS10和更高版本:

func addRepeatedTimerWithClosure() {
    if #available(iOS 10.0, *) {
        weak var weakSelf = self
        let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
            weakSelf?.doSomething()
        }
        self.timer = timer
     } else {
            // Fallback on earlier versions
    }
}

func doSomething() {
   print("fire timer")
}

方式2:

既然Apple为我们提供了block方式解决循环引用问题,我们也可以模仿Apple使用block来解决,扩展Timer添加一个新方法来创建Timer

extension Timer {
    class func sh_scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer {
        if #available(iOS 10.0, *) {
           return Timer.scheduledTimer(withTimeInterval: interval,
                                       repeats: repeats,
                                       block: block)
        } else {
            return Timer.scheduledTimer(timeInterval: interval,
                                        target: self,
                                        selector: #selector(timerAction(_:)),
                                        userInfo: block,
                                        repeats: repeats)
        }
    }

    @objc class func timerAction(_ timer: Timer) {
        guard let block = timer.userInfo as? ((Timer) -> Void) else {
            return
        }
        block(timer)
    }
}

由上可知很简单iOS10还是使用官方API,iOS10以前也是使用的官方API,只不过将target变成了Timer自己,然后将block作为userInfo的参数传入,当定时器启动的时候,获取block,并执行。

简单使用一下

func addNewMethodOfTimer() {
     weak var weakSelf = self
     let timer = Timer.sh_scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
         weakSelf?.doSomething()
     }
    self.timer = timer
}

运行程序可以看到,controllertimer都得到了释放

当然,除了扩展Timer,也可以创建一个新的类,实现都大同小异,通过中间类切断强引用。

final class WeakTimer {

    fileprivate weak var timer: Timer?
    fileprivate weak var target: AnyObject?
    fileprivate let action: (Timer) -> Void

    fileprivate init(timeInterval: TimeInterval,
                     target: AnyObject,
                     repeats: Bool,
                     action: @escaping (Timer) -> Void) {
        self.target = target
        self.action = action
        self.timer = Timer.scheduledTimer(timeInterval: timeInterval,
                                          target: self,
                                          selector: #selector(fire),
                                          userInfo: nil,
                                          repeats: repeats)
    }

    class func scheduledTimer(timeInterval: TimeInterval,
                              target: AnyObject,
                              repeats: Bool,
                              action: @escaping (Timer) -> Void) -> Timer {
        return WeakTimer(timeInterval: timeInterval,
                         target: target,
                         repeats: repeats,
                         action: action).timer!
    }

    @objc fileprivate func fire(timer: Timer) {
        if target != nil {
            action(timer)
        } else {
            timer.invalidate()
        }
    }
}

更多详情可以看 Weak Reference to NSTimer Target To Prevent Retain Cycle

3、定时器的精确

一般情况下使用Timer是没什么问题,但是对于精确到要求较高可以使用CADisplayLink(做动画)和GCD,对于CADisplayLink不了解,可以看CADisplayLink的介绍,对于定时器之间的比较,可以看更可靠和高精度的 iOS 定时器

定时器不准时的原因

  • 定时器计算下一个触发时间是根据初始触发时间计算的,下一次触发时间是定时器的整数倍+容差tolerance
  • 定时器是添加到runloop中的,如果runloop阻塞了,调用或执行方法所花费的时间长于指定的时间间隔(第1点计算得到的时间,就会推迟到下一个runloop周期。
  • 定时器是不会尝试补偿在调用或执行指定方法时可能发生的任何错过的触发
  • runloop的模式影响

高精度的 iOS 定时器

提高调度优先级:

#include <mach/mach.h>
#include <mach/mach_time.h>
#include <pthread.h>

void move_pthread_to_realtime_scheduling_class(pthread_t pthread) {
    mach_timebase_info_data_t timebase_info;
    mach_timebase_info(&timebase_info);

    const uint64_t NANOS_PER_MSEC = 1000000ULL;
    double clock2abs = ((double)timebase_info.denom / (double)timebase_info.numer) * NANOS_PER_MSEC;

    thread_time_constraint_policy_data_t policy;
    policy.period      = 0;
    policy.computation = (uint32_t)(5 * clock2abs); // 5 ms of work
    policy.constraint  = (uint32_t)(10 * clock2abs);
    policy.preemptible = FALSE;

    int kr = thread_policy_set(pthread_mach_thread_np(pthread_self()),
                   THREAD_TIME_CONSTRAINT_POLICY,
                   (thread_policy_t)&policy,
                   THREAD_TIME_CONSTRAINT_POLICY_COUNT);
    if (kr != KERN_SUCCESS) {
        mach_error("thread_policy_set:", kr);
        exit(1);
    }
}

精确延时:

#include <mach/mach.h>
#include <mach/mach_time.h>

static const uint64_t NANOS_PER_USEC = 1000ULL;
static const uint64_t NANOS_PER_MILLISEC = 1000ULL * NANOS_PER_USEC;
static const uint64_t NANOS_PER_SEC = 1000ULL * NANOS_PER_MILLISEC;

static mach_timebase_info_data_t timebase_info;

static uint64_t abs_to_nanos(uint64_t abs) {
    return abs * timebase_info.numer  / timebase_info.denom;
}

static uint64_t nanos_to_abs(uint64_t nanos) {
    return nanos * timebase_info.denom / timebase_info.numer;
}

void example_mach_wait_until(int argc, const char * argv[]) {
    mach_timebase_info(&timebase_info);
    uint64_t time_to_wait = nanos_to_abs(10ULL * NANOS_PER_SEC);
    uint64_t now = mach_absolute_time();
    mach_wait_until(now + time_to_wait);
}

High Precision Timers in iOS / OS X

利用GCD实现一个好的定时器

而众所周知的是,NSTimer有不少需要注意的地方。

  1. 循环引用问题

    NSTimer会强引用target,同时RunLoop会强引用未invalidate的NSTimer实例。 容易导致内存泄露。
    (关于NSTimer引起的内存泄露可阅读iOS夯实:ARC时代的内存管理 NSTimer一节)

  2. RunLoop问题

    因为NSTimer依赖于RunLoop机制进行工作,因此需要注意RunLoop相关的问题。NSTimer默认运行于RunLoop的default mode中。
    而ScrollView在用户滑动时,主线程RunLoop会转到UITrackingRunLoopMode。而这个时候,Timer就不会运行,方法得不到fire。如果想要在ScrollView滚动的时候Timer不失效,需要注意将Timer设置运行于NSRunLoopCommonModes

  3. 线程问题

    NSTimer无法在子线程中使用。如果我们想要在子线程中执行定时任务,必须激活和自己管理子线程的RunLoop。否则NSTimer是失效的。

  4. 不支持动态修改时间间隔

    NSTimer无法动态修改时间间隔,如果我们想要增加或减少NSTimer的时间间隔。只能invalidate之前的NSTimer,再重新生成一个NSTimer设定新的时间间隔。

  5. 不支持闭包。

    NSTimer只支持调用selector,不支持更现代的闭包语法。

利用DispatchSource来解决上述问题,基于DispatchSource构建Timer

class SwiftTimer {
    
    private let internalTimer: DispatchSourceTimer
    
    init(interval: DispatchTimeInterval, repeats: Bool = false, queue: DispatchQueue = .main , handler: () -> Void) {
        
        internalTimer = DispatchSource.makeTimerSource(queue: queue)
        internalTimer.setEventHandler(handler: handler)
        if repeats {
            internalTimer.scheduleRepeating(deadline: .now() + interval, interval: interval)
        } else {
            internalTimer.scheduleOneshot(deadline: .now() + interval)
        }
    }
    
    deinit() {
        //事实上,不需要手动cancel. DispatchSourceTimer在销毁时也会自动cancel。
        internalTimer.cancel()
    }
    
    func rescheduleRepeating(interval: DispatchTimeInterval) {
        internalTimer.scheduleRepeating(deadline: .now() + interval, interval: interval)
    }
}

原文内容

4、后台定时器继续运行

苹果上面的App一般都是不允许在后台运行的,比如说:定时器计时,当用户切换到后台,定时器就被被挂起,等回到App之后,才会Resume

但是任何的app都能够使用 UIApplication background tasks在后台运行一小段时间,除此之外没有其他的办法。

在后台运行定时器需要注意:

  • You need to opt into background execution with beginBackgroundTaskWithExpirationHandler.
  • Either create the Timer on the main thread, OR you will need to add it to the mainRunLoop manually withRunLoop.current.add(timer, forMode: .default)

实现如下

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var backgroundUpdateTask: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier(rawValue: 0)

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }

    func applicationDidEnterBackground(_ application: UIApplication) {
        let application = UIApplication.shared

        self.backgroundUpdateTask = application.beginBackgroundTask {
            self.endBackgroundUpdateTask()
        }

        DispatchQueue.global().async {
            let timer = Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(self.methodRunAfterBackground), userInfo: nil, repeats: true)
            RunLoop.current.add(timer, forMode: .default)
            RunLoop.current.run()
        }
    }

    @objc func methodRunAfterBackground() {
        print("methodRunAfterBackground")
    }

    func endBackgroundUpdateTask() {
        UIApplication.shared.endBackgroundTask(self.backgroundUpdateTask)
        self.backgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
    }


    func applicationWillEnterForeground(_ application: UIApplication) {
          self.endBackgroundUpdateTask()
    }
}

注意:

  • Apps only get ~ 10 mins (~3 mins as of iOS 7) of background execution - after this the timer will stop firing.
  • As of iOS 7 when the device is locked it will suspend the foreground app almost instantly. The timer will not fire after an iOS 7 app is locked.

内容参考Scheduled NSTimer when app is in background,如果想了解后台任务Background Modes Tutorial: Getting Started

参考

Timer

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

推荐阅读更多精彩内容