【轉(zhuǎn)】Unity異步等待

原文:http://www.stevevermeulen.com/index.php/2017/09/using-async-await-in-unity3d-2017/

在Unity中使用協(xié)同程序通常是解決某些問題的好方法钙姊,但它也有一些缺點(diǎn):

  • 1.協(xié)同程序無法返回值霜威。這鼓勵(lì)程序員創(chuàng)建巨大的單片協(xié)程饥臂,而不是用許多小方法編寫它們。存在一些變通方法骗绕,例如將Action <>類型的回調(diào)參數(shù)傳遞給協(xié)同程序筹煮,或者在協(xié)程完成后轉(zhuǎn)換從協(xié)同程序產(chǎn)生的最終無類型值筏勒,但這些方法使用起來很容易并且容易出錯(cuò)移迫。
  • 2.協(xié)同程序使錯(cuò)誤處理變得困難。您不能將yield放在try-catch中管行,因此無法處理異常厨埋。此外,當(dāng)異常確實(shí)發(fā)生時(shí)捐顷,堆棧跟蹤僅告訴您拋出異常的協(xié)同程序荡陷,因此您必須猜測它可能從哪個(gè)協(xié)程調(diào)用。

隨著Unity 2017的發(fā)布迅涮,現(xiàn)在可以使用名為async-await的新C#功能代替我們的異步方法废赞。與協(xié)同程序相比,它具有許多不錯(cuò)的功能逗柴。

要啟用此功能蛹头,您只需打開播放器設(shè)置(File - >Build Settings - >Player Settings.. ->Other Settings )并將“Scripting Runtime Version“改為(.NET 4.x)”。

我們來看一個(gè)簡單的例子戏溺。鑒于以下協(xié)程:

public class AsyncExample : MonoBehaviour
{
    IEnumerator Start()
    {
        Debug.Log("Waiting 1 second...");
        yield return new WaitForSeconds(1.0f);
        Debug.Log("Done!");
    }
}

使用async-await執(zhí)行此操作的等效方法如下:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        Debug.Log("Waiting 1 second...");
        await Task.Delay(TimeSpan.FromSeconds(1));
        Debug.Log("Done!");
    }
}

在這兩種情況下渣蜗,有點(diǎn)意識到引擎蓋下發(fā)生了什么是有幫助的。

簡而言之旷祸,Unity協(xié)程是使用C#對迭代器塊的內(nèi)置支持實(shí)現(xiàn)的耕拷。您提供給StartCoroutine方法的IEnumerator迭代器對象由Unity保存,每個(gè)幀此迭代器對象向前推進(jìn)以獲取由您的協(xié)同程序產(chǎn)生的新值托享。然后骚烧,Unity會(huì)讀取“返回”的不同值以觸發(fā)特殊情況行為,例如執(zhí)行嵌套協(xié)程(返回另一個(gè)IEnumerator時(shí))闰围,延遲一些秒(當(dāng)返回WaitForSeconds類型的實(shí)例時(shí))赃绊,或者只是等到下一幀(返回null時(shí))。

不幸的是羡榴,由于async-await在Unity中是一個(gè)非常新的事實(shí)碧查,如上所述的這種對協(xié)同程序的內(nèi)置支持并不像async-await那樣以類似的方式存在。這意味著我們必須自己添加很多這種支持校仑。

Unity確實(shí)為我們提供了一個(gè)重要的部分忠售。正如您在上面的示例中所看到的,默認(rèn)情況下迄沫,我們的異步方法將在主Unity線程上運(yùn)行稻扬。在非統(tǒng)一C#應(yīng)用程序中,異步方法通常自動(dòng)在不同的線程上運(yùn)行羊瘩,這在Unity中是一個(gè)很大的問題泰佳,因?yàn)樵谶@些情況下我們無法始終與Unity API進(jìn)行交互盼砍。如果沒有Unity引擎的支持,我們在異步方法中對Unity方法/對象的調(diào)用有時(shí)會(huì)失敗乐纸,因?yàn)樗鼈儗⒃谝粋€(gè)單獨(dú)的線程上執(zhí)行衬廷。它的工作原理是這樣,因?yàn)閁nity提供了一個(gè)名為UnitySynchronizationContext的默認(rèn)SynchronizationContext汽绢,它自動(dòng)收集每幀排隊(duì)的任何異步代碼,并繼續(xù)在主統(tǒng)一線程上運(yùn)行它們侧戴。

