Android 性能优化篇之--复杂listView高效渲染

列表是APP必用功能,Item多了,会使App内存占用升高,于是有了ViewHolder对每个重用Item进行缓存。但是在复杂的数据类型中:新闻、图片、网页链接、视频、视频+文字、文字加图片、转发+文字等等,这种情况下还要添加逻辑去缓存各种类型的View,同样的处理不好,App内存占用过高,列表卡顿,这里我就写写我以前的各种优化心得。

07.jpg
一、ViewHolder原理:重用View和减少Child View查找时间

先看一下BaseAdapter默认重新方法

    @Override
    public int getCount() {
        return 0;
    }

    @Override
    public Object getItem(int position) {
        return null;
    }

    @Override
    public long getItemId(int position) {
        return 0;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        return null;
    }

其中getView是渲染每个Item时进行回调生成View的,方法参数convertView就是ListView传回可以复用的View,当其不为null时,无需重新创建View,可以直接使用convertView,进行数据渲染即可。其原理是当第一次调用时ListView直接将生成的View缓存到一个ArrayList<View>中,当需要时直接从ArrayList中取出即可:

二、多类型Item

多类型Item时,BaseAdapter提供了两个方法用来返回不同类型

    @Override
    //返回view类型数量
    public int getViewTypeCount() {
        return super.getViewTypeCount();
    }

    @Override
    //返回每个Item的类型
    public int getItemViewType(int position) {
        return super.getItemViewType(position);
    }
开发场景

在Android开发中,可能会遇到一个可滚动且布局比较复杂的界面,但它并不是一个纯粹的List,类似如下图:

Paste_Image.png

通常实现方法可以直接用一个ScrollView将所有内容包起来,里面是列表的部分在代码中用动态添加布局的方式实现;或者外层ScrollView,里面列表部分用ListView(或RecyclerView)实现,但这样需要解决滑动冲突问题(有时并不能很好解决)

思路

将整个页面的划分为不同的item,并处理不同的数据模块,使代码更加模块化,直观而且更容易维护。其中HomeAdapter是处理List不同item的适配器,相对于普通适配器多了一个getItemViewType()方法的处理;ImageAdapter 是图片轮播适配器;HomeItem是整个页面的数据模型,包含了所有item的不同数据模型,接收到网络数据时需要对数据加工再设置到HomeItem,然后根据ItemType 作为不同item类型的判断,再根据不同item获取对应的字段;各个item的数据处理是在单独一个ViewHolder上处理

public class HomeAdapter extends BaseAdapter{
    private Context context;
    private List<HomeItem> homeItemList;
    private final static int SIGN_MALL=0;
    private final static int TAG=1;
    private final static int SPECIAL=2;
    private final static int AD=3;
    private final static int MENU=4;
    private final static int MEAL_SHOW=5;
    private final static int TALENT_SHOW=6;

    public HomeAdapter(Context context, List<HomeItem> homeItemList){
        this.context=context;
        this.homeItemList=homeItemList;
    }

    @Override
    public int getCount(){
        return homeItemList.size();          //头部4个,广告位3个
    }

    @Override
    public Object getItem(int position){
        return homeItemList== null ? null : homeItemList.get(position);
    }

