Stanford-cs193p-05|Protocol-Enum-Optional

本文主要内容转载于闻者通达的个人博客

本文是斯坦福大学 cs193p 公开课程第05集的相关笔记。

cs193p 课程介绍:

The lectures for the Spring 2023 version of Stanford University’s course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You’ll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).

cs193p 课程网址: https://cs193p.sites.stanford.edu/2023


1. Equatable Protocol

//  EmojiMemoryGameView.swift
struct EmojiMemoryGameView: View {
    @ObservedObject var viewModel: EmojiMemoryGame
        
    var body: some View {
        VStack {
            ScrollView {
                cards
                    .animation(.default, value: )
            }
            Button("Shuffle") {
                viewModel.shuffle()
            }
        }
        .padding()
    }
...

我们可以通过添加 .animation 视图修饰符来制作动画。value 表示它只会在此值更改时进行动画处理。

但是,这会导致错误 “Referencing instance method ‘animation(:value:)’ on ‘Array’ requires that ‘MemoryGame<String>.Card’ conform to ‘Equatable’”。这表明,如果某些内容发生更改,动画将复制该更改。当某些内容也发生变化时,它需要另一个副本。仅当两个副本不相等时,动画才会进行动画处理。因此,我们需要使 Card 遵循 Equatable 协议(protocol)。

//  MemorizeGame.swift
...
    struct Card: Equatable {
    	// 为了实现 Equatable 协议,此处需要编写一个静态函数如下:
        static func == (lhs: Card, rhs: Card) -> Bool {
            return lhs.isFaceUp == rhs.isFaceUp &&
            lhs.isMatched == rhs.isMatched &&
            lhs.content == rhs.content
        }
        
        var isFaceUp = true
        var isMatched = false
        let content: CardContent
    }
...

为了实现 Equatable 协议,我们需要编写一个静态函数,该函数接受一个函数并返回一个 Bool。该函数需要两个参数,一个左侧 Card 和一个右侧 Card

但是,错误信息 “Referencing operator function '==' on 'Equatable' requires that 'CardContent' conform to 'Equatable'” 表示我们需要使 CardContent 相等。

//  MemorizeGame.swift
...
struct MemoryGame<CardContent> where CardContent: Equatable {
    ...
    ...
    
    struct Card: Equatable {
        static func == (lhs: Card, rhs: Card) -> Bool {
            return lhs.isFaceUp == rhs.isFaceUp &&
            lhs.isMatched == rhs.isMatched &&
            lhs.content == rhs.content
        }
        
        var isFaceUp = true
        var isMatched = false
        let content: CardContent
    }
}

为了使我们的 CardContent 相等,我们使用 where CardContent: Equatable 来实现它。这意味着,我们对 Card 还是有所限制的。另外,Swift 中另一个很酷的特性是,如果我们像上面这样对 CardContent 比较每件事,我们就可以直接删除之前在 Card 结构体中添加的静态函数。

struct MemoryGame<CardContent> where CardContent: Equatable {  
    ...
      ...
    
    struct Card: Equatable {
        var isFaceUp = true
        var isMatched = false
        let content: CardContent
    }
}

它确实有效,但卡片的动画效果为淡入淡出,看起来不像洗牌。

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
            ForEach(viewModel.cards.indices, id: \.self) { index in
                CardView(viewModel.cards[index])
                    .aspectRatio(2/3, contentMode: .fit)
                    .padding(4)
            }
        }
        .foregroundColor(.orange)
    }
...

因为我们的 ForEach 迭代数组的索引。它从卡 0、1、2、3 …并为每个 API 创建 Card 视图。例如,当我们洗牌时,我们会将牌从 0 号移动到 7 号。但是 ForEach 仍然显示从 0、1、2…

我们想要移动卡片本身,即 Card 视图。

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
            ForEach(viewModel.cards, id: \.self) { card in
                CardView(card)
                    .aspectRatio(2/3, contentMode: .fit)
                    .padding(4)
            }
        }
        .foregroundColor(.orange)
    }
...

我们对 ForEach 做了一些改变。上面的代码将产生错误 "Referencing initializer 'init(\_:id:content:)' on 'ForEach' requires that 'MemoryGame\\<String\\>.Card' conform to 'Hashable'"

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
            ForEach(viewModel.cards) { card in
                CardView(card)
                    .aspectRatio(2/3, contentMode: .fit)
                    .padding(4)
            }
        }
        .foregroundColor(.orange)
    }
...

现在,是时候谈谈 id: \.self 了,用来识别这些 cards

