Swift探索(四): 指针和内存管理

一:指针

1. 指针的定义

Swift 中引用了某个引用类型实例的常量或变量,与 C 语言中的指针类似,不过它并不直接指向某个内存地址,也不要求你使用星号(*)来表明你在创建一个引用。相反,Swift 中引用的定义方式与其它的常量或变量的一样。

指针是不安全的:

  • 比如我们在创建一个对象的时候,是需要在堆分配内存空间的。但是这个内存空间的生命周期是有限的,也就意味着如果我们使用指针指向这块内存空间,如果当前内存空间的生命周期到了(引用计数变为0),那么我们当前的指针就变成了未定义的行为了,也就变成了野指针。
  • 创建的内存空间是越界的,比如我创建了一个大小为 10 的数组,这个时候我们通过指针访问到了 index = 11 的位置,这个时候就数组越界了,访问了一个未知的内存空间。
  • 指针所指向的类型与内存的值类型不一致,也是不安全的。

2. 指针类型

Swift 中的指针分为两类

  • typed pointer 指定数据类型指针
  • raw pointer 未指定数据类型的指针(原生指针)
    基本上我们接触的指针有以下几种
    Swift的指针和OC的指针对比.png
2.1 原生指针

首先来了解一下步长信息

struct Person {
    var age: Int = 18
    var sex: Bool = true
}

print(MemoryLayout<Person>.size) // 真实大小
print(MemoryLayout<Person>.stride) // 步长信息
print(MemoryLayout<Person>.alignment) // 对齐信息

// 打印结果
9 // 8(int) + 1(bool)
16 // 8 + 8  bool虽然只占用一个字节
8

我们可以看到

  • size 的结果是 9 = int8字节 + bool1 字节
  • stride 的结果是 16, 因为 alignment 的值为 8 ,也就是说是按照 8 字节对齐,所以步长信息为 8 + 8 = 16 字节。

接下来使用原生指针 (Raw Pointer) 存储4个整型的数据
示例代码

// 首先开辟一块内存空间 byteCount: 当前总的字节大小 4 x 8 = 32 alignment: 对齐的大小
let p = UnsafeMutableRawPointer.allocate(byteCount: 4 * 8, alignment: 8)

for i in 0..<4 {
    // 调用 advanced 获取到每个地址排列的过程中应该距离首地址的大小 i x MemoryLayout<Int>.stride
    // 调用 store 方法存储当前的整型数值
    p.advanced(by:i * MemoryLayout<Int>.stride).storeBytes(of: i, as: Int.self)
}

for i in 0..<4 {
    // 调用 load 方法加载当前指针当中对应的内存数据
    let value = p.load(fromByteOffset: i * 8, as: Int.self)
    print("index--\(i), value--\(value)")
}

// 释放创建的连续的内存空间
p.deallocate()

// 打印结果
index--0, value--0
index--1, value--1
index--2, value--2
index--3, value--3
2.2 类型指针

类型指针相较于原生指针来说,其实就是指定当前指针已经绑定到了具体的类型,在进行类型指针访问的过程中,我们不再使用 storeload 方法进行存储操操作,而是直接使用类型指针内置的变量 pointee
获取 UnsafePointer 有两种方式

  • 通过已有变量获取
var age = 18

// 通过 withUnsafePointer 来访问到当前变量的地址
withUnsafePointer(to: &age) { ptr in
    print(ptr)
}

age = withUnsafePointer(to: &age) { ptr in
    //注意这里我们不能直接修改ptr.pointee
    return ptr.pointee + 12
}

var b = 18

// 使用mutable修改ptr.pointee
withUnsafeMutablePointer(to: &b) { ptr in
    ptr.pointee += 10
    print(ptr)
}
  • 直接分配内存
var age = 10

// 分配一块int类型内存空间, 注意当前内存空间还没被初始化
let tPtr = UnsafeMutablePointer<Int>.allocate(capacity: 1)

// 初始化分配内存空间
tPtr.initialize(to: age)

