II.7 隐式动画

做我表达的,而非我说的。——Edna Krabappel, The Simpsons

第一部分讲述了所有Core Animation可以做到的东西,除了动画。动画是Core Animation框架中一个相当显著的部分。在这一章中,我们将看一下它的工作原理。特别的,我们讲解隐式动画,这是框架自动运行的动画(除非你禁用它)。

事务

Core Animation假设屏幕上的所有东西将要(至少可能)运动。动画并不是你在Core Animation启用的东西。动画必须显示的禁用,否则它们时时刻刻都会发生。

无论何时你改变CALayer的可动画的属性,改变并不会立即反映在屏幕上。相反,图层属性通过动画平滑地从前一个值过渡到新值。你无需做什么就可以使这一切发生;它们是默认行为。

这看起来有一点太过好了,所以让我们用一个例子来演示它:我们将使用第1章“图层树”中的蓝色方块项目,然后添加一个按钮设置图层为一个随机颜色。表7.1展示了代码。点击按钮你会看见颜色平滑过渡而非跳转到另一个新值(见图7.1)。

表7.1 随机图层颜色
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!
    var colorLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建子图层
            self.colorLayer = CALayer()
            self.colorLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
            self.colorLayer.backgroundColor = UIColor.blueColor().CGColor

            // 加到视图中
            self.layerView.layer.addSublayer(self.colorLayer)
        }
    }

    @IBAction func changeColor(sender: AnyObject) {
        // 随机图层背景色
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
    }
}
图7.1 添加按钮来改变图层颜色

这种动画被称作隐式动画。它是隐式的,因为我们没有指定我们想要发生的动画类型;我们只是改变了属性,Core Animation决定如何以及何时去动画改变它。Core Animation也提供显式动画,这将在下一章中讲解。

当你改变属性时,Core Animation是如何决定它将显示的动画的类型和时长的?动画时长由当前事务设置,动画类型由图层动作控制。

事务是Core Animation是用来概括一系列特定属性动画的机制。任何可动画的图层属性有一个给定事务的改变就不会立马改变,相反会在事务执行时开始动画过渡到新值。

事务使用CATransaction类管理。CATransaction类有一个奇怪的设计,它并不如名字所示的单一事务,而是管理着一堆事务而没有给你直接的访问。CATransaction没有属性或实例方法,你不能正常使用+alloc-init来创建一个事务。相反,你使用类方法+begin+commit来使一个新事务入栈顶或使当前事务出栈。

任何可动画的图层属性改变变添加上栈顶的事务。你可以通过使用+setAnimationDuration:方法设置当前事务动画时长,或者你可以使用+animationDuraion方法得知当前时长。(默认为0.25秒。)

Core Animation在每个运行周期迭代中自动开始新事务。(运行周期是iOS用来收集用户输入、处理所有显式定时器或网络事件的,最终重新绘制屏幕。)即使你没有显示使用[CATransaction begin]开始一次事务,所有你在一个给定运行周期迭代中的属性改变都会组到一起,在0.25秒的阶段里进行动画。

有了这个知识,我们可以很容易地改变我们的颜色动画时长。使用+setAnimationDuration:方法来改变当前(默认)事务的时长足够了,但我们会先开始一个新事务来防止改变时长带来不想要的副作用。改变当前事务时长可能影响其它同一时间发生的动画(例如屏幕旋转),所以调整动画设置前显式入栈一个事务总是一个好主意。

表7.2显示了修改后的代码。如果你运行应用,你会注意到颜色渐变地比以前慢多了。

表7.2 使用CATransaction控制动画时长
@IBAction func changeColor(sender: AnyObject) {
    // 开始新事务
    CATransaction.begin()

    // 设置动画时长为1秒
    CATransaction.setAnimationDuration(1.0)

    // 随机图层背景色
    let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

    // 执行事务
    CATransaction.commit()
}

如果你曾使用UIView的动画方法实现任何动画,这个模式看起来应该很熟悉。UIView有两个方法+beginAnimations:context:+commitAnimations,它们工作原理类似于CATransaction中的+begin+commite方法。你在+beginAnimations:context:+commitAnimations中改变的所有视图或图层属性会自动产生动画,这是因为这些UIView动画方法实际上就是在设置一个CATransaction

在iOS4中,Apple为UIView添加了一个新的基于闭包的动画方法+animateWithDuration:animations:。它在语法上比分离属性动画的开始、结束代码块更为干净,但事实上它在幕后做一样的事情。