    @Override
    public long getItemId(int position){
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup viewGroup){
        HomeItem homeItem=homeItemList.get(position);
        LayoutInflater inflater=LayoutInflater.from(context);
        SignMallHolder signMallHolder;
        TagHolder tagHolder;
        SpecialHolder specialHolder;
        MenuHolder menuHolder;
        AdHolder adHolder;
        MealShowHolder mealShowHolder;
        TalentShowHolder talentShowHolder;
        int type=homeItem.getItemType().getValue();
        switch(type){
            case SIGN_MALL:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_sign_mall,null);
                    signMallHolder=new SignMallHolder(convertView);
                    convertView.setTag(signMallHolder);
                }else{
                    signMallHolder=(SignMallHolder)convertView.getTag();
                }
                break;
            case TAG:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_tag,null);
                    tagHolder=new TagHolder(convertView);
                    convertView.setTag(tagHolder);
                }else{
                    tagHolder=(TagHolder)convertView.getTag();
                }
                tagHolder.refreshUI(homeItem);
                break;
            case SPECIAL:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_special,null);
                    specialHolder=new SpecialHolder(convertView);
                    convertView.setTag(specialHolder);
                }else{
                    specialHolder=(SpecialHolder)convertView.getTag();
                }
                specialHolder.refreshUI(homeItem);
                break;
            case AD:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_ad,null);
                    adHolder=new AdHolder(context,convertView);
                    convertView.setTag(adHolder);
                }else{
                    adHolder=(AdHolder)convertView.getTag();
                }
                adHolder.setViewPager(homeItem);
                break;
            case MENU:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_menu,null);
                    menuHolder=new MenuHolder(convertView);
                    convertView.setTag(menuHolder);
                }else{
                    menuHolder=(MenuHolder)convertView.getTag();
                }
                menuHolder.refreshUI(homeItem);
                break;
            case MEAL_SHOW:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_meal_show,null);
                    mealShowHolder=new MealShowHolder(context,convertView);
                    convertView.setTag(mealShowHolder);
                }else{
                    mealShowHolder=(MealShowHolder)convertView.getTag();
                }
                mealShowHolder.setViewPager(homeItem);
                break;
            case TALENT_SHOW:
                if(convertView==null){
                    convertView=inflater.inflate(R.layout.view_home_talent,null);
                    talentShowHolder=new TalentShowHolder(context,convertView);
                    convertView.setTag(talentShowHolder);
                }else{
                    talentShowHolder=(TalentShowHolder)convertView.getTag();
                }
                talentShowHolder.initView(homeItem);
                break;
        }
        return convertView;
    }

    @Override
    public int getItemViewType(int position){
        if (homeItemList!= null && position < homeItemList.size()) {
            return homeItemList.get(position).getItemType().getValue();
        }
        return super.getItemViewType(position);
    }

    @Override
    public int getViewTypeCount(){
        return 7;
    }
}
public class ImageAdapter extends PagerAdapter{
    private List<ImageView> imgList=new ArrayList<ImageView>();

    public ImageAdapter(Context context,int[] imgIds) {
        for(int i=0;i<imgIds.length;i++){
            ImageView imageView=new ImageView(context);
            imageView.setImageResource(imgIds[i]);
            imgList.add(imageView);
        }
    }

    public interface OnItemClickListener {
        void onItemClick(View view, int position);
    }

    private OnItemClickListener onItemClickListener;

    public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
        this.onItemClickListener = onItemClickListener;
    }

    @Override
    public int getCount() {
        //设置成最大,使用户看不到边界
        return Integer.MAX_VALUE;
    }

    @Override
    public boolean isViewFromObject(View arg0, Object arg1) {
        return arg0==arg1;
    }

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        //Warning:不要在这里调用removeView
    }

    @Override
    public Object instantiateItem(final ViewGroup container, int position) {
        //对ViewPager页号求模取出View列表中要显示的项
        position %= imgList.size();
        if (position<0){
            position = imgList.size()+position;
        }
        final ImageView view = imgList.get(position);
        //如果View已经在之前添加到了一个父组件,则必须先remove,否则会抛出IllegalStateException。
        ViewParent vp =view.getParent();
        if (vp!=null){
            ViewGroup parent = (ViewGroup)vp;
            parent.removeView(view);
        }
        container.addView(view);
        //add listeners here if necessary
        final int positionId=position;
        if (onItemClickListener != null) {
            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    int pos = positionId;
                    onItemClickListener.onItemClick(view, pos);
                }
            });
        }

        return view;
    }
}

ComplexListViewDemo

三、NotifyDataSetChanged刷新机制

当ListView中的数据发生了改变,我们希望刷新ListView中的View时,我们一般会调用NotifyDataSetChanged来刷新ListView。看一下它的源码:

public void notifyChanged() {   
    synchronized (mObservers) {   
        // 向每一个子View发送onChanged   
        for (int i = mObservers.size() - 1; i >= 0; i--) {   
            mObservers.get(i).onChanged();   
        }   
    }   
} 

发现它针对每一个子View都做了刷新,当然,如果我们的数据都变量还可以理解。但是,一般条件下,我们需要更新的View不多。频繁的调用NotifyDataSetChanged方法,刷新整个界面不合适。这样会把界面上显示的所有item都全部重绘一次,即使只有一个view的内容发生 了变化。
所以,我们可以写一个update的方法,来单独刷新一个View

