WidgetKit框架詳細解析(二) —— 一個基于WidgetKit和SwiftUI的簡單示例(一)

版本記錄

版本號 時間
V1.0 2020.11.20 星期五

前言

WidgetKit是iOS14的新的SDK,接下來幾篇我們就一起看一下這個專題。感興趣的可以看下面幾篇文章。
1. WidgetKit框架詳細解析(一) —— 基本概覽(一)

開始

首先看下主要內容:

在本教程中,您將向一個大型SwiftUI應用添加小部件(widget),重用其視圖以顯示該應用存儲庫中的條目。內容來自翻譯

下面就看下寫作環境

Swift 5, iOS 14, Xcode 12

接著就是正文了

在今年的WWDC Platforms State of the Union中看到新的主屏幕小部件(widgets)后,我就知道必須為自己喜歡的應用制作一個!在主屏幕上看到它真是太好了。我并不孤單。每個人都在做!蘋果公司知道自己是贏家,并提供了一個由三部分組成的代碼,以使所有人開始使用。

已經發布了幾本指導手冊,那么本教程有什么不同?好吧,我決定將一個小部件添加到由一組開發人員編寫的相當大的SwiftUI應用程序中,但沒人是我。有大量的代碼可供篩選,以查找構建窗口小部件所需的內容。而且所有這些都沒有考慮到小部件的編寫。因此,請跟隨我,向我展示如何做到。

注意:您將需要Xcode 12 beta。您還需要運行iOS 14的iOS設備。Catalina可以。如果您有運行Big Sur BetaMac [partition],則可以嘗試在其中運行代碼,以防它無法在Catalina上運行。

最重要的是,目前這是一個真正的bleeding-edge APIWWDC演示中出現的內容不是Xcode 12 beta 1的一部分。您可能會遇到一些不穩定的情況。就是說,Widgets很酷,很有趣!

在打開啟動程序項目之前,請打開Terminalcdstarter / emitron-iOS-development文件夾,然后運行以下命令:

scripts/generate_secrets.sh

您正在生成運行項目所需的一些機密文件。

現在,在starter / emitron-iOS-development文件夾中打開Emitron項目。這需要一些時間才能獲取一些軟件包,因此,這里有一些有關項目的信息,您可以在等待的同時進行。

Emitronraywenderlich.com應用程序。如果您是訂閱者(subscriber),則一定已將其安裝在iPhoneiPad上。它可以讓您流式傳輸視頻,并且,如果您具有專業訂閱,則可以下載視頻以進行離線播放。

該項目是開源的。您可以在其GitHub存儲庫GitHub repository,中閱讀有關它的信息,當然,歡迎您為它的改進做出貢獻。

您下載的starter版進行了一些修改:

  • 設置是最新的,iOS Deployment Target14.0。
  • Downloads / DownloadService.swift中,注釋了兩個在Xcode beta 1中引起錯誤的promise語句。下載服務是針對專業訂閱的,本教程不需要它。
  • Guardpost / Guardpost.swift中,將authSession?.prefersEphemeralWebBrowserSession設置為false,從而避免每次構建和運行應用程序時都需要輸入登錄詳細信息。您仍然必須點擊Sign in,提示您使用raywenderlich.com登錄。點擊Continue。首次構建和運行時,您可能仍必須輸入電子郵件和密碼,但是在隨后的構建和運行中,點擊Continue會跳過登錄表單。

到目前為止,Xcode已經安裝了所有軟件包。在模擬器中構建并運行。

忽略有關HashableSwiftyJSON的警告。 滾動和播放在Xcode beta 1中效果不佳,但是您不會在本教程中對此進行修復。 如果滾動“太多”,則該應用程序將崩潰。 也不是你的問題。


WidgetKit

本教程全部關于向Emitron添加閃亮的新小部件。


Adding a Widget Extension

首先添加帶有File ? New ? Target…widget extension。

Create a new target

搜索widget,選擇Widget Extension并點擊Next

將其命名為EmitronWidget,并確保未選中Include Configuration Intent

Don’t select Include Configuration Intent

有兩種窗口小部件配置(widget configurations)StaticIntent。 具有IntentConfiguration的小部件使用Siri Intents來使用戶自定義小部件參數。

單擊Finish并同意激活方案(activate-scheme)對話框:

1. Running Your Widget