然而宁昭,事實(shí)證明,這足以讓我們開始使用async-await酗宋!我們只需要一些輔助代碼就可以讓我們做一些有趣的事情积仗,而不僅僅是簡單的時(shí)間延遲。

定制Awaiters

目前蜕猫,我們無法編寫很多有趣的異步代碼寂曹。我們可以調(diào)用其他異步方法,我們可以使用Task.Delay回右,就像上面的例子一樣隆圆,但不是很多。

舉個(gè)簡單的例子翔烁,讓我們添加直接'等待'在TimeSpan上的能力渺氧,而不是每次都像上面的例子一樣調(diào)用Task.Delay。像這樣:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        await TimeSpan.FromSeconds(1);
    }
}

我們需要做的就是只需向TimeSpan類添加一個(gè)自定義GetAwaiter擴(kuò)展方法:

 public static class AwaitExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
    {
        return Task.Delay(timeSpan).GetAwaiter();
    }
}

這是有效的蹬屹,因?yàn)闉榱酥С衷谛掳姹镜腃#中“等待”給定對象侣背,所需要的只是該對象有一個(gè)名為GetAwaiter的方法,它返回一個(gè)Awaiter對象慨默。這很好贩耐,因?yàn)樗试S我們通過使用上面的擴(kuò)展方法等待我們想要的任何東西,而無需更改實(shí)際的TimeSpan類厦取。

我們也可以使用相同的方法來支持等待其他類型的對象潮太,包括Unity用于協(xié)程指令的所有類!我們可以使WaitForSeconds蒜胖,WaitForFixedUpdate消别,WWW等等所有等待它們在協(xié)同程序中可以獲得的方式相同。我們還可以向IEnumerator添加一個(gè)GetAwaiter方法台谢,以支持等待協(xié)同程序寻狂,以允許與舊的IEnumerator代碼交換異步代碼。

實(shí)現(xiàn)所有這些的代碼可以從資產(chǎn)商店github 倉庫的發(fā)布部分下載朋沮。這允許您執(zhí)行以下操作:

public class AsyncExample : MonoBehaviour
{
    public async void Start()
    {
        // Wait one second
        await new WaitForSeconds(1.0f);
 
        // Wait for IEnumerator to complete
        await CustomCoroutineAsync();
 
        await LoadModelAsync();
 
        // You can also get the final yielded value from the coroutine
        var value = (string)(await CustomCoroutineWithReturnValue());
        // value is equal to "asdf" here
 
        // Open notepad and wait for the user to exit
        var returnCode = await Process.Start("notepad.exe");
 
        // Load another scene and wait for it to finish loading
        await SceneManager.LoadSceneAsync("scene2");
    }
 
    async Task LoadModelAsync()
    {
        var assetBundle = await GetAssetBundle("www.my-server.com/myfile");
        var prefab = await assetBundle.LoadAssetAsync<GameObject>("myasset");
        GameObject.Instantiate(prefab);
        assetBundle.Unload(false);
    }
 
    async Task<AssetBundle> GetAssetBundle(string url)
    {
        return (await new WWW(url)).assetBundle
    }
 
    IEnumerator CustomCoroutineAsync()
    {
        yield return new WaitForSeconds(1.0f);
    }
 
    IEnumerator CustomCoroutineWithReturnValue()
    {
        yield return new WaitForSeconds(1.0f);
        yield return "asdf";
    }
}

正如您所看到的蛇券,使用異步等待可能非常強(qiáng)大缀壤,尤其是當(dāng)您開始像上面的LoadModelAsync方法一樣組合多個(gè)異步方法時(shí)。

請注意纠亚,對于返回值的異步方法塘慕,我們使用Task的泛型版本并將返回類型作為泛型參數(shù)傳遞,就像上面的GetAssetBundle一樣蒂胞。

另請注意图呢,在大多數(shù)情況下,使用上面的WaitForSeconds實(shí)際上比我們的TimeSpan擴(kuò)展方法更可取骗随,因?yàn)閃aitForSeconds將使用Unity游戲時(shí)間蛤织,而我們的TimeSpan擴(kuò)展方法將始終使用實(shí)時(shí)(因此它不會(huì)受到Time.timeScale更改的影響)

觸發(fā)異步代碼和異常處理

您可能已經(jīng)注意到我們上面的代碼有一件事是,某些方法被定義為'async void'鸿染,而有些方法被定義為'async Task'指蚜。那么什么時(shí)候應(yīng)該使用另一個(gè)呢?

