照着“自定义控件其实很简单”博客来学自定义view(三)

From AigeStudio(http://blog.csdn.net/aigestudio/article/details/41447349)Power by Aige
根据这篇文章起,开始重“抄”一遍。
继续Paint ,讲怎么“写字”。
首先要了解Android中一个和字体相关的很重要的类FontMetrics

FontMetrics意为字体测量,这么一说大家是不是瞬间感受到了这玩意的重要性?那这东西有什么用呢?我们通过源码追踪进去可以看到FontMetrics其实是Paint的一个内部类,而它里面呢就定义了top,ascent,descent,bottom,leading五个成员变量。


Paste_Image.png

  这张图很简单但是也很扼要的说明了ascent,descent,leading这三个参数。首先我们要知道Baseline基线,在Android中,文字的绘制都是从Baseline处开始的,Baseline往上至字符最高处的距离我们称之为ascent(上坡度),Baseline往下至字符最底处的距离我们称之为descent(下坡度),而leading(行间距)则表示上一行字符的descent到该行字符的ascent之间的距离。
  top和bottom文档描述地很模糊,其实这里我们可以借鉴一下TextView对文本的绘制,TextView在绘制文本的时候总会在文本的最外层留出一些内边距,为什么要这样做?因为TextView在绘制文本的时候考虑到了类似读音符号,下图中的A上面的符号就是一个拉丁文的类似读音符号的东西:



  然而根据世界范围内已入案的使用语言中能够标注在字符上方或者下方的除了类似的符号肯定是数不胜数的。而top的意思其实就是除了Baseline到字符顶端的距离外还应该包含这些符号的高度即ascent+leading,bottom的意思也是一样,一般情况下我们极少使用到类似的符号所以往往会忽略掉这些符号的存在,但是Android依然会在绘制文本的时候在文本外层留出一定的边距,这就是为什么top和bottom总会比ascent和descent大一点的原因。而在TextView中我们可以通过xml设置其属性android:includeFontPadding="false"去掉一定的边距值但是不能完全去掉。下面我们在Canvas上绘制一段文本并尝试打印文本的top,ascent,descent,bottom和leading:

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

/**
 * Created by 仁昌居士 on 2017/5/3.
 * Description:
 */

public class FontView extends View {
    private static final String mContent = "rcjs仁昌居士ξτβбпшㄎㄊěǔぬも┰┠№@↓";
    private Paint mPaint;// 画笔
    private Paint.FontMetrics mFontMetrics;// 文本测量对象
    private String TAG = "rcjs_zy";

    public FontView(Context context) {
        this(context, null);
    }

    public FontView(Context context, AttributeSet attrs) {
        super(context, attrs);

        // 初始化画笔
        initPaint();
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
        // 实例化画笔
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setTextSize(50);
        mPaint.setColor(Color.BLACK);

        mFontMetrics = mPaint.getFontMetrics();

        Log.d(TAG, "ascent:" + mFontMetrics.ascent);
        Log.d(TAG, "top:" + mFontMetrics.top);
        Log.d(TAG, "leading:" + mFontMetrics.leading);
        Log.d(TAG, "descent:" + mFontMetrics.descent);
        Log.d(TAG, "bottom:" + mFontMetrics.bottom);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawText(mContent, 0, Math.abs(mFontMetrics.top), mPaint);
    }
}

输出结果:


Paste_Image.png

注:Baseline上方的值为负,下方的值为正
  如图我们得到了top,ascent,descent,bottom和leading的值,因为只有一行文本所以leading恒为0,那么此时的显示效果是如何的呢?上面我们说到Android中文本的绘制是从Baseline开始的,在屏幕上的体现便是Y轴坐标,所以在

canvas.drawText(TEXT, 0, Math.abs(mFontMetrics.top), mPaint);  

中我们将文本绘制的起点Y坐标向下移动Math.abs(mFontMetrics.top)个单位(注:mFontMetrics.top是负数),相当于把文本的Baseline向下移动Math.abs(mFontMetrics.top)个单位,此时文本的顶部刚好会和屏幕顶部重合:

Paste_Image.png

从代码中我们可以看到一个很特别的现象,在我们绘制文本之前我们便可以获取文本的FontMetrics属性值,也就是说我们FontMetrics的这些值跟我们要绘制什么文本是无关的,而仅与绘制文本Paint的size和typeface有关。那么我们知道这样的一个东西有什么用呢?如上所说文本的绘制是从Baseline开始,并且Baseline并非文本的分割线,当我们想让文本绘制的时候居中屏幕或其他的东西时就需要计算Baseline的Y轴坐标,比如我们让我们的文本居中画布:

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

import static android.provider.Telephony.Mms.Part.TEXT;

/**
 * Created by 仁昌居士 on 2017/5/3.
 * Description:
 */

public class FontView extends View {
    private static final String mContent = "rcjs仁昌居士ξτβбпшㄎㄊěǔぬも┰┠№@↓";

    private Paint textPaint, centerLinePaint,baseLinePaint;// 文本的画笔和中心线的画笔

    private int baseX, baseY;// Baseline绘制的XY坐标

    public FontView(Context context) {
        this(context, null);
    }

    public FontView(Context context, AttributeSet attrs) {
        super(context, attrs);

        // 初始化画笔
        initPaint();
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
        // 实例化画笔
        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setTextSize(70);
        textPaint.setColor(Color.BLACK);

        centerLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        centerLinePaint.setStyle(Paint.Style.STROKE);
        centerLinePaint.setStrokeWidth(5);
        centerLinePaint.setColor(Color.RED);

        baseLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        baseLinePaint.setStyle(Paint.Style.STROKE);
        baseLinePaint.setStrokeWidth(5);
        baseLinePaint.setColor(Color.BLUE);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 计算Baseline绘制的起点X轴坐标
        baseX = (int) (canvas.getWidth() / 2 - textPaint.measureText(mContent) / 2);

        // 计算Baseline绘制的Y坐标
        baseY = (int) ((canvas.getHeight() / 2) - ((textPaint.descent() + textPaint.ascent()) / 2));


        canvas.drawText(mContent, baseX, baseY, textPaint);

        // 为了便于理解我们在画布中心处绘制一条中线
        canvas.drawLine(0, baseY, canvas.getWidth(), baseY, baseLinePaint);

        // 为了便于理解我们在画布中心处绘制一条中线
        canvas.drawLine(0, canvas.getHeight() / 2, canvas.getWidth(), canvas.getHeight() / 2, centerLinePaint);
    }
}

效果如图:


Paste_Image.png

  Baseline绘制的起点x坐标为画布宽度的一半(中点x坐标)减去文本宽度的一半(这里我们的画布大小与屏幕大小一样),这个很好理解,而y坐标为画布高度的一半(中点y坐标)减去ascent和descent绝对值之差的一半,这一点很多朋友可能不是很好理解,其实很简单,如果直接以画布的中心为Baseline:

baseY = canvas.getHeight() / 2;  

那么画出来的效果必定是如下的样子


Paste_Image.png

  也就是说Baseline和屏幕中线重合,而这样子绘制出来的文本必定不在屏幕中心,因为ascent的距离大于descent的距离(大多数情况下我们没有考虑top和bottom),那么我们就需要将Baseline往下移使绘制出来的文本能在中心

那么该下移多少呢?这是一个问题,很多童鞋的第一反应是下移ascent的一半高度,但是你要考虑到已经在中线下方的descent的高度,所以我们应该先在ascent的高度中减去descent的高度再除以二再让屏幕的中点Y坐标(也就是高度的一半)加上这个偏移值

baseY = (int) ((canvas.getHeight() / 2) + ((Math.abs(textPaint.ascent()-Math.abs(textPaint.descent()))) / 2));  

这个公式跟我们上面代码中的是一样的,不信大家可以自己算算这里就不多说了。这里我们的需求是让文本绘制在某个区域的中心,实际情况中有很多不同的需求不如靠近某个区域离某个区域需要多少距离等等,熟练地去学会计算文本测绘中的各个值就显得很有必要了!

Paint有一个唯一的子类TextPaint就是专门为文本绘制量身定做的“笔”,而这支笔就如API所描述的那样能够在绘制时为文本添加一些额外的信息,这些信息包括:baselineShift,bgColor,density,drawableState,linkColor,这些属性都很简单大家顾名思义或者自己去尝试下即可这里就不多说了,那么这支笔有何用呢?最常用的用法是在绘制文本时能够实现换行绘制
  在正常情况下Android绘制文本是不能识别换行符之类的标识符的,这时候如果我们想实现换行绘制就得另辟途径使用StaticLayout结合TextPaint实现换行,StaticLayout是android.text.Layout的一个子类,很明显它也是为文本处理量身定做的,其内部实现了文本绘制换行的处理,该类不是本系列重点我们不再多说直接Look一下它是如何实现换行的:

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.text.Layout;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.view.View;

/**
 * Created by 仁昌居士 on 2017/5/3.
 * Description:
 */

public class StaticLayoutView extends View {
    private static final String TEXT = "该配合你演出的我演视而不见,在逼一个最爱你的人即兴表演,什么时候我们开始收起了底线,顺应时代的改变看那些拙劣的表演";
    private TextPaint mTextPaint;// 文本的画笔
    private StaticLayout mStaticLayout;// 文本布局

    public StaticLayoutView(Context context) {
        this(context, null);
    }

    public StaticLayoutView(Context context, AttributeSet attrs) {
        super(context, attrs);

        // 初始化画笔
        initPaint();
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
        // 实例化画笔
        mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setTextSize(50);
        mTextPaint.setColor(Color.BLACK);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mStaticLayout = new StaticLayout(TEXT, mTextPaint, canvas.getWidth(), Layout.Alignment.ALIGN_NORMAL, 1.0F, 0.0F, false);
        mStaticLayout.draw(canvas);
        canvas.restore();
    }

}

运行效果如下:


Paste_Image.png

对Paint绘制文本的一个简单了解就先到这,我们来看看Paint中到底提供了哪些实用的方法来绘制文本
ascent()
顾名思义就是返回上坡度的值,我们已经用过了
descent()
同上,不多说了
breakText (CharSequence text, int start, int end, boolean measureForwards, float maxWidth, float[] measuredWidth)
这个方法让我们设置一个最大宽度在不超过这个宽度的范围内返回实际测量值否则停止测量,参数很多但是都很好理解:
text表示我们的字符串;
start表示从第几个字符串开始测量;
end表示从测量到第几个字符串为止;
measureForwards表示向前还是向后测量;
maxWidth表示一个给定的最大宽度在这个宽度内能测量出几个字符;
measuredWidth为一个可选项,可以为空,不为空时返回真实的测量值。

同样的方法还有breakText (String text, boolean measureForwards, float maxWidth, float[] measuredWidth)breakText (char[] text, int index, int count, float maxWidth, float[] measuredWidth)。这些方法在一些结合文本处理的应用里比较常用,比如文本阅读器的翻页效果,我们需要在翻页的时候动态折断或生成一行字符串,这就派上用场了~~~

getFontMetrics (Paint.FontMetrics metrics)
这个和我们之前用到的getFontMetrics()相比多了个参数,getFontMetrics()返回的是FontMetrics对象而getFontMetrics(Paint.FontMetrics metrics)返回的是文本的行间距,如果metrics的值不为空则返回FontMetrics对象的值。

getFontMetricsInt()
该方法返回了一个FontMetricsInt对象,FontMetricsInt和FontMetrics是一样的,只不过FontMetricsInt返回的是int而FontMetrics返回的是float
getFontMetricsInt(Paint.FontMetricsInt fmi)
和getFontMetrics (Paint.FontMetrics metrics)理解上类同
getFontSpacing()
返回字符行间距
setUnderlineText(boolean underlineText)
设置下划线
setTypeface(Typeface typeface)
设置字体类型,上面我们也使用过,Android中字体有四种样式:
BOLD(加粗);
BOLD_ITALIC(加粗并倾斜);
ITALIC(倾斜);
NORMAL(正常)。
而其为我们提供的字体有五种:
DEFAULT;
DEFAULT_BOLD;
MONOSPACE;
SANS_SERIF;
SERIF。
这些什么类型啊、字体啊之类的都很简单大家自己去试试就知道就不多说了。但是系统给我们的字体有限我们可不可以使用自己的字体呢?答案是肯定的!Typeface这个类中给我们提供了多个方法去个性化我们的字体:
defaultFromStyle(int style)
最简单的,简而言之就是把上面所说的四种Style封装成Typeface
create(String familyName, int style)和create(Typeface family, int style)
两者大概意思都一样,比如

textPaint.setTypeface(Typeface.create("SERIF", Typeface.NORMAL));  
textPaint.setTypeface(Typeface.create(Typeface.SERIF, Typeface.NORMAL));  

两者效果是一样的createFromAsset(AssetManager mgr, String path)、createFromFile(String path)和createFromFile(File path)

这三者也是一样的,它们都允许我们使用自己的字体比如我们从asset目录读取一个字体文件:

// 获取字体并设置画笔字体  
Typeface typeface = Typeface.createFromAsset(context.getAssets(), "kt.ttf");  
textPaint.setTypeface(typeface);  

setTextSkewX(float skewX)
这个倾斜值没有具体的范围,但是官方推崇的值为-0.25可以得到比较好的倾斜文本效果,值为负右倾值为正左倾,默认值为0
这个方法可以设置文本在水平方向上的倾斜,效果类似下图:

// 设置画笔文本倾斜  
textPaint.setTextSkewX(-0.25F);  
Paste_Image.png

setTextSize (float textSize)
设置字体大小
setTextScaleX (float scaleX)
将文本沿X轴水平缩放,默认值为1,当值大于1会沿X轴水平放大文本,当值小于1会沿X轴水平缩放文本

// 设置画笔文本缩小 
textPaint.setTextScaleX(0.5F);  
Paste_Image.png
// 设置画笔文本放大  
textPaint.setTextScaleX(1.5F);  

Paste_Image.png

注意事项:setTextScaleX不仅放大了文本宽度同时还拉伸了字符!
setTextLocale (Locale locale)
设置地理位置,这个不讲,我们会在屏幕适配系列详解什么是Locale,这里如果你要使用,直接传入Locale.getDefault()即可
setTextAlign (Paint.Align align)
设置文本的对齐方式,可供选的方式有三种:CENTER,LEFT和RIGHT,其实从这三者的名字上看我们就知道其意思,但是问题是这玩意怎么用的?
我们的文本大小是通过size和typeface确定的(其实还有其他的因素但这里影响不大忽略~~),一旦baseline确定,对不对齐好像不相干吧……但是,你要知道一点,文本的绘制是从baseline开始没错,但是是从哪边开始绘制的呢?左端还是右端呢?而这个Align就是为我们定义在baseline绘制文本究竟该从何处开始,上面我们在进行对文本的水平居中时是用Canvas宽度的一半减去文本宽度的一半:

  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 计算Baseline绘制的起点X轴坐标
        baseX = (int) (canvas.getWidth() / 2 - textPaint.measureText(mContent) / 2);

        // 计算Baseline绘制的Y坐标
        baseY = (int) ((canvas.getHeight() / 2) - ((textPaint.descent() + textPaint.ascent()) / 2));


        canvas.drawText(mContent, baseX, baseY, textPaint);

        // 为了便于理解我们在画布中心处绘制一条中线
        canvas.drawLine(0, baseY, canvas.getWidth(), baseY, baseLinePaint);

        // 为了便于理解我们在画布中心处绘制一条中线
        canvas.drawLine(0, canvas.getHeight() / 2, canvas.getWidth(), canvas.getHeight() / 2, centerLinePaint);
    }

