如何掌握自定義View

國內(nèi)自定義View的文章汗牛充棟,但是,即便是你全部看完也未必掌握這一知識(shí)(實(shí)際上,我也看了很多,但是一旦涉及自定義View,依然無從下手)。為什么,一言以蔽之,你是得其術(shù),不明其道(本文不打算講自定義View的屬性和事件分發(fā),太多文章已經(jīng)很詳細(xì)講解了)。

一、自定義View你真的掌握了嗎?

如果說你已經(jīng)掌握了自定義View,那么請嘗試回答下列問題

  • Goolge提出View這個(gè)概念的目的是什么?
  • View這個(gè)概念和Activity、Fragment、Drawable之間是一種什么樣的關(guān)系?
  • View能夠感知Activity的生命周期事件嗎?為什么?

What?如果你說這些問題太抽象?那么請繼續(xù)回答如下問題:

  • View的聲明周期是什么?
  • 當(dāng)View所在的Activity進(jìn)入stop狀態(tài)后,View去哪兒了?如果在一個(gè)后臺(tái)線程中持有一個(gè)View引用,我們此時(shí)能夠改變他的狀態(tài)嗎?為什么?
  • View能夠與其他的View交叉重疊嗎?重疊區(qū)域發(fā)生的點(diǎn)擊事件交給誰去處理?可不可以重疊的兩個(gè)View都處理?
  • View控制一個(gè)Drawable的方法途徑有哪些?Drawable能不能與View通信?如果能,如何通信?
  • 假如View所在的ViewGroup中的子View減少了,View因此獲得了更大的空間,View如何及時(shí)有效地利用這些空間,改變自己的繪制?
  • 假如我要在View中動(dòng)態(tài)地注冊與接觸廣播接收器,應(yīng)該在那里完成呢?
  • 假如我的手機(jī)帶鍵盤(自帶或外接),你的自定義View應(yīng)該如何響應(yīng)鍵盤事件。
  • AnimationDrawable 作為View的背景,會(huì)自動(dòng)進(jìn)行動(dòng)畫,View在其中扮演了怎樣的角色?

假如以上問題你能準(zhǔn)確的回答出來,那么,恭喜你!我覺得你的自定義View應(yīng)學(xué)到家了,如果有那么幾個(gè)問題你還搞不清楚,或者不是很確定,那么,請上終南山,閉關(guān)三個(gè)月,繼續(xù)參悟自定義View的內(nèi)在玄機(jī)。

為什么看了那么多文章,還是無法愉快地與自定義View玩耍呢?是那些文章寫的不好嗎?非也!是你沒有掌握學(xué)習(xí)自定義View的正確方式。你看那些作者,輕輕松松整出一個(gè)漂亮的 自定義View,你依葫蘆畫瓢也整出一個(gè),就覺得自己好像也會(huì)了,年輕人,你太傲嬌了!你想過沒有這些寫文章的人怎么掌握自定義View的?請把這個(gè)問題默念三遍。以后讀任何文章,都問自己這樣的問題,相信不久的將來,你也會(huì)稱為Android大牛的,至少也是小壯牛一頭!!!,因?yàn)椋阋呀?jīng)從學(xué)習(xí)別人的知識(shí),進(jìn)入到學(xué)習(xí)別人的方法境界了,功力自然大增!

好了,說了那么多,到底怎樣才能學(xué)好自定義View呢?其實(shí)只需要掌握三個(gè)問題,就可以輕松搞定它:

  • 問題一:從Android系統(tǒng)設(shè)計(jì)者的的角度,View這個(gè)概念究竟是做什么的?
  • 問題二:Android系統(tǒng)中那個(gè)View類,它有哪些默認(rèn)功能和行為,能干什么,不能干什么(知彼知己,才好自定義!)
  • 問題三:我要改變這個(gè)View的行為、外觀,肯定要覆寫View類中的方法,但是怎么覆寫,覆寫哪些方法能夠改變行為?

以上三個(gè)問題,從抽象到具體,我覺得適用于學(xué)習(xí)任何技術(shù)知識(shí),只是每個(gè)問題的問法可能因具體的技術(shù)而有所調(diào)整,總體上就是從概念上、從默認(rèn)實(shí)現(xiàn)上、從自己定制上去提問,比如你學(xué)習(xí)RecycleView,也可以問以上三個(gè)問題,按照這三個(gè)問題的順序一個(gè)一個(gè)搞懂了,也就完全掌握了這一知識(shí)。

從Android系統(tǒng)設(shè)計(jì)者的角度,View這個(gè)概念究竟是做什么的?

關(guān)于這個(gè)問題最權(quán)威的當(dāng)然是官方文檔,如下:

This class represents the basic building block for user interface components. A View occupies a rectangular area on the screen and is responsible for drawing and event handling. View is the base class for
widgets, which are used to create interactive UI components (buttons, text fields, etc.). The ViewGroup
subclass is the base class for layouts, which are invisible containers that hold other Views (or other ViewGroups) and define their layout properties.

譯:View是用戶接口組件的基本構(gòu)建塊,它在屏幕上占據(jù)一塊矩形區(qū)域,負(fù)責(zé)繪制和事件處理。View是小組件的基類,用于創(chuàng)建交互式UI組件(TextView、Button等);ViewGroup是布局類的基類,是一個(gè)容納其他視圖并定義布局屬性的不可見容器。