這里的主要區(qū)別是涨椒,其他異步方法無法等待定義為“async void”的方法摊鸡。這表明我們應(yīng)該總是更喜歡用返回類型Task定義我們的異步方法,以便我們可以“等待”它們蚕冬。

此規(guī)則的唯一例外是當(dāng)您要從非異步代碼調(diào)用異步方法時(shí)免猾。請看以下示例:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTaskAsync();
        }
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

在此示例中,當(dāng)用戶單擊按鈕時(shí)播瞳,我們要啟動(dòng)異步方法掸刊。此代碼將編譯并運(yùn)行,但是它存在一個(gè)主要問題赢乓。如果在RunTaskAsync方法中發(fā)生任何異常忧侧,它們將以靜默方式發(fā)生。該異常將不會(huì)記錄到統(tǒng)一控制臺(tái)牌芋。

這是因?yàn)楫?dāng)異步方法返回Task時(shí)發(fā)生異常時(shí)蚓炬,它們將被返回的Task對象捕獲,而不是被Unity拋出和處理躺屁。此行為存在的原因很充分:允許異步代碼與try-catch塊一起正常工作肯夏。以下面的代碼為例:

async Task DoSomethingAsync()
{
    var task = DoSomethingElseAsync();
 
    try
    {
        await task;
    }
    catch (Exception e)
    {
        // do something
    }
}
 
async Task DoSomethingElseAsync()
{
    throw new Exception();
}

這里,異常由DoSomethingElseAsync方法返回的Task捕獲犀暑,并且僅在“等待”時(shí)才重新拋出驯击。如您所見,調(diào)用異步方法與等待它們不同耐亏,這就是為什么必須讓Task對象捕獲異常徊都。

因此,在上面的OnGUI示例中广辰,當(dāng)在RunTaskAsync方法中拋出異常時(shí)暇矫,它會(huì)被返回的Task對象捕獲主之,并且由于此Task上沒有任何內(nèi)容,因此異常不會(huì)冒泡到Unity李根,因此永遠(yuǎn)不會(huì)記錄到安慰槽奕。

但這讓我們想到在這些情況下我們想要從非異步代碼調(diào)用異步方法的問題。在上面的示例中房轿,我們希望從OnGUI方法內(nèi)部啟動(dòng)RunTaskAsync異步方法粤攒,我們不關(guān)心等待它完成,因此我們不希望只添加await以便可以記錄異常冀续。

要記住的經(jīng)驗(yàn)法則是:

永遠(yuǎn)不要在沒有等待返回的Task的情況下調(diào)用async Task方法琼讽。如果您不想等待異步行為完成,則應(yīng)該調(diào)用async void方法洪唐。

所以我們的例子變成:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTask();
        }
    }
 
    async void RunTask()
    {
        await RunTaskAsync();
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

如果再次運(yùn)行此代碼,您現(xiàn)在應(yīng)該看到記錄了異常吼蚁。這是因?yàn)楫?dāng)在RunTask方法中的await期間拋出異常時(shí)凭需,它會(huì)冒泡到Unity并記錄到控制臺(tái),因?yàn)樵谶@種情況下沒有任何Task對象可以捕獲它肝匆。

標(biāo)記為“async void”的方法表示某些異步行為的根級別“入口點(diǎn)”粒蜈。考慮它們的一個(gè)好方法是旗国,它們是“發(fā)射并忘記”的任務(wù)枯怖,在任何調(diào)用代碼立即繼續(xù)的情況下,在后臺(tái)執(zhí)行某些操作能曾。

順便說一句度硝,這也是遵循總是在返回Task的異步方法上使用后綴“Async”的慣例的一個(gè)很好的理由。這是大多數(shù)使用async-await的代碼庫的標(biāo)準(zhǔn)做法寿冕。它有助于傳達(dá)這樣一個(gè)事實(shí):該方法應(yīng)始終以'await'開頭蕊程,但也允許您為不包含后綴的方法創(chuàng)建一個(gè)async void對應(yīng)物。

另外值得一提的是驼唱,如果您在Visual Studio中編譯代碼藻茂,那么當(dāng)您嘗試在沒有相關(guān)等待的情況下調(diào)用“異步任務(wù)”方法時(shí),您應(yīng)該會(huì)收到警告玫恳,這是避免此錯(cuò)誤的好方法辨赐。

