Android实现自定义解析Html标签

前言

    在开发App的过程中,有的时候需要后端返回包含Html标签的文本来实现文案样式的动态性或者有的时候我们也需要在一个TextView中同时展示不同样式的一段文本,比如“这是一段包含粗体以及<font color=#AEAAE4>多</font><font color=#FF11FF>种</font><font color=#1100AA>颜</font><font color=#AACC00>色</font>的文案”。我们知道Android中支持Html标签的使用——Html.fromHtml(Str)。但Android中的支持并不完善,比如我们可以用< font>标签实现字体颜色的变化,但是无法直接指定字号,只能通过< big>< small>标签去嵌套改变,这种方式一是麻烦,二是不能精确指定字体大小,只能不断嵌套去尝试多少个标签可以达到理想的效果。
    我们知道Android中Html标签的处理也是通过匹配的对应xml结构文本中的标签内容,对SpannableString进行Span样式的设置。在Android 7.0之后Android系统的Html.fromHtml方法支持自定义TagHandler的使用,即支持自定义标签,也就说我们可以自定义去接收哪些属性去设置SpannableString。

代码

主要分了三个文件
BaseHtmlTag中封装了标签的处理抽象方法和公共常见的提取数值的方法,如字体字号、色值的提取、Span标签的获取。

public abstract class BaseHtmlTag {
    private static final String UNIT_PX = "px";
    protected static final String FONT_SIZE = "font-size";
    protected static final String COLOR = "color";
    protected static final String BACKGROUND_COLOR = "background-color";
    protected static final String FONT_WEIGHT = "font-weight";
    protected static final String BOLD = "bold";
    protected static final String STYLE = "style";

    /**
     * 处理头标签<AAA>
     *
     * @param originEditable
     * @param atts
     */
    public abstract void startHandleTag(Editable originEditable, Attributes atts);

    /**
     * 处理尾标签</AAA>
     *
     * @param originEditable
     */
    public abstract void endHandleTag(Editable originEditable);

    /**
     * </custom>标签结束处理
     *
     * @param originEditable
     */
    public abstract void finishHandleTag(Editable originEditable);

    public int getFontSize(String fontSize) {
        if (TextUtils.isEmpty(fontSize)) {
            return -1;
        }
        fontSize = fontSize.toLowerCase();
        //单位px
        if (fontSize.endsWith(UNIT_PX) && TextUtils.isDigitsOnly(fontSize.substring(0, fontSize.indexOf(UNIT_PX)))) {
            return (int) Float.parseFloat(fontSize.substring(0, fontSize.indexOf(UNIT_PX)));
        }
        //无单位
        if (TextUtils.isDigitsOnly(fontSize)) {
            return (int) Float.parseFloat(fontSize);
        }
        return -1;
    }

    /**
     * 重写Color.parseColor 不希望出现Exception
     *
     * @param colorString
     * @return
     */
    public int parseColor(String colorString) {
        if (TextUtils.isEmpty(colorString)) {
            return -1;
        }
        try {
            return Color.parseColor(colorString);
        } catch (IllegalArgumentException ex) {
            return -1;
        }
    }

    /**
     * 获取editable中已经存在的span集合,获取最新添加的span
     *
     * @param start 匹配查询起点
     * @param editable
     * @param kind
     */
    public static <T> T getLastSpanFromEdit(int start, Editable editable, Class<T> kind) {
        T[] objs = editable.getSpans(start, editable.length(), kind);
        if (objs.length == 0) {
            return null;
        } else {
            return objs[objs.length - 1];
        }
    }
}

CustomSpanTag继承了BaseHtmlTag,主要实现了头标签、尾标签、自定义标签结束处理。

public class CustomSpanTag extends BaseHtmlTag {
    public static final String SPAN = "span";
    private final Stack<Integer> spanStartIndexStack = new Stack<>();
    private final Stack<StashedSpanStyle> stashSpanStyleStack = new Stack<>();