实际上我们大可不必这样计算,我们只需设置Paint的文本对齐方式为CENTER,drawText的时候起点x = canvas.getWidth() / 2即可:

textPaint.setTextAlign(Align.CENTER);  
canvas.drawText(TEXT, canvas.getWidth() / 2, baseY, textPaint);  

当我们将文本对齐方式设置为CENTER后就相当于告诉Android我们这个文本绘制的时候从文本的中点开始向两端绘制,如果设置为LEFT则从文本的左端开始往右绘制,如果为RIGHT则从文本的右端开始往左绘制:


Paste_Image.png

setSubpixelText (boolean subpixelText)
设置是否打开文本的亚像素显示,什么叫亚像素显示呢?你可以理解为对文本显示的一种优化技术,如果大家用的是Win7+系统可以在控制面板中找到一个叫ClearType的设置,该设置可以让你的文本更好地显示在屏幕上就是基于亚像素显示技术。具体我们在设计色彩系列将会细说,这里就不扯了
setStrikeThruText (boolean strikeThruText)
是否添加文本删除线
setLinearText (boolean linearText)
设置是否打开线性文本标识,这玩意对大多数人来说都很奇怪不知道这玩意什么意思。想要明白这东西你要先知道文本在Android中是如何进行存储和计算的。在Android中文本的绘制需要使用一个bitmap作为单个字符的缓存,既然是缓存必定要使用一定的空间,我们可以通过setLinearText (true)告诉Android我们不需要这样的文本缓存。
setFakeBoldText (boolean fakeBoldText)
设置文本仿粗体
measureText (String text),measureText (CharSequence text, int start, int end),measureText (String text, int start, int end),measureText (char[] text, int index, int count)
测量文本宽度,上面我们已经使用过了,这四个方法都是一样的只是参数稍有不同。