作為創(chuàng)建自己的“async void”方法的替代方法,您還可以使用輔助方法(包含在與本文相關(guān)的源代碼中)來執(zhí)行等待京办。在這種情況下掀序,我們的例子將成為:

public class AsyncExample : MonoBehaviour
{
    public void OnGUI()
    {
        if (GUI.Button(new Rect(100, 100, 100, 100), "Start Task"))
        {
            RunTaskAsync().WrapErrors();
        }
    }
 
    async Task RunTaskAsync()
    {
        Debug.Log("Started task...");
        await new WaitForSeconds(1.0f);
        throw new Exception();
    }
}

WrapErrors()方法只是確保等待任務(wù)的通用方法,因此Unity將始終接收任何拋出的異常臂港。它只是做了等待森枪,就是這樣:

public static async void WrapErrors(this Task task)
{
    await task;
}

從協(xié)同程序調(diào)用異步

對于某些代碼庫视搏,從協(xié)程遷移到使用async-await似乎是一項(xiàng)艱巨的任務(wù)。我們可以通過逐步采用async-await來簡化這個(gè)過程县袱。但是浑娜,為了做到這一點(diǎn),我們不僅需要能夠從異步代碼調(diào)用IEnumerator代碼式散,而且我們還需要能夠從IEnumerator代碼調(diào)用異步代碼筋遭。值得慶幸的是,我們可以使用另一種擴(kuò)展方法輕松添加:

public static class TaskExtensions
{
    public static IEnumerator AsIEnumerator(this Task task)
    {
        while (!task.IsCompleted)
        {
            yield return null;
        }
 
        if (task.IsFaulted)
        {
            throw task.Exception;
        }
    }
}

現(xiàn)在我們可以從coroutines調(diào)用異步方法暴拄,如下所示:

public class AsyncExample : MonoBehaviour
{
    public void Start()
    {
        StartCoroutine(RunTask());
    }
 
    IEnumerator RunTask()
    {
        yield return RunTaskAsync().AsIEnumerator();
    }
 
    async Task RunTaskAsync()
    {
        // run async code
    }
}

多線程

我們也可以使用async-await來執(zhí)行多個(gè)線程漓滔。你可以用兩種方式做到這一點(diǎn)。第一種方法是使用ConfigureAwait方法乖篷,如下所示:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Here we are on the unity thread
 
        await Task.Delay(TimeSpan.FromSeconds(1.0f)).ConfigureAwait(false);
 
        // Here we may or may not be on the unity thread depending on how the task that we
        // execute before the ConfigureAwait is implemented
    }
}

如上所述响驴,Unity提供了一種稱為默認(rèn)SynchronizationContext的東西,默認(rèn)情況下它將在主Unity線程上執(zhí)行異步代碼撕蔼。ConfigureAwait方法允許我們覆蓋此行為豁鲤,因此結(jié)果將是await下面的代碼將不再保證在主Unity線程上運(yùn)行,而是從我們正在執(zhí)行的任務(wù)繼承上下文鲸沮,有些情況可能是我們想要的琳骡。

如果要在后臺(tái)線程上顯式執(zhí)行代碼,還可以執(zhí)行以下操作:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // We are on the unity thread here
 
        await new WaitForBackgroundThread();
 
        // We are now on a background thread
        // NOTE: Do not call any unity objects here or anything in the unity api!
    }
}

WaitForBackgroundThread是這篇文章的源代碼中包含的一個(gè)類讼溺,它將完成啟動(dòng)新線程的工作楣号,并確保重寫Unity的默認(rèn)SynchronizationContext行為。

那么回到Unity線程呢怒坯?

您只需等待我們在上面創(chuàng)建的任何Unity特定對象即可炫狱。例如:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForSeconds(1.0f);
 
        // Unity thread again
    }
}

包含的源代碼還提供了一個(gè)WaitForUpdate()類,如果您只想在沒有任何延遲的情況下返回Unity線程敬肚,可以使用它:

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        // Unity thread
 
        await new WaitForBackgroundThread();
 
        // Background thread
 
        await new WaitForUpdate();
 
        // Unity thread again
    }
}

當(dāng)然毕荐,如果您使用后臺(tái)線程,則需要非常小心以避免并發(fā)問題艳馒。但是憎亚,在很多情況下,提高性能是值得的弄慰。

