文字路径动画控件TextPathView解析

本文出处
炎之铠csdn博客:http://blog.csdn.net/totond
炎之铠邮箱:yanzhikai_yjk@qq.com
本项目Github地址:https://github.com/totond/TextPathView
本文原创,转载请注明本出处!
本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

前言

此博客主要是介绍TextPathView的实现原理,而TextPathView的使用可以参考README,效果如图:

image

思路介绍

下面写的实现TextPathView思路介绍主要有两部分:一部分是文字路径的实现,包括文字路径的获取、同步绘画和异步绘画;一部分是画笔特效,包括各种画笔特效的实现思路。

文字路径

文字路径的实现是核心部分,主要的工作就是把输入的文字转化为Path,然后绘画出来。绘画分为两种绘画:

  • 一种是同步绘画,也就是相当于只有一支“画笔”,按顺序来每个笔画来绘画出文字Path。如下面:


    image
  • 一种是异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字Path。如下面:


    image
  • 这两者的区别大概就像一个线程同步绘画和多个异步绘画一样,当然实际实现是都是在主线程里面绘画的,具体实现可以看下面介绍。

文字路径的获取

获取文字路径用到的是Paint的一个方法getTextPath(String text, int start, int end,float x, float y, Path path),这个方法可以获取到一整个String的Path(包括所有闭合Path),然后设置在一个PathMeasure类里面,方便后面绘画的时候截取路径。如SyncTextPathView里面的:

    //初始化文字路径
    @Override
    protected void initTextPath(){
        //...
        mTextPaint.getTextPath(mText, 0, mText.length(), 0, mTextPaint.getTextSize(), mFontPath);
        mPathMeasure.setPath(mFontPath, false);
        mLengthSum = mPathMeasure.getLength();
        //获取所有路径的总长度
        while (mPathMeasure.nextContour()) {
            mLengthSum += mPathMeasure.getLength();
        }
    }

每次设定输入的String值的时候都会调用initTextPath()来初始化文字路径。

PathMeasure是Path的一个辅助类,可以实现截取Path,获取Path上点的坐标,正切值等等,具体使用网上很多介绍。

文字路径的同步绘画

同步绘画,也就是按顺序绘画每个笔画(至于笔画的顺序是谁先谁后,就要看Paint.getTextPath()方法的实现了,这不是重点),这种刻画在SyncTextPathView实现。
  这种绘画方法不复杂,就是根据输入的比例来决定文字路径的显示比例就行了,想是这样想,具体实现还是要通过代码的,这里先给出一些全局属性的介绍:

    //文字装载路径、文字绘画路径、画笔特效路径
    protected Path mFontPath = new Path(), mDst = new Path(), mPaintPath = new Path();
    //属性动画
    protected ValueAnimator mAnimator;
    //动画进度值
    protected float mAnimatorValue = 0;
    //绘画部分长度
    protected float mStop = 0;
    //是否展示画笔
    protected boolean showPainter = false, canShowPainter = false;
    //当前绘画位置
    protected float[] mCurPos = new float[2];

根据之前init时候获取的总长度mLengthSum和比例progress,来求取将要绘画的文字路径部分的长度mStop,然后用一个while循环使得mPathMeasure定位到最后一段Path片段,在这期间把循环的到片段都加入到要绘画的目标路径mDst,然后最后在按照剩下的长度截取最后一段Path片段:

    /**
     * 绘画文字路径的方法
     * @param progress 绘画进度,0-1
     */
    @Override
    public void drawPath(float progress) {
        if (!isProgressValid(progress)){
            return;
        }
        mAnimatorValue = progress;
        mStop = mLengthSum * progress;

        //重置路径
        mPathMeasure.setPath(mFontPath, false);
        mDst.reset();
        mPaintPath.reset();

        //根据进度获取路径
        while (mStop > mPathMeasure.getLength()) {
            mStop = mStop - mPathMeasure.getLength();
            mPathMeasure.getSegment(0, mPathMeasure.getLength(), mDst, true);
            if (!mPathMeasure.nextContour()) {
                break;
            }
        }
        mPathMeasure.getSegment(0, mStop, mDst, true);

        //绘画画笔特效
        if (canShowPainter) {
            mPathMeasure.getPosTan(mStop, mCurPos, null);
            drawPaintPath(mCurPos[0], mCurPos[1], mPaintPath);
        }

        //绘画路径
        postInvalidate();
    }

