SwiftNotice源码详解

之前用到了一个比较方便的第三方HUD库SwiftNotice,一行代码即可实现一个简洁方便的HUD,最近心血来潮突发奇想准备研究研究这个库的内部实现原理。在看之前本以为挺简单的,殊不知麻雀虽小,五脏俱全,涉及到的知识点非常多,看得我这个小白一头包。不过经过各种查阅资料后,基本原理还是搞清楚了,接下来我将逐步给大家分析下这个库是如何一步步地实现下图所示的各种功能的(GIF来自于作者的github):

SwiftNotice.gif

从gif图中我们可以看到作者一共为我们提供了六种HUD类型,还有一个一键清除所有Notice。调用非常方便,我以noticeOnlyText为例,代码如下:

self.noticeOnlyText("Only Text")

根据作者的使用场景介绍:

Pretty easy to use:
In any subclass of UIView, UIScrollView, UIViewController, UITableViewController, UITableViewCell.

我们猜测作者应该针对上述UI控件实现了一个扩展,现在看代码,证明了我们的猜测是正确的:

extension UIResponder {
    /// wait with your own animated images
    @discardableResult
    func pleaseWaitWithImages(_ imageNames: Array<UIImage>, timeInterval: Int) -> UIWindow{
        return SwiftNotice.wait(imageNames, timeInterval: timeInterval)
    }
    ......
}

作者对UIResponder做了一个扩展,UIResponder是上述控件的基类,具体细节就不展开了,有兴趣的童鞋可以自行查阅资料了解。

作者在UIResponder的扩展中声明了展示所有种类Notice需要调用的方法,包括:
pleaseWaitWithImages
noticeTop
noticeSuccess
noticeError
noticeInfo
noticeOnlyText
clearAllNotice
等等等等,这些是作为接口暴露给用户的,具体的实现由SwiftNotice这个类完成,SwiftNotice中对应的方法才是真正展示HUD的核心。

因为具体的实现都是大同小异的,所以我选了几个有代表性的方法来具体分析,分别是:
noticeOnStatusBar:状态栏通知;
wait:等待中HUD;
showNoticeWithText:带文字描述的HUD,包括三种类别:成功、错误、信息;
hideNotice:隐藏通知或HUD;
clear:清空所有的通知和HUD;
其它的都是类似的。

限于篇幅,我不会分析作者对iOS9之前的系统所做的处理,也不会对其它非核心知识点展开分析,例如:动画效果、位置大小属性等等。

在分析方法之前,我们首先看下SwiftNotice类中对应的变量:

static var windows = Array<UIWindow!>()
static let rv = UIApplication.shared.keyWindow?.subviews.first as UIView!
static var timer: DispatchSource!
static var timerTimes = 0

windows是一个UIWindow数组,每一个的HUD都建立在一个UIWindow上。为什么要用UIWindow呢,按照我的理解,每一个HUD对象的出现都会打断用户当前的操作,而UIWindow有三个不同级别的层级,分别是Normal,StatusBar,Alert,层级越高显示越靠上,通常我们的程序的界面都是处于Normal这个级别上的,所以我们可以自己定义一个较高层级的UIWindow,来防止我们的HUD被其他UI控件遮挡;

rv是一个keyWindow的第一个子View,这里需要先解释下keyWindow,在我们的程序中,可以有很多个window,但是同一时刻只能有一个keyWindow,举个例子:一个程序启动,此时只有一个默认的window,级别为Normal,这个window同时也是keyWindow,然后我们自己新建一个window并将其显示在屏幕上,级别设置成更高的StatusBar,注意,此时的keyWindow就变成了我们新建的window,默认的window变为非keyWindow。换句话说,rv是获取当前级别最高的window的第一个子View;

timer是一个计时器,所有的HUD和通知的自动消失都是通过计时器完成的,作者并没有使用NSTimer而是使用了DispatchSource,关于DispatchSource的使用和优点可以参考这篇文章:打造一个优雅的Timer

timerTimes用来计数,稍后在wait方法中具体分析;

我们首先分析第一个方法,noticeOnStatusBar,直接上代码:

static func noticeOnStatusBar(_ text: String, autoClear: Bool, autoClearTime: Int) -> UIWindow{
        let frame = UIApplication.shared.statusBarFrame
        let window = UIWindow()
        window.backgroundColor = UIColor.clear
        let view = UIView()
        view.backgroundColor = UIColor(red: 0x6a/0x100, green: 0xb4/0x100, blue: 0x9f/0x100, alpha: 1)
        
        let label = UILabel(frame: frame)
        label.textAlignment = NSTextAlignment.center
        label.font = UIFont.systemFont(ofSize: 12)
        label.textColor = UIColor.white
        label.text = text
        view.addSubview(label)
        
        window.frame = frame
        view.frame = frame
        
        if let version = Double(UIDevice.current.systemVersion),
            version < 9.0 {
            // change center
            var array = [UIScreen.main.bounds.width, UIScreen.main.bounds.height]
            array = array.sorted(by: <)
            let screenWidth = array[0]
            let screenHeight = array[1]
            let x = [0, screenWidth/2, screenWidth/2, 10, screenWidth-10][UIApplication.shared.statusBarOrientation.hashValue] as CGFloat
            let y = [0, 10, screenHeight-10, screenHeight/2, screenHeight/2][UIApplication.shared.statusBarOrientation.hashValue] as CGFloat
            window.center = CGPoint(x: x, y: y)
            
            // change direction
            window.transform = CGAffineTransform(rotationAngle: CGFloat(degree * Double.pi / 180))
        }
        
        window.windowLevel = UIWindowLevelStatusBar //UIWindow的层级
        window.isHidden = false
        window.addSubview(view)
        windows.append(window)
      
        var origPoint = view.frame.origin //确定初始位置
        origPoint.y = -(view.frame.size.height)
        let destPoint = view.frame.origin //确定最终位置
        view.tag = sn_topBar
      
        view.frame = CGRect(origin: origPoint, size: view.frame.size)
        UIView.animate(withDuration: 0.3, animations: {
          view.frame = CGRect(origin: destPoint, size: view.frame.size)
        }, completion: { b in //闭包判断是否需要自动消失
          if autoClear {
              let selector = #selector(SwiftNotice.hideNotice(_:))
              self.perform(selector, with: window, afterDelay: TimeInterval(autoClearTime))
          }
        })
      return window
    }

上述代码可以分为两个部分:确定位置和大小(属性)、显示和消失(动作)(iOS9以下处理的部分不考虑)。

window本身不用做展示,显示层级依次为:window->view->label,需要确定他们的位置和大小以及一些其它的属性。需要注意的是设置window的层级以及将window添加到windows数组中。因为该通知以动画的方式出现,所以需要确定动画初始位置和动画结束位置,动画闭包中判断是否需要自动消失(根据传的参数),如果需要的话调用hideNotice方法。这里我们调用了一个perform方法,注意注意,敲黑板划重点了,这是iOS中延迟执行的一个方法,引用这位博主所说:

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

RunLoop我目前也不是特别了解,有兴趣的读者可以自行查阅资料或者查看上面我引用的那个博主的博客。

接下来我们分析hideNotice这个方法,代码先上:

static func hideNotice(_ sender: AnyObject) {
        if let window = sender as? UIWindow {
          
          if let v = window.subviews.first { //获取window的第一个子view并将它消失(动画效果)
            UIView.animate(withDuration: 0.2, animations: {
              
              if v.tag == sn_topBar { //再次确定view的tag(确定是否是StatusNotice)
                v.frame = CGRect(x: 0, y: -v.frame.height, width: v.frame.width, height: v.frame.height) //移出屏幕
              }
              v.alpha = 0 //变为透明
            }, completion: { b in
              
              if let index = windows.index(where: { (item) -> Bool in //这里用到了Swift数组的index方法,参数是一个闭包,只有参数和window相同时返回真,找到window在windows中的下标
                  return item == window
              }) {
                  windows.remove(at: index) //从视图数组中移除视图
              }
            })
          }
          
        }
    }