    @Override
    public void startHandleTag(Editable originEditable, Attributes atts) {
        String style = atts.getValue("", STYLE);
        if (TextUtils.isEmpty(style)) {
            return;
        }
        final String textColorStr = getValueFromStyle(style, COLOR);
        final String fontSizeStr = getValueFromStyle(style, FONT_SIZE);
        final String backgroundColorStr = getValueFromStyle(style, BACKGROUND_COLOR);
        final String fontWeight = getValueFromStyle(style, FONT_WEIGHT);
        final int fontSize = getFontSize(fontSizeStr);
        boolean isFind = false;
        if (fontSize != -1) {
            //接收数据为px单位但因为iOS、Android使用单位不同且无法使用px实现适配,暂时采用15px当做15dp进行处理
            setSpanStartIndex(originEditable, new FontSize(fontSize));
            isFind = true;
        }
        final int textColor = parseColor(textColorStr);
        if (textColor != -1) {
            setSpanStartIndex(originEditable, new ForegroundColor(textColor));
            isFind = true;
        }
        final int backgroundColor = parseColor(backgroundColorStr);
        if (backgroundColor != -1) {
            setSpanStartIndex(originEditable, new BackgroundColor(backgroundColor));
            isFind = true;
        }
        if (fontWeight != null && fontWeight.toLowerCase().equals(BOLD)) {
            setSpanStartIndex(originEditable, new Bold());
            isFind = true;
        }
        if (isFind) {
            spanStartIndexStack.push(originEditable.length());
        }
    }

    private String getValueFromStyle(String style, String matchAttr) {
        if (TextUtils.isEmpty(style)) {
            return null;
        }
        return getHtmlCssAttrs(style, matchAttr);
    }

    private String getHtmlCssAttrs(@NonNull String style, String matchAttr) {
        if (TextUtils.isEmpty(style)) {
            return null;
        }
        String[] styleAttrs = style.trim().toLowerCase().split(";");
        for (String attr : styleAttrs) {
            attr = attr.trim();
            if (attr.indexOf(matchAttr) == 0) {
                String[] split = attr.split(":");
                if (split.length != 2) {
                    continue;
                }
                return split[1].trim();
            }
        }
        return null;
    }

    @Override
    public void endHandleTag(Editable originEditable) {
        Integer index = 0;
        if (!spanStartIndexStack.empty()) {
            index = spanStartIndexStack.pop();
            if (index == null) {
                index = 0;
            }
        }
        FontSize fontSizeSpan = getLastSpanFromEdit(index, originEditable, FontSize.class);
        if (fontSizeSpan != null) {
            tagSpans(originEditable, fontSizeSpan, new AbsoluteSizeSpan(fontSizeSpan.fontSize, true));
        }
        ForegroundColor foregroundColorSpan = getLastSpanFromEdit(index, originEditable, ForegroundColor.class);
        if (foregroundColorSpan != null) {
            tagSpans(originEditable, foregroundColorSpan, new ForegroundColorSpan(foregroundColorSpan.foregroundColor));
        }
        BackgroundColor backgroundColorSpan = getLastSpanFromEdit(index, originEditable, BackgroundColor.class);
        if (backgroundColorSpan != null) {
            tagSpans(originEditable, backgroundColorSpan, new BackgroundColorSpan(backgroundColorSpan.backgroundColor));
        }
        Bold boldSpans = getLastSpanFromEdit(index, originEditable, Bold.class);
        if (boldSpans != null) {
            tagSpans(originEditable, boldSpans, new CustomFontBoldSpan());
        }
    }