小部件模板(widget template)提供了許多您只需自定義的樣板代碼。它可以直接使用,因此,您可以立即設置所有內容,以確保在準備測試代碼時一切都能順利運行。

注意:Xcode 12 beta 1模擬器不會在widget gallery中顯示您的widget。因此,在將來的某個Beta版本之前,您必須在iOS 14設備上構建并運行。如果您沒有可用的設備,則可以運行Widget scheme而不是主Emitron scheme,該Widget將出現在模擬器的主屏幕上。

在項目導航器中,選擇頂級Emitron文件夾對targets進行簽名。更改bundle identifier,并為每個target的每個版本設置team。

注意:對于widget,您可能會遇到一個明顯的Xcode bug,該bug將三個版本中的兩個的簽名標識設置為Distribution。如果看到此錯誤,請打開Build Settings,搜索distribution并將簽名標識更改為Apple Development。

最后一個陷阱:確保小部件的bundle ID前綴與應用程序的ID匹配。這意味著您將需要在“ ios”“ EmitronWidget”之間插入dev以獲取your.prefix.emitron.ios.dev.EmitronWidget。

OK,現在連接您的iOS設備,選擇Emitron scheme和您的設備,然后構建并運行。登錄,然后關閉應用程序,然后在主窗口的空白區域上按,直到圖標開始抖動。

點擊右上角的+按鈕,然后向下滾動以找到raywenderlich

Scroll down in widget gallery

選擇它可以查看三種尺寸的快照:

Snapshots of the three widget sizes

點擊Add Widget以在屏幕上查看您的小部件:

Your widget on the home screen

點擊widget以重新打開Emitron。

您的小部件widget起作用了! 現在,您只需要使其顯示來自Emitron的信息即可。


Defining Your Widget

使您的小部件顯示應用程序為每個教程顯示的一些信息是很有意義的。

Card view in the Emitron app

此視圖在UI / Shared / Content List / CardView.swift中定義。 我的第一個想法是將窗口widget target添加到此文件中。 但這需要添加越來越多的文件,以容納Emitron中所有復雜的連接。

您真正需要的只是Text視圖。 這些圖片很可愛,但是您需要包括持久性基礎結構以防止它們消失。

您將復制相關Text視圖的布局。 它們使用幾個實用程序擴展,因此找到這些文件并將EmitronWidgetExtension target添加到其中:

Add the widget target to these files

注意:確保注意到圖像頂部Assets

CardView顯示ContentListDisplayable對象的屬性。 這是Displayable / ContentDisplayable.swift中定義的協議:

protocol ContentListDisplayable: Ownable {
  var id: Int { get }
  var name: String { get }
  var cardViewSubtitle: String { get }
  var descriptionPlainText: String { get }
  var releasedAt: Date { get }
  var duration: Int { get }
  var releasedAtDateTimeString: String { get }
  var parentName: String? { get }
  var contentType: ContentType { get }
  var cardArtworkUrl: URL? { get }
  var ordinal: Int? { get }
  var technologyTripleString: String { get }
  var contentSummaryMetadataString: String { get }
  var contributorString: String { get }
  // Probably only populated for screencasts
  var videoIdentifier: Int? { get }
}

您的widget僅需要name,cardViewSubtitle,descriptionPlainTextreleasedAtDateTimeString。 因此,您將為這些屬性創建一個結構。

1. Creating a TimelineEntry

創建一個新的名為WidgetContent.swiftSwift文件,并確保其targetsemitronEmitronWidgetExtension

Create WidgetContent with targets emitron and widget

它應該在EmitronWidget組中。

現在,將此代碼添加到新文件中:

import WidgetKit

struct WidgetContent: TimelineEntry {
  var date = Date()
  let name: String
  let cardViewSubtitle: String
  let descriptionPlainText: String
  let releasedAtDateTimeString: String
}

要在窗口小部件中使用WidgetContent,它必須符合TimelineEntry。 唯一必需的屬性是date,您可以將其初始化為當前日期。

2. Creating an Entry View

接下來,創建一個視圖以顯示四個String屬性。 創建一個新的SwiftUI View文件,并將其命名為EntryView.swift。 確保其target僅是EmitronWidgetExtension,并且也應位于EmitronWidget組中:

Create EntryView with only the widget as target