CATransaction+begin+commit方法会在+animateWithDuration:animations:方法中间调用,动画闭包中的动画会在其中执行。所以任何你在闭包内做的属性改变会被事务包含。这样可以避免开发者错误的没有将+begin+commit一一对应。

完成闭包

UIVIew基于闭包的动画允许你提供一个完成闭包在动画结束时调用。同样的特性在CATransaction接口中也是可以的,通过调用+setCompletionBlock:方法实现。让我们再次修改先前的例子让颜色改变之后执行一个动作。我们将加上一个完成闭包,使用它触发每二个动画来使图层每当颜色改变后旋转90度。表7.3显示了代码,图7.2显示了结果。

表7.3 当颜色动画结束时增加回调
@IBAction func changeColor(sender: AnyObject) {
    // 开始新事务
    CATransaction.begin()

    // 设置动画时长为1秒
    CATransaction.setAnimationDuration(1.0)

    // 结束时增加旋转动画
    CATransaction.setCompletionBlock({
        // 图层旋转90度
        var transform = self.colorLayer.affineTransform()
        transform = CGAffineTransformRotate(transform, CGFloat(M_PI_2))
        self.colorLayer.setAffineTransform(transform)
    })

    // 随机图层背景色
    let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
    self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

    // 执行事务
    CATransaction.commit()
}
图7.2 颜色渐变完成后应用的旋转动画

注意,我们的旋转动画比颜色渐变动画快很多。这是因为施加旋转的完成闭包是有颜色渐变的事务提交后执行并出栈的。因此,会使用默认的事务,以及默认的0.25秒时长。

图层动作

现在让我们做个试验:不给单独子图层施加动画,我们直接给视图的主图层施加动画。表7.4展示了修改后的代码版本,它从表7.2中移除了colorLayer并直接设置layerView的主图层颜色。

表7.4 直接设置主图层的属性
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 直接设置我们layerView主图层的颜色
            self.layerView.layer.backgroundColor = UIColor.blueColor().CGColor
        }
    }

    @IBAction func changeColor(sender: AnyObject) {
        // 开始新交易
        CATransaction.begin()

        // 设置动画时长为1秒
        CATransaction.setAnimationDuration(1.0)

        // 随机图层背景色
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        self.layerView.layer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor

        // 执行交易
        CATransaction.commit()
    }
}

如果你运行这个项目,你会注意到当按钮按下后颜色立刻跳转成一个新值而非像之前一样的平滑动画。发生了什么?隐式动画看起来好像是在UIView的主图层中被禁用了。

想想看,我们可能有注意到如果每当UIView属性被改变时都会自动有动画。所以,如果UIKit是构建在Core Animaiton(它总是默认给任何东西加上动画)之上,UIKit的隐式动画怎么会默认禁用?

我们知道Core Animaiton通常会给CALayer的所有属性变化增加动画(假如它可动画),但UIView以某种方式将它的主图层关闭了这一行为。为了理解这个怎么发生的,我们首先需要理解隐式动画是如何实现的。

CALayer在属性改变时自动施加的动画叫动作。当CALayer的属性被修改时,它会调用-actionForKey:方法,传递其中的属性。接下来发生的在CALayer头文件中有完整的文档,但它最终总结如下:

  1. 图层首先检查它是否有委托,如果委托实现了CALayerDelegate协议中指定的-actionForLayer:forKey方法。如果是,则调用它并返回结果。
  2. 如果没有委托,或委托没有实现-actionForLayer:forKey方法,图层检查它的actions词典,这个词典包含了属性名与动作的映射。
  3. 如果actions词典没有任何要求的属性入口,图层会搜索它的style词典层次来找寻任何匹配属性名的动作。
  4. 最后,如果在style层次中找不到适合的动作,图层会回调-defualtActionForKey:方法,这是给属性定义标准动作的。

全面搜索的结果会是-actionForKey:nil(此时,没有动画发生,属性值会立马改变)或一个遵守CAAction协议的对象,这是CALayer会用来在先前值和当前值之间的动画的。

这解释了UIKit是如何禁用隐式动画的:每个UIView表现的像是主图层的委托,并提供一个-actionForLayer:forKey方法的实现。当不在动画闭包中时,UIView为所有的图层动作返回nil,但在动画闭包的作用域中返回非空值。我们可以用一个小例子演示这点(如表7.5)。