在最后调用的onDraw():

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //...

        //画笔特效绘制
        if (canShowPainter) {
            canvas.drawPath(mPaintPath, mPaint);
        }
        //文字路径绘制
        canvas.drawPath(mDst, mDrawPaint);

    }

这样子就可以画出progress相对应比例的文字路径了。


image

文字路径的异步绘画

异步绘画,也就是相当于多支“画笔”,每个笔画(闭合的路径)有一支,来一起绘画出文字Path。,这种刻画在AsyncTextPathView实现。
  这种绘画方法也不是很复杂,就是根据比例来决定文字路径里面每一个笔画(闭合的路径)的显示比例就行了。
  具体就是使用while循环遍历所有笔画(闭合的路径)Path,循环里面根据progress比例算出截取的长度mStop,然后加入到mDst中,最后绘画出来。这里给出drawPath()代码就行了:

    /**
     * 绘画文字路径的方法
     * @param progress 绘画进度,0-1
     */
    @Override
    public void drawPath(float progress){
        if (!isProgressValid(progress)){
            return;
        }
        mAnimatorValue = progress;

        //重置路径
        mPathMeasure.setPath(mFontPath,false);
        mDst.reset();
        mPaintPath.reset();

        //根据进度获取路径
        while (mPathMeasure.nextContour()) {
            mLength = mPathMeasure.getLength();
            mStop = mLength * mAnimatorValue;
            mPathMeasure.getSegment(0, mStop, mDst, true);

            //绘画画笔特效
            if (canShowPainter) {
                mPathMeasure.getPosTan(mStop, mCurPos, null);
                drawPaintPath(mCurPos[0],mCurPos[1],mPaintPath);
            }
        }

        //绘画路径
        postInvalidate();
    }

这样就能以每个笔画作为一个个体,按比例显示文字路径了。


image

画笔特效

画笔特效的原理

画笔特效就是以当前绘画终点为基准,增加一点Path,来使整个动画看起来更加好看的操作。如下面的火花特效:


image

具体的原理就是利用PathMeasurel类的getPosTan(float distance, float pos[], float tan[])方法,在每次绘画文字路径的时候调用drawPaintPath()来绘画附近的mPaintPath,然后在ondraw()画出来就好了:

    /**
     * 绘画文字路径的方法
     * @param progress 绘画进度,0-1
     */
    @Override
    public void drawPath(float progress) {
        //...

        //绘画画笔特效
        if (canShowPainter) {
            mPathMeasure.getPosTan(mStop, mCurPos, null);
            drawPaintPath(mCurPos[0], mCurPos[1], mPaintPath);
        }

        //绘画路径
        postInvalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //...

        //画笔特效绘制
        if (canShowPainter) {
            canvas.drawPath(mPaintPath, mPaint);
        }
        //文字路径绘制
        canvas.drawPath(mDst, mDrawPaint);

    }

drawPaintPath()方法的实现是这样的(以SyncTextPathView为例):

    //画笔特效
    private SyncTextPainter mPainter;

    private void drawPaintPath(float x, float y, Path paintPath) {
        if (mPainter != null) {
            mPainter.onDrawPaintPath(x, y, paintPath);
        }
    }

这里的画笔特效Painter就是一个接口,可以让使用者自定义的,因为绘画的原理不一样,Painter也分两种:

    public interface SyncTextPainter extends TextPainter {
        //开始动画的时候执行
        void onStartAnimation();

        /**
         * 绘画画笔特效时候执行
         * @param x 当前绘画点x坐标
         * @param y 当前绘画点y坐标
         * @param paintPath 画笔Path对象,在这里画出想要的画笔特效
         */
        @Override
        void onDrawPaintPath(float x, float y, Path paintPath);
    }

    public interface AsyncTextPainter extends TextPainter{
        /**
         * 绘画画笔特效时候执行
         * @param x 当前绘画点x坐标
         * @param y 当前绘画点y坐标
         * @param paintPath 画笔Path对象,在这里画出想要的画笔特效
         */
        @Override
        void onDrawPaintPath(float x, float y, Path paintPath);
    }