这个方法将StatusBar通知和HUD的隐藏写在了一起,首先确定要移除的window,然后获取到window的子View,在执行消失动画的时候判断该View是不是StatusBar通知View,对应不同的消失方法,在动画方法的闭包中获取到要移除的windowwindows数组中的下标,然后将其从数组中移除。这里多说几句,注意下面这个方法:

if let index = windows.index(where: { (item) -> Bool in //这里用到了Swift数组的index方法,参数是一个闭包,只有参数和window相同时返回真,找到window在windows中的下标
    return item == window
}) {
    windows.remove(at: index) //从视图数组中移除视图
}

注意我写的注释:这里用到了Swift数组的index方法,参数是一个闭包,只有参数和window相同时返回真,找到window在windows中的下标

现在我们再来分析下wait方法,老套路,先看代码:

static func wait(_ imageNames: Array<UIImage> = Array<UIImage>(), timeInterval: Int = 0) -> UIWindow { //参数为可选参数,调用时没有参数即使用默认参数
        let frame = CGRect(x: 0, y: 0, width: 78, height: 78)
        let window = UIWindow()
        window.backgroundColor = UIColor.clear
        let mainView = UIView()
        mainView.layer.cornerRadius = 12
        mainView.backgroundColor = UIColor(red:0, green:0, blue:0, alpha: 0.8)
        
        if imageNames.count > 0 {
            if imageNames.count > timerTimes {
                let iv = UIImageView(frame: frame)
                iv.image = imageNames.first!
                iv.contentMode = UIViewContentMode.scaleAspectFit
                mainView.addSubview(iv)
                timer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: UInt(0)), queue: DispatchQueue.main) as! DispatchSource
                timer.scheduleRepeating(deadline: DispatchTime.now(), interval: DispatchTimeInterval.milliseconds(timeInterval))
                timer.setEventHandler(handler: { () -> Void in
                    let name = imageNames[timerTimes % imageNames.count]
                    iv.image = name
                    timerTimes += 1
                })
                timer.resume()
            }
        } else {
            let ai = UIActivityIndicatorView(activityIndicatorStyle: UIActivityIndicatorViewStyle.whiteLarge)
            ai.frame = CGRect(x: 21, y: 21, width: 36, height: 36)
            ai.startAnimating()
            mainView.addSubview(ai)
        }
        
        window.frame = frame
        mainView.frame = frame
        window.center = rv!.center
        
        if let version = Double(UIDevice.current.systemVersion),
            version < 9.0 {
            // change center
            window.center = getRealCenter()
            // change direction
            window.transform = CGAffineTransform(rotationAngle: CGFloat(degree * Double.pi / 180))
        }
        
        window.windowLevel = UIWindowLevelAlert
        window.isHidden = false
        window.addSubview(mainView)
        windows.append(window)
      
        mainView.alpha = 0.0
        UIView.animate(withDuration: 0.2, animations: {
          mainView.alpha = 1
        })
        return window
    }

从代码中可以看出视图层级是这样的:
window->mainView->imageView,基本类似于之前的层级。
唯独需要注意的地方是image部分,作者已经为我们自定义wait时的动画做好了准备工作,我们只要将动画分成几个不同的image,放到imageNames这个数组中。那么具体是如何实现image依次出现并循环播放的呢?这里用到了之前定义的timer。下面是核心代码:

timer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: UInt(0)), queue: DispatchQueue.main) as! DispatchSource
timer.scheduleRepeating(deadline: DispatchTime.now(), interval: DispatchTimeInterval.milliseconds(timeInterval))
timer.setEventHandler(handler: { () -> Void in
    let name = imageNames[timerTimes % imageNames.count]
    iv.image = name
    timerTimes += 1
})
timer.resume()

在分析代码之前,我首先引用上文中提到的《打造一个优雅的Timer》中的一段话:

一般来说,DispatchSource的使用步骤就是:创建一个想要监听的事件类型对应的dispatch source,然后给这个source指定一个闭包,指定一个Dispatch Queue。当source监听到相应的事件时,就会将该闭包自动加到queue中执行。