這句話言簡意賅,高屋建瓴,一針見血,力透紙背,入木三分,令人銷魂佩服!需要我們認(rèn)真體會(huì),它包含三層含義:

  • View是用戶接口組件的基本構(gòu)建塊。通俗講,在Android中,一個(gè)用戶與一個(gè)應(yīng)用的交互,其實(shí)就是與這個(gè)應(yīng)用中的許許多多的View的交互,這些View既可以是簡單的View,也可以是若干View組合而成的一個(gè)復(fù)合View。由此我們可以明白,所謂View是基本構(gòu)件塊,原因就在于它是復(fù)合View(就是ViewGroup)的基本組成單元。這層含義,就是告訴你,View就是用來與用戶交互的,那么很自然地,我們要問,我們用戶在哪里與View交互,以及怎樣與View交互呢?
  • View在屏幕上占據(jù)一個(gè)矩形區(qū)域。這是說,既然View是用戶與應(yīng)用交互的基本構(gòu)建塊,而用戶使用Android設(shè)備時(shí),主要是通過一個(gè)觸摸屏來交互的,相應(yīng)的,Andorid的設(shè)計(jì)者們,就讓一個(gè)View就在屏幕上占據(jù)一個(gè)矩形區(qū)域,用戶在這個(gè)區(qū)域中發(fā)生的交互動(dòng)作(點(diǎn)擊、滑動(dòng)、拖動(dòng)等),就是與這個(gè)View的交互。什么?為什么不讓View占據(jù)一個(gè)圓形區(qū)域或者五角星區(qū)域呢?當(dāng)然是為了簡單。這就解決了在哪里與View交互的問題。很自然地,我們又想問,View在屏幕上占據(jù)一個(gè)矩形區(qū)域,這個(gè)區(qū)域的大小、位置怎么確定,它們會(huì)不會(huì)變化,誰來決定這個(gè)變化呢?如果這個(gè)變化不是由View自己來決定的,而是其他外界因素決定的,View又要怎樣響應(yīng)這種變化呢?不要急,后面都會(huì)有答案。
  • View通過繪制自己與事件處理兩種方式與用戶交互。這是解決了如何交互的問題。簡單講,View與用戶交互就兩個(gè)辦法,一個(gè)是改變自己的模樣,也就是通過繪制自己與用戶交互,比如,當(dāng)用戶點(diǎn)擊自己時(shí),就改變自己的背景顏色,以此來告訴用戶:“本View已經(jīng)響應(yīng)你的點(diǎn)擊了!”第二個(gè)方式就是事件處理,比如,當(dāng)用戶點(diǎn)擊View時(shí),就完成一定的任務(wù),然后彈出一個(gè)Toast,告訴用戶該View完成了什么任務(wù),這樣,用戶也就知道這次交互結(jié)果如何。

看到?jīng)],這就是官方文檔的魅力,短短一句話,勝君讀千篇水文。現(xiàn)在我們明白了,設(shè)計(jì)View,主要是為了讓應(yīng)用能夠與用戶交互,要想完成交互,這個(gè)View就要在屏幕上占據(jù)一個(gè)矩形區(qū)域,然后利用這塊屏幕區(qū)域與用戶交互,交互的方式就兩種,繪制自己與事件處理。

View類,有哪些默認(rèn)的功能和行為,能干什么,不能干什么?

對于上面的解釋,想必大家有很多疑問,我們想知道:

  • View 是怎樣被顯示到屏幕上的?
  • View 在屏幕上的位置是怎樣決定的?
  • View 所占的矩形大小是怎樣決定的?
  • 屏幕上肯定不止一個(gè)View,View之間相互知道嗎?他們能協(xié)作嗎?
  • View 完成與用戶的交互后,能夠自動(dòng)隱藏嗎,在需要交互的時(shí)候,能重新顯示在屏幕上嗎?
    .....

現(xiàn)在我們就一點(diǎn)點(diǎn)來講,學(xué)習(xí)的同時(shí),最好能用心體會(huì)Google工程師設(shè)計(jì)的思路,這樣學(xué)習(xí)效果最好。

首先,一個(gè)用戶界面,上面有許多View,既有基本的View,也有復(fù)合的View,把他們組織起來還讓他們很好的協(xié)作確實(shí)是一個(gè)難題,Google的解決方案是:首先,一套完整的用戶界面用一個(gè)Window來表示,Window這個(gè)概念和我們在計(jì)算機(jī)上所說的Window很相似。Window負(fù)責(zé)管理所有的View,怎么管理?很簡單,借鑒復(fù)合View的思路,Window首先加載一個(gè)超級(jí)復(fù)合View,用它包含住所有的其他View,這個(gè)超級(jí)復(fù)合View就叫做DecorView。但是這個(gè)DecorView除了包含我們的用戶界面上的那些View,還包含了作為一個(gè)Window特有的View,叫做TitleBar,這個(gè)我們就不細(xì)說了。

這樣,在Window中的所有View被組織起來了,一個(gè)巨大的ViewGroup(以后我們不再用復(fù)合View這個(gè)說法,用ViewGroup取而代之,二者是一回事),下面有若干ViewGroup和若干View,每個(gè)ViewGroup下面又有若干ViewGroup和若干View,很像數(shù)據(jù)結(jié)構(gòu)中的樹,葉子結(jié)點(diǎn)就是基本View。

好了,這些View已經(jīng)被組織起來了,DecorView已經(jīng)能夠完全控制它們了,同時(shí),DecorView掌握著能夠分配給這些View的屏幕區(qū)域,包括區(qū)域的大小和位置。我們知道,屏幕的大小是有限的,一個(gè)Window的DecorView能控制的屏幕區(qū)域更加有限,AndroidN中引入多Window機(jī)制后,DecorView能夠掌控的屏幕區(qū)域更加小了,因?yàn)槠聊簧嫌卸鄠€(gè)Window將成為常態(tài)。這些有限的區(qū)域還要被Window特有的View(TitleBar)占去一小部分,剩下的才是留給用戶界面上的View分的。如果你是DecorView,你肯定為難了,如何將這些有限的屏幕區(qū)域分給這些View們?分給他們還得為每個(gè)View排好在屏幕上的位置,難上加難。