    @Override
    public void finishHandleTag(Editable originEditable) {
        while (!stashSpanStyleStack.empty()) {
            final StashedSpanStyle stashedSpanStyle = stashSpanStyleStack.pop();
            if (stashedSpanStyle == null) {
                continue;
            }
            originEditable.setSpan(stashedSpanStyle.span, stashedSpanStyle.start, stashedSpanStyle.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
    }

    /**
     * 标记span样式的起点位置
     *
     * @param editable
     * @param mark
     */
    private void setSpanStartIndex(Editable editable, Object mark) {
        // startHandle阶段 setSpan只做标记位置作用不实现具体效果
        int length = editable.length();
        editable.setSpan(mark, length, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
    }

    /**
     * 根据起点终点保存span样式
     *
     * @param editable
     * @param mark
     * @param spans
     */
    private void tagSpans(Editable editable, Object mark, Object... spans) {
        int start = editable.getSpanStart(mark);
        editable.removeSpan(mark);
        int end = editable.length();
        if (start != end) {
            for (Object span : spans) {
                stashSpanStyleStack.push(new StashedSpanStyle(span, start, end));
            }
        }
    }

    private static class StashedSpanStyle {
        Object span;
        int start;
        int end;

        public StashedSpanStyle(Object span, int start, int end) {
            this.span = span;
            this.start = start;
            this.end = end;
        }
    }

    private static class Bold {

    }

    private static class FontSize {
        int fontSize;

        public FontSize(int fontSize) {
            this.fontSize = fontSize;
        }
    }

    private static class BackgroundColor {
        int backgroundColor;

        public BackgroundColor(int backgroundColor) {
            this.backgroundColor = backgroundColor;
        }
    }

    private static class ForegroundColor {
        int foregroundColor;

        public ForegroundColor(int foregroundColor) {
            this.foregroundColor = foregroundColor;
        }
    }

    private static class CustomFontBoldSpan extends StyleSpan {

        public CustomFontBoldSpan() {
            super(Typeface.BOLD);
        }

        public CustomFontBoldSpan(@NonNull Parcel src) {
            super(src);
        }

        @Override
        public void updateDrawState(TextPaint tp) {
            tp.setStrokeWidth(1.0F);
            tp.setStyle(Paint.Style.FILL_AND_STROKE);
        }
    }
}

CustomHtmlTagHandler作为Html.fromHtml的自定义标签的入口,负责分发匹配到的标签。

public class CustomHtmlTagHandler implements Html.TagHandler, ContentHandler {
    private static final String TAG = "CustomHtmlTagHandler";
    private final String CUSTOM_TAG = "custom";
    private XMLReader originXmlReader;
    private ContentHandler originContentHandler;
    private Editable originEditable;
    private int count;
    private final ArraySet<String> ORIGIN_TAGS = new ArraySet<>(Arrays.asList(
            "br", "p", "ul", "li", "div", "span", "strong", "b", "em", "cite", "dfn", "i",
            "big", "small", "font", "blockquote", "tt", "a", "u", "del", "s", "strike",
            "sup", "sub", "h1", "h2", "h3", "h4", "h5", "h6", "img"
    ));

    private ArrayMap<String, BaseHtmlTag> tagMaps = new ArrayMap<>();

    public void registerTag(String tag, BaseHtmlTag htmlTag) {
        tagMaps.put(tag.toLowerCase(), htmlTag);
    }

    public BaseHtmlTag removeTag(String tag) {
        tag = tag.toLowerCase();
        if (tagMaps.containsKey(tag)) {
            return tagMaps.remove(tag);
        }
        return null;
    }

    @Override
    public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
        if (opening) {
            startHandleTag(tag.toLowerCase(), output, xmlReader);
        } else {
            endHandleTag(tag.toLowerCase(), output, xmlReader);
        }
    }

    private void startHandleTag(String tag, Editable output, XMLReader xmlReader) {
        switch (tag) {
            case CUSTOM_TAG:
                if (originContentHandler == null) {
                    originContentHandler = xmlReader.getContentHandler();
                    originXmlReader = xmlReader;
                    xmlReader.setContentHandler(this);
                    originEditable = output;
                }
                count++;
                break;
            default:
        }
    }

    private void endHandleTag(String tag, Editable output, XMLReader xmlReader) {
        switch (tag) {
            case CUSTOM_TAG:
                count--;
                if (count == 0) {
                    for (String key : tagMaps.keySet()) {
                        tagMaps.get(key).finishHandleTag(originEditable);
                    }
                    originXmlReader.setContentHandler(originContentHandler);
                    originXmlReader = null;
                    originContentHandler = null;
                    originEditable = null;
                }
                break;
            default:
        }
    }

    @Override
    public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {
        //忽略大小写
        localName = localName.toLowerCase();
        if (localName.equalsIgnoreCase(CUSTOM_TAG)) {
            handleTag(true, localName, originEditable, originXmlReader);
        } else if (canHandleTag(localName)) {
            tagMaps.get(localName).startHandleTag(originEditable, atts);
        } else if (ORIGIN_TAGS.contains(localName)) {
            originContentHandler.startElement(uri, localName, qName, atts);
        } else {
            Log.e(TAG, "startElement: <" + localName + ">标签不可被解析");
        }
    }

    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
        //忽略大小写
        localName = localName.toLowerCase();
        if (localName.equalsIgnoreCase(CUSTOM_TAG)) {
            handleTag(false, localName, originEditable, originXmlReader);
        } else if (canHandleTag(localName)) {
            tagMaps.get(localName).endHandleTag(originEditable);
        } else if (ORIGIN_TAGS.contains(localName)) {
            originContentHandler.endElement(uri, localName, qName);
        } else {
            Log.e(TAG, "endElement: </" + localName + ">标签不可被解析");
        }
    }

    public boolean canHandleTag(String tagName) {
        if (!tagMaps.containsKey(tagName)) {
            return false;
        }
        BaseHtmlTag baseHtmlTag = tagMaps.get(tagName);
        return baseHtmlTag != null;
    }

    @Override
    public void characters(char[] ch, int start, int length) throws SAXException {
        originContentHandler.characters(ch, start, length);
    }

    @Override
    public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
        originContentHandler.ignorableWhitespace(ch, start, length);
    }