陷阱和最佳實(shí)踐

  • 避免async void支持異步任務(wù)第美,除了你想要從非異步代碼啟動(dòng)異步代碼的'fire and forget'情況
  • 將后綴“Async”附加到返回Task的所有異步方法。這有助于傳達(dá)它應(yīng)該始終以'await'開頭的事實(shí)陆爽,并允許在不沖突的情況下輕松添加異步void對應(yīng)
  • 在visual studio中使用斷點(diǎn)調(diào)試異步方法尚不可行什往。然而,正如此處所示慌闭,“VS Unity for Unity”團(tuán)隊(duì)表示正在開展工作

UniRx

另一種做異步邏輯的方法是使用像UniRx這樣的庫進(jìn)行反應(yīng)式編程别威。就個(gè)人而言躯舔,我是這種編碼的忠實(shí)粉絲,并且在我參與的許多項(xiàng)目中廣泛使用它省古。幸運(yùn)的是粥庄,與async-await和另一個(gè)自定義awaiter一起使用非常容易。例如:

public class AsyncExample : MonoBehaviour
{
    public Button TestButton;
 
    async void Start()
    {
        await TestButton.OnClickAsObservable();
        Debug.Log("Clicked Button!");
    }
}

我發(fā)現(xiàn)UniRx observable與長期運(yùn)行的異步方法/協(xié)同程序有不同的用途豺妓,所以它們自然適合使用async-await的工作流程惜互,如上例所示。我不會(huì)在這里詳細(xì)介紹琳拭,因?yàn)閁niRx和反應(yīng)式編程本身就是一個(gè)單獨(dú)的主題训堆,但是我會(huì)說,一旦你對UniRx“流”中的數(shù)據(jù)流感到滿意白嘁,就不會(huì)有回頭路了坑鱼。 。

源代碼

您可以從資產(chǎn)商店github repo的版本部分下載包含async-await支持的源代碼絮缅。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末姑躲,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子盟蚣,更是在濱河造成了極大的恐慌,老刑警劉巖卖怜,帶你破解...
    沈念sama閱讀 222,729評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件屎开,死亡現(xiàn)場離奇詭異,居然都是意外死亡马靠,警方通過查閱死者的電腦和手機(jī)奄抽,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,226評論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來甩鳄,“玉大人逞度,你說我怎么就攤上這事∶羁校” “怎么了档泽?”我有些...
    開封第一講書人閱讀 169,461評論 0 362
  • 文/不壞的土叔 我叫張陵,是天一觀的道長揖赴。 經(jīng)常有香客問我馆匿,道長,這世上最難降的妖魔是什么燥滑? 我笑而不...
    開封第一講書人閱讀 60,135評論 1 300
  • 正文 為了忘掉前任渐北,我火速辦了婚禮,結(jié)果婚禮上铭拧,老公的妹妹穿的比我還像新娘赃蛛。我一直安慰自己恃锉,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,130評論 6 398
  • 文/花漫 我一把揭開白布呕臂。 她就那樣靜靜地躺著破托,像睡著了一般。 火紅的嫁衣襯著肌膚如雪诵闭。 梳的紋絲不亂的頭發(fā)上炼团,一...
    開封第一講書人閱讀 52,736評論 1 312
  • 那天,我揣著相機(jī)與錄音疏尿,去河邊找鬼瘟芝。 笑死,一個(gè)胖子當(dāng)著我的面吹牛褥琐,可吹牛的內(nèi)容都是我干的锌俱。 我是一名探鬼主播,決...
    沈念sama閱讀 41,179評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼敌呈,長吁一口氣:“原來是場噩夢啊……” “哼贸宏!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起磕洪,我...
    開封第一講書人閱讀 40,124評論 0 277
  • 序言:老撾萬榮一對情侶失蹤吭练,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后析显,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鲫咽,經(jīng)...
    沈念sama閱讀 46,657評論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,723評論 3 342
  • 正文 我和宋清朗相戀三年谷异,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了分尸。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,872評論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡歹嘹,死狀恐怖箩绍,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情尺上,我是刑警寧澤材蛛,帶...
    沈念sama閱讀 36,533評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站尖昏,受9級特大地震影響仰税,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜抽诉,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,213評論 3 336
  • 文/蒙蒙 一陨簇、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦河绽、人聲如沸己单。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,700評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽纹笼。三九已至,卻和暖如春苟跪,著一層夾襖步出監(jiān)牢的瞬間廷痘,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,819評論 1 274
  • 我被黑心中介騙來泰國打工件已, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留笋额,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 49,304評論 3 379
  • 正文 我出身青樓篷扩,卻偏偏與公主長得像兄猩,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子鉴未,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,876評論 2 361

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