CoordinatorLayout 分析

CoordinatorLayout

一、實現滑動 RecyclerView 實現 FAB 以及 Toolbar 的顯示和隱藏

(一)傳統實現思路:

  1. 監聽 RecyclerView 的滑動
  2. 根據滑動距離及狀態執行顯示和隱藏的動畫

(二)CoordinatorLayout 方式

二、CoordinatorLayout

CoordinatorLayout 繼承自 ViewGroup,通過協調并調度里面的子控件或者子布局來實現觸摸 (一般指滑動) 產生一些相關的動畫效果。可以通過設置 View 的 Behavior 屬性來實現觸摸的動畫調度。

1. CoordinatorLayout 中使用 SnackBar

可以解決 SnackBar 出現時遮擋 FloatingActionButton 的情況,其 Behavior 實現類是 FloatingActionButton.Behavior

2. AppBarLayout

AppBarLayout 繼承了 LinearLayout,并且是垂直方向,里面可以放多個 View, 在 CoordinatorLayout 中的 Scrolling View 滑動時,AppBarLayout 中的 View 可以實現多種隱藏、顯示效果。

Scrolling 是指:RecyclerView、NestedScrollView 等實現了 NestedScrollChild 接口的類

CoordinatorLayout 中,使用 AppBarLayout 包裹 Toolbar,再為 Scrolling View 設置 app:layout_behavior 屬性為 appbar_scrolling_view_behavior ,設置 Toolbar 的 layout_scrollFlags 屬性的值為 scroll,就實現了 RecyclerView 滑動時 Toolbar 自動的顯示隱藏的效果。為 layout_scrollFlags 參數設置不同的值就可以實現不同的效果。

  1. scroll: 里面所有的子控件想要滑出屏幕的時候都需要設置這個 Flag,里面沒有設置這個 Flag 的 View 都將被固定在頂部,效果為:隱藏的時候,先整體向上滾動,直到 AppBarLayout 完全隱藏,再開始滾動 Scrolling View;顯示的時候,直到 Scrolling View 頂部完全出現后,再開始滾動整體直到 AppBarLayout 完全顯示。

  2. enterAlways ,快速返回,設置這個屬性后,與 scroll 類似,只不過向下滾動先顯示子控件到完全,再滾動 Scrolling View了,需要與 scroll 配合使用

  3. enterAlwaysCollapsed: 需要和 enterAlways 一起使用(scroll|enterAlways|enterAlwaysCollapsed),還需要為子控件設置 minHight 屬性,和 enterAlways 不一樣的是,不會顯示子控件到完全再滾動 Scrolling View,而是先滾動 子控件到最小高度,再滾動 Scrolling View,最后再滾動 AppBarLayout 到完全顯示。

  4. exitUnitilCollapsed: 定義了子控件 消失的規則。發生向上滾動事件時,子控件向上滾動退出直至最小高度(minHeight),然后 Scrolling View 開始滾動。也就是,子控件不會完全退出屏幕

  5. snap: 定義了是子控件滾動比例的一個吸附效果。也就是說,子控件不會存在局部顯示的情況,滾動子控件的部分高度,當我們松開手指時,子控件要么向上全部滾出屏幕,要么向下全部滾進屏幕,有點類似 ViewPager 的左右滑動。而向上還是向下滑動取決于顯示和隱藏部分的比例,顯示的多就會向下全部顯示,隱藏的多就會向上完全隱藏。

可以使用 CoordinatorLayout + View + Toolbar + TabLayout + ViewPager(內容可垂直滑動) 組合實現的 TabLayout 貼頂效果。

3. CollapsingToolbarLayout

可以實現 Toolbar 的折疊效果,使用 AppBarLayout 嵌套 CollapsingToolbarLayout,再使用 CollapsingToolbarLayout 嵌套 Toolbar,