    @Override
    public void processingInstruction(String target, String data) throws SAXException {
        originContentHandler.processingInstruction(target, data);
    }

    @Override
    public void skippedEntity(String name) throws SAXException {
        originContentHandler.skippedEntity(name);
    }

    @Override
    public void setDocumentLocator(Locator locator) {
        originContentHandler.setDocumentLocator(locator);
    }

    @Override
    public void startDocument() throws SAXException {
        originContentHandler.startDocument();
    }

    @Override
    public void endDocument() throws SAXException {
        originContentHandler.endDocument();
    }

    @Override
    public void startPrefixMapping(String prefix, String uri) throws SAXException {
        originContentHandler.startPrefixMapping(prefix, uri);
    }

    @Override
    public void endPrefixMapping(String prefix) throws SAXException {
        originContentHandler.endPrefixMapping(prefix);
    }
}

具体使用
将我们匹配的自定义标签注册进TagHandler中保存的Map。我们这次直接重写的span标签,当然你任意随便命名也没有关系。

public static CharSequence fromHtml(String htmlStr) {
        if (TextUtils.isEmpty(htmlStr)) {
            return "";
        }
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                CustomHtmlTagHandler tagHandler = new CustomHtmlTagHandler();
                tagHandler.registerTag(CustomSpanTag.SPAN, new CustomSpanTag());
                return Html.fromHtml(htmlStr, Html.FROM_HTML_MODE_LEGACY, null, tagHandler);
            } else {
                return Html.fromHtml(htmlStr);
            }

        } catch (Exception ignore) {

        }
        return htmlStr;
    }

简单分析

Html.fromHtml(String source, int flags, ImageGetter imageGetter,TagHandler tagHandler)
return converter.convert();