在这里,我们想让 id 唯一,即对某张 Card 具有唯一 id 且不会变化,但通过 id:\.self 无法实现,因为 Card 包括 isFaceUpisMatch 属性,当我们单击卡片时,这些属性会发生变化,id 就会发生变化。

我们需要一些其他东西来识别 Card

2. Identifiable Protocol

//  MemorizeGame.swift
import Foundation
struct MemoryGame<CardContent> where CardContent: Equatable {
    private(set) var cards: Array<Card>
    
    init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
        cards = []
        // add numberOfParisOfCards x 2 cards
        for pairIndex in 0..<max(2, numberOfPairsOfCards) {
            let content = cardContentFactory(pairIndex)
            cards.append(Card(content: content, id: "\(pairIndex+1)a"))
            cards.append(Card(content: content, id: "\(pairIndex+1)b"))
        }
    }
    
    ...
      ...
    
    struct Card: Equatable, Identifiable {
        var isFaceUp = true
        var isMatched = false
        let content: CardContent
        
        var id: String
    }
}

我们添加了一个键入为 String 的 id,并在创建新卡时分配该 id。id 看起来像 1a、1b、2a、2b、3a、3b 等…

3. CustomDebugStringConvertible

cs193p05_debugConsoleinformation

我们当前的印刷信息相当复杂。所以,我们可以让它变得更好。

//  MemorizeGame.swift
...
    struct Card: Equatable, Identifiable, CustomDebugStringConvertible {
        var isFaceUp = true
        var isMatched = false
        let content: CardContent
        
        var id: String
        var debugDescription: String {
            "\(id): \(content) \(isFaceUp ? "up" : "down") \(isMatched ? "matched": "")"
        }
    }
...

我们为 Card 实施 CustomDebugStringConvertible protocol 协议,并定义debugDescription。

现在,它变得更简洁了。

网页剪藏|Stanford cs193p Protocols Enum Optional-1

4. ViewModel Intent - Part 1:翻转卡片

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85), spacing: 0)], spacing: 0) {
            ForEach(viewModel.cards) { card in
                CardView(card)
                    .aspectRatio(2/3, contentMode: .fit)
                    .padding(4)
                    .onTapGesture {
                        viewModel.choose(card)
                    }
            }
        }
        .foregroundColor(.orange)
    }
...
//  MemorizeGame.swift
...
    func choose(_ card: Card) {
        card.isFaceUp.toggle()
    }
...

现在,我们开始实现用户的意图(翻转卡片)。

我们可以使用 .onTapGesture.toggle() 来在用户触摸屏幕时翻转卡片。但是,由于卡片是值类型,当 choose 函数获取卡片的副本时,我们无法直接使用 .toggle() 来翻转它。(当你在 MemoryGame 的 choose 函数中传递 card 作为参数时,Swift 会创建该卡片的一个副本。这是因为 Card 是一个结构体,而结构体在 Swift 中是值类型。对值类型的任何修改会影响拷贝,而不会影响原始值。)

//  MemorizeGame.swift
...
    mutating func choose(_ card: Card) {
        let chosenIndex = index(of: card)
        cards[chosenIndex].isFaceUp.toggle()
    }
    
    func index(of card: Card) -> Int {
        for index in cards.indices {
            if cards[index].id == card.id {
                return index
            }
        }
        
        return 0 // FIXME: bogus!
    }
...

我们将直接使用卡片的索引来修改卡片数组。我们实现了一个名为 index 的函数,用于查找卡片的索引。

目前,如果找不到卡片,我们将返回 0,也就是我们的第一张卡片。

在我们知道如何解决这个问题之前,先来了解一下枚举(enum)。

5. Enum

5.1 Enum 简介

enum FastFoodMenuItem {
	case hamburger
	case fries
	case drink
	case cookie
}
  • Enum 是除了 structclass 以外的另一种数据结构。

  • 它只能包含离散的状态。

  • 枚举是一种值类型(如 struct),因此它会在传递时被复制。

  • 每个状态都可以(但不是必须) 有自己的“关联数据”

    enum FastFoodMenuItem {
        case hamburger(numberOfPatties: Int)
      	case fries(size: FryOrderSize)
      	case drink(String, ounces: Int) // the unnamed String is the brand, e.g. "Coke"
      	case cookie
    }
    

    请注意,饮料案例有 2 条关联数据(其中一条是“未命名的”)
    在上面的示例中, FryOrderSize 也可能是一个枚举,例如 …

    enum FryOrderSize {
        case large
      case small
    }
    