注意:

  1. AppBarLayout 需要設置固定的高度,實現折疊效果時要大于 Toolbar 高度

  2. CollapsingToolbarLayout 設置 height 為占滿父布局

  3. CollapsingToolbarLayout 為 AppBarLayout 的直接子控件,因為需要折疊 CollapsingToolbarLayout ,所以需要為 CollapsingToolbarLayout 設置 layout_scrollFlags 屬性設置為可隱藏

  4. 在 CollapsingToolbarLayout 中添加其他 View 放在 Toolbar 上面,并且為這個 View 和 Toolbar 設置 layout_collapseMode 屬性

    • parallax: 效果為視差模式,折疊的時候會有視差效果。可以搭配 layout_collapseParallaxMultiplier 屬性,值的區間為 0 - 1,用來設置視差效果的明顯程度,為 1 時候的表現為 CollapsingToolbarLayout 其余部分被折疊后再折疊 toolbar 也就是無視差效果,0 的時候為先折疊 toolbar 再折疊其余部分也就是視差效果最明顯
    • none:沒有任何效果,往上滑動的時候 Toolbar 會被首先退出去
    • pin:固定模式,toolbar 設置該模式,在滑動時會有一個融合效果,融合完成后 toolbar 會固定在頂端
  5. CollapsingToolbarLayout 可以設置 expandedTitleMargin 屬性控制展開時的文字 margin,collapsedTitleTextAppearance 屬性控制折疊時的文字樣式等

  6. contentScrim 是一個顏色,內容部分的沉浸式效果,可以讓 Toolbar 和其他 View 有一個漸變的過渡效果,statusBarScrim 是為狀態欄設置顏色(5.0+ 才有效果)

  7. 還有其他很多屬性設置不同的效果

4. Behavior (CoordinatorLayout.Behavior) 需配合 CoordinatorLayout 使用

四、Behavior + CoordinatorLayout

Behavior 可以看作一個橋梁或者監聽者,實現包裹在 CoordinatorLayout 里面的所有子控件或者容器產生聯動效果

自定義 Behavior 的兩種效果,繼承 CoordinatorLayout.Behavior

為觀察者設置 Behavior,這樣被觀察者中 Behavior 監聽的狀態發生變化時,Behavior 中的對應方法會被回調。

1. 某個 View 需要監聽另一個 View 的狀態(比如:位置、大小、顯示狀態)

需要重寫方法:layoutDependsOn,onDependentViewChanged

  1. layoutDependsOn

用來決定需要監聽哪些控件或者容器的狀態,參數 parent 是 CoordinatorLayout, child 指定了當前 behavior 的需要監其他 View 的觀察者,dependency 是被觀察的 View;返回值是 dependency 是否是 child 需要監聽的 View (通過 id 或者 tag 等方式來判斷) 以及是否是觀察者需要監聽的狀態發生的改變

  1. onDependentViewChanged

當被監聽的 View 發生改變時回調,可以在此方法里面做一些相應的聯動動畫等效果

例如 AppBarLayout 與 RecyclerView 的聯動,就是 AppBarLayout 監聽了 RecyclerView 的滑動。可以根據這個規則自定義更多的效果!!!

2. 某個 View 需要監聽 CoordinatorLayout 里面所有控件的滑動狀態 ( Google 專門提供的對滑動效果的處理,主要是計算了滑動的距離等屬性 )

能被 CoordinatorLayout 捕獲到的滑動狀態的控件有:RecyclerView、NestedScrollView 等實現了 NestedScrollChild 接口的類

需要重寫的方法:onStartNestedScroll, onNestedPreScroll, onNestedScroll、onNestedFling 等

onNestedFling 方法是 Fling 狀態時回調,其中可以通過 NestedScrollView 的 fling 方法直接傳入參數中計算好的速度值進行滑動

五、Behavior 機制的實現原理

分析 Behavior 主要是為了探索 CoordinatorLayout 是如何做到監聽里面子控件的狀態改變并執行 Behavior 中回調方法的過程。在這個過程中我們也可以將 Behavior 中的所有回調方法做一個梳理,明確每一個方法的作用,在自定義 Behavior 時也就更能最高效、穩定的實現我們想要的效果。

1. CoordinatorLayout 中的 LayoutParams 及 Behavior 的實例化

CoordinatorLayout 類中有一個內部類 LayoutParams 繼承了 ViewGroup.MarginLayoutParams,LayoutParams 中保存了 CoordinatorLayout 中子 View 的布局信息。