下面我们来看一个比较深奧的东西
setDither(boolean dither)
这玩意用来设置我们在绘制图像时的抗抖动,也称为递色,那什么叫抗抖动呢?在Android中我确实不好拿出一个明显的例子,我就在PS里模拟说明一下


大家看到的这张七彩渐变图是一张RGB565模式下图片,即便图片不是很大我们依然可以很清晰地看到在两种颜色交接的地方有一些色块之类的东西感觉很不柔和,因为在RGB模式下只能显示2^16=65535种色彩,因此很多丰富的色彩变化无法呈现,而Android呢为我们提供了抗抖动这么一个方法,它会将相邻像素之间颜色值进行一种“中和”以呈现一个更细腻的过渡色:

放大来看,其在很多相邻像素之间插入了一个“中间值”:

抗抖动不是Android的专利,是图形图像领域的一种解决位图精度的技术。上面说了太多理论性的东西,估计大家都疲惫了,接下来我们来瞅瞅一个比较酷的东西MaskFilter遮罩过滤器!在Paint我们有个方法来设置这东西
setMaskFilter(MaskFilter maskfilter)
MaskFilter类中没有任何实现方法,而它有两个子类BlurMaskFilter和EmbossMaskFilter,前者为模糊遮罩滤镜(比起称之为过滤器哥更喜欢称之为滤镜)而后者为浮雕遮罩滤镜,我们先来看第一个
BlurMaskFilter
Android中的很多自带控件都有类似软阴影的效果,比如说Button