5.2 设置枚举的值

设置枚举的值时,如果有关联数据,必须提供关联的数据值。

let menuItem: FastFoodMenuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = FastFoodMenuItem.cookie

Swift 可以在赋值左侧或另一侧推断类型,但不能同时。

let menuItem = FastFoodMenuItem.hamburger(patties: 2)
var otherItem: FastFoodMenuItem = .cookie

// Swift can't figure this out
var yetAnotherItem = .cookie

5.3 检查枚举的状态

通常使用 switch 来检查枚举的状态,尽管我们可以使用 if 语句,但如果有关联数据,则这种情况不常见。

var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
  case FastFoodMenuItem.hamburger: print("burger")
  case FastFoodMenuItem.fries: print("fries")
  case FastFoodMenuItem.drink: print("drink")
  case FastFoodMenuItem.cookie: print("cookie")
}

上述代码将在控制台上打印 “burger”。

var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
  case .hamburger: print("burger")
  case .fries: print("fries")
  case .drink: print("drink")
  case .cookie: print("cookie")
}

没有必要使用完全表示的 FastFoodMenuItem.fries (因为 Swift 可以推断出 FastFoodMenuItem

5.4 break

如果您不想在给定的情况下执行任何操作,请使用 break

var menuItem = FastFoodMenuItem.hamburger(patties: 2)
switch menuItem {
  case .hamburger: break
  case .fries: print("fries")
  case .drink: print("drink")
  case .cookie: print("cookie")
}

该代码不会在控制台上打印任何内容。

5.5 Default

switch 必须处理所有可能的情况,因此通常使用 default 来管理不感兴趣的状态。

var menuItem = FastFoodMenuItem.cookie
switch menuItem {
  case .hamburger: break
  case .fries: print("fries")
  default: print("other")
}

如果 menuItem 是一个 cookie,则上面的代码将在控制台上打印 “other”。

顺便说一句,你可以 switch 任何类型(不仅仅是 enum),例如 string

let s: String = "hello"
switch s {
    case "goodbye": ...
	case "hello": ...
  	default: ... // gotta have this for String because switch has to cover ALL cases
}

5.6 允许多行

switch 中的每个 case 都可以是多行,并且不会落入下一个 case …

var menuItem = FastFoodMenuItem.fries(size: FryOrderSize.large)
switch menuItem {
  case .hamburger: print("burger")
  case .fries:
      print("yummy")
      print("fries")
  case .drink:
      print("drink")
  case .cookie: print("cookie")
}

上面的代码会在控制台上打印 “yummy” 和 “fries”,而不是 “饮料”。

如果您将关键字 fallthrough 放在某个 case 的最后一行,那么它将会继续执行下一个 case

关于关联数据,您可以通过 switch 语句访问这些数据,使用 let语法进行解构。

var menuItem = FastFoodMenuItem.drink("Coke", ounces: 32)
switch menuItem {
  case .hamburger(let pattyCount): print("a burger with \(pattyCount) patties")
  case .fries(let size): print("a \(size) order of fries!")
  case .drink(let brand, let ounces): print("a \(ounces)oz \(brand)")
  case .cookie: print("a cookie!")
}

请注意,检索关联数据的局部变量可以具有不同的名称

(例如 pattyCount 与 enum 声明中的 patties 的比较)

(例如,上面的 brand 在枚举声明中甚至没有名称)

5.7 有 Method ,不能有存储属性

枚举可以有方法(和计算属性),但不能有存储属性。

enum FastFoodMenuItem {
  case hamburger(numberOfPatties: Int)
  case fries(size: FryOrderSize)
  case drink(String, ounces: Int)
  case cookie
  
  func isIncludedInSpecialOrder(number: Int) -> Bool { }
  var colories: Int { // switch on self and calculate caloric value here }
}

其中,coloriesisIncludedInSpecialOrder 分别为计算属性和方法。

枚举的状态仅取决于它所处的 case 以及该 case 的关联数据.

在枚举自己的方法中,你可以使用 self …

enum FastFoodMenuItem {
  ...
  
  func isIncludedInSpecialOrder(number: Int) -> Bool {
    switch self {
      case .hamburger(let pettyCount): return pettyCount == number
      case .fries, .cookie: return true // a drink and cookie in every special order
      case .drink(_, let ounces): return ounces == 16 // & 16oz drink of any kind
    }
  }
}

5.8 获取枚举的所有成员

enum TeslaModel: CaseIterable {
  case X
  case S
  case Three
  case Y
}

现在,这个枚举将有一个静态变量 .allCases,可以迭代。

for model in TestlaModel.allCases {
  reportSalesNumbers(for: model)
}
func reportSalesNumbers(for model: TeslaModel) {
  switch model { ... }
}

6. optional

optional 是一个枚举。它看起来像这样:

enum Optional<T> { // a generic type
  case none
  case some(T) // the some case has associated value of type T
}

你可以看到它只能有两种值: 已设置值的 case (some) 或未设置值的 case(none)。在已设置的case 中,它可以有一些关联值,它们是“generic type” T

我们在哪些地方使用 Optional?我们可能有一个值,它有时可以是“未设置”、“未指定”或“未确定”的。这种情况经常发生。这就是 Swift 引入许多“语法糖”的原因,以便于使用可选值(Optionals)。

6.1 optional 声明

声明一个类型为 Optional<T> 的变量可以使用语法 T?。然后,您可以将其赋值为 nil(Optional.none),或者可以将其赋值为类型 T 的某个值(Optional.some,并使得 关联值 = 该值)。

请注意,可选值总是隐式地以 nil 开始。

var hello: String?             var hello: Optional<String> = .none
var hello: String? = "hello"   var hello: Optional<String> = .some("hello")
var hello: String? = nil       var hello: Optional<String> = .none

6.2 optional 访问值

  1. 你可以通过 ! 强制访问关联值。
let hello: String? = ...
print(hello!)

switch hello {
  case .none: // raise an exception (crash)
  case .some(let data): print(data)
}
  1. 或者“安全地”使用 if let 然后使用 { } 中安全获取的关联值。
if let safehello = hello {
  print(safehello)
} else {
  // do something else
}

switch hello {
  case .none: // raise an exception (crash)
  case .some(let safehello): print(safehello)
}
  1. 您也可以使用较短的版本,同 2
if let hello {
  print(hello)
} else {
  // do something else
}
  1. 还有 ?? 它执行 “Optional defaulting”,被称为“nil-coalescng 运算符”。
let hello: String? = ...
let y = x ?? "foo"

switch hello {
  case .none: y = "foo"
  case .some(let data): y = data
}

现在回到 Code。

7. ViewModel Intent - Part 2

//  MemorizeGame.swift
...
    private func index(of card: Card) -> Int? {
        for index in cards.indices {
            if cards[index].id == card.id {
                return index
            }
        }
        return nil
    }
...

让我们使用 Optional 修复虚假信息。

//  MemorizeGame.swift
...
    mutating func choose(_ card: Card) {
        if let chosenIndex = index(of: card) {
            cards[chosenIndex].isFaceUp.toggle()
        }
    }
...

我们还需要更改 choose 函数,因为 chosenIndex 现在是 Optional 类型。我们可以通过使用 !,但如果 index 返回 nil 会报错。所以,我们使用 safe unwrap。

7.1 函数作为参数

//  MemorizeGame.swift
...
    mutating func choose(_ card: Card) {
        if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
            cards[chosenIndex].isFaceUp.toggle()
        }
    }
...

我们不必自行实现函数 index。我们实际上可以找到 chosenIndex

7.2 Flip the cards down when NOT matched

//  MemorizeGame.swift
...
    var indexOfTheOneAndOnlyFaceUpCard: Int?

    mutating func choose(_ card: Card) {
        if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
            if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
                if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
                    // Two Cards Face Up
                    if cards[chosenIndex].content == cards[potentialMatchIndex].content {
                        cards[chosenIndex].isMatched = true
                        cards[potentialMatchIndex].isMatched = true
                    }
                    indexOfTheOneAndOnlyFaceUpCard = nil
                } else {
                    for index in cards.indices {
                        cards[index].isFaceUp = false
                    }
                    indexOfTheOneAndOnlyFaceUpCard = chosenIndex
                }
            }
            cards[chosenIndex].isFaceUp = true
        }
    }