现在我们分析代码:
第一行,作者创建了一个Dispatch Source,对应Timer Dispatch Source类型,并指定了DispatchQueue为主队列;
第二行,作者设置了Source的监听事件为重复执行;
第三行,作者为该Source指定了一个闭包,为了实现循环播放图片形成动画,作者用计数变量timerTimes和image数组个数取余,得到的结果作为image数组的下标,非常巧妙;
最后一行,不要忘了启动计时器。

如果没有自定义的图片作为等待时的展示,就直接使用默认的UIActivityIndicatorView,这里不再细说,代码写得非常清楚。

下面我们要分析的是showNoticeWithText这个方法,和之前的wait方法不同的是,showNoticeWithText不需要用户提供图片,图片由库本身提供,具体如何提供则是由另一个类来完成,SwiftNoticeSDK。因此,SwiftNoticeSDK是我们着重分析的对象。

先看代码:

class SwiftNoticeSDK {
    struct Cache {
        static var imageOfCheckmark: UIImage?
        static var imageOfCross: UIImage?
        static var imageOfInfo: UIImage?
    }
    class func draw(_ type: NoticeType) {
        let checkmarkShapePath = UIBezierPath()
        
        /*
        画圆参数解释:
        1.center: CGPoint  中心点坐标
        2.radius: CGFloat  半径
        3.startAngle: CGFloat 起始点所在弧度
        4.endAngle: CGFloat   结束点所在弧度
        5.clockwise: Bool     是否顺时针绘制
        7.画圆时,没有坐标这个概念,根据弧度来定位起始点和结束点位置。M_PI即是圆周率。画半圆即(0,M_PI),代表0到180度。全圆则是(0,M_PI*2),代表0到360度
        */ 
        // draw circle
        checkmarkShapePath.move(to: CGPoint(x: 36, y: 18))
        checkmarkShapePath.addArc(withCenter: CGPoint(x: 18, y: 18), radius: 17.5, startAngle: 0, endAngle: CGFloat(Double.pi*2), clockwise: true)
        checkmarkShapePath.close()
        
        switch type {
        case .success: // draw checkmark
            checkmarkShapePath.move(to: CGPoint(x: 10, y: 18))
            checkmarkShapePath.addLine(to: CGPoint(x: 16, y: 24))
            checkmarkShapePath.addLine(to: CGPoint(x: 27, y: 13))
            checkmarkShapePath.move(to: CGPoint(x: 10, y: 18))
            checkmarkShapePath.close()
        case .error: // draw X
            checkmarkShapePath.move(to: CGPoint(x: 10, y: 10))
            checkmarkShapePath.addLine(to: CGPoint(x: 26, y: 26))
            checkmarkShapePath.move(to: CGPoint(x: 10, y: 26))
            checkmarkShapePath.addLine(to: CGPoint(x: 26, y: 10))
            checkmarkShapePath.move(to: CGPoint(x: 10, y: 10))
            checkmarkShapePath.close()
        case .info:
            checkmarkShapePath.move(to: CGPoint(x: 18, y: 6))
            checkmarkShapePath.addLine(to: CGPoint(x: 18, y: 22))
            checkmarkShapePath.move(to: CGPoint(x: 18, y: 6))
            checkmarkShapePath.close()
            
            UIColor.white.setStroke()
            checkmarkShapePath.stroke()
            
            let checkmarkShapePath = UIBezierPath()
            checkmarkShapePath.move(to: CGPoint(x: 18, y: 27))
            checkmarkShapePath.addArc(withCenter: CGPoint(x: 18, y: 27), radius: 1, startAngle: 0, endAngle: CGFloat(Double.pi*2), clockwise: true)
            checkmarkShapePath.close()
            
            UIColor.white.setFill()
            checkmarkShapePath.fill()
        }
        
        UIColor.white.setStroke()
        checkmarkShapePath.stroke()
    }
    class var imageOfCheckmark: UIImage {
        if (Cache.imageOfCheckmark != nil) {
            return Cache.imageOfCheckmark!
        }
        UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
        
        SwiftNoticeSDK.draw(NoticeType.success)
        