public Spanned convert() {
        //xmlReader设置ContentHandler
        mReader.setContentHandler(this);
        try {
            //xmlReader解析文本
            mReader.parse(new InputSource(new StringReader(mSource)));
        } catch (IOException e) {
            // We are reading from a string. There should not be IO problems.
            throw new RuntimeException(e);
        } catch (SAXException e) {
            // TagSoup doesn't throw parse exceptions.
            throw new RuntimeException(e);
        }
        ...

xmlReader.parse触发startElement,调用handleStartTag

private void handleStartTag(String tag, Attributes attributes) {
        if (tag.equalsIgnoreCase("br")) {
            // We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
            // so we can safely emit the linebreaks when we handle the close tag.
        } else if (tag.equalsIgnoreCase("p")) {
            startBlockElement(mSpannableStringBuilder, attributes, getMarginParagraph());
            startCssStyle(mSpannableStringBuilder, attributes);
        } else if (tag.equalsIgnoreCase("ul")) {
            startBlockElement(mSpannableStringBuilder, attributes, getMarginList());
        } else if (tag.equalsIgnoreCase("li")) {
            startLi(mSpannableStringBuilder, attributes);
        } else if (tag.equalsIgnoreCase("div")) {
            startBlockElement(mSpannableStringBuilder, attributes, getMarginDiv());
            ...
        } else if (tag.equalsIgnoreCase("img")) {
            startImg(mSpannableStringBuilder, attributes, mImageGetter);
        } else if (mTagHandler != null) {
            mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);
        }
    }

    可以得知Android支持的Html标签种类,并且是要在所有标签都匹配不上的前提才会进入使用自定义TagHandler的判断分支,所以创建一个新的自定义标签是有必要的,无法直接重写现有标签,因为那样无法进入自定义标签处理。
    CustomHtmlTagHandler startHandleTag方法匹配对应的标签,首先提取出原有Html自身的ContentHandler作为私有变量,之后改变xmlReader的contentHandler为CustomHtmlTagHandler,之后标签的匹配就由CustomHtmlTagHandler#startElement触发,如果标签并不是Map保存的标签,则调用原有的ContentHandler处理,否则进入CustomSpanTag处理。CustomSpanTag存在两个变量spanStartIndexStack、stashSpanStyleStack分别保存span的起始位置和span的起点终点的对象。CustomSpanTag#startHandleTag方法记录每个头标签在整段文案中的位置保存到spanStartIndexStack以及Editable对象添加span标记。由于使用的是栈对象保存的头标签起始位置,所以每次endHandleTag都可以匹配到当前尾标签对应的头标签,记录下当前Span的起点和终点位置。最后等到匹配上</ custom>自定义标签,触发finishHandleTag,将所有入栈的span对象依次出栈将样式设置到Editable对象上,实现功能。不直接在endHandleTag处理的原因是Html标签的嵌套特点是越里层的标签展示的优先级越高,如果在endHandleTag直接设置样式将导致外层的标签样式覆盖里层的标签样式。
    最后用几个常见的html标签嵌套样式测试功能,均与html的展示样式一致。

public class MainActivity extends AppCompatActivity {
    private TextView tv;
    private Button btn;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv = findViewById(R.id.tv);
        String txt = "<custom><span style=\"color:1111;font-size:50px;background-color:#FFFF00\">测试内容<span style=\"color:#AAFFAF;font-size:25px\"><u>测试内容</u></span></span><font color='#CCFFCC'>网上搜</font></custom>";
        tv.setText(HtmlUtil.fromHtml(txt, this));
        btn = findViewById(R.id.btn);
        String txt2 = "<custom><p>话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分" +
                "店发的,都快疯了接口及打开了辅导费的,f'k'd'j'l'k'f'j'd'f'd</p><p><span style=\"color: " +
                "#ff7e00; font-size: 20px;\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发" +
                "的,都快疯了接口及打开了辅导费的,飞快的将离开房间大幅度。</span><span style=\"color: #27ad9a; font" +
                "-size: 16px;font-weight:bold\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发的," +
                "都快疯了接口及打开了辅导费的,飞快的将离开房间大幅度。</span><br><span style=\"color: #333333; font-size" +
                ": 25px;\">话题故事内容,巴坎布副对戒多家分店。反馈到洛杉矶发动机弗兰克多家分店发的,都快疯了接口及打开了辅导费的,飞快" +
                "的将离开房间大幅度。</span><br></p></custom>";
        String txt3 = "<custom><span>普通样式文本</span><br><span style=\"color: #ff7e00; font-size: 20px;\">黄色文字,字号偏大。</span>" +
                "<span style=\"color: #27ad9a; font-size: 20px;font-weight:bold\">" +
                "后面的绿色文字,字体加粗的。</span><br><del>中间的删除线文本</del></custom>";

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

推荐阅读更多精彩内容