停下來,想一想,如果是你,怎么解決這個(gè)問題?

首先,不同的View是為了完成特定的交付任務(wù)的。比如,Button就是用來點(diǎn)擊的,TextView就是用來顯示字符的,等等。DecorView知道,不同的View,為了完成自己的交互任務(wù)所需要的屏幕區(qū)域大小是不同的,所以DecorView在確定給每個(gè)View分配的屏幕區(qū)域大小時(shí),是允許View參與進(jìn)來的,與它一起商量的。但是每個(gè)View在屏幕區(qū)域位置就不能讓View自己來決定了,而是有DecorView一手操辦,這個(gè)比較簡單,我們先看看DecorView是怎樣決定每個(gè)View的位置的吧。

1、確定每個(gè)View的位置

我們在Activity中,調(diào)用了setContentView(View view),實(shí)際上就是將用戶界面所有的View交給了DecorView中的一個(gè)FrameLayout,這個(gè)FrameLayout代表著可以分配給用戶界面使用的區(qū)域。而用戶界面View既可以是一個(gè)簡單的View,也可以是一個(gè)ViewGroup,如果是一個(gè)簡單的View,比如就是一個(gè)TextView,那么這個(gè)TextView就會(huì)占據(jù)整個(gè)FrameLayout的屏幕區(qū)域,也就是說,此時(shí)用戶在FrameLayout的屏幕區(qū)域內(nèi)的所有交互是與這個(gè)TextView交互。但是更常見的情況是,我們的用戶界面是一個(gè)ViewGroup,里面包含著其他的ViewGroup和View。這個(gè)時(shí)候,首先這個(gè)ViewGroup就會(huì)占據(jù)FrameLayout所代表的屏幕區(qū)域,剩下的任務(wù),就是這個(gè)ViewGroup給它內(nèi)部的小弟們分配區(qū)域了。至于怎么分,不同的ViewGroup有不同的分法,總體來看,可說是有總有分。所謂總,舉例來講,就像LinearLayout的vertical,他按照自己 小弟的數(shù)量,把自己豎向裁成不同的區(qū)域,如下圖所示:


LinearLayout-sample

雖然View無法決定自己在ViewGroup中的位置,但是開發(fā)者在使用View時(shí),可以向ViewGroup表達(dá)自己所用的View要放在哪里,以LinearLayout vertical 為例,開發(fā)者書寫布局文件時(shí),子View在LinearLayout中的出現(xiàn)順序?qū)Q定他們在屏幕上的上下順序,同時(shí)還可以借助layout_margin,layout_gravity等配置進(jìn)一步調(diào)整子View分給自己的矩形區(qū)域中的位置。到這里,我么可以理解,layout_*之類的配置雖然在書寫上與View的屬性在一起,但他們并不是View的屬性,他們只是使用該View的使用者來細(xì)化調(diào)整該View在ViewGroup中的位置的,同時(shí),這些值在Inflate時(shí),是由ViewGroup讀取,然后生成一個(gè)ViewGroup特定的LayoutParams對象,再把這個(gè)對象存入子View中的,這樣,ViewGroup在為該子View安排位置時(shí),就可以參考這個(gè)LayoutParams中的信息了。

進(jìn)一步思考,我們發(fā)現(xiàn),調(diào)用inflate時(shí),除了輸入布局文件的id外,一般要求傳入parent ViewGroup,傳入這個(gè)參數(shù)的目的,就是為了讀取布局文件中的layout配置信息,如果沒有傳入,這些信息將會(huì)丟失,感興趣的同學(xué)可以自己實(shí)驗(yàn)驗(yàn)證下,這里就不展開了。

不同的ViewGroup擁有不同的LayoutParam內(nèi)部類,這是因?yàn)椋麄兯试S的子view微微調(diào)整自己的位置的方式是不一樣的,具體講究就是配置子View時(shí),允許使用的layout_*是不一樣的,比如,RelativeLayout就允許layout_toRightOf等配置,其他的ViewGroup就沒有這些配置。

這些確定View的位置的過程,被包裝在View的Layout方法中,這樣我們也很容易理解,對于基本View而言,這個(gè)方法是沒有用的,所有都是空的,你可以查看下ImageView、TextView等的源代碼,驗(yàn)證下這一點(diǎn)。對于ViewGroup而言,他們會(huì)用該方法為自己的子View安排位置。

2、確定View的大小

下面就要確定View的大小了,這是一個(gè)開發(fā)者、View與ViewGroup三方相互商量的過程。(這里的講解可能與一般的文章不同,是我個(gè)人的理解,一搬的文章都不會(huì)說三反商量,二十直接說View與ViewGroup兩方的商量)

第一步,開發(fā)者在書寫布局文件時(shí),會(huì)為一個(gè)View寫上android:layout_width="..." android:layout_height="..."兩個(gè)配置,這是開發(fā)者向ViewGroup表達(dá)的,我這個(gè)View需要的大小是多少。...的取值有三種:

  • 具體值,如50dp,很簡單,不多講
  • match_parent ,表示開發(fā)者向ViewGroup說,把你所有的屏幕區(qū)域都給這個(gè)View吧。
  • wrap_parent,表示開發(fā)者向ViewGroup說,只要給這個(gè)View夠他展示自己的空間就行,至于到底給多少,你直接跟View溝通吧,看它怎么說。