private void updateView(int itemIndex){   
    intvisiblePosition = yourListView.getFirstVisiblePosition();   
    Viewv = yourListView.getChildAt(itemIndex - visiblePosition);   
         ViewHolder viewHolder =(ViewHolder)v.getTag();   
         if(viewHolder!= null){   
               viewHolder.titleTextView.setText("我更新了");   
         }      
} 
四、多层嵌套列表的优化。

有这样的场景:如QQ列表层级是2,这时候我们会使用ExpandableListView来展示。刚好ExpandableListView还可以收缩和展开。但是ExpandableListView只能展示两层,遇到层级更复杂的数据,就不太适用了。如果遇到多层级的优化,应该怎么做?

1 将数据源层层遍历,添加到列表或者数组中,用ListView展示。

这是非常直观的做法,但仍有局限性。
一是展平后的数据和原始数据失去了关联,如果单纯的展示数据还好,如果需要操作数据,操作起来就比较麻烦。
二是展示复杂数据的场景太多了,经常为特定的场景写类似的代码很麻烦,而且数据不同,写法也不一样,每次都要为类似的事情重新构思,是不是很烦?

解决思路

用一个Node类表示树节点,用来构造树,Node类需要维护一个int类型的数量,表示这个节点包含的子节点数(包括子节点的子节点的子节点..,也就是节点展平后的数量),这样,就能方便地在索引中添加和删除节点的引用了(因为在索引中添加和删除节点需要同时处理其子节点)。

demo代码:https://github.com/jack-cook/HierarchicalViewSample
2480008-b99790ec7222baa0.gif
五、总结
1 观察多种类型的Item,并找出他们的相同点:并拆分可以单独进行复用的模块,这样可以使缓存ArrayList中保存的View数量减少,内存消耗减了不少。
2 尽可能减少布局层次
3 只刷新变化的部分View
4 避免调用addView这样的方法
5 首次加载图片就处理(圆角/缩放等)并缓存在本地
6 只加载当前视图需要的图片,并且在滑动列表的时候停止后台的加载线程,为UI线程空出cpu资源,在停止的时候再请求。
7 尽量使用RecyclerView代替ListView: 每个item内容的变动,listview都需要去调用notifyDataSetChanged来更新全部的item,太浪费性能了。RecycleView可以实现当个item的局部刷新,并且引入了增加和删除的动态效果,在性能上和定制上都有很大的改善。
8 尽量能保证 Adapter 的 hasStableIds() 返回 true 这样在 notifyDataSetChanged() 的时候,如果item内容并没有变化,ListView 将不会重新绘制这个 View,达到优化的目的
9 ListView 中元素避免半透明: 半透明绘制需要大量乘法计算,在滑动时不停重绘会造成大量的计算,在比较差的机子上会比较卡。 在设计上能不半透明就不不半透明。实在要弄就把在滑动的时候把半透明设置成不透明,滑动完再重新设置成半透明。
10 避免在getView方法中做耗时操作。

上面各种优化之后,运行程序,观察前后的效果,内存占用可以减少10~20m,滑动流畅度也提高不少,在低端手机上的效果尤其明显,掉帧明显减少。非常建议有需要的同学尝试。


另外网上性能总结

1,在Activity,Fragment等生命周期方法中和Adapter重写类中,避免有些频繁触发的逻辑方法中存在大量对象分配

2,懒加载和缓存机制。访问网络的耗时操作启动一个新线程来做,而不要再UI线程来做,单例最好懒加载,Fragment也最好懒加载

3,UI线程不做耗时操作,耗时操作放在子线程处理

4,布局文件要尽可能的优化,减少布局的解析时间。尽量减少布局的嵌套层次,尽量使用include,merge,ViewStub

5,减少同一时刻的动画执行次数

6,自定义view时,减少onMeasure,onLayout,onDraw等的调用次数,注意避免有些频繁触发的逻辑方法中存在大量对象分配

7,对象引用之后要及时回收

8,减少冗余资源和代码逻辑的使用

9,减少没必要的背景、暂时不显示的View设置为GONE而不是INVISIBLE、自定义View的onDraw方法设置canvas.clipRect()指定绘制区域或通过canvas.quickreject()减少绘制区域等。

10,尽量避免在多次for循环中频繁分配对象

11,避免在自定义View的onDraw()方法中执行复杂的操作及创建对象(譬如Paint的实例化操作不要写在onDraw()方法中等)