...

我们的游戏逻辑现在看起来是正确的,但是我们需要在牌匹配时隐藏它们。

//  EmojiMemoryGameView.swift
...
    var body: some View {
        ZStack {
            let base = RoundedRectangle(cornerRadius: 12)
            Group {
                base.fill(.white)
                base.strokeBorder(lineWidth: 2)
                Text(card.content)
                    .font(.system(size: 200))
                    .minimumScaleFactor(0.01)
                    .aspectRatio(1, contentMode: .fit)
            }
            .opacity(card.isFaceUp ? 1 : 0)
            base.fill().opacity(card.isFaceUp ? 0 : 1)
        }
        .opacity(card.isFaceUp || !card.isMatched ? 1 : 0)
    }
...

所以,我们回到我们的视图,让匹配的卡片不透明度为 0。

我们的主要游戏逻辑现在应该可以工作了。

7.3 set+get 计算属性

//  MemorizeGame.swift
...
        var indexOfTheOneAndOnlyFaceUpCard: Int? {
        get {
            var faceUpCardIndices = [Int]()
            for index in cards.indices {
                if cards[index].isFaceUp {
                    faceUpCardIndices.append(index)
                }
            }
            if faceUpCardIndices.count == 1 {
                return faceUpCardIndices.first
            } else {
                return nil
            }
        }
        set {
            for index in cards.indices {
                if index == newValue {
                    cards[index].isFaceUp = true
                } else {
                    cards[index].isFaceUp = false
                }
            }
        }
    }
