手寫動態(tài)換膚

前言:

換膚油挥,目前包括靜態(tài)換膚和動態(tài)換膚
靜態(tài)換膚

這種換膚的方式,也就是我們所說的內(nèi)置換膚,就是在APP內(nèi)部放置多套相同的資源。進行資源的切換款熬。

這種換膚的方式有很多缺點深寥,比如, 靈活性差贤牛,只能更換內(nèi)置的資源惋鹅、apk體積太大,在我們的應用Apk中等一般圖片文件能占到apk大小的一半左右。

當然了,這種方式也并不是一無是處, 比如我們的應用內(nèi)盔夜,只是普通的 日夜間模式 的切換负饲,并不需要圖片等的更換堤魁,只是更換顏色,那這樣的方式就很實用。
動態(tài)換膚

適用于大量皮膚返十,用戶選擇下載妥泉,像QQ、網(wǎng)易云音樂這種洞坑。它是將皮膚包下載到本地盲链,皮膚包其實是個APK。

換膚包括替換圖片資源迟杂、布局顏色刽沾、字體、文字顏色排拷、狀態(tài)欄和導航欄顏色侧漓。

動態(tài)換膚步驟包括:

1、采集需要換膚的控件
2监氢、 加載皮膚包
3布蔗、 替換資源
鏈接:http://www.reibang.com/p/eebb8eae5ea1

按照步驟我們試著實現(xiàn)一下動態(tài)換膚的效果

1、采集需要的換膚控件浪腐,比如(android.widget.TextView纵揍,android.widget.ImageView)

通過采集支持換膚的控件以及屬性,然后保存到集合中议街,待遍歷替換
那么怎么采集控件呢泽谨?

我們可以看下setContentView(int id)這個指定布局的方法

  @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);//這里實現(xiàn)view布局的加載
        mOriginalWindowCallback.onContentChanged();
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
 public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            ...
            final String name = parser.getName();
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            ...
            return temp;
    }

可以看到inflate會返回具體的View對象出去,那么我們的關注焦點就放在createViewFromTag中了

    /**
     * Creates a view from a tag name using the supplied attribute set.
     * <p>
     * <strong>Note:</strong> Default visibility so the BridgeInflater can
     * override it.
     *
     * @param parent the parent view, used to inflate layout params
     * @param name the name of the XML tag used to define the view
     * @param context the inflation context for the view, typically the
     *                {@code parent} or base layout inflater context
     * @param attrs the attribute set for the XML tag used to define the view
     * @param ignoreThemeAttr {@code true} to ignore the {@code android:theme}
     *                        attribute (if set) for the view being inflated,
     *                        {@code false} otherwise
     */
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        try {
            View view;
            if (mFactory2 != null) {
                view = mFactory2.onCreateView(parent, name, context, attrs);
            } else if (mFactory != null) {
                view = mFactory.onCreateView(name, context, attrs);
            } else {
                view = null;
            }
            return view;
        } catch (Exception e) {
        }
    }

為了方便特漩,這里代碼直接轉(zhuǎn)至 http://www.reibang.com/p/eebb8eae5ea1
inflate最終調(diào)用了createViewFromTag方法來創(chuàng)建View,在這之中用到了factory吧雹,如果factory存在就用factory創(chuàng)建對象,如果不存在就由系統(tǒng)自己去創(chuàng)建拾稳。我們只需要實現(xiàn)我們的Factory然后設置給mFactory2就可以采集到所有的View了吮炕。

到目前為止我們只知道要去自定義一個factory,那么這個東西到底是什么呢? 上面我們通過源碼簡單的了解了访得,如果factory存在就用factory創(chuàng)建對象龙亲,如果不存在就由系統(tǒng)自己去創(chuàng)建view。
那么我們就重寫一個factory

public class SkinLayoutInflateFactory implements LayoutInflater.Factory2 {
    private static final String TAG = SkinLayoutInflateFactory.class.getSimpleName();
    private Activity activity;

    public SkinLayoutInflateFactory(Activity mActivity) {
        this.activity = mActivity;
    }

    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    //do sth
        return null;
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {

        return null;
    }
}