TextPainter就不用说了,是父接口。然后使用者是通过set方法来传入TextPainter

    //设置画笔特效
    public void setTextPainter(SyncTextPainter listener) {
        this.mPainter = listener;
    }

以上就是画笔特效的原理,使用者通过重写TextPainter接口来绘画附加特效。

特效实现示例

TextPathView暂时实现了3种自带的画笔特效可以选择:


//箭头画笔特效,根据传入的当前点与上一个点之间的速度方向,来调整箭头方向
public class ArrowPainter implements SyncTextPathView.SyncTextPainter{}

//一支笔的画笔特效,就是在绘画点旁边画多一支笔
public class PenPainter implements SyncTextPathView.SyncTextPainter,AsyncTextPathView.AsyncTextPainter {}

//火花特效,根据箭头引申变化而来,根据当前点与上一个点算出的速度方向来控制火花的方向
public class FireworksPainter implements SyncTextPathView.SyncTextPainter{}

下面介绍箭头和火花,笔太简单了不用说,直接看代码就可以懂。然后这两者都用到了一个计算速度的类:

/**
 * author : yany
 * e-mail : yanzhikai_yjk@qq.com
 * time   : 2018/02/08
 * desc   : 计算传入的当前点与上一个点之间的速度
 */

public class VelocityCalculator {
    private float mLastX = 0;
    private float mLastY = 0;
    private long mLastTime = 0;
    private boolean first = true;

    private float mVelocityX = 0;
    private float mVelocityY = 0;

    //重置
    public void reset(){
        mLastX = 0;
        mLastY = 0;
        mLastTime = 0;
        first = true;
    }

    //计算速度
    public void calculate(float x, float y){
        long time = System.currentTimeMillis();
        if (!first){
            //因为只需要方向,不需要具体速度值,所以默认deltaTime = 1,提高效率
//            float deltaTime = time - mLastTime;
//            mVelocityX = (x - mLastX) / deltaTime;
//            mVelocityY = (y - mLastY) / deltaTime;
            mVelocityX = x - mLastX;
            mVelocityY = y - mLastY;
        }else {
            first = false;
        }

        mLastX = x;
        mLastY = y;
        mLastTime = time;

    }

    public float getVelocityX() {
        return mVelocityX;
    }

    public float getVelocityY() {
        return mVelocityY;
    }
}
  • 箭头特效:根据传入的当前点与上一个点之间的速度方向,来使箭头方向始终向前。

所以这个Path就应该是:在前进速度的反方向,以当前绘画点为起点,以一定夹角画出两条直线

image

所以我们可以转化为几何数学问题:已知箭头长别为r,夹角为a,还有当前点坐标(x,y),还有它的速度夹角angle,求出箭头两个末端的坐标(字写的难看,不要在意这些细节啦O(∩_∩)O):

image

上面这个简单的高中数学问题居然搞了半天,具体是因为我一开始没有使用Android的View坐标系来画,一直用传统的数学坐标系来画,所以算出来每次都有偏差,意识到这个问题之后就简单了。

根据上面的推导过程我们可以得出箭头两个末端的坐标,然后就是用代码表达出来了:

/**
 * author : yany
 * e-mail : yanzhikai_yjk@qq.com
 * time   : 2018/02/09
 * desc   : 箭头画笔特效,根据传入的当前点与上一个点之间的速度方向,来调整箭头方向
 */

public class ArrowPainter implements SyncTextPathView.SyncTextPainter {
    private VelocityCalculator mVelocityCalculator = new VelocityCalculator();
    //箭头长度
    private float radius = 60;
    //箭头夹角
    private double angle = Math.PI / 8;

//...