現在,用以下代碼替換struct EntryView的內容:

let model: WidgetContent

var body: some View {
  VStack(alignment: .leading) {
    Text(model.name)
      .font(.uiTitle4)
      .lineLimit(2)
      .fixedSize(horizontal: false, vertical: true)
      .padding([.trailing], 15)
      .foregroundColor(.titleText)
    
    Text(model.cardViewSubtitle)
      .font(.uiCaption)
      .lineLimit(nil)
      .foregroundColor(.contentText)
    
    Text(model.descriptionPlainText)
      .font(.uiCaption)
      .fixedSize(horizontal: false, vertical: true)
      .lineLimit(2)
      .lineSpacing(3)
      .foregroundColor(.contentText)
    
    Text(model.releasedAtDateTimeString)
      .font(.uiCaption)
      .lineLimit(1)
      .foregroundColor(.contentText)
  }
  .background(Color.cardBackground)
  .padding()
  .cornerRadius(6)
}

您實質上是從CardView復制Text視圖并添加填充間距。

完全刪除EntryView_Previews。

3. Creating Your Widget

現在開始定義窗口widget。 打開EmitronWidget.swift并在該行中雙擊SimpleEntry

public typealias Entry = SimpleEntry

選擇Editor ? Edit All in Scope,并將名稱更改為WidgetContent。 這將導致一些錯誤,您將在接下來的幾個步驟中進行修復。 首先刪除聲明:

struct WidgetContent: TimelineEntry {
  public let date: Date
}

現在,此聲明是多余的,并且與WidgetContent.swift中的聲明沖突。

4. Creating a Snapshot Entry

provider的一種方法提供了一個快照條目,以顯示在widget gallery中。 為此,您將使用特定的WidgetContent對象。

import語句的下面,添加此全局對象:

let snapshotEntry = WidgetContent(
  name: "iOS Concurrency with GCD and Operations",
  cardViewSubtitle: "iOS & Swift",
  descriptionPlainText: """
    Learn how to add concurrency to your apps! \
    Keep your app's UI responsive to give your \
    users a great user experience.
    """,
  releasedAtDateTimeString: "Jun 23 2020 ? Video Course (3 hrs, 21 mins)")

這是我們的并發視頻課程的更新,該課程在WWDC第2天發布。

現在,將snapshot(with:completion:)的第一行替換為:

let entry = snapshotEntry

當您在gallery中查看此小部件時,它將顯示此條目。

5. Creating a Temporary Timeline

小部件需要一個TimelineProvider才能為其提供TimelineEntry類型的條目。 它會在條目的date屬性指定的時間顯示每個條目。

最重要的provider方法是timeline(with:completion:)。 它已經有一些代碼來構造時間軸,但是您沒有足夠的條目。 因此,注釋掉最后兩行以外的所有內容,并添加以下行:

let entries = [snapshotEntry]

您正在創建一個僅包含snapshotEntryentries數組。

6. Creating a Placeholder View

小部件在等待實際時間軸條目時顯示其PlaceholderView。 您還將為此使用snapshotEntry。

以此替換Text視圖:

EntryView(model: snapshotEntry)

WWDC代碼中還顯示了一個特殊的修飾符,該修飾符使視圖的內容模糊不清,以表明這是一個占位符,而不是真實的東西。 是這樣的:

.isPlaceholder(true)

WWDC視頻中看起來很酷,但是在Xcode 12 beta 1中沒有編譯。有關更多信息,請參閱Apple開發者論壇中的此項 this entry。

7. Defining Your Widget

最后,您可以將所有這些部分放在一起。

首先,刪除EmitronWidgetEntryView。 您將改用EntryView

現在,將EmitronWidget的內部替換為以下內容:

private let kind: String = "EmitronWidget"

public var body: some WidgetConfiguration {
  StaticConfiguration(
    kind: kind, 
    provider: Provider(), 
    placeholder: PlaceholderView()
  ) { entry in
    EntryView(model: entry)
  }
  .configurationDisplayName("RW Tutorials")
  .description("See the latest video tutorials.")
}

這三個字符串是您想要的:kind描述您的窗口小部件,最后兩個字符串顯示在庫中每個窗口小部件上方的尺寸。

在您的設備上構建并運行,登錄,然后關閉該應用以查看您的小部件。