12,对于并发下载等类似逻辑的实现尽量避免多次创建线程对象,而是交给线程池处理

13,使用foreach代替for i

14,尽量少的声明全局变量

15,声明全局静态变量,一定要加final声明

16,声明非静态的全局变量,最好不要初始化任何值,在使用到的地方,在进行初始化

17,函数中若干次使用全局变量,应该将全局变量赋值给本地变量,然后直接使用本地变量

18,能用Int,不要使用浮点数

19,能用乘法不用除法

20,尽量避免使用geter和setter方法

21,在Activity的onCreate函数中,尽量做少的事

22,在Activity中声明的静态数组或者静态代码块,重构到单独的一个类里

23,Activity启动后开始进行异步线程的加载,最好delay一下。再开启线程

24,对于存在于集合中的Bean对象,尽可能少的声明变量。能用int 就不要用long.声明的string等复杂变量,最好不要进行初始化

25,使用线程,一定要给它传一个名字,然后需要定义线程的优先级

26,在使用集合的时候,优先选择SparseArray

27,尽量避免使用枚举

28,工具方法尽量写成是静态方法

29,线程间同步尽量使用开销小的同步锁

30,在使用集合类的时候,如果已知数据的规模,在初始化的时候,就设定好默认大小

31,私有内部类访问外部类的私有变量,要将变量修改为包继承权限,在私有内部类中,考虑用包访问权限替代私有访问权限

32,对于开销大的算法,且不止是执行一次的,要使用缓存策略

33,避免在绘制或者解析布局的时候,分配对象。例如onDraw方法

34,不要给布局写无用的参数,例如RelativeLayout,写layout_weight属性

35,尽量减少布局的嵌套层数。例如包含一个ImageView和TextView的线性布局,可以用CompoundDrawable的TextView来代替

36,尽量用Android提供的SparseArray来代替HashMap

37,如果LinearLayout用于嵌套的layout空间计算,它的android:baselineAligned设置为false,可以加速layout计算

38,尽量避免嵌套的使用layout_weight,那样会影响执行效率

39,如果为rootView设置了背景,那么会先用Theme指定的背景绘制一遍,然后才用指定的背景绘制,这叫做"overdraw",可以通过theme的background为null来避免

40,不要有无用的任何资源,代码或者文件

41,一个Activity中使用同一个View.onClickListener()处理所有的业务逻辑

42,数据一定要校验,如用户填写的日期时间数据、电话号码数据等

43,不要随意的使用stingA=StringB+StringC的写法,有大量拼接操作的地方用StringBuilder代替

44,有些能用文件操作的,尽量采用文件操作,文件操作的速度比数据库的操作要快10倍左右

45,避免重复点击和快速点击

46,尽量避免static成员变量引用资源耗费过多的实例,比如Context

47,应用开发中自定义View的时候,交互部分,千万不要写成线程不断刷新界面显示,而是根据TouchListener事件主动触发界面的更新

48,如果ImageView的图片是来自网络,进行异步加载

49,.保证Cursor 占用的内存被及时的释放掉,而不是等待GC来处理。并且 Android明显是倾向于编 程者手动的将Cursor close掉

50,软键盘的弹出控制,不要让其覆盖输入框

51,使用styles,复用样式定义

52,复杂布局使用RelativeLayout

53,自适应屏幕,使用dp替代pix

54,使用animation-list制作动画效果

官网规范

记得关闭启动的服务
当服务中的任务完成后,要记得停止该服务。可以考虑使用 IntentService,因为IntentService 在完成任务后会自动停止。

UI 不可见时释放资源
在 onStop 中关闭网络连接、注销广播接收器、释放传感器等资源;

在 onTrimMemory() 回调方法中监听TRIM_MEMORY_UI_HIDDEN 级别的信号,此时可在 Activity 中释放 UI 使用的资源,大符减少应用占用的内存,从而避免被系统清除出内存。

内存紧张时释放资源
运行中的程序,如果内存紧张,会在 onTrimMemory(int level) 回调方法中接收到以下级别的信号:

TRIM_MEMORY_RUNNING_MODERATE:系统可用内存较低,正在杀掉 LRU缓存中的进程。你的进程正在运行,没有被杀掉的危险。