...

我们可以通过将 indexOfTheOneAndOnlyFaceUpCard 制作成一个计算属性来优化游戏逻辑。这个计算属性在被获取时将返回其他面朝上的卡片(或 nil)。当被设置(indexOfTheOneAndOnlyFaceUpCard = something )时,它将把传入的卡片设置为面朝上,所有其他卡片则面朝下。

//  MemorizeGame.swift
...
    mutating func choose(_ card: Card) {
        if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
            if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
                if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
                    // Two Cards Face Up
                    if cards[chosenIndex].content == cards[potentialMatchIndex].content {
                        cards[chosenIndex].isMatched = true
                        cards[potentialMatchIndex].isMatched = true
                    }
                } else {
                    indexOfTheOneAndOnlyFaceUpCard = chosenIndex
                }
            }
            cards[chosenIndex].isFaceUp = true
        }
    }
...

我们的计算属性已经实现了很多步骤,我们可以对 choose 函数进行简化。

7.4 choose 函数优化

//  MemorizeGame.swift
...
    var indexOfTheOneAndOnlyFaceUpCard: Int? {
        get {
            let faceUpCardIndices = cards.indices.filter { index in cards[index].isFaceUp }
            return faceUpCardIndices.count == 1 ? faceUpCardIndices.first : nil
        }
        set {
            cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) }
        }
    }
...

现在,我们使用 functional programming 来优化我们的代码。我们还可以使用 extension 使代码变得更好。

7.5 extension

//  MemorizeGame.swift
...
import Foundation

struct MemoryGame<CardContent> where CardContent: Equatable {
    private(set) var cards: Array<Card>
    
        ...
      ...
    
    var indexOfTheOneAndOnlyFaceUpCard: Int? {
        get { cards.indices.filter { index in cards[index].isFaceUp }.only }
        set { cards.indices.forEach { cards[$0].isFaceUp = (newValue == $0) } }
    }
  
    ...
      ...
}

extension Array {
    var only: Element? {
        count == 1 ? first : nil
    }
}

我们对 array 类型添加 extension,以便在我们的 indexOfTheOneAndOnlyFaceUpCard 计算属性中使用 .only

爬虫Python学习是指学习如何使用Python编程语言来进行网络爬取和数据提取的过程。Python是一种简单易学且功能强大的编程语言,因此被广泛用于爬虫开发。爬虫是指通过编写程序自动抓取网页上的信息,可以用于数据采集、数据分析、网站监测等多个领域。 对于想要学习爬虫的新手来说,Python是一个很好的入门语言。Python的语法简洁易懂,而且有丰富的第三方库和工具,如BeautifulSoup、Scrapy等,可以帮助开发者更轻松地进行网页解析和数据提取。此外,Python还有很多优秀的教程和学习资源可供选择,可以帮助新手快速入门并掌握爬虫技能。 如果你对Python编程有一定的基础,那么学习爬虫并不难。你可以通过观看教学视频、阅读教程、参与在线课程等方式来学习。网络上有很多免费和付费的学习资源可供选择,你可以根据自己的需求和学习风格选择适合自己的学习材料。 总之,学习爬虫Python需要一定的编程基础,但并不难。通过选择合适的学习资源和不断实践,你可以逐步掌握爬虫的技能,并在实际项目中应用它们。 #### 引用[.reference_title] - *1* *3* [如何自学Python爬虫? 零基础入门教程](https://blog.youkuaiyun.com/zihong523/article/details/122001612)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^koosearch_v1,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [新手小白必看 Python爬虫学习路线全面指导](https://blog.youkuaiyun.com/weixin_67991858/article/details/128370135)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^koosearch_v1,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值