如果仍然顯示時間,請將其刪除并重新添加。

Widget gallery with snapshot entry

這是中等大小的小部件現在的樣子:

The medium size widget on the home screen

只有中等大小的小部件看起來不錯,因此請修改您的小部件以僅提供該大小。 在.description下面添加此修飾符:

.supportedFamilies([.systemMedium])

接下來,您將直接從應用程序的存儲庫中為時間線提供真實的條目!


Providing Timeline Entries

該應用程序將在Data / ContentRepositories / ContentRepository.swift中創建的contents中顯示ContentListDisplayable對象的數組。 要與您的小部件widget共享此信息,您將創建一個應用程序組。 然后,在ContentRepository.swift中,將文件寫入此應用程序組,并在EmitronWidget.swift中讀取該文件。

1. Creating an App Group

在項目頁面上,選擇emitron target。 在Signing & Capabilities選項卡中,單擊+ Capability,然后將App Group拖到窗口中。 將其命名為group.your.prefix.emitron.contents;確保適當替換your.prefix。

現在,選擇EmitronWidgetExtension target并添加App Group功能。 滾動瀏覽App Group以查找并選擇group.your.prefix.emitron.contents。

2. Writing the Contents File

ContentRepository.swift的頂部,在import Combine語句的下面,添加以下代碼:

import Foundation

extension FileManager {
  static func sharedContainerURL() -> URL {
    return FileManager.default.containerURL(
      forSecurityApplicationGroupIdentifier: "group.your.prefix.emitron.contents"
    )!
  }
}

這只是獲取應用程序組容器的URL的一些標準代碼。 確保替換您的應用標識符前綴。

現在,在var contents下面,添加此輔助方法:

  func writeContents() {
    let widgetContents = contents.map {
      WidgetContent(name: $0.name, cardViewSubtitle: $0.cardViewSubtitle,
      descriptionPlainText: $0.descriptionPlainText, 
      releasedAtDateTimeString: $0.releasedAtDateTimeString)
    }
    let archiveURL = FileManager.sharedContainerURL()
      .appendingPathComponent("contents.json")
    print(">>> \(archiveURL)")
    let encoder = JSONEncoder()
    if let dataToSave = try? encoder.encode(widgetContents) {
      do {
        try dataToSave.write(to: archiveURL)
      } catch {
        print("Error: Can't write contents")
        return
      }
    }
  }

在這里,您將創建一個WidgetContent對象的數組,每個對象用于存儲庫中的每個項目。 您將它們分別轉換為JSON并將其保存到app group的容器中。

let archiveURL行設置一個斷點。

設置contents后,您將調用此方法。 將此didSet閉包添加到contents中:

didSet {
  writeContents()
}

如果Xcode在警告WidgetContent。 跳轉到WidgetContent的定義,使其符合Codable