// 访问当前内存的值, 直接通过pointee进行访问
print(tPtr.pointee)`

类型指针主要涉及到的api主要有


类型指针api.png

示例

struct Person {
    var age = 18
    var name = "小明"
}

// 方式一
// capacity 内存空间 5个连续的内存空间
var tptr = UnsafeMutablePointer<Person>.allocate(capacity: 5)

// tptr就是当前分配的内存空间的首地址
tptr[0] = Person.init(age: 18, name: "小明")
tptr[1] = Person.init(age: 19, name: "小强")

// 这两个是成对出现的
// 清除内存空间中内容
tptr.deinitialize(count: 5)
// 回收内存空间
tptr.deallocate()

// 方式二
// 开辟2个连续的内存空间
let p = UnsafeMutablePointer<Person>.allocate(capacity: 2)
p.initialize(to: Person())
p.advanced(by: MemoryLayout<Person>.stride).initialize(to: Person(age: 18, name: "小明"))

// 当前程序运行完成后 执行defer
defer {
    // 这两个是成对出现的
    p.deinitialize(count: 2)
    p.deallocate()
}
2.3 内存指针的使用-内存绑定

Swift 提供了三种不同的API来绑定/重新绑定指针:

  • assumingMemoryBound(to:)
    有些时候我们处理代码的过程中只有原生指针(没有报错指针类型),但此刻对于处理代码的的我们来说明确知道指针的类型,我们就可以使用 assumingMemoryBound(to:) 来告诉编译器预期的类型。
    (注意:这里只是让编译器绕过类型检查,并没有发生实际的类型转换)
func testPointer(_ p: UnsafePointer<Int>) {
    print(p[0])
    print(p[1])
}

// 这里的元祖是值类型,本质上这块内存空间中存放的就是Int类型的数据
let tuple = (10, 20)
withUnsafePointer(to: tuple) { (tuplePtr: UnsafePointer<(Int, Int)>) in
    // 先将tuplePtr 转换成原生指针, 在调用assumingMemoryBound(to:) 告诉编译器当前内存已经绑定过Int了,这个时候编译器就不会进行检查
    testPointer(UnsafeRawPointer(tuplePtr).assumingMemoryBound(to: Int.self))
}
  • bindMemory(to: capacity:)
    用于更改内存绑定的类型,如果当前内存还没有类型绑定,则将首次绑定为该类型,否则重新绑定该类型,并且内存中所有的值都会变成该类型
func testPointer(_ p: UnsafePointer<Int>) {
    print(p[0])
    print(p[1])
}

// 这里的元祖是值类型,本质上这块内存空间中存放的就是Int类型的数据
let tuple = (10, 20)
withUnsafePointer(to: tuple) { (tuplePtr: UnsafePointer<(Int, Int)>) in
    // 先将tuplePtr 转换成原生指针, 将原生指针转换成UnsafePointer<Int>类型
    testPointer(UnsafeRawPointer(tuplePtr).bindMemory(to: Int.self, capacity: 1))
}
  • withMemoryRebound(to: capacity: body:)
    当我们在给外部函数传递参数时,不免会有一些数据类型上的差距,如果我们进行类型转换,必然要来会复制数据,这个时候就可以调用 withMemoryRebound(to: capacity: body:) 来临时更改内存绑定类型。
func testPointer(_ p: UnsafePointer<Int8>) {
    print(p[0])
    print(p[1])
}

let uint8Ptr = UnsafePointer<uint8>.init(bitPattern: 10)
// 减少代码复杂度
uint8Ptr?.withMemoryRebound(to: Int8.self, capacity: 1) { (int8Ptr: UnsafePointer<Int8>) in
    testPointer(int8Ptr)
    
}

3.利用指针还原Macho文件中的属性和函数表

class Person {
    var age: Int = 18
    var name: String = "小明"
}

var size: UInt = 0
//__swift5_types section 的pFile
var typesPtr = getsectdata("__TEXT", "__swift5_types", &size)

// 获取当前程序运行地址 相当于 LLDB 中 image list 命令
var mhHeaderPtr = _dyld_get_image_header(0)

// 获取 __LINKEDIT 中的内容 其中 getsegbyname 返回的是 UnsafePointer<segment_command_64>,  segment_command_64 就包含了 vmaddr(虚拟内存地址) 和 fileoff(偏移量)
var setCommond64LinkeditPtr = getsegbyname("__LINKEDIT")

// 计算链接的基地址
var linkBaseAddress: UInt64 = 0
if let vmaddr = setCommond64LinkeditPtr?.pointee.vmaddr, let fileOff = setCommond64LinkeditPtr?.pointee.fileoff{
    linkBaseAddress = vmaddr - fileOff
}

// 或者 直接去 LC_SEGMENT_64(__PAGEZERO)中的VM Size
var setCommond64PageZeroPtr = getsegbyname("__PAGEZERO")
if let vmsize = setCommond64PageZeroPtr?.pointee.vmsize {
    linkBaseAddress = vmsize
}

// 获取__TEXT, __swift5_types 在Macho中的偏移量
var typesOffSet: UInt64 = 0
if let unwrappedPtr = typesPtr {
    // 将当前的地址信息转换成UInt64
    let intRepresentation = UInt64(bitPattern: Int64(Int(bitPattern: unwrappedPtr)))
    typesOffSet = intRepresentation - linkBaseAddress
}

// 程序运行的首地址 转换成UInt64类型
let mhHeaderPtr_IntRepresentation = UInt64(bitPattern: Int64(Int(bitPattern: mhHeaderPtr)))

// DataLo的内存地址
var dataLoAddress = mhHeaderPtr_IntRepresentation + typesOffSet

// 转换成指针类型
var dataLoAddressPtr = withUnsafePointer(to: &dataLoAddress){return $0}

// 获取dataLo指针指向的内容
var dataLoContent = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: dataLoAddress) ?? 0)?.pointee

// 获取typeDescriptor的偏移量
let typeDescOffset = UInt64(dataLoContent!) + typesOffSet - linkBaseAddress

// 获取typeDescriptor在程序运行中的地址
var typeDescAddress = typeDescOffset + mhHeaderPtr_IntRepresentation

// typeDescriptor结构体
struct TargetClassDescriptor{
    var flags: UInt32
    var parent: UInt32
    var name: Int32
    var accessFunctionPointer: Int32
    var fieldDescriptor: Int32
    var superClassType: Int32
    var metadataNegativeSizeInWords: UInt32
    var metadataPositiveSizeInWords: UInt32
    var numImmediateMembers: UInt32
    var numFields: UInt32
    var fieldOffsetVectorOffset: UInt32
    var Offset: UInt32
    var methods: UInt32
}

// 将 typeDescriptor 的内存地址直接转换成指向 TargetClassDescriptor 结构体的指针
let classDescriptor = UnsafePointer<TargetClassDescriptor>.init(bitPattern: Int(exactly: typeDescAddress) ?? 0)?.pointee

if let name = classDescriptor?.name {
    // 获取name的偏移量地址
    let nameOffset = Int64(name) + Int64(typeDescOffset) + 8
    // 获取name在运行中的内存地址
    let nameAddress = nameOffset + Int64(mhHeaderPtr_IntRepresentation)
    if let cChar = UnsafePointer<CChar>.init(bitPattern: Int(nameAddress)){
        print(String(cString: cChar))
    }
}

// 获取属性
// 获取属性相关的filedDescriptor 在运行中的内存地址
let filedDescriptorRelaticveAddress = typeDescOffset + 4 * 4 + mhHeaderPtr_IntRepresentation

struct FieldDescriptor  {
    var mangledTypeName: Int32
    var superclass: Int32
    var Kind: UInt16
    var fieldRecordSize: UInt16
    var numFields: UInt32
    var fieldRecords: [FieldRecord]
}

struct FieldRecord{
    var Flags: UInt32
    var mangledTypeName: Int32
    var fieldName: UInt32
}

// 获取fieldDescriptor 指针在的内容 就是FieldDescriptor 的偏移量
let fieldDescriptorOffset = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: filedDescriptorRelaticveAddress) ?? 0)?.pointee

// 获取 FieldDescriptor 的在运行中的内存地址
let fieldDescriptorAddress = filedDescriptorRelaticveAddress + UInt64(fieldDescriptorOffset!)

// 将 FieldDescriptor 的内存地址直接转换成指向 FieldDescriptor 结构体的指针
let fieldDescriptor = UnsafePointer<FieldDescriptor>.init(bitPattern: Int(exactly: fieldDescriptorAddress) ?? 0)?.pointee

// 循环遍历属性
for i in 0..<fieldDescriptor!.numFields{
    // FieldRecord 结构体由 3个 4字节组成,并且保持3 * 4 = 12字节对齐
    let stride: UInt64 = UInt64(i * 3 * 4)
    // 计算 fieldRecord 的地址
    let fieldRecordAddress = fieldDescriptorAddress + stride + 16
    // 计算 fieldRecord 结构体中的 name 在程序运行中的内存地址
    let fieldNameRelactiveAddress = UInt64(2 * 4) + fieldRecordAddress - linkBaseAddress + mhHeaderPtr_IntRepresentation
    // 将上面地址的地址转换成指针,并且获取指向的内容 (偏移量)
    let nameOffset = UnsafePointer<UInt32>.init(bitPattern: Int(exactly: fieldNameRelactiveAddress) ?? 0)?.pointee
    
    // 获取 name 的地址
    let fieldNameAddress = fieldNameRelactiveAddress + UInt64(nameOffset!) - linkBaseAddress
    // 将 name 地址转换成指针
    if let cChar = UnsafePointer<CChar>.init(bitPattern: Int(fieldNameAddress)){
        // 打印指针内容
        print(String(cString: cChar))
    }
}


// 获取v-table
// 函数的结构体
struct TargetMethodDescriptor {
    var kind: UInt32
    var offset: UInt32
}

// 获取方法的数量
if let methods = classDescriptor?.methods {
    for i in 0..<methods {
        // 获取v-table的的首地址
        let VTableRelaticveAddress = typeDescOffset + 4 * 13 + mhHeaderPtr_IntRepresentation
        // 获取当前函数的地址
        let currentMethodAddress = VTableRelaticveAddress + UInt64(i) * UInt64(MemoryLayout<TargetMethodDescriptor>.size)
        // 将 当前函数 的内存地址直接转换成指向 TargetMethodDescriptor 结构体的指针
        let currentMethod = UnsafePointer<TargetMethodDescriptor>.init(bitPattern: Int(exactly: currentMethodAddress) ?? 0)?.pointee
        // 获取到imp的地址
        let impAddress = currentMethodAddress + 4 + UInt64(currentMethod!.offset) - linkBaseAddress
        print(impAddress);
    }
}

注意: 在 Xcode 13_dyld_get_image_header(0) 对比在 LLDB 中输入命令 image list,发现没有正确获取到程序运行的基地址,但是在 Xcode 12 中不会出现这样的问题。

_dyld_get_image_header(0)对比image list.png

发现 _dyld_get_image_header(0) 获取到的地址是 image list 中第三个元素的地址,目前还没找到解决办法,如果您正好知道请留意或者私信我,万分感谢。

  • 经过后面的研究这里找到一个方式获取当前程序运行的基地址
var mhHeaderPtr: UnsafePointer<mach_header>?
let count = _dyld_image_count()
for i in 0..<count {
    var excute_header = _dyld_get_image_header(i)
    if excute_header!.pointee.filetype == MH_EXECUTE {
        mhHeaderPtr = excute_header
        break
    }
}

就是循环遍历 _dyld_get_image_header 中的元素判断是不是 mach-o 的执行地址。

二:内存管理

Swift 中使用自动引用计数(ARC)机制来追踪和管理内存,通常情况下,Swift 内存管理机制会一直起作用,你无须自己来考虑内存的管理。ARC 会在类的实例不再被使用时,自动释放其占用的内存。

1. 强引用

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    deinit {
        print("\(name) is being deinitialized")
    }
}

var p1: Person?
var p2: Person?
var p3: Person?

p1 = Person(name: "小明")

// 打印结果
小明 is being initialized

由于 Person 类的新实例被赋值给了 p1 变量,所以 p1Person 类的新实例之间建立了一个强引用。正是因为这一个强引用,ARC 会保证 Person 实例被保持在内存中不被销毁。
我们接着添加代码

p2 = p1
p3 = p1

现在这一个 Person 实例已经有三个强引用了。
将其中两个变量赋值 nil 的方式断开两个强引用(包括最先的那个强引用),只留下一个强引用,Person 实例不会被销毁

p1 = nil
p2 = nil

只有当最后一个引用被断开时 ARC 才会销毁它

p3 = nil

// 打印结果
小明 is being deinitialized

2. 弱引用

弱引用不会对其引用的实例保持强引用,因而不会阻止 ARC 销毁被引用的实例。这个特性阻止了引用变为循环强引用。声明属性或者变量时,在前面加上 weak 关键字表明这是一个弱引用。
因为弱引用不会保持所引用的实例,即使引用存在,实例也有可能被销毁。因此,ARC 会在引用的实例被销毁后自动将其弱引用赋值为 nil。并且因为弱引用需要在运行时允许被赋值为 nil,所以它们一定是可选类型。

class Person {
    var age: Int = 18
    var name: String = "小明"
}

weak var t = Person()

进入汇编代码

weak的汇编代码.png

我们可以看到这里的实质是调用了 swift_weakInit 函数,根据 Swift 源码的分析,其内部实现其实就是:一个对象在初始化的时候后是没有 SideTable (散列表)的,当我们创建一个弱引用的时候,系统会创建一个 SideTable
实质上 Swift 存在两种引用计算的布局方式

HeapObject {
    isa
    InlineRefCounts {
      atomic<InlineRefCountBits> {
        strong RC + unowned RC + flags
        OR
        HeapObjectSideTableEntry*
      }
    }
  }

  HeapObjectSideTableEntry {
    SideTableRefCounts {
      object pointer
      atomic<SideTableRefCountBits> {
        strong RC + unowned RC + weak RC + flags
      }
    }   
  }

其中

  • InlineRefCountsSideTableRefCounts 共享当前模板类 RefCounts<T>.的实现。
  • InlineRefCountBitsSideTableRefCountBits 共享当前模板类 RefCountBitsT<bool>
  • InlineRefCounts 其实是一个 uint64_t 可以当引用计数也可以当Side Table 的指针
  • SideTableRefCounts 是一种名为 HeapObjectSideTableEntry 的结构体,里面也有 RefCounts 成员,内部是 SideTableRefCountBits ,其实就是原来的 uint64_t 加上一个存储弱引用数的 uint32_t

3. 无主引用

和弱引用类似,无主引用不会牢牢保持住引用的实例。和弱引用不同的是,无主引用在其他实例有相同或者更长的生命周期时使用。你可以在声明属性或者变量时,在前面加上关键字 unowned 表示这是一个无主引用。
但和弱引用不同,无主引用通常都被期望拥有值。所以,将值标记为无主引用不会将它变为可选类型,ARC 也不会将无主引用的值设置为 nil 。总之一句话就是,无主引用假定是永远有值的。

  • 如果两个对象的生命周期完全和对方没关系(其中一方什么时候赋值为 nil ,对对方没有影响),使用 weak
  • 如果能确保:其中一个对象销毁,另一个对象也要跟着销毁,这时候可以(谨慎)使用 unowned

4. 闭包循环引用

闭包会一般默认捕获外部的变量

var age = 18

let closure = {
    age += 1
}
closure()
print(age)

// 打印结果
19

可以看出 闭包的内部对变量的修改将会改变外部原始变量的值

class Person {
    var age: Int = 18
    var name: String = "小明"
    
    var testClosure:(() -> ())?
    
    deinit {
        print("Person deinit")
    }
}

func testARC() {
    let t = Person()
    
    t.testClosure = {
        print(t.age)
    }
    
    print("end")
}

testARC()

// 打印结果
end

我们发现没有打印 Person deinit ,也就意味着 t 并没有被销毁,此时出现了循环引用。解决办法:就是使用捕获列表

func testARC() {
    let t = Person()
    
    t.testClosure = { [weak t] in
        t?.age += 1
    }
//    t.testClosure = { [unowned t] in
//        t.age += 1
//    }
}

5. 捕获列表

默认情况下,闭包表达式从起周围的范围捕获常量和变量,并强引用这些值。可以使用捕获列表来显式控制如何在闭包中捕获值。
在参数列表之前,捕获列表被写为用逗号括起来的表达式列表,并用方括号括起来。如果使用捕获列表,则即使省略参数名称,参数类型和返回类型,也必须使用 in 关键字。
创建闭包时,将初始化捕获列表中的条目。对于捕获列表中的每个条目,将常量初始化为在周围范围内具有相同名称的常量或变量的值。

var age = 0
var height = 0.0
let closure = { [age] in
    print(age)
    print(height)
}
age = 10
height = 1.85
closure()

// 打印结果
0
1.85

创建闭包时,内部作用域中的 age 会用外部作用域中的 age 的值进行初始化,但他们的值未以任何特殊方式连接。这意味着更改外部作用域中的 age 的值不会影响内部作用域中的 age 的值,也不会更改封闭内部的值,也不会影响封闭外的值。先比之下,只有一个名为 height 的变量-外部作用域中的 height - 因此,在闭包内部或外部进行的更改在两个均可见。

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

推荐阅读更多精彩内容