在 LayoutInflater 的 inflate 方法中,對 ViewGroup 添加 View 時會調用 addView 方法其中為 Child 指定的 LayoutParams 由 generateLayoutParams 方法創建。CoordinatorLayout 重寫了 generateLayoutParams 方法,會創建一個新的 LayoutParams 對象并返回。

// CoordinatorLayout
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}

在 LayoutParams 的創建過程中會解析 View 在 xml 中配置的屬性,除了解析普通 View 的屬性外還包括 AnchorId、Behavior 等,在 Behavior 的解析過程中,會根據配置的 Behavior 屬性的值,先通過規律找到對應的 Behavior 類,然后通過反射創建指定的 Behavior 實例,并將這個實例保存在 LayoutParams 中。如若 Behavior 對象不為空,還會調用其 onAttachedToLayoutParams 方法。

2. Behavoir 的 onMeasureChild、onLayoutChild 方法

在 CoordinatorLayout 的 onMeasure 方法中,在對子 View 進行測量時,如果 View 綁定了 Behavior,會先調用該 Behavior 的 onMeasureChild 方法,由 Behavior 對當前 View 進行自定義的測量并返回是否測量完成,如果 Behavior 測量完成 CoordinatorLayout 將不會測量該 View。

CoordinatorLayout 的 onlayout 方法同 onMeasure 方法,會調用 Behavior 的 onLayoutChild 方法

3. Behavoir 的 onInterceptTouchEvent 和 onTouchEvent 方法

在 CoordinatorLayout 的 onInterceptTouchEvent 方法中,如果子 View 有 Behavior 就會調用該 Behavior 的 onInterceptTouchEvent 方法,也就是在 CoordinatorLayout 將事件分發到子 View 之前,先由 Behavoir 進行攔截判斷,DOWN、UP、CANCLE 時 CoordinatorLayout 的 onInterceptTouchEvent 方法不會使用 Behavoir 的返回結果,其他事件時會使用 Behavoir 的返回結果作為自己的返回結果。如果 Behavoir 攔截事件,還會為 CoordinatorLayout 的 mBehaviorTouchView 屬性賦值為攔截事件的 Behavoir 綁定的 View

這里需要注意,CoordinatorLayout 的 onInterceptTouchEvent 方法的返回值決定了 CoordinatorLayout 是否攔截當前事件,Behavoir 決定攔截也是作用在 CoordinatorLayout 上。

CoordinatorLayout 的 onTouchEvent 中會判斷 mBehaviorTouchView 的值是否為空,不為空時會調用其綁定的 Behavior 的 onTouchEvent 方法,然后返回 Behavoir 的 onTouchEvent 方法的返回值和 CoordinatorLayout 的 super.onTouchEvent 的的返回值進行求或運算后的結果。如果 mBehaviorTouchView 為空則直接返回 CoordinatorLayout 的 super.onTouchEvent 的結果。

4. Behavior 的 layoutDependsOn 、onDependentViewRemoved、 onDependentViewChanged 方法

在 CoordinatorLayout 的 onMeasure 方法中,在對子 View 進行測量之前,調用了一個 prepareChildren 方法,其中通過兩個 List 將 View 間的依賴進行整理


// CoordinatorLayout

// 保存了存在依賴的 View
private final List<View> mDependencySortedChildren = new ArrayList<>();

// DirectedAcyclicGraph 是一個其中無環的圖結構
private final DirectedAcyclicGraph<View> mChildDag = new DirectedAcyclicGraph<>();

private void prepareChildren() {
    mDependencySortedChildren.clear();
    mChildDag.clear();

    for (int i = 0, count = getChildCount(); i < count; i++) {
        final View view = getChildAt(i);

        final LayoutParams lp = getResolvedLayoutParams(view);
        lp.findAnchorView(this, view);

        mChildDag.addNode(view);

        // 遍歷 CoordinatorLayout 中的其他 View,判斷當前 View 是否依賴遍歷到的 View,如果依賴,則將遍歷到的 View 加入 mChildDag 中
        for (int j = 0; j < count; j++) {
            if (j == i) {
                continue;
            }
            final View other = getChildAt(j);
            
            // dependsOn 方法中會調用 lp 中 Behavior 的 layoutDependsOn 來決定是否依賴 other
            if (lp.dependsOn(this, view, other)) {
                if (!mChildDag.contains(other)) {
                    // 如果依賴則將遍歷到的 View 加入 mChildDag 中
                    mChildDag.addNode(other);
                }
                // 為圖添加一條邊
                mChildDag.addEdge(other, view);
            }
        }
    }

    // 將 mChildDag 圖構造成集合然后添加到 mDependencySortedChildren 中
    mDependencySortedChildren.addAll(mChildDag.getSortedList());
    
    // We also need to reverse the result since we want the start of the list to contain
    // Views which have no dependencies, then dependent views after that
    Collections.reverse(mDependencySortedChildren);
}