它周围就有一圈很淡的阴影效果,这种效果看起来让控件更真实,那么是怎么做的呢?其实很简单,使用BlurMaskFilter就可以得到类似的效果

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.BlurMaskFilter;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;

import rcjs.com.customviewdemo.utils.UIHelper;

/**
 * Created by 仁昌居士 on 2017/5/4.
 * Description:
 */

public class MaskFilterView extends View {
    private static final int RECT_SIZE = 800;
    private Paint mPaint;// 画笔
    private Context mContext;// 上下文环境引用

    private int left, top, right, bottom;//

    public MaskFilterView(Context context) {
        this(context, null);
    }

    public MaskFilterView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;

        // 初始化画笔
        initPaint();

        // 初始化资源
        initRes(context);
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
        // 实例化画笔
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(0xFF603811);

        // 设置画笔遮罩滤镜
        mPaint.setMaskFilter(new BlurMaskFilter(20, BlurMaskFilter.Blur.SOLID));
    }

    /**
     * 初始化资源
     */
    private void initRes(Context context) {
        /*
         * 计算位图绘制时左上角的坐标使其位于屏幕中心
         */
        left = UIHelper.getWindowWidthHeight(mContext)[0] / 2 - RECT_SIZE / 2;
        top = UIHelper.getWindowWidthHeight(mContext)[1] / 2 - RECT_SIZE / 2;
        right = UIHelper.getWindowWidthHeight(mContext)[0] / 2 + RECT_SIZE / 2;
        bottom = UIHelper.getWindowWidthHeight(mContext)[1] / 2 + RECT_SIZE / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawColor(Color.GRAY);

        // 画一个矩形
        canvas.drawRect(left, top, right, bottom, mPaint);
    }
}

代码中我们在画布中央绘制了一个正方形,并设置了了它的模糊滤镜,但是当你运行后发现并没有任何的效果:


Paste_Image.png

为什么会这样呢?还记得上一节中我们讲的AvoidXfermode么,在API 16的时候该类已经被标注为过时了,因为AvoidXfermode不支持硬件加速,如果在API 16+上想获得正确的效果就必需关闭应用的硬件加速,当时我们是在AndroidManifest.xml文件中设置android:hardwareAccelerated为false来关闭的,具体有哪些绘制的方法不支持硬件加速可以参考下图



但是大家想过没如果在AndroidManifest.xml文件中关闭硬件加速那么我们整个应用都将不支持硬件加速,这显然是不科学的,如果可以只针对某个View关闭硬件加速那岂不是很好么?当然,Android也给我们提供了这样的功能,我们可以在View中通过
setLayerType(LAYER_TYPE_SOFTWARE, null);  

来关闭单个View的硬件加速功能
再次运行即可得到正确的效果:

Paste_Image.png

BlurMaskFilter只有一个含参的构造函数BlurMaskFilter(float radius, BlurMaskFilter.Blur style),其中radius很容易理解,值越大我们的阴影越扩散,比如在上面的例子中我将radius改为50:

Paste_Image.png

可以明显感到阴影的范围扩大了,这个很好理解。而第二个参数style表示的是模糊的类型,上面我们用到的是SOLID,其效果就是在图像的Alpha边界外产生一层与Paint颜色一致的阴影效果而不影响图像本身,除了SOLID还有三种,NORMAL,OUTER和INNER。
NORMAL会将整个图像模糊掉:

Paste_Image.png

而OUTER会在Alpha边界外产生一层阴影且会将原本的图像沿内部距离为radius变成透明:


Paste_Image.png

INNER则会在图像内部产生模糊:

Paste_Image.png

INNER效果其实并不理想,实际应用中我们使用的也少,我们往往会使用混合模式和渐变和获得更完美的内阴影效果。如上所说BlurMaskFilter是根据Alpha通道的边界来计算模糊的,如果是一张图片(注:上面我们说过Android会把拷贝到资源目录的图片转为RGB565,具体原因具体分析我会单独开一篇帖子说,这里就先假设所有提及的图片格式为RGB565)你会发现没有任何效果,那么假使我们需要给图片加一个类似阴影的效果该如何做呢?其实很简单,我们可以尝试从Bitmap中获取其Alpha通道,并在绘制Bitmap前先以该Alpha通道绘制一个模糊效果不就行了?

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BlurMaskFilter;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;

import rcjs.com.customviewdemo.R;
import rcjs.com.customviewdemo.utils.UIHelper;

/**
 * Created by 仁昌居士 on 2017/5/4.
 * Description:
 */

public class BlurMaskFilterView extends View {
    private Paint shadowPaint;// 画笔
    private Context mContext;// 上下文环境引用
    private Bitmap srcBitmap, shadowBitmap;// 位图和阴影位图

    private int x, y;// 位图绘制时左上角的起点坐标

    public BlurMaskFilterView(Context context) {
        this(context, null);
    }

    public BlurMaskFilterView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        // 记得设置模式为SOFTWARE
        setLayerType(LAYER_TYPE_SOFTWARE, null);

        // 初始化画笔
        initPaint();

        // 初始化资源
        initRes(context);
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
        // 实例化画笔
        shadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        shadowPaint.setColor(Color.DKGRAY);
        shadowPaint.setMaskFilter(new BlurMaskFilter(10, BlurMaskFilter.Blur.NORMAL));
    }

    /**
     * 初始化资源
     */
    private void initRes(Context context) {
        // 获取位图
        srcBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.nav_header);

        // 获取位图的Alpha通道图
        shadowBitmap = srcBitmap.extractAlpha();

        /*
         * 计算位图绘制时左上角的坐标使其位于屏幕中心
         */
        x = UIHelper.getWindowWidthHeight(context)[0] / 2 - srcBitmap.getWidth() / 2;
        y = UIHelper.getWindowWidthHeight(context)[1] / 2 - srcBitmap.getHeight() / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 先绘制阴影
        canvas.drawBitmap(shadowBitmap, x, y, shadowPaint);

        // 再绘制位图
        canvas.drawBitmap(srcBitmap, x, y, null);
    }
}

如代码所示我们通过Bitmap的extractAlpha()方法从原图中分离出一个Alpha通道位图并在计算模糊滤镜的时候使用该位图生成模糊效果:


Paste_Image.png

这段直接copy,懂就行了
相对于BlurMaskFilter来说
EmbossMaskFilter
的常用性比较低,倒不是说EmbossMaskFilter很没用,只是相对于EmbossMaskFilter实现的效果来说远不及BlurMaskFilter给人的感觉霸气,说了半天那么EmbossMaskFilter到底是做什么的呢?
我们先来看一张图:


这么一个看着像巧克力的东西就是用EmbossMaskFilter实现了,正如其名,他可以实现一种类似浮雕的效果,说白了就是让你绘制的图像感觉像是从屏幕中“凸”起来更有立体感一样(在设计软件中类似的效果称之为斜面浮雕)。该类也只有一个含参的构造方法EmbossMaskFilter(float[] direction, float ambient, float specular, float blurRadius),这些参数理解起来要比BlurMaskFilter困难得多,如果你没有空间想象力的话,首先我们来看第一个direction指的是方向,什么方向呢?光照的方向!如果大家接触过三维设计就一定懂,没接触也没关系,我跟你说明白。假设一个没有任何光线的黑屋子里有一张桌子,桌子上有一个小球,这时我们打开桌子上的台灯,台灯照亮了小球,这时候小球的状态与下图类似:

PS:略草,凑合看
小球最接近光源的地方肯定是最亮的这个没有异议,在参数中specular就是跟高光有关的,其值是个双向值越小或越大高光越强中间值则是最弱的,那么再看看什么是ambient呢?同样我们看个球,你会发现即便只有一盏灯光,在球底部跟桌面相接的地方依然不会出现大片的“死黑”,这是因为光线在传播的过程中碰到物体会产生反射!这种反射按照物体介质的粗糙度可以分为漫反射和镜面反射,而这里我们的小球之所以背面没有直接光照但仍能有一定的亮度就是因为大量的漫反射在空间传播让光线间接照射到小球背面,这种区别于直接照明的二次照明我们称之为间接照明,产生的光线叫做环境光ambient,参数中的该值就是用来设置环境光的,在Android中环境光默认为白色,其值越大,阴影越浅,blurRadius则是设置图像究竟“凸”出多大距离的很好理解,最难理解的一个参数是direction,上面我们也说了是光照方向的意思,该数组必须要有而且只能有三个值即float[x,y,z],这三个值代表了一个空间坐标系,我们的光照方向则由其定义,那么它是怎么定义的呢?首先x和y很好理解,平面的两个维度嘛是吧,上面我们使用的是[1,1]也就是个45度角,而z轴表示光源是在屏幕后方还是屏幕前方,上面我们是用的是1,正值表示光源往屏幕外偏移1个单位,负值表示往屏幕里面偏移,这么一说如果我把其值改为[1,1,-1]那么我们的巧克力朝着我们的一面应该就看不到了对吧,试试看撒~~这个效果我就不截图了,因为一片漆黑……但是你依然能够看到一点点灰度就是因为我们的环境光ambient!,如果我们把值改为[1,1,2]往屏幕外偏移两个单位,那么我们巧克力正面光照将更强:

看吧都爆色了!这里要提醒一点[x,y,z]表示的是空间坐标,代表光源的位置,那么一旦这个位置确定,[ax,ay,az]则没有意义,也就是说同时扩大三个轴向值的倍数是没有意义的,最终效果还是跟[x,y,z]一样!懂了不?

public class EmbossMaskFilterView extends View {  
  private static final int H_COUNT = 2, V_COUNT = 4;// 水平和垂直切割数  
  private Paint mPaint;// 画笔  
  private PointF[] mPointFs;// 存储各个巧克力坐上坐标的点  

  private int width, height;// 单个巧克力宽高  
  private float coorY;// 单个巧克力坐上Y轴坐标值  

  public EmbossMaskFilterView(Context context) {  
      this(context, null);  
  }  

  public EmbossMaskFilterView(Context context, AttributeSet attrs) {  
      super(context, attrs);  
      // 不使用硬件加速  
      setLayerType(LAYER_TYPE_SOFTWARE, null);  

      // 初始化画笔  
      initPaint();  

      // 计算参数  
      cal(context);  
  }  

  /** 
   * 初始化画笔 
   */  
  private void initPaint() {  
      // 实例化画笔  
      mPaint = new Paint();  
      mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);  
      mPaint.setStyle(Paint.Style.FILL);  
      mPaint.setColor(0xFF603811);  

      // 设置画笔遮罩滤镜  
      mPaint.setMaskFilter(new EmbossMaskFilter(new float[] { 1, 1, 1F }, 0.1F, 10F, 20F));  
  }  

  /** 
   * 计算参数 
   */  
  private void cal(Context context) {  
      int[] screenSize = MeasureUtil.getScreenSize((Activity) context);  

      width = screenSize[0] / H_COUNT;  
      height = screenSize[1] / V_COUNT;  

      int count = V_COUNT * H_COUNT;  

      mPointFs = new PointF[count];  
      for (int i = 0; i < count; i++) {  
          if (i % 2 == 0) {  
              coorY = i * height / 2F;  
              mPointFs[i] = new PointF(0, coorY);  
          } else {  
              mPointFs[i] = new PointF(width, coorY);  
          }  
      }  
  }  

  @Override  
  protected void onDraw(Canvas canvas) {  
      super.onDraw(canvas);  
      canvas.drawColor(Color.GRAY);  

      // 画矩形  
      for (int i = 0; i < V_COUNT * H_COUNT; i++) {  
          canvas.drawRect(mPointFs[i].x, mPointFs[i].y, mPointFs[i].x + width, mPointFs[i].y + height, mPaint);  
      }  
  }  
}  

上面我们说了EmbossMaskFilter的使用面并不是很大,因为所说其参数稍复杂但是其实现原理是简单粗暴的,简而言之就是根据参数在图像周围绘制一个“色带”来模拟浮雕的效果,如果我们的图像很复杂EmbossMaskFilter很难会正确模拟,所以一般遇到这类图直接call美工 = = 哈哈哈。

setRasterizer (Rasterizer rasterizer)
设置光栅,该方法不支持HW在API 21中被遗弃了
setPathEffect(PathEffect effect)
PathEffect见文知意很明显就是路径效果的意思~~那这玩意肯定跟路径Path有关咯?那是必须的撒!PathEffect跟上面的很多类一样没有具体的实现,但是其有六个子类:


这六个子类分别可以实现不同的路径效果:
Paste_Image.png

上图从上往下分别是没有PathEffect、CornerPathEffect、DiscretePathEffect、DashPathEffect、PathDashPathEffect、ComposePathEffect、SumPathEffect的效果,代码的实现也非常简单:

package rcjs.com.customviewdemo.widget;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ComposePathEffect;
import android.graphics.CornerPathEffect;
import android.graphics.DashPathEffect;
import android.graphics.DiscretePathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathDashPathEffect;
import android.graphics.PathEffect;
import android.graphics.SumPathEffect;
import android.util.AttributeSet;
import android.view.View;

/**
 * Created by 仁昌居士 on 2017/5/4.
 * Description:
 */

public class PathEffectView extends View {
    private float mPhase;// 偏移值
    private Paint mPaint;// 画笔对象
    private Path mPath;// 路径对象
    private PathEffect[] mEffects;// 路径效果数组

    public PathEffectView(Context context, AttributeSet attrs) {
        super(context, attrs);

        /*
         * 实例化画笔并设置属性
         */
        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(5);
        mPaint.setColor(Color.DKGRAY);

        // 实例化路径
        mPath = new Path();

        // 定义路径的起点
        mPath.moveTo(0, 0);

        // 定义路径的各个点
        for (int i = 0; i <= 30; i++) {
            mPath.lineTo(i * 35, (float) (Math.random() * 100));
        }

        // 创建路径效果数组
        mEffects = new PathEffect[7];
    }

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