第二步:ViewGroup收到了開發(fā)者對View大小的說明,然后ViewGroup會(huì)綜合考慮自己的空間大小以及開發(fā)者的請求,然后生成兩個(gè)MeasureSpace對象(width與height)傳給View,這兩個(gè)對象是ViewGroup向子View提出的要求,就像相當(dāng)于告訴子View:“我已經(jīng)與你的使用者(開發(fā)者)商量過了,現(xiàn)在把我們商量確定的結(jié)果告訴你,你的寬度不能違反width MeasureSpec對象的要求,你的高度不能違反height MeasureSpec對象的要求,現(xiàn)在,你趕緊根據(jù)這個(gè)要求確定下自己要多大空間,只許少,不許多哦。”

然后,這兩個(gè)對象將會(huì)傳到子View的protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法中。子View能怎么辦呢?它肯定是要先看看ViewGroup的要求是什么吧,于是,它從傳入的兩個(gè)對象中解譯出如下信息:

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize =  MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize =  MeasureSpec.getSize(heightMeasureSpec);

Mode與Size一起,準(zhǔn)確表達(dá)出了ViewGroup的要求。下面我們舉例說明,假設(shè)Size是100dp,
Mode的取值有三種,它們代表了ViewGroup的總體態(tài)度:

  • 1、EXACTLY 表示,ViewGroup對View說,你只能用100dp,原因是多樣的,可能是你的使用者說要你完全占據(jù)我的空間,而我只有100dp。也可能這是你的使用者的要求,他需要你占這么大的空間,而我恰好也有這么多的空間,你的使用者讓你占這么大的空間,肯定有他自己的考慮,你不能不理不顧,不然你達(dá)不到他的要求,他可能就不用你了。
  • 2、AT_MOST表示,你最多只能用100dp,這是因?yàn)槟愕氖褂谜哒f讓你占據(jù)wrap_content的大小,讓我跟你商量,我又不知道你到底要占多大區(qū)域,但是我告訴你,我只有100dp,你最多也只能用這么多哈。(這里,可以看出,當(dāng)使用者在布局文件中要求一個(gè)View是wrap_content時(shí),此時(shí),View的大小決定權(quán)就交給View自己了,默認(rèn)的View類中的實(shí)現(xiàn),比較粗暴,就是將此時(shí)ViewGroup提供的空間全占據(jù),完全沒有真正根據(jù)自己的內(nèi)容來確定大小,為什么這么粗暴?因?yàn)閂iew是一個(gè)基類,所有的組件都是它的子類,每個(gè)子類的content都各不相同,View怎么可能知道content的大小呢,所以,它把wrap_content情況下,自己尺寸大小的決定權(quán)下放給了不同的子組件,讓它們自己根據(jù)自己的內(nèi)容去決定自己的大小,同樣,我們自定義View時(shí),也要考慮這一點(diǎn))
  • 3、UNSPECIFIED表示,你自己看著辦,把你最理想的大小告訴我,我考慮考慮。

第三步:好了,子View已經(jīng)清楚第理解了ViewGroup和它的使用者對它的大小的期望和要求了。下步就要在該要求下來確定自己的大小并告訴ViewGroup了。(廢話,不告訴ViewGroup大小,它怎么給你安排位置(layout),無法給你layout,你也就占據(jù)不了一塊屏幕區(qū)域,占不了屏幕區(qū)域,你就無法與用戶交互,無法與用戶交互,要你何用啊!)

關(guān)于子View怎么確定自己的大小,不同的View有不同的態(tài)度,但是有幾點(diǎn)基本的規(guī)矩是要遵守的:
規(guī)矩一就是,不要違反ViewGroup的規(guī)定,最后設(shè)置的尺寸一定要在ViewGroup要求的范圍內(nèi)(不論是寬度還是高度),但是你說,假如我就是想要更大的空間,難道就沒有辦法了嗎,我能不能遵守要求的情況下,同時(shí)告訴ViewGroup,雖然我告訴你的我要求的尺寸是遵照你的旨意來的,但實(shí)際上我是委屈求全的,我真實(shí)想要的大小不是這樣的,你能不能再考慮一下。答案是:有。那就是如下調(diào)用:

    esolveSizeAndState((int)(wantedWidth), widthMeasureSpec, 0),    
    resolveSizeAndState((int) (wantedHeight), heightMeasureSpec, 0);

View可以把自己想要的寬和高進(jìn)行一個(gè)resolveSizeAndState處理,就可以達(dá)到上述目的。即如果想要的大小沒超過要求,一切都Ok,如果超過了,在該方法內(nèi)部,就會(huì)把尺寸調(diào)整成符合ViewGroup要求的,但是也會(huì)在尺寸中設(shè)置一個(gè)標(biāo)記,告訴ViewGroup,這個(gè)大小是子View委屈求全的結(jié)果。至于ViewGroup會(huì)不會(huì)理會(huì)這一標(biāo)記,要看不同的ViewGroup了。如果你實(shí)現(xiàn)自己的ViewGroup,最好還是關(guān)注下這個(gè)標(biāo)記,畢竟作為大哥的你,最主要的職責(zé)就是把自己的小弟(子View)安排好,讓它們都滿意嘛。(這一點(diǎn),我沒有看到任何一篇講解自定義View的文章提到過!)
什么?好奇的你想看看究竟是怎樣設(shè)置標(biāo)記的?來來來,滿足你:

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {  
      final int specMode = MeasureSpec.getMode(measureSpec);  
      final int specSize = MeasureSpec.getSize(measureSpec);  
      final int result;  
      switch (specMode) {     
         case MeasureSpec.AT_MOST:         
             if (specSize < size) {            
                  result = specSize | MEASURED_STATE_TOO_SMALL;         
             } else {            
                  result = size;      
             }         
             break;      
         case MeasureSpec.EXACTLY:          
              result = specSize;      
              break;       
         case MeasureSpec.UNSPECIFIED:   
         default:        
              result = size;   
       }   
       return result | (childMeasuredState & MEASURED_STATE_MASK);
}