prepareChildren 方法執行完畢后,mDependencySortedChildren 集合中就保存了有依賴的 View。prepareChildren 方法執行后,CoordinatorLayout 的 onMeasure 方法中會接著調用 ensurePreDrawListener 方法,該方法中會判斷 View 間是否有依賴,如果有則會調用 addPreDrawListener() 方法,如果沒有會調用 removePreDrawListener 方法。

// Coordinatorlayout
void addPreDrawListener() {
    if (mIsAttachedToWindow) {
        // Add the listener
        if (mOnPreDrawListener == null) {
            mOnPreDrawListener = new OnPreDrawListener();
        }
        final ViewTreeObserver vto = getViewTreeObserver();
        vto.addOnPreDrawListener(mOnPreDrawListener);
    }

    // Record that we need the listener regardless of whether or not we're attached.
    // We'll add the real listener when we become attached.
    mNeedsPreDrawListener = true;
}

/**
 * Remove the pre-draw listener if we're attached to a window and mark that we currently
 * do not need it when attached.
 */
void removePreDrawListener() {
    if (mIsAttachedToWindow) {
        if (mOnPreDrawListener != null) {
            final ViewTreeObserver vto = getViewTreeObserver();
            vto.removeOnPreDrawListener(mOnPreDrawListener);
        }
    }
    mNeedsPreDrawListener = false;
}

class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
    @Override
    public boolean onPreDraw() {
        onChildViewsChanged(EVENT_PRE_DRAW);
        return true;
    }
}

addPreDrawListener 方法中是將一個 OnPreDrawListener 對象注冊到了 ViewTreeObserver 中,removePreDrawListener 則是將 OnPreDrawListener 對象從 ViewTreeObserver 中取消注冊。ViewTreeObserver 是管理 View 樹的觀察者,View 發生變化時,ViewTreeObserver 會將變化分發到所有已經注冊的 OnPreDrawListener 中。

如果 View 間有依賴,那么 View 狀態變化時 CoordinatorLayout 的 onChildViewsChanged 就會被調用

// CoordinatorLayout
final void onChildViewsChanged(@DispatchChangeEvent final int type) {
    // ... 其他代碼
    
    // 遍歷 View 將事件分發下去
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (type == EVENT_PRE_DRAW && child.getVisibility() == View.GONE) {
            // Do not try to update GONE child views in pre draw updates.
            continue;
        }

        // 先遍歷依賴的 View 中是否有卯點依賴,如果有,則先將 View 的變化分發到通過卯點依賴的 View
        for (int j = 0; j < i; j++) {
            final View checkChild = mDependencySortedChildren.get(j);

            if (lp.mAnchorDirectChild == checkChild) {
                // 存在卯點依賴時,卯點發生狀態改變時同時將對應的 View 狀態修改
                offsetChildToAnchor(child, layoutDirection);
            }
        }
    
        // ... 其他代碼
    
    
        // 遍歷所有存在依賴的 View
        for (int j = i + 1; j < childCount; j++) {
            final View checkChild = mDependencySortedChildren.get(j);
            final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
            final Behavior b = checkLp.getBehavior();
    
            // layoutDependsOn 判斷是否依賴
            if (b != null && b.layoutDependsOn(this, checkChild, child)) {
                if (type == EVENT_PRE_DRAW && checkLp.getChangedAfterNestedScroll()) {
                    // If this is from a pre-draw and we have already been changed
                    // from a nested scroll, skip the dispatch and reset the flag
                    checkLp.resetChangedAfterNestedScroll();
                    continue;
                }
    
                final boolean handled;
                switch (type) {
                    case EVENT_VIEW_REMOVED:
                        // View 的移除事件,則調用 Behavoir 的 onDependentViewRemoved 方法
                        b.onDependentViewRemoved(this, checkChild, child);
                        handled = true;
                        break;
                    default:
                        // 其他事件,調用 Behavoir 的 onDependentViewRemoved 方法
                        handled = b.onDependentViewChanged(this, checkChild, child);
                        break;
                }
    
                if (type == EVENT_NESTED_SCROLL) {
                    // If this is from a nested scroll, set the flag so that we may skip
                    // any resulting onPreDraw dispatch (if needed)
                    checkLp.setChangedAfterNestedScroll(handled);
                }
            }
        }
    }
    // ... 其他代碼
}