        /*
         * 实例化各类特效
         */
        mEffects[0] = null;
        mEffects[1] = new CornerPathEffect(10);
        mEffects[2] = new DiscretePathEffect(3.0F, 5.0F);
        mEffects[3] = new DashPathEffect(new float[]{20, 10, 5, 10}, mPhase);
        Path path = new Path();
        path.addRect(0, 0, 8, 8, Path.Direction.CCW);
        mEffects[4] = new PathDashPathEffect(path, 12, mPhase, PathDashPathEffect.Style.ROTATE);
        mEffects[5] = new ComposePathEffect(mEffects[2], mEffects[4]);
        mEffects[6] = new SumPathEffect(mEffects[4], mEffects[3]);

        /*
         * 绘制路径
         */
        for (int i = 0; i < mEffects.length; i++) {
            mPaint.setPathEffect(mEffects[i]);
            canvas.drawPath(mPath, mPaint);

            // 每绘制一条将画布向下平移250个像素
            canvas.translate(0, 250);
        }

        // 刷新偏移值并重绘视图实现动画效果
        mPhase += 1;
        invalidate();
    }
}

当我们不设置路径效果的时候路径的默认效果就如上图第一条线那样直的转折生硬;而CornerPathEffect则可以将路径的转角变得圆滑如图第二条线的效果,这六种路径效果类都有且只有一个含参的构造方法,CornerPathEffect的构造方法只接受一个参数radius,意思就是转角处的圆滑程度,我们尝试更改一下上面的代码:

mEffects[1] = new CornerPathEffect(50);  
CornerPathEffect(10)
CornerPathEffect(50)

Look Pic是不是更平滑了呢?CornerPathEffect相对于其他的路径效果来说最简单了。
DiscretePathEffect离散路径效果相对来说则稍微复杂点,其会在路径上绘制很多“杂点”的突出来模拟一种类似生锈铁丝的效果如上图第三条线,其构造方法有两个参数,第一个呢指定这些突出的“杂点”的密度,值越小杂点越密集,第二个参数呢则是“杂点”突出的大小,值越大突出的距离越大。
DashPathEffect的效果相对与上面两种路径效果来说要略显复杂,其虽说也是包含了两个参数,但是第一个参数是一个浮点型的数组,那这个数组有什么意义呢?其实是这样的,我们在定义该参数的时候只要浮点型数组中元素个数大于等于2即可,也就是说上面我们的代码可以写成这样的:

mEffects[3] = new DashPathEffect(new float[] {20, 10}, mPhase);  

Paste_Image.png

从图中我们可以看到我们之前的那种线条变成了一长一短的间隔线条,而float[] {20, 10}的偶数参数20(注意数组下标是从0开始哦)定义了我们第一条实线的长度,而奇数参数10则表示第一条虚线的长度,如果此时数组后面不再有数据则重复第一个数以此往复循环,比如我们20,10后没数了,那么整条线就成了[20,10,20,10,20,10…………………………]这么一个状态,当然如果你无聊,也可以:

mEffects[3] = new DashPathEffect(new float[] {20, 10, 50, 5, 100, 30, 10, 5}, mPhase);  
Paste_Image.png

而DashPathEffect的第二个参数我称之为偏移值,动态改变其值会让路径产生动画的效果,上面代码已给出大家可以自己去试试;PathDashPathEffect和DashPathEffect是类似的,不同的是PathDashPathEffect可以让我们自己定义路径虚线的样式,比如我们将其换成一个个小圆组成的虚线:

Path path = new Path();  
path.addCircle(0, 0, 3, Direction.CCW);  
mEffects[4] = new PathDashPathEffect(path, 12, mPhase, PathDashPathEffect.Style.ROTATE);  
Paste_Image.png

ComposePathEffect和SumPathEffect都可以用来组合两种路径效果,唯一不同的是组合的方式,ComposePathEffect(PathEffect outerpe, PathEffect innerpe)会先将路径变成innerpe的效果,再去复合outerpe的路径效果,即:outerpe(innerpe(Path));而SumPathEffect(PathEffect first, PathEffect second)则会把两种路径效果加起来再作用于路径。

Path应用的广泛性注定了PathEffect应用的广泛,所谓一人得道鸡犬升天就是这么个道理,只要是Path能存在的地方都可以考虑使用,下面我们来模拟一个类似心电图的路径小动画:


Paste_Image.png

这种效果呢也是非常非常地简单,说白了就是无数条短小精悍的小“Path”连接成一条完整的心电路径:

public class ECGView extends View {  
    private Paint mPaint;// 画笔  
    private Path mPath;// 路径对象  
  
    private int screenW, screenH;// 屏幕宽高  
    private float x, y;// 路径初始坐标  
    private float initScreenW;// 屏幕初始宽度  
    private float initX;// 初始X轴坐标  
    private float transX, moveX;// 画布移动的距离  
  
    private boolean isCanvasMove;// 画布是否需要平移  
  
    public ECGView(Context context, AttributeSet set) {  
        super(context, set);  
  
        /* 
         * 实例化画笔并设置属性 
         */  
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);  
        mPaint.setColor(Color.GREEN);  
        mPaint.setStrokeWidth(5);  
        mPaint.setStrokeCap(Paint.Cap.ROUND);  
        mPaint.setStrokeJoin(Paint.Join.ROUND);  
        mPaint.setStyle(Paint.Style.STROKE);  
        mPaint.setShadowLayer(7, 0, 0, Color.GREEN);  
  