可以看到方法onCreateView是創(chuàng)建view的方法悍抑,其中AttributeSet表示屬性集鳄炉。為了方便管理以及盡可能減少代碼的入侵,我們使用ActivityLifecycleCallbacks搜骡。

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
    private static final String TAG = SkinActivityLifecycle.class.getSimpleName();
    private Map<Activity,SkinLayoutInflateFactory> factoryMap = new HashMap<>();

    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        LayoutInflater layoutInflater = LayoutInflater.from(activity);

        try {
            //Android 布局加載器 使用 mFactorySet 標記是否設置過Factory
            //如設置過拋出一次
            //設置 mFactorySet 標簽為false
            Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
            mFactorySet.setAccessible(true);
            mFactorySet.setBoolean(layoutInflater,false);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        Log.d(TAG, "onActivityCreated: ");
        //使用factory2 設置布局加載工程
        SkinLayoutInflateFactory skinLayoutInflaterFactory = new SkinLayoutInflateFactory(activity);
        LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
        factoryMap.put(activity,skinLayoutInflaterFactory);

    }

    @Override
    public void onActivityStarted(@NonNull Activity activity) {

    }

    @Override
    public void onActivityResumed(@NonNull Activity activity) {

    }

    @Override
    public void onActivityPaused(@NonNull Activity activity) {

    }

    @Override
    public void onActivityStopped(@NonNull Activity activity) {

    }

    @Override
    public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {

    }

    @Override
    public void onActivityDestroyed(@NonNull Activity activity) {
        factoryMap.remove(activity);
    }
}

現(xiàn)在演示下SkinLayoutInflateFactory的使用拂盯,我們在SkinLayoutInflateFactory的onCreateView打印AttributeSet屬性

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        Log.d(TAG, "onCreateView: name "+ name);
        int attributeCount = attrs.getAttributeCount();
        for (int i = 0; i < attributeCount; i++) {
            Log.d(TAG, "onCreateView: "+attrs.getAttributeName(i)+"---"+attrs.getAttributeValue(i));
        }

        return null;
    }
name androidx.constraintlayout.widget.ConstraintLayout
layout_width----1
layout_height----1
name TextView
layout_width----2
layout_height----2
text---Hello World!
layout_constraintBottom_toBottomOf---0
layout_constraintLeft_toLeftOf---0
layout_constraintRight_toRightOf---0
layout_constraintTop_toTopOf---0

對應的布局

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

可以看到布局和屬性是一一對應的,那么現(xiàn)在我們玩一個好玩的東西记靡,把textview換成button

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, 
                @NonNull Context context, @NonNull AttributeSet attrs) {
       switch (name){
           case "TextView":
               Button button = new Button(context);
               button.setText("替換文本");
               button.setTextColor(Color.RED);

               return button;
       }

        return null;
    }
圖片.png

通過以上的實驗我們就可以簡單的理解谈竿,自定義factory就可以獲取到需要換膚的控件了团驱,但是控件還包含了自定義控件,比如com.xxx.widget.MyView, 或者Android系統(tǒng)的控件空凸,剩下的就是只有標簽的比如ImageView的控件嚎花,但是只帶標簽的控件需要補全包名,android.widget.ImageView.才能轉(zhuǎn)成View
通過以上分析我們先完成控件篩選

private static final String[] mClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };
  @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
      //獲取只帶標簽的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (null != view) {

            L.e(String.format("檢查[%s]:" + name, context.getClass().getName()));

        }
        return view;
    }

 private View createViewFromTag(String name, Context context, AttributeSet
            attrs) {
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (-1 != name.indexOf('.')) {
            return null;
        }
        for (int i = 0; i < mClassPrefixList.length; i++) {
            return createView(mClassPrefixList[i] +
                    name, context, attrs);

        }
        return null;
    }

    private View createView(String name, Context context, AttributeSet
            attrs) {
        L.e(String.format("name= [%s]:",name));
        Constructor<? extends View> constructor = findConstructor(context, name);
        try {
            return constructor.newInstance(context, attrs);
        } catch (Exception e) {
        }
        return null;
    }

    private Constructor<? extends View> findConstructor(Context context, String name) {
        Constructor<? extends View> constructor = mConstructorMap.get(name);
        if (null == constructor) {
            try {
                Class<? extends View> clazz = context.getClassLoader().loadClass
                        (name).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);


                L.e(String.format("constructor name = [%s]",constructor.getName()));

                mConstructorMap.put(name, constructor);
            } catch (Exception e) {
            }
        }
        return constructor;
    }

代碼分析

如果name包含有 " . "則說明可能是自定義控件(比如com.wzw.MyView)或者系統(tǒng)控件(android.support.v4.view.ViewPager)否則需要添加完整包名呀洲。出現(xiàn)異常比如循環(huán)中可能出現(xiàn)
android.view.TextView則拋出異常不處理紊选,最后通過反射的原理轉(zhuǎn)換成view。

        //獲取只帶標簽的控件道逗,添加包名轉(zhuǎn)成view
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
    
        return view;