通過代碼分析知道了 CoordinatorLayout 中只要 View 間存在依賴,那么 View 變化時 onChildViewsChanged 方法就會被調用,該方法中會將卯點變化事件處理,卯點改變時對應的 View 狀態也要改變。還會將事件改變分發到依賴的 Behavior 中,這樣在 Behavior 中就可以處理啊依賴的 View 狀態的變化事件了。

5. Behavior 的嵌套滑動系列方法

CoordinatorLayout 實現了 NestedScrollingParent 接口,所以當其中的子 View 存在實現了 NestedScrollingChild 接口的類時,子 View 的滑動事件都會分發到 CoordinatorLayout 中。這部分嵌套滑動機制有專門文章講解。

我們就以 onNestedScroll 方法為例來分析滑動事件分發到 CoordinatorLayout 后的處理。


@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed) {
    onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
            ViewCompat.TYPE_TOUCH);
}

@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, int type) {
    final int childCount = getChildCount();
    boolean accepted = false;

    // 遍歷子 View 分發滑動事件
    for (int i = 0; i < childCount; i++) {
        final View view = getChildAt(i);
        if (view.getVisibility() == GONE) {
            // If the child is GONE, skip...
            continue;
        }

        final LayoutParams lp = (LayoutParams) view.getLayoutParams();
        
        // 如果 LayoutParams 沒有接受這個事件序列,則不用處理
        if (!lp.isNestedScrollAccepted(type)) {
            continue;
        }

        final Behavior viewBehavior = lp.getBehavior();
        if (viewBehavior != null) {
            // 如果子 View 有 Behavior ,則調用 Bihavior 的 onNestedScroll 方法將滑動事件分發給子 View
            viewBehavior.onNestedScroll(this, view, target, dxConsumed, dyConsumed,
                    dxUnconsumed, dyUnconsumed, type);
            accepted = true;
        }
    }

    if (accepted) {
        onChildViewsChanged(EVENT_NESTED_SCROLL);
    }
}

這里的分發就比較簡單了,只要 CoordinatorLayout 中檢測到了子 View 產生了滑動,就會將對應的滑動事件分發給所有配置了 Behavior 的 View 中,這樣在 Behavior 的滑動系列方法中,當前 View 就可以根據滑動做不同的相應。

其他 onNestedPreScroll、onStopNestedScroll、onNestedFling 等一系列方法同 onNestedScroll 過程類似,就不一一分析了。

6. Behavior 總結

到這里 Behavior 的工作過程就分析完了,在分析的過程中也逐漸發現設計的巧妙。通過 Behavior 我們也能實現更多的 View 間的依賴效果。Google 也給我們提供了 AppBarLayout、CollapsingToolbarLayout 等類提供了很多效果。當然我們不僅要會用這些類實現我們的需求,還要了解其深層次的原理與工作機制,這樣在自定義、修改的時候就會更加得心應手。

思路延伸

通過 CoordinatorLayout 對其中配置了 Behavior 的 View 的處理方式我們可以得到一些思路,在自定義 ViewGroup 的時候,如果其中的子 View 可以配置一些由 Viewroup 提供的自定義的一些屬性,當然系統的子 View 是無法感知的,我們可以通過 ViewGroup 在 inflat 解析布局時解析到 View 的 attr 中配置的自定義屬性

然后由 ViewGroup 來管理配置了這些屬性的 View ,在 ViewGroup 想要這些屬性生效或者根據這些屬性作出一定的效果時,就可以直接操作這些配置了自定義屬性的 View,并且可以根據配置的自定義屬性的值作出不同的效果。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容