上面的代碼中的MEASURED_STATE_TOO_SMALL就是在子View想要的空間太大時(shí)設(shè)置的標(biāo)記了。

規(guī)矩二就是要在該方法中調(diào)整自己的繪制參數(shù),這一點(diǎn)很好理解,畢竟ViewGroup提出了尺寸要求,要及時(shí)根據(jù)這一要求調(diào)整自己的繪制,比如,如果自己的背景圖片太大,那就算算要縮放多少才合適,并且設(shè)置一個(gè)合理的縮放值。
規(guī)矩三就是一定要設(shè)置自己考慮后的尺寸,如果不設(shè)置就相當(dāng)于沒有告訴ViewGroup自己想要的大小,這會(huì)導(dǎo)致ViewGroup無法正常工作,設(shè)置的辦法就是在onMeasure方法的最后,調(diào)用
setMeasuredDimension方法。為什么調(diào)用這個(gè)方法就可以了呢?這只是一個(gè)約定,沒有必要深究了。

關(guān)于View的繪制,非常簡單,就是一個(gè)方法onDraw,后面的自定義View實(shí)戰(zhàn)部分會(huì)細(xì)說,這里先略過了。

以上,View的三個(gè)基本知識(shí)點(diǎn),我們都了解了,即View 的位置如何確定,大小如何確定以及如何繪制自己。這都是默認(rèn)的View類中為我們準(zhǔn)備好的。

四、我要改變這個(gè)View的行為,外觀,肯定是覆寫View類中的方法,但是怎么覆寫,覆寫哪些方法能夠改變哪些行為?

好了,View的位置和大小怎么確定我們都清楚了,現(xiàn)在,是時(shí)候開始自定義View了。
首先,關(guān)于View所要具備的一般功能,View類中都有了基本的實(shí)現(xiàn),比如確定位置,它有l(wèi)ayout方法,當(dāng)然,這個(gè)只適用于ViewGroup,實(shí)現(xiàn)自己的ViewGroup時(shí),才需要修改該方法。確定大小,它有onMeasure方法,如果你不滿意默認(rèn)的確認(rèn)大小的方法,也可以自己定義。改變默認(rèn)的繪制,就覆寫onDraw方法。下面,我們通過一張圖,來看看,自定義View時(shí),我們最可能需要修改的方法是哪些:

image.png

把這些方法都搞明白了,你也就理解了View的生命周期了。

比如View被inflated出來后,系統(tǒng)會(huì)回調(diào)該View的onFinishInflate方法,你的View可以在這個(gè)方法中,做一些準(zhǔn)備工作。

如果你的View所屬的Window可見性發(fā)生了變化,系統(tǒng)會(huì)回調(diào)該View的onWindowVisibilityChanged方法,你也可以根據(jù)需要,在該方法中完成一定的工作,比如,當(dāng)Window顯示時(shí),注冊一個(gè)監(jiān)聽器,根據(jù)監(jiān)聽到的廣播事件改變自己的繪制,當(dāng)Window不可見時(shí),解除注冊,因?yàn)榇藭r(shí)改變自己的繪制已經(jīng)沒有意義了,自己也要跟著Window變成不可見了。

當(dāng)ViewGroup中的子View數(shù)量增加或者減少,導(dǎo)致ViewGroup給自己分配的屏幕區(qū)域大小發(fā)生變化時(shí),系統(tǒng)會(huì)回調(diào)View的onSizeChanged方法,該方法中,View可以獲取自己最新的尺寸,然后根據(jù)這個(gè)尺寸相應(yīng)調(diào)整自己的繪制。

當(dāng)用戶在View所占據(jù)的屏幕區(qū)域發(fā)生了觸摸交互,系統(tǒng)會(huì)將用戶的交互動(dòng)作分解成如DOWN、MOVE、UP等一系列的MotionEvent,并且把這些事件傳遞給View的onTouchEvent方法,View可以在這個(gè)方法中進(jìn)行與用戶的交互處理。當(dāng)然這個(gè)是基本的流程,實(shí)際的流程會(huì)稍復(fù)雜些,你可以閱讀我的另一篇文章,是專門講解事件分發(fā)的,文章非常經(jīng)典,你讀了一定不后悔。

除了這些方法,View還實(shí)現(xiàn)了三個(gè)接口,如下


View繼承類關(guān)系圖.jpg

三個(gè)接口是:
Drawable.Callback
KeyEvent.Callback
AccessibilityEventSource

每個(gè)接口都有自己的作用。

KeyEvent回調(diào)接口,是用來處理鍵盤事件的,這與onTouchEvent用來處理觸摸事件是相對的。

Drawable回調(diào)接口是用來讓View中的Drawable能夠與View通信的,尤其是AnimationDrawable,更是必須依賴該回調(diào)才能實(shí)現(xiàn)動(dòng)畫效果,關(guān)于這一點(diǎn),我深入地研究了FrameWork的源碼,對AnimationDrawable如何實(shí)現(xiàn)動(dòng)畫,有了深入徹底的掌握,我也在考慮要不要就此寫一篇文章,看大家需要吧,如果本文贊數(shù)過百,我就寫,絕不食言。

第三個(gè)回調(diào)接口,我沒有細(xì)致研究,不便多說。

寫到這里你應(yīng)該發(fā)現(xiàn),我們的第三個(gè)問題,自定義View,應(yīng)該覆寫哪些方法,能夠?qū)崿F(xiàn)哪些功能也已經(jīng)解決了。


休息一刻,如侵權(quán),刪除

五、光說不練假把式,實(shí)戰(zhàn)自定義View

說了這么多,不自定一個(gè)View,怎么對的起你辛苦讀到這里呢。好,我們現(xiàn)在就來自定義一個(gè)鐘表,而且可以自己走的。如下圖所示:

Demo圖

這個(gè)時(shí)鐘可是能夠走動(dòng)的哈。下面我們就開始吧。首先,準(zhǔn)備三張圖片資源,如下:

clock_dial.png
clock_hand_hour.png
clock_hand_minute.png

聰明如你,一看就應(yīng)該知道這是做什么用的了。準(zhǔn)備圖片時(shí),使用了一個(gè)小技巧,就是時(shí)針和分針,你所看到的圖像只是圖片的一半,在圖像的下方,還有同樣大小的空白,這個(gè)是做什么用的呢?主要是為了繪制圖片時(shí)的方便,待會(huì)兒就可以明白了。

材料齊全,開工!

public class AnalogClock extends View {   

      private Time mCalendar;    //用來記錄當(dāng)前時(shí)間

      //用來存放三張圖片資源
      private Drawable mHourHand;  
      private Drawable mMinuteHand; 
      private Drawable mDial;   

    //用來記錄表盤圖片的寬和高,
    //以便幫助我們在onMeasure中確定View的大
    //小,畢竟,我們的View中最大的一個(gè)Drawable就是它了。
       private int mDialWidth; 
       private int mDialHeight;   


//用來記錄View是否被加入到了Window中,我們在View attached到
//Window時(shí)注冊監(jiān)聽器,監(jiān)聽時(shí)間的變更,并根據(jù)時(shí)間的變更,改變自己
//的繪制,在View從Window中剝離時(shí),解除注冊,因?yàn)槲覀儾恍枰俦O(jiān)聽
//時(shí)間變更了,沒人能看得到我們的View了。
       private boolean mAttached;    

//看名字
        private float mMinutes;    
        private float mHour;    

//用來跟蹤我們的View 的尺寸的變化,
//當(dāng)發(fā)生尺寸變化時(shí),我們在繪制自己
//時(shí)要進(jìn)行適當(dāng)?shù)目s放。
        private boolean mChanged;
...
}

下面,我們來確定自定義View 的構(gòu)造方法,查看View類,我們知道,View類有四個(gè)構(gòu)造方法,我們相應(yīng)地,也寫四個(gè)構(gòu)造方法,并且初始化相關(guān)變量:

/第一個(gè)構(gòu)造方法
public AnalogClock(Context context) {   
     this(context, null);
}
//第二個(gè)構(gòu)造方法
public AnalogClock(Context context, AttributeSet attrs) {  
      this(context, attrs, 0);
}
//第三個(gè)構(gòu)造方法
public AnalogClock(Context context, AttributeSet attrs, int defStyleAttr) { 
      this(context, attrs, defStyleAttr, 0);
}
//第四個(gè)構(gòu)造方法
public AnalogClock(Context context, AttributeSet attrs, 
int defStyleAttr, int defStyleRes) {    

    super(context, attrs, defStyleAttr, defStyleRes);    
    final Resources r = context.getResources();  
    if (mDial == null) {    
          mDial = context.getDrawable(R.drawable.clock_dial);  
    }  
    if (mHourHand == null) {        
        mHourHand = context.getDrawable(R.drawable.clock_hand_hour);   
    }     
    if (mMinuteHand == null) {      
          mMinuteHand = 
                context.getDrawable(R.drawable.clock_hand_minute);   
     }  

     mCalendar = new Time(); 

    mDialWidth = mDial.getIntrinsicWidth();   
    mDialHeight = mDial.getIntrinsicHeight();
}

請注意,以上為自定義View設(shè)置的構(gòu)造方法是適用性最廣的一種寫法,這樣寫,可以確保我們的自定義View能夠被最大多數(shù)的開發(fā)者使用,是一種最佳實(shí)踐。

接下來,確定我們的自定義View 的大小,也就是改寫onMeasure方法:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {   

         int widthMode = MeasureSpec.getMode(widthMeasureSpec);  
         int widthSize =  MeasureSpec.getSize(widthMeasureSpec);  

         int heightMode = MeasureSpec.getMode(heightMeasureSpec);  
         int heightSize =  MeasureSpec.getSize(heightMeasureSpec); 

         float hScale = 1.0f;  
         float vScale = 1.0f;   

         if (widthMode != MeasureSpec.UNSPECIFIED && widthSize < mDialWidth) {       
             hScale = (float) widthSize / (float) mDialWidth;   
         }   
         if (heightMode != MeasureSpec.UNSPECIFIED && heightSize < mDialHeight) {       
             vScale = (float )heightSize / (float) mDialHeight;  
          }    
         float scale = Math.min(hScale, vScale);    
        setMeasuredDimension(
              resolveSizeAndState((int) (mDialWidth * scale), widthMeasureSpec, 0),           
             resolveSizeAndState((int) (mDialHeight * scale), heightMeasureSpec, 0)
        );
}

在該方法中,我們的View想要的尺寸當(dāng)然就是與表盤一樣大的尺寸,這樣可以保證我們的View有最佳的展示,可是如果ViewGroup給的尺寸比較小,我們就根據(jù)表盤圖片的尺寸,進(jìn)行適當(dāng)?shù)陌幢壤s放。注意,這里我們沒有直接使用ViewGroup給我們的較小的尺寸,而是對我們的表盤圖片的寬高進(jìn)行相同比例的縮放后,設(shè)置的尺寸,這樣的好處是,可以防止表盤圖片繪制時(shí)的拉伸或者擠壓變形。

確定了大小,是不是就可以繪制了,先不著急,我們先要處理兩件事,一件就是讓我們的自定義View能夠感知自己尺寸的變化,這樣每次繪制時(shí),可以先判斷下尺寸是否發(fā)生了變化,如果有變化,就及時(shí)調(diào)整我們的繪制策略。代碼如下:

protected void onSizeChanged(int w, int h, int oldw, int oldh) {    
       super.onSizeChanged(w, h, oldw, oldh);   
       mChanged = true;
}

我們會(huì)在onDraw使用mChanged變量的。

第二件事就是讓我們的View能夠監(jiān)聽時(shí)間變化,并及時(shí)更新該View中的mCalendar變量,然后根據(jù)它來更新自身的繪制。為此,我們先寫一個(gè)更新時(shí)間的方法,代碼如下:

private void onTimeChanged() {    
        mCalendar.setToNow();  

        int hour = mCalendar.hour;   
        int minute = mCalendar.minute;  
        int second = mCalendar.second;   
        /*這里我們?yōu)槭裁床恢苯影裮inute設(shè)置給mMinutes,而是要加上
            second /60.0f呢,這個(gè)值不是應(yīng)該一直為0嗎?
            這里又涉及到Calendar的 一個(gè)知識(shí)點(diǎn),
            也就是它可以是Linient模式,
            此模式下,second和minute是可能超過60和24的,具體這里就不展開了,
            如果不是很清楚,建議看看Google的官方文檔中講Calendar的部分*/
         mMinutes = minute + second / 60.0f;    
         mHour = hour + mMinutes / 60.0f;   
         mChanged = true;
}

然后我們還要實(shí)現(xiàn)一個(gè)廣播接收器,接收系統(tǒng)發(fā)出的時(shí)間變化廣播,然后更新該View的mCalendar,如下:

private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {   
       @Override  
        public void onReceive(Context context, Intent intent) {    
            //這個(gè)if判斷主要是用來在時(shí)區(qū)發(fā)生變化時(shí),更新mCalendar的時(shí)區(qū)的,這
            //樣,我們的自定義View在全球都可以使用了。
            if (intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) {            
                  String tz = intent.getStringExtra("time-zone");         
                   mCalendar = new Time(TimeZone.getTimeZone(tz).getID());    
            }     
          //進(jìn)行時(shí)間的更新  
             onTimeChanged();     
          //invalidate當(dāng)然是用來引發(fā)重繪了。
           invalidate();   
         }
};

現(xiàn)在,我們要給我們的View動(dòng)態(tài)地注冊廣播接收器,沒錯(cuò),我們就是要在
onAttachedToWindow和onDetachedFromWindow中完成這一功能。代碼如下:

@Override
protected void onAttachedToWindow() {   
       super.onAttachedToWindow();    
      if (!mAttached) {      
          mAttached = true;      
          IntentFilter filter = new IntentFilter();        
        //這里確定我們要監(jiān)聽的三種系統(tǒng)廣播
          filter.addAction(Intent.ACTION_TIME_TICK);   
          filter.addAction(Intent.ACTION_TIME_CHANGED);        
          filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);        
          getContext().registerReceiver(mIntentReceiver,   filter); 
       }   

        mCalendar = new Time();   
        onTimeChanged();
}

@Override
protected void onDetachedFromWindow() {    
          super.onDetachedFromWindow();  
          if (mAttached) {     
               getContext().unregisterReceiver(mIntentReceiver);     
               mAttached = false;   
           }
}