2兵罢、加載皮膚包

實際上皮膚包也是一個Android文件,不管你將后綴名改為什么滓窍,只要是創(chuàng)建出的Android 項目就會存在res包以及底下的文件卖词,利用這個特點,我們可以將想要替換的資源文件放到對應的包中贰您,那么換膚的時候就只要去加載皮膚包中的對應的圖片就可以了坏平。
好了問題來了,比如我們需要替換某個ImageView的圖片锦亦,那么正常的做法是要先加載到皮膚包中的資源文件,context.getResource.getDrawable(xxxx)令境;那么請問杠园,如果我們這么寫能加載到資源嗎?
答案是否定的舔庶,因為context是屬于當前app的上下文抛蚁,并不能加載插件app的資源文件。那么要怎么獲取資源并設置呢惕橙?
我們先來看下Resource的構造方法:

@Deprecated
    public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }

先看后面兩個參數(shù)分別表示屏幕相關參數(shù)和設備信息瞧甩。這兩個參數(shù)可以使用本app的context提供,重點看AssetManager
AssetsManager 直接對接Android系統(tǒng)底層弥鹦。
Assets Manager有一個方法:addAssetPath(String path) 方法肚逸,app啟動的時候會把當前的APK路徑傳遞進去,然后我們就可以訪問資源了彬坏。
根據(jù)思路我們將插件apk放在app項目的assets文件夾下朦促,然后寫入緩存文件中提供訪問。接著我們必須創(chuàng)建出resource和assetmanger栓始,以及dexclassloader(可訪問未安裝apk類)

 //獲取assetManager
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager,dataFile.getAbsolutePath());

            //獲取到插件resource
            Resources appResource = application.getResources();
            Resources skinResource = new Resources(assetManager,appResource.getDisplayMetrics(),appResource.getConfiguration());

            //獲取插件進程名
            PackageManager packageManager = application.getPackageManager();
            PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(dataFile.getAbsolutePath(), PackageManager.GET_ACTIVITIES);
            String packageName = packageArchiveInfo.applicationInfo.packageName;

            //獲取插件dexClassloader
            File optimizedDirectory = application.getDir("dex", Context.MODE_PRIVATE);
            DexClassLoader dexClassLoader = new DexClassLoader(dataFile.getAbsolutePath(),optimizedDirectory.getAbsolutePath(),
                    null,application.getClassLoader());

            SkinResource.getInstance().init(dexClassLoader,skinResource,appResource,packageName);
3务冕、替換資源

第一步我們以及篩選除了需要替換的控件以及控件的屬性,為了簡單說明幻赚,目前我們制作Imageview的background的替換以及Textview的文本顏色替換禀忆。