    @Override
    public void onDrawPaintPath(float x, float y, Path paintPath) {
        mVelocityCalculator.calculate(x, y);
        double angleV = Math.atan2(mVelocityCalculator.getVelocityY(), mVelocityCalculator.getVelocityX());
        double delta = angleV - angle;
        double sum = angleV + angle;
        double rr = radius / (2 * Math.cos(angle));
        float x1 = (float) (rr * Math.cos(sum));
        float y1 = (float) (rr * Math.sin(sum));
        float x2 = (float) (rr * Math.cos(delta));
        float y2 = (float) (rr * Math.sin(delta));

        paintPath.moveTo(x, y);
        paintPath.lineTo(x - x1, y - y1);
        paintPath.moveTo(x, y);
        paintPath.lineTo(x - x2, y - y2);
    }

    @Override
    public void onStartAnimation() {
        mVelocityCalculator.reset();
    }
}

//一些set方法...
  • 火花特效,是箭头特效的引申,就是在箭头的基础上加多几个角度随机,长度随机的箭头,然后把箭头的线段切成随机的段数(段长递增),就成了火花:
    image
/**
 * author : yany
 * e-mail : yanzhikai_yjk@qq.com
 * time   : 2018/02/11
 * desc   : 火花特效,根据箭头引申变化而来,根据当前点与上一个点算出的速度方向来控制火花的方向
 */

public class FireworksPainter implements SyncTextPathView.SyncTextPainter {
    private VelocityCalculator mVelocityCalculator = new VelocityCalculator();
    private Random random = new Random();
    //箭头长度
    private float radius = 100;
    //箭头夹角
    private double angle = Math.PI / 8;
    //同时存在箭头数
    private static final int arrowCount = 6;
    //最大线段切断数
    private static final int cutCount = 9;


    public FireworksPainter(){
    }

    public FireworksPainter(int radius,double angle){
        this.radius = radius;
        this.angle = angle;
    }

    @Override
    public void onDrawPaintPath(float x, float y, Path paintPath) {
        mVelocityCalculator.calculate(x, y);

        for (int i = 0; i < arrowCount; i++) {
            double angleV = Math.atan2(mVelocityCalculator.getVelocityY(), mVelocityCalculator.getVelocityX());
            double rAngle = (angle * random.nextDouble());
            double delta = angleV - rAngle;
            double sum = angleV + rAngle;
            double rr = radius * random.nextDouble() / (2 * Math.cos(rAngle));
            float x1 = (float) (rr * Math.cos(sum));
            float y1 = (float) (rr * Math.sin(sum));
            float x2 = (float) (rr * Math.cos(delta));
            float y2 = (float) (rr * Math.sin(delta));

            splitPath(x, y, x - x1, y - y1, paintPath, random.nextInt(cutCount) + 2);
            splitPath(x, y, x - x2, y - y2, paintPath, random.nextInt(cutCount) + 2);
        }
    }

    @Override
    public void onStartAnimation() {
        mVelocityCalculator.reset();
    }

    //分解Path为虚线
    //注意count要大于0
    private void splitPath(float startX, float startY, float endX, float endY, Path path, int count) {
        float deltaX = (endX - startX) / count;
        float deltaY = (endY - startY) / count;
        for (int i = 0; i < count; i++) {
            if (i % 3 == 0) {
                path.moveTo(startX, startY);
                path.lineTo(startX + deltaX, startY + deltaY);
            }
            startX += deltaX;
            startY += deltaY;
        }
    }
}

整体结构

上面介绍的都是局部的细节实现,但是TextPathView作为一个自定义View,是需要封装一个整体的工作流程的,这样才能让使用者方便地使用,降低耦合性。

父类TextPathView

看过README的都知道,TextPathView并不提供给用户直接使用,而是让用户来使用它的子类SyncTextPathView和AsyncTextPathView来实现同步绘画和异步绘画的功能。而父类TextPathView则是负责写一些给子类复用的代码。具体代码就不贴了,可以直接看Github。

工作流程