萬事具備,只欠東風(fēng),開始繪制我們的View吧。代碼如下:

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

      //View尺寸變化后,我們用changed變量記錄下來,
    //同時(shí),恢復(fù)mChanged為false,以便繼續(xù)監(jiān)聽View的尺寸變化。
          boolean changed = mChanged;   
          if (changed) {      
                mChanged = false;   
           }   
        /* 請注意,這里的availableWidth和availableHeight,
           每次繪制時(shí)是可能變化的,
           我們可以從mChanged變量的值判斷它是否發(fā)生了變化,
           如果變化了,說明View的尺寸發(fā)生了變化,
           那么就需要重新為時(shí)針、分針設(shè)置Bounds,
           因?yàn)槲覀冃枰獣r(shí)針,分針始終在View的中心。*/
           int availableWidth = super.getRight() - super.getLeft();   
           int availableHeight = super.getBottom() - super.getTop();  


        /* 這里的x和y就是View的中心點(diǎn)的坐標(biāo),
          注意這個(gè)坐標(biāo)是以View的左上角為0點(diǎn),向右x,向下y的坐標(biāo)系來計(jì)算的。
          這個(gè)坐標(biāo)系主要是用來為View中的每一個(gè)Drawable確定位置。
          就像View的坐標(biāo)是用parent的左上角為0點(diǎn)的坐標(biāo)系計(jì)算得來的一樣。
          簡單來講,就是ViewGroup用自己左上角為0點(diǎn)的坐標(biāo)系為
          各個(gè)子View安排位置,
          View同樣用自己左上角為0點(diǎn)的坐標(biāo)系
          為它里面的Drawable安排位置。
          注意不要搞混了。*/

           int x = availableWidth / 2;    
           int y = availableHeight / 2;   

           final Drawable dial = mDial;  
           int w = dial.getIntrinsicWidth();   
           int h = dial.getIntrinsicHeight();   
            boolean scaled = false;   

        /*如果可用的寬高小于表盤圖片的寬高,
           就要進(jìn)行縮放,不過這里,我們是通過坐標(biāo)系的縮放來實(shí)現(xiàn)的。
          而且,這個(gè)縮放效果影響是全局的,
          也就是下面繪制的表盤、時(shí)針、分針都會(huì)受到縮放的影響。*/
           if (availableWidth < w || availableHeight < h) {     
                 scaled = true;      
                  float scale = Math.min((float) availableWidth / (float) w,   
                              (float) availableHeight / (float) h);     
                 canvas.save();    
                 canvas.scale(scale, scale, x, y);  
             }    

         /*如果尺寸發(fā)生變化,我們要重新為表盤設(shè)置Bounds。
           這里的Bounds就相當(dāng)于是為Drawable在View中確定位置,
           只是確定的方式更直接,直接在View中框出一個(gè)與Drawable大小
           相同的矩形,
           Drawable就在這個(gè)矩形里繪制自己。
           這里框出的矩形,是以(x,y)為中心的,寬高等于表盤圖片的寬高的一個(gè)矩形,
           不用擔(dān)心表盤圖片太大繪制不完整,
            因?yàn)槲覀円呀?jīng)提前進(jìn)行了縮放了。*/
          if (changed) {       
                 dial.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
           }    
          dial.draw(canvas);    

          canvas.save();   
          /*根據(jù)小時(shí)數(shù),以點(diǎn)(x,y)為中心旋轉(zhuǎn)坐標(biāo)系。
            如果你對來回旋轉(zhuǎn)的坐標(biāo)系感到頭暈,摸不著頭腦,
            建議你看一下**徐宜生**《安卓群英傳》中講解2D繪圖部分中的Canvas一節(jié)。*/

           canvas.rotate(mHour / 12.0f * 360.0f, x, y);  
           final Drawable hourHand = mHourHand;   

          //同樣,根據(jù)變化重新設(shè)置時(shí)針的Bounds
           if (changed) {     
                   w = hourHand.getIntrinsicWidth();    
                   h = hourHand.getIntrinsicHeight();      

            /* 仔細(xì)體會(huì)這里設(shè)置的Bounds,我們所畫出的矩形,
                同樣是以(x,y)為中心的
                矩形,時(shí)針圖片放入該矩形后,時(shí)針的根部剛好在點(diǎn)(x,y)處,
                因?yàn)槲覀冎白鰰r(shí)針圖片時(shí),
                已經(jīng)讓圖片中的時(shí)針根部在圖片的中心位置了,
                雖然,看起來浪費(fèi)了一部分圖片空間(就是時(shí)針下半部分是空白的),
                但卻換來了建模的簡單性,還是很值的。*/
                  hourHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2));  
             }    
              hourHand.draw(canvas);  
              canvas.restore();  

              canvas.save();    
            //根據(jù)分針旋轉(zhuǎn)坐標(biāo)系
              canvas.rotate(mMinutes / 60.0f * 360.0f, x, y);   
              final Drawable minuteHand = mMinuteHand;   

              if (changed) {     
                       w = minuteHand.getIntrinsicWidth();    
                       h = minuteHand.getIntrinsicHeight();    
                       minuteHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
               }   
               minuteHand.draw(canvas);    
                canvas.restore();    
            //最后,我們把縮放的坐標(biāo)系復(fù)原。
              if (scaled) {      
                   canvas.restore();   
              }

}

大功告成,現(xiàn)在我們的時(shí)鐘終于完成了,任何開發(fā)者都可以使用我們的View,獲得一個(gè)不斷走動(dòng)的模擬時(shí)鐘。該View的完整代碼已經(jīng)上傳到Github,猛戳https://github.com/like4hub/CustomViewForClock。(注:該時(shí)鐘的實(shí)現(xiàn),主要參考了AOSP中模擬時(shí)鐘)

關(guān)于本文前面提出的問題,簡單回答一下:

Q1:google提出view概念的目的是給android app提供用戶交互的機(jī)制。
Q2、Q3、Q7:android framework采用的是層次架構(gòu):從上到下是:Activity、Fragment
View
Drawable
上層知道下層,下層卻不知道上層。上層可以直接使用支配下層,下層卻無法支配使用上層,下層與上層的通信主要靠回調(diào)。所以View處于Activity、Fragment與Drawable中間,意味著View不能夠感知Activity的生命周期,但是View可以完全控制Drawable,控制的手段定義在Drawable中,凡是Drawable提供的方法,都是View控制Drawable的手段,最典型的,在本文中也使用了的就是setBounds方法。正如View無法感知Activity的聲明周期一樣,Drawable同樣無法感知View的生命周期。但是View實(shí)現(xiàn)了Drawable.Callback接口,Drawable可以通過這個(gè)接口與View通信。本文中有說明
Q4:View的生命周期請見本文View-Method-For-Override一圖,這張圖來自google官方文檔,如果看不懂,可以查看文檔獲得相關(guān)說明,如果還是看不懂,歡迎留言討論。

Q5:Activity進(jìn)入stop狀態(tài)后,它的窗口會(huì)被最新呈現(xiàn)的窗口擋住,窗口中的view也因此無法被我們看見,如果此時(shí)在后臺(tái)線程中更新一個(gè)view是可以的,前提是要提交到UI線程中,但通常意義不大,因?yàn)榇藭r(shí)用戶無法看到view的改變,而且,當(dāng)這個(gè)Activity從stop狀態(tài)中進(jìn)入resume時(shí),一般都會(huì)重新更新view,以便繼續(xù)與用戶交互,所以,在stop狀態(tài)下對view的更新沒有什么意義。
Q6:View直接是可以重疊,重疊區(qū)域的點(diǎn)擊事件由誰處理取決于它們的parent 在dispatch這個(gè)點(diǎn)擊事件時(shí),先dispatch給誰。能不能都處理呢?一般情況下是不可以的,但是在最新的CoordinateLayout中,可以通過behavior實(shí)現(xiàn)這一需求。具體內(nèi)容太多,請自行搜索。

Q8:View利用這些空間的方法很簡單啊,就是在onSizeChanged方法中在新的寬高下繪制自己 。新的寬高由其parent ViewGroup在其他子View被移除后,重新layout時(shí)確定。本文的案例中就利用了這個(gè)方法。

參考文章
步步為營

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,737評論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,699評論 2 370

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