表7.5 测试UIView的actionForLayer:forKey:实现
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 测试动画闭包外的图层动作
            let outsideAction = self.layerView.actionForLayer(self.layerView.layer, forKey: "backgroundColor")
            println("Outside: \(outsideAction)")

            // 开始动画闭包
            UIView.beginAnimations(nil, context: nil)

            // 测试动画闭包内的图层动作
            let insideAction = self.layerView.actionForLayer(self.layerView.layer, forKey: "backgroundColor")
            println("Inside: \(insideAction)")

            // 结果动画闭包
            UIView.commitAnimations()
        }
    }
}

当我们运行项目时,我们会在控制台中看见这个:

$ LayerTest[21215:c07] Outside: <null>
$ LayerTest[21215:c07] Inside: <CABasicAnimation: 0x757f090>

译者测试结果为:

Outside: <CABasicAnimation: 0x7ffa0b11a7b0>
Inside: <CABasicAnimation: 0x7ffa0b08a060>
Outside: <CABasicAnimation: 0x7ffa0ad070c0>
Inside: <CABasicAnimation: 0x7ffa0ad821c0>

正如预测的一样,UIView当属性在动画闭包外改变并为属性动作返回nil时会禁用隐式动画。当动画可用时返回的动作取决于属性类型,但在这里,它是CABasicAniamtion。(你会在第8章“显式动画”中学习这点。)

返回nil并不是禁用隐式动画的唯一方式;CATransaction有一个方法叫+setDisableActions:可以用来同时启用或禁用所有属性的动画。如果我们修改表7.2的代码,添加下面几行在CATransaction.begin()后面,它会阻止所有动画发生:

CATransaction.setDisableActions(true)

总结一下,我们学了这些东西:

  • UIView的主图层禁用陷式动画。主图层属性动画的唯一方法是使用UIView动画方法(而不是依赖CATransaction),子类化UIView并且重写-actionForLayer:forKey:方法,或创建一个显式动画(第8章第详细讲解)。
  • 对于有主(即非主图层)图层,我们可以通过实现-actionForLayer:forKey:图层代理方法或者提供actions词典来控制隐式属性动画。

让我们为我们的渐隐例子指定一个不一样的动作。我们会修改表7.1,为colorLayer设置一个自定义的actions词典。我们可以使用委托来实现这点,但actions词典方法需要更少的代码。所以我们如何创建一个合适的动作对象?

动作通常由一个隐式动画对象指定,它会在需要时被Core Animaiton隐式调用。这里我们用的动画是推过渡,这是由一个CATransition实例实现的(如表7.6)。

过渡将在第8章详细解释,但这里足够说明CATransition遵循CAAction协议,可以因此被当作图层动作使用。结果非常酷炫;无论何时我们改变我们的图层颜色,新的值将从左侧滑入而不是默认的交叉渐变效果(如图7.3)。

表7.6 实现自定义动作
import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var layerView: UIView!
    var colorLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建子图层
            self.colorLayer = CALayer()
            self.colorLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
            self.colorLayer.backgroundColor = UIColor.blueColor().CGColor

            // 添加自定义动作
            let transition = CATransition()
            transition.type = kCATransitionPush
            transition.subtype = kCATransitionFromLeft
            self.colorLayer.actions = ["backgroundColor": transition]

            // 添加进视图中
            self.layerView.layer.addSublayer(self.colorLayer)
        }
    }

    @IBAction func changeColor(sender: AnyObject) {
        // 随机图层背景色
        let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
        self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
    }
}
图7.3 使用推过渡实现的颜色值动画

演示与模型

CALayer的属性表现并不寻常,改变图层属性并不会有一个立即的效果,但随时间慢慢更新。这是如何做到的呢?

当你改变图层的属性时,属性值本身实际是立即更新的(如果你尝试读取它,你会发现其值是你刚刚设置的),但改变并没有屏幕上反映出来。这是因为你设置的属性并不直接改变图层的样子;相反,它指定了图层在属性动画完成后将要有的样子。

当你设置CALayer属性,你实际上是在定义你想当前事务最终显示的模型Core Animation会如同控制器般负责更新这些基于图层动作和事务设置的屏幕上的属性的视图状态。

我们在讨论是的高效的MVC模式。CALayer是一个你通常会用于MVC(模型-视图-控制器)模式中用户界面(也叫视图)部分的可视化类,但在用户界面自身的上下文中,CALayer表现的更像是一个模型,它表明在所有动画结束后视图将要展示的样子。事实上,在Apple自身的文档中,图层树有时也指模型图层树。