        Cache.imageOfCheckmark = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return Cache.imageOfCheckmark!
    }
    class var imageOfCross: UIImage {
        if (Cache.imageOfCross != nil) {
            return Cache.imageOfCross!
        }
        UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
        
        SwiftNoticeSDK.draw(NoticeType.error)
        
        Cache.imageOfCross = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return Cache.imageOfCross!
    }
    class var imageOfInfo: UIImage {
        if (Cache.imageOfInfo != nil) {
            return Cache.imageOfInfo!
        }
        UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
        
        SwiftNoticeSDK.draw(NoticeType.info)
        
        Cache.imageOfInfo = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return Cache.imageOfInfo!
    }
}

SwiftNoticeSDK主要由三个部分组成,分别是结构体Cache,绘制方法draw和三个通过懒加载实现的UIImage对象,其中核心是draw方法,至此我们知道,SwiftNoticeSDK中并不包含图像,它直接绘制了三个图像。那么它是如何绘制的呢?这里作者使用了一个狂拽酷炫吊炸天的类:UIBezierPath。使用UIBezierPath可以创建基于矢量的路径。使用此类可以定义简单的形状,如椭圆、矩形或者有多个直线和曲线段组成的形状等。它的部分属性如下:

moveToPoint: //设置起始点
addLineToPoint: //从上一点连接一条线到本次指定的点
closePath() //闭合路径,把起始点和终点连接起来
appendPath: //多条路径合并
removeAllPoints() //删除所有点和线
lineWidth //路径宽度
lineCapStyle //端点样式(枚举)
lineJoinStyle //连接点样式(枚举)
//下面这几个属性要用在UIView中重写drawRect:方法中使用才有效,否则不会出现效果
UIColor.redColor().setStroke() //设置路径颜色(不常用)
stroke()渲染路径
UIColor.redColor().setFill() //设置填充颜色(不常用)
fill()渲染填充部分

通过上述属性我们可以很容易理解各种图形的绘制过程。但是我们还需要将绘制好的内容赋值给对应的图像,在draw方法调用之前调用UIGraphicsBeginImageContextWithOptions方法,按照苹果官方文档的解释,UIGraphicsBeginImageContextWithOptions方法使用指定的选项创建一个基于位图的图形上下文。当此函数创建的上下文是当前上下文,就可以调用UIGraphicsGetImageFromCurrentImageContext函数根据上下文的当前内容检索图像对象,因此我们在draw方法之后调用UIGraphicsGetImageFromCurrentImageContext函数得到UIImage图像并赋值给对应的值。当然了,最后需要从栈中删除顶部当前基于位图的图形上下文,使用UIGraphicsEndImageContext方法。

最后我们来分析清除所有屏幕上的HUD和通知的clear方法,代码如下:

static func clear() {
        self.cancelPreviousPerformRequests(withTarget: self)
        if let _ = timer {
            timer.cancel()
            timer = nil
            timerTimes = 0
        }
        windows.removeAll(keepingCapacity: false)
    }

总体思路比较简单,就是将windows数组中的window全部移除,同时完成一些收尾工作:如果perform方法还未执行则取消执行,计时器取消并置为nil。

至此,SwiftNotice的核心代码,包括实现思路就都过了一遍。在查看并研究源码的这两天,我也查漏补缺,学习了不少新知识,但是限于我个人的知识面,可能一些分析并不是十分完整,也欢迎大家给我提出宝贵意见。在我看来,知其然,更要知其所以然,在我们使用第三方库的时候,不光要会用,也应该知道作者是如何实现它的,然后自己再实现一次,从易到难,对自己的编程能力一定会有很大的提升!

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,434评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • As for me , writing in English is an arduous process , ...
    Y型思考阅读 532评论 3 3
  • 有很多小伙伴知道我买了kindle paperwhite的电纸书,纷纷也资讯我如何选择电纸书。然而,我这部小K是两...
    zerofool阅读 2,471评论 3 1
  • 明天就出成绩了,今天的我陷入了一种焦灼的情绪里。想要到处求安慰,又因为考了太多次,不能理解的人会有些风言风语...
    庭生阅读 163评论 0 0