public void setDrawable(ImageView imageView, String drawableName) {
        try {
            Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$mipmap");
            Field field = aClass.getField(drawableName);
            //獲取到圖片的id
            int anInt = field.getInt(R.id.class);
            imageView.setImageDrawable(skinResources.getDrawable(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public void setTextColor(TextView textView, String colorName) {
        try {
            Class<?> aClass = dexClassLoader.loadClass(skinPackageName + ".R$color");
            Field field = aClass.getField(colorName);
            Log.d(TAG, "setTextColor: "+field.getName());
            int anInt = field.getInt(R.id.class);
            textView.setTextColor(skinResources.getColor(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

代碼說明:
通過dexClassloader后去mipmap和color資源文件臊旭,其中兩個方法中的drawableName和colorName分別表示資源名,比如mipmap中保存了一張 ic_bg.png箩退,那么drawableName 就等于“ ic_bg”。
接著通過field獲取同名資源文件的資源id,最后通過插件的resource對象設置乏德,達到替換的效果。
喊括。
那么現(xiàn)在的重點就是怎么獲取drawableName(colorName)。
在第一步的時候我們收集了xml中的控件以及屬性

   @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        //獲取只帶標簽的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 則不是SDK中的view 可能是自定義view包括support庫中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (view != null) {
            attribute.load(view, attrs);
        }
        return view;
    }

我們繼續(xù)看 attribute.load(view, attrs);方法

public void load(View view, AttributeSet attrs) {
        List<SkinPain> skinPains = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //獲取屬性名字
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                //獲取屬性對應的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }

                int resId = 0;
                //判斷前綴字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系統(tǒng)屬性值

                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId, view.getClass().getName());
                    skinPains.add(skinPain);
                }
            }
        }
        if (!skinPains.isEmpty() || view instanceof TextView) {
            SkinView skinView = new SkinView(view, skinPains);
//            skinView.applySkin();
            skinViews.add(skinView);
        }
    }

其實以上代碼還是通過attrs這個類獲取到控件名字郑什,控件的屬性府喳,以及通過attrs.getAttributeValue(int i);獲取到了設置的資源名字蘑拯,比如?6453213432,通過字符串截取,就可以獲取到當前設置的resId申窘,緊接利用resId,通過Resources.getResourceEntryName(resId)方法我們就可以獲取到resName了弯蚜。

   public String getResName(int resId) {
        //R.drawable.ic_launcher
        return appResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark

    }

由于換膚的前提是宿主設置的資源名和插件的資源名一致,所以通過獲取到宿主設置的資源名我們就可以獲取到插件的資源名從而設置進去剃法。
本例子我們使用了Observable觀察者,當點擊按鈕加載資源的時候就通知被觀察設置插件中的同名資源從而達到了換膚的效果收厨。

總結

該例子只做了ImageView背景替換和TextView文本顏色替換优构,當然還有類似自定義控件的替換,文本字體替換等钦椭,這里就不做一一解釋。因為我們只要懂得核心就可以舉一反三玉凯。總體的步驟就是:
1捎拯、采集需要換膚的控件
2、 加載皮膚包
3署照、 替換資源
他的核心還是離不開Android的插件化
Android插件化從技術上來說就是如何啟動未安裝的apk(主要是四大組件)里面的類,主要問題涉及如何加載類没隘、如何加載資源禁荸、如何管理組件生命周期。
感興趣的可以參考 https://zhuanlan.zhihu.com/p/136001039

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末瑰妄,一起剝皮案震驚了整個濱河市映砖,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌邑退,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,277評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蜈七,死亡現(xiàn)場離奇詭異乓土,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,689評論 3 393
  • 文/潘曉璐 我一進店門梯轻,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人彬伦,你說我怎么就攤上這事伊诵。” “怎么了曹宴?”我有些...
    開封第一講書人閱讀 163,624評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長笛坦。 經(jīng)常有香客問我苔巨,道長废离,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,356評論 1 293
  • 正文 為了忘掉前任悼尾,我火速辦了婚禮,結果婚禮上闺魏,老公的妹妹穿的比我還像新娘窥妇。我一直安慰自己,他們只是感情好活翩,可當我...
    茶點故事閱讀 67,402評論 6 392
  • 文/花漫 我一把揭開白布材泄。 她就那樣靜靜地躺著,像睡著了一般峦树。 火紅的嫁衣襯著肌膚如雪旦事。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,292評論 1 301
  • 那天姐浮,我揣著相機與錄音,去河邊找鬼肾扰。 笑死蛋逾,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的偷拔。 我是一名探鬼主播,決...
    沈念sama閱讀 40,135評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼悦污,長吁一口氣:“原來是場噩夢啊……” “哼钉蒲!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起顷啼,我...
    開封第一講書人閱讀 38,992評論 0 275
  • 序言:老撾萬榮一對情侶失蹤钙蒙,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后躬厌,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,429評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡鸿捧,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,636評論 3 334
  • 正文 我和宋清朗相戀三年匙奴,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片泼菌。...
    茶點故事閱讀 39,785評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡啦租,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出笋颤,到底是詐尸還是另有隱情内地,我是刑警寧澤赋除,帶...
    沈念sama閱讀 35,492評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站荆针,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏航背。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,092評論 3 328
  • 文/蒙蒙 一箕肃、第九天 我趴在偏房一處隱蔽的房頂上張望今魔。 院中可真熱鬧,春花似錦吟宦、人聲如沸涩维。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,723評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽垄分。三九已至,卻和暖如春薄湿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背豺瘤。 一陣腳步聲響...
    開封第一講書人閱讀 32,858評論 1 269
  • 我被黑心中介騙來泰國打工坐求, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人桥嗤。 一個月前我還...
    沈念sama閱讀 47,891評論 2 370
  • 正文 我出身青樓泛领,卻偏偏與公主長得像,于是被迫代替她去往敵國和親渊鞋。 傳聞我的和親對象是個殘疾皇子瞧挤,可洞房花燭夜當晚...
    茶點故事閱讀 44,713評論 2 354

推薦閱讀更多精彩內(nèi)容