struct WidgetContent: Codable, TimelineEntry {

現在,在模擬器中構建并運行該應用程序。 在斷點處,widgetContents具有20個值。

繼續執行程序并在應用程序中向下滾動。 在斷點處,widgetContents現在具有40個值。 因此,您可以控制與小部件共享多少項。

停止應用程序,禁用斷點,然后從調試控制臺復制URL文件夾路徑并在Finder中定位。 看一下contents.json

接下來,轉到并設置widget以讀取此文件。

3. Reading the Contents File

EmitronWidget.swift中,添加相同的FileManager代碼:

extension FileManager {
  static func sharedContainerURL() -> URL {
    return FileManager.default.containerURL(
      forSecurityApplicationGroupIdentifier: "group.your.prefix.emitron.contents"
    )!
  }
}

確保更新您的前綴。

將此幫助程序方法添加到Provider

func readContents() -> [Entry] {
  var contents: [WidgetContent] = []
  let archiveURL = 
    FileManager.sharedContainerURL()
      .appendingPathComponent("contents.json")
  print(">>> \(archiveURL)")

  let decoder = JSONDecoder()
  if let codeData = try? Data(contentsOf: archiveURL) {
    do {
      contents = try decoder.decode([WidgetContent].self, from: codeData)
    } catch {
      print("Error: Can't decode contents")
    }
  }
  return contents
}

這將讀取您保存到應用程序組容器中的文件。

取消注釋timeline(with:completion:)中的代碼,然后替換此行:

var entries: [WidgetContent] = []

使用下面

var entries = readContents()

接下來,修改注釋和for循環以將日期添加到條目中:

// Generate a timeline by setting entry dates interval seconds apart,
// starting from the current date.
let currentDate = Date()
let interval = 5
for index in 0 ..< entries.count {
  entries[index].date = Calendar.current.date(byAdding: .second,
    value: index * interval, to: currentDate)!
}

刪除for循環下面的let entry行。

之后的那一行設置時間軸運行并指定刷新策略。 在這種情況下,時間軸將在用完所有當前條目后刷新。

在您的設備上構建并運行,登錄并加載列表。 然后關閉該應用程序,添加您的小部件并觀看它每5秒更新一次。

Widget updating entry every 5 seconds

我可以整天看這個。

如果您沒有滾動列表,則該widget將在20個項目后用完所有條目。如果等待那么長時間,您會在刷新時看到它暫停。

注意:這是Beta版軟件。如果未獲得預期的結果,請嘗試從設備中刪除該應用,然后重新啟動設備。另外,請記住,小部件并不是要以秒為單位測量時間間隔。在教程設置中,非常短的間隔只是更加方便。但是結果是,時間軸刷新的等待時間感覺很長!最后一條警告:不要讓5秒小部件在設備上運行,因為它會耗盡電池電量。


Enabling User Customization

我為時間軸間隔選擇了5秒,因此無需等待很長時間即可看到更新。如果您想要更短或更長的間隔,只需更改代碼中的值即可?;蛘?code>...創建一個intent,讓您可以通過在主屏幕上直接編輯小部件來設置時間間隔!

注意:使用intent更改時間間隔時,直到widget刷新其時間軸,您才會看到效果。

1. Adding an Intent

首先,添加您的intent:創建一個新文件(Command-N),搜索intent,選擇SiriKit Intent Definition File并將其命名為TimelineInterval。確保其target同時是emitronEmitronWidgetExtension。

intent側邊欄的左下角,單擊+,然后選擇New Intent。

Add new intent

intent命名為TimelineInterval。 如圖所示,使用Category View設置Custom Intent

Custom intent with category view

并添加一個名為Integer類型的interval的參數,其默認值,最小值和最大值(如所示)和Type Field。 或設置您自己的值和/或使用步進器。

Add interval parameter

2. Reconfiguring Your Widget

EmitronWidget.swift中,將小部件重新配置為IntentConfiguration。

Provider協議更改為IntentTimelineProvider。

struct Provider: IntentTimelineProvider {

snapshot(with:completion:)定義為:

public func snapshot(
  for configuration: TimelineIntervalIntent, 
  with context: Context, 
  completion: @escaping (Entry) -> Void
) {

現在,將timeline(with:completion:)的定義更改為:

public func timeline(
  for configuration: TimelineIntervalIntent, 
  with context: Context, 
  completion: @escaping (Timeline<Entry>) -> Void
) {

timeline(for:with:completion)中,更改interval以使用配置參數:

let interval = configuration.interval as! Int

最后,在EmitronWidget中,將StaticConfiguration(kind:provider:placeholder :)更改為此:

IntentConfiguration(
  kind: kind, 
  intent: TimelineIntervalIntent.self, 
  provider: Provider(), 
  placeholder: PlaceholderView()
) { entry in

在您的設備上構建并運行,登錄并加載列表。 關閉應用程序,添加小部件,然后長按該小部件。 它會翻轉以顯示Edit Widget按鈕。

Edit widget button

點擊此按鈕更改間隔值

Change the interval value
Setting a new interval

本教程向您展示了如何利用大型應用程序中的代碼來創建一個小部件,以顯示該應用程序自己的存儲庫中的項目。 以下是一些您可以添加到Emitron小部件中的想法:

  • 為大型窗口小部件設計一個視圖,該視圖顯示兩個或更多條目。 查看AppleEmojiRangers示例應用程序,以了解如何為小部件系列修改EntryView。
  • 將一個widgetURL添加到EntryView,以便點按該小部件可在該項目的詳細信息視圖中打開Emitron。
  • 添加intent以使用戶可以從小部件設置應用程序的過濾器。

后記

本篇主要講述了基于WidgetKitSwiftUI的簡單示例,感興趣的給個贊或者關注~~~

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

推薦閱讀更多精彩內容