SyncTextPathView和AsyncTextPathView的工作过程是差不多的,这里以SyncTextPathView为例,介绍它从创建到使用完动画的过程。

  • 首先创建的时候,需要会执行init()方法:
    protected void init() {

        //初始化画笔
        initPaint();

        //初始化文字路径
        initTextPath();

        //是否自动播放动画
        if (mAutoStart) {
            startAnimation(0,1);
        }
        
        //是否一开始就显示出完整的文字路径
        if (mShowInStart){
            drawPath(1);
        }
    }

    protected void initPaint(){
        mTextPaint = new Paint();
        mTextPaint.setTextSize(mTextSize);

        mDrawPaint = new Paint();
        mDrawPaint.setAntiAlias(true);
        mDrawPaint.setColor(mTextStrokeColor);
        mDrawPaint.setStrokeWidth(mTextStrokeWidth);
        mDrawPaint.setStyle(Paint.Style.STROKE);
        if (mTextInCenter){
            mDrawPaint.setTextAlign(Paint.Align.CENTER);
        }

        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(mPaintStrokeColor);
        mPaint.setStrokeWidth(mPaintStrokeWidth);
        mPaint.setStyle(Paint.Style.STROKE);
    }

//省略对initTextPath()和drawPath()方法的代码,因为前面已经有...
  • 进入测量过程onMeasure:
    /**
     * 重写onMeasure方法使得WRAP_CONTENT生效
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
//        int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
        int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
//        int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
        int width = wSpeSize;
        int height = hSpeSize;

        mTextWidth = TextUtil.getTextWidth(mTextPaint,mText);
        mTextHeight = mTextPaint.getFontSpacing() + 1;

        if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
            width = (int) mTextWidth;
        }
        if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
            height = (int) mTextHeight;
        }
        setMeasuredDimension(width,height);
    }
  • 用户调用startAnimation()开始绘制文字路径动画:
    /**
     * 开始绘制文字路径动画
     * @param start 路径比例,范围0-1
     * @param end 路径比例,范围0-1
     */
    public void startAnimation(float start, float end) {
        if (!isProgressValid(start) || !isProgressValid(end)){
            return;
        }
        if (mAnimator != null) {
            mAnimator.cancel();
        }
        initAnimator(start, end);
        initTextPath();
        canShowPainter = showPainter;
        mAnimator.start();
        if (mPainter != null) {
            mPainter.onStartAnimation();
        }
    }

以上就是SyncTextPathView的一个简单的工作流程,注释应该都写的挺清楚的了,里面还有一些细节,如果想了解可以查看源码。

更新

  • 2018/03/08 version 0.0.5:
    • 增加了showFillColorText()方法来设置直接显示填充好颜色了的全部文字。
    • 把TextPathAnimatorListener从TextPathView的内部类里面解放出来,之前使用太麻烦了。
    • 增加showPainterActually属性,设置所有时候是否显示画笔效果,由于动画绘画完毕应该将画笔特效消失,所以每次执行完动画都会自动将它设置为false。因此它用处就是在不使用自带Animator的时候显示画笔特效。
image

后话

终于完成了TextPathView的原理介绍,TextPathView我目前想到的应用场景就是做一些简单的开场动画或者进度显示。它是我元旦后在工作外抽空写的,最近几个月工作很忙,生活上遇到了很多的事情,但是还是要坚持做一些自己喜欢的事情,TextPathView会继续维护下去和开发新的东西,希望大家喜欢的话给个star,有意见和建议的提个issue,多多指教。

最后再贴上地址:https://github.com/totond/TextPathView

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

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,019评论 4 62
  • 早上她煮螺蛳粉,我煮面条,两人在厨房里热热闹闹。 给黄姑娘换手机电池,她趴在一边傻傻的看,喜欢这种感觉。 逛超市我...
    黄姑娘与高先生的日常阅读 132评论 0 0
  • 怀念在遥远的大山里的那栋房子,那是曾经做梦的地方,在风雨中屹立了二十多年。自从爷爷奶奶前几年的相继离世,它就像没了...
    David_Panda阅读 579评论 1 0
  • 杨柳浅草醉春风,赤日朝霞浴长空, 一年三百六十日,策马扬鞭任驰骋。
    妖娆郎阅读 285评论 2 6
  • 菟丝子附着在一片枯黄的老叶上,愤怒的农夫拽起菟丝子的藤蔓,真的好远好远…… 农...
    赵先森的小地瓜阅读 686评论 0 2