在iOS中,屏幕每秒重绘60次。如果动画时间长于一秒的1/60,Core Animation因此会要求在屏幕上重组这个图层多次,次数在你设置可动画属性的新值和新值最终显示在屏幕上之间。这意味着CALayer必须以一种方法保持除了当前属性的“实际”值(你设置的值)之外的显示值。

每个图层的属性的显示值被存储在一个叫显示层的独立图层,这是通过-presentationLayer方法访问的。显示层本质上是模型层的副本,但它的属性值通常是当前时刻点的样子。用另一句话来说,你可以访问展示层的属性来查看相应模型图层属性在屏幕上的当前值(如图7.4)。

我们在第1章有提及到,除了图层树外有一个叫展示树的东西。展示树是一个由图层树中所有图层的展示图层组成的树。注意,展示层只在图层每一次提交(就是当它第一次显示在屏幕上时)时创建,所以在这之前尝试调用-presentationLayer会返回nil

你可能注意到也有一个-modelLayer方法。在展示图层上调用-modelLayer会返回其下正在显示的CALayer。在一个普通图层上调用modelLayer只会返回-self。(我们早已说过普通图层实际上就是一种模型。)

图7.4 一个移动图层的展示与模型的关系.png

大多情况下,你不需要直接访问展示图层;你只需要与模型图层的属性打交道,Core Animation会帮你更新显示。有两种情况下展示图层确实有用,一种是异步动画,另一种是处理用户交互:

  • 如果你在实现基于时间的动画(见第11章“基于时间的动画”)再非普通的基于事务的动画,明确某一时刻点指定图层在屏幕上的显示是十分有用的,这样你就可以在动画时正确放置其它元素。
  • 如果你想你的动画图层响应用户输入,而且你在使用-hitTest:方法(见第3章“图层几何”)来判定指定图层是否被触摸了,对展示图层而非模型图层调用-hitTest:方法会更有意义,这是因为展示图层展示了用户当前看见的图层位置,而非当前动画结束后将处于的位置。

我们可以用一个简单的例子(见表7.7)来演示后一个例子。在这个例子中,点击屏幕上的任何位置会让图层以动画的形式移动到点击处。点击图层本身会给它设置一个随机的颜色值。我们于图层的展示层上调用-hitTest:方法来判定点击是否在图层中。

如果你修改代码来使-hitTest:方法直接在colorLayer上而非其展示图层上调用,你会发现在图层移动时将无法正常工作,这样你不得不点击图层将要移动到的位置来触发(这是为什么我们最初用展示层来进行点击测试)。

表7.7 使用presentationLayer来判定当前图层位置
import UIKit

class ViewController: UIViewController {

    var colorLayer: CALayer!

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // 判断横屏
        let screenSize = UIScreen.mainScreen().applicationFrame.size
        if (screenSize.width > screenSize.height) {
            // 创建一个红色图层
            self.colorLayer = CALayer()
            self.colorLayer.frame = CGRectMake(0, 0, 100, 100)
            self.colorLayer.position = CGPointMake(self.view.bounds.size.width / 2, self.view.bounds.size.height / 2)
            self.colorLayer.backgroundColor = UIColor.redColor().CGColor
            self.view.layer.addSublayer(self.colorLayer)
        }
    }

    override func touchesBegan(touches: Set<NSObject>, withEvent event: UIEvent) {
        // 获得触摸点
        let point = (touches as NSSet).anyObject()?.locationInView(self.view) as CGPoint!

        // 检测是否点击了移动图层
        if ((self.colorLayer.presentationLayer().hitTest(point)) != nil) {
            // 随机图层背景色
            let red: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
            let green: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
            let blue: CGFloat = CGFloat(arc4random()) / CGFloat(INT_MAX)
            self.colorLayer.backgroundColor = UIColor(red: red, green: green, blue: blue, alpha: 1.0).CGColor
        } else {
            // 否则(慢慢地)移动图层到新的位置
            CATransaction.begin()
            CATransaction.setAnimationDuration(4.0)
            self.colorLayer.position = point
            CATransaction.commit()
        }
    }

}

总结

这一章讲解了隐匿动画以及Core Animation为一个指定属性选择合适动画动作的机制。你也学习了UIKit如何使用Core Animation的隐式动画机制来扩充其自身的显式系统的,在显示系统中动画是默认禁用的,只有在需要时启用。最后,你学习了展示图层和模型图层,以及它们如何使Core Animation同时追踪图层当前和将来的位置。

下一章中,我们将讲解Core Animation提供的显式动画类型,它可以直接用于图层属性动画或复写默认的图层行为。

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

推荐阅读更多精彩内容