        mPath = new Path();  
        transX = 0;  
        isCanvasMove = false;  
    }  
  
    @Override  
    public void onSizeChanged(int w, int h, int oldw, int oldh) {  
        /* 
         * 获取屏幕宽高 
         */  
        screenW = w;  
        screenH = h;  
  
        /* 
         * 设置起点坐标 
         */  
        x = 0;  
        y = (screenH / 2) + (screenH / 4) + (screenH / 10);  
  
        // 屏幕初始宽度  
        initScreenW = screenW;  
  
        // 初始X轴坐标  
        initX = ((screenW / 2) + (screenW / 4));  
  
        moveX = (screenW / 24);  
  
        mPath.moveTo(x, y);  
    }  
  
    @Override  
    public void onDraw(Canvas canvas) {  
        canvas.drawColor(Color.BLACK);  
  
        mPath.lineTo(x, y);  
  
        // 向左平移画布  
        canvas.translate(-transX, 0);  
  
        // 计算坐标  
        calCoors();  
  
        // 绘制路径  
        canvas.drawPath(mPath, mPaint);  
        invalidate();  
    }  
  
    /** 
     * 计算坐标 
     */  
    private void calCoors() {  
        if (isCanvasMove == true) {  
            transX += 4;  
        }  
  
        if (x < initX) {  
            x += 8;  
        } else {  
            if (x < initX + moveX) {  
                x += 2;  
                y -= 8;  
            } else {  
                if (x < initX + (moveX * 2)) {  
                    x += 2;  
                    y += 14;  
                } else {  
                    if (x < initX + (moveX * 3)) {  
                        x += 2;  
                        y -= 12;  
                    } else {  
                        if (x < initX + (moveX * 4)) {  
                            x += 2;  
                            y += 6;  
                        } else {  
                            if (x < initScreenW) {  
                                x += 8;  
                            } else {  
                                isCanvasMove = true;  
                                initX = initX + initScreenW;  
                            }  
                        }  
                    }  
                }  
            }  
  
        }  
    }  
}  

我们在onSizeChanged(int w, int h, int oldw, int oldh)方法中获取屏幕的宽高,该方法的具体用法以后会写
上面在设置Paint属性的时候我们使用到了一个setStrokeCap(Paint.Cap cap)方法。
setStrokeCap(Paint.Cap cap)
该方法用来设置我们画笔的笔触风格,上面的例子中我使用的是ROUND,表示是圆角的笔触,那么什么叫笔触呢,其实很简单,就像我们现实世界中的笔,如果你用圆珠笔在纸上戳一点,那么这个点一定是个圆,即便很小,它代表了笔的笔触形状,如果我们把一支铅笔笔尖削成方形的,那么画出来的线条会是一条弯曲的“矩形”,这就是笔触的意思。除了ROUND,Paint.Cap还提供了另外两种类型:SQUARE和BUTT。
setStrokeJoin(Paint.Join join)
这个方法用于设置结合处的形态,就像上面的代码中我们虽说是花了一条心电线,但是这条线其实是由无数条小线拼接成的,拼接处的形状就由该方法指定。
上面的例子中我们还使用到了一个方法
setShadowLayer(float radius, float dx, float dy, int shadowColor)
该方法为我们绘制的图形添加一个阴影层效果:

public class ShadowView extends View {  
    private static final int RECT_SIZE = 800;// 方形大小  
    private Paint mPaint;// 画笔  
  
    private int left, top, right, bottom;// 绘制时坐标  
  
    public ShadowView(Context context, AttributeSet attrs) {  
        super(context, attrs);  
        // setShadowLayer不支持HW  
        setLayerType(LAYER_TYPE_SOFTWARE, null);  
  
        // 初始化画笔  
        initPaint();  
  
        // 初始化资源  
        initRes(context);  
    }  
  
    /** 
     * 初始化画笔 
     */  
    private void initPaint() {  
        // 实例化画笔  
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);  
        mPaint.setColor(Color.RED);  
        mPaint.setStyle(Style.FILL);  
        mPaint.setShadowLayer(10, 3, 3, Color.DKGRAY);  
    }  
  
    /** 
     * 初始化资源 
     */  
    private void initRes(Context context) {  
        /* 
         * 计算位图绘制时左上角的坐标使其位于屏幕中心 
         */  
        left = MeasureUtil.getScreenSize((Activity) context)[0] / 2 - RECT_SIZE / 2;  
        top = MeasureUtil.getScreenSize((Activity) context)[1] / 2 - RECT_SIZE / 2;  
        right = MeasureUtil.getScreenSize((Activity) context)[0] / 2 + RECT_SIZE / 2;  
        bottom = MeasureUtil.getScreenSize((Activity) context)[1] / 2 + RECT_SIZE / 2;  
    }  
  
    @Override  
    protected void onDraw(Canvas canvas) {  
        super.onDraw(canvas);  
  
        // 先绘制位图  
        canvas.drawRect(left, top, right, bottom, mPaint);  
    }  
}  

radius表示阴影的扩散半径,而dx和dy表示阴影平面上的偏移值,shadowColor就不说了阴影颜色,最后提醒一点setShadowLayer同样不支持HW哦!



上面我们讲MaskFilter的时候曾用其子类BlurMaskFilter模拟过类似效果,跟BlurMaskFilter比起来这方法是不是更简捷呢?但是BlurMaskFilter能做的setShadowLayer却不一定能做到哦!

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

推荐阅读更多精彩内容