TRIM_MEMORY_RUNNING_LOW:系统可用内存更加紧张,程序虽然暂没有被杀死的危险,但是应该尽量释放一些资源,以提升系统的性能(这也会直接影响你程序的性能)。

TRIM_MEMORY_RUNNING_CRITICAL:系统内存极度紧张,而LRU缓存中的大部分进程已被杀死,如果仍然无法获得足够的资源的话,接下来会清理掉 LRU 中的所有进程,并且开始杀死一些系统通常会保留的进程,比如后台运行的服务等。

当程序未在运行,保留在 LRU 缓存中时, onTrimMemory(int level) 中会返回以下级别的信号:

TRIM_MEMORY_BACKGROUND:系统可用内存低,而你的程序处在 LRU的顶端,因此暂时不会被杀死,但是此时应释放一些程序再次打开时比较容易恢复的 UI 资源。

TRIM_MEMORY_MODERATE:系统可用内存低,程序处于 LRU的中部位置,如果内存状态得不到缓解,程序会有被杀死的可能。

TRIM_MEMORY_COMPLETE:系统可用内存低,你的程序处于 LRU尾部,如果系统仍然无法回收足够的内存资源,你的程序将首先被杀死。此时应释放无助于恢复程序状态的所有资源。

注:该 API 在版本 14 中加入。旧版本的onLowMemory() 方法,大致相当于 onTrimMemory(int level) 中接收到 TRIM_MEMORY_COMPLETE 级别的信号。

另:尽管系统主要按照 LRU 中顺序来杀进程,不过系统也会考虑程序占用的内存多少,那些占用内存高的进程有更高的可能性会被首先杀死。

确定你的程序应该占用多少内存
可以通过 getMemoryClass()来获取你的程序被分配的可用内存,以 M 为单位。

你可以通过在 <application> 标签下将 largeHeap 属性设为 true 来要求更多的内存,这时通过 getLargeMemoryClass() 方法来获取可用内存。

大部分应用程序不需要使用此功能,因此使用该标签前,确认你的程序是否真的需要更多内存。使用更多内存会对整个系统的性能产生影响,而且当程序进入 LRU时会更容易首先被系统清理掉。

正确使用 Bipmap,避免浪费内存
如果你的 ImageViwe 的尺寸只有 100 100,那么没有必要将一张 2560 1600 的图片整个加载入内存。

使用 Android提供的优化过的数据结构
如 SparseArray, SparseBooleanArray, LongSparseArray 等,相比 Java 提供的 HashMap,这些结构更节省内存。

始终对内存使用情况保持关注
枚举类型 Enum 会比静态常量占用更多的内存;

Java 中每个类(包括匿名内部类)都占用至少 500字节左右的代码;

每个类的实例会在 RAM 中占用大约 12 ~ 16 字节的内存;

每向 HashMap 中添加一个 Entry 时,新生成的 Entry 占用大约 32 个字节。

谨慎使用第三方类库
这些外部类库可能原先并非针对移动平台,因此未进行过优化,在使用前应注意。另外尽量不要因为一两个特性而使用一个体积很大的类库。

使用 ProGuard
使用 ProGuard 移除无用的代码并重命名一些类、字段、方法等,使你的代码更紧凑,节省内存空间。

使用 zipalign
zipaligned 对最终打包的 apk进行字节对齐。

注:Google Play 不接受未对齐过的 apk。

分析内存使用情况
如果已经获得一个相对稳定的版本,应对程序整个生命周期的内存使用状况进行分析。

使用多个进程
如果程序需要执行大量的后台工作,可考虑将程序分为两个进程,一个进程负责 UI,另一个进程负责后台任务。比如音乐播放器。

代码示例:

<serviceandroid:name=".PlaybackService"android:process=":background"/>

android:process属性的值以“:”开头,名称可任意选取。

在决定是否使用多进程前,应注意,一个不执行任何任务的空进程至少也要占用 1.4 MB内存。

另外要注意进程的相互依赖性,比如如果将 ContentProvider 放在 UI 进程中,而后台任务进程也需要调用 ContentProvider,就会导致 UI 进程一直保留在 RAM 中。

参考文章

Android ListView工作原理完全解析,带你从源码的角度彻底理解,androidlistviewhttp://www.android100.org/html/201507/26/168809.htmlhttp://android.jobbole.com/81834/
MultiTypeDemo

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

推荐阅读更多精彩内容