Skip to content

22功能实战:如何使用现有架构添加点赞功能?

你有没有遇到过接手一份新的代码却不知道如何下手的情况?其实,一套良好的开发框架就能有效地解决这种问题。规范的架构与框架不仅具有良好的可扩展性,例如,可以灵活地替换网络层、数据库甚至 UI 层的实现,而且还为开发者提供了统一的开发步骤与规范,方便新功能的快速迭代。

我们的 Moments App 使用了 MVVM 架构来支持快速开发,在这一讲中,我们再以添加点赞功能为例来看看如何一步一步去开发一个新功能。

如下面的动图所示,我们可以摇动手机来打开内部功能菜单页面,在该页面内点击开启点赞按钮来启动点赞功能。当重启 App 以后,我们就能在朋友圈页面里看到点赞按钮了。

根据组件间的依赖关系,我们可以按照以下五个步骤来进行开发:

  • 增加"添加点赞功能"的功能开关;

  • 开发网络层来更新 BFF 的点赞信息;

  • 开发 Repository 层来存储数据;

  • 开发 ViewModel 层来准备 UI 所需的数据;

  • 开发 UI/View 层呈现点赞按钮和点赞朋友列表。

下面我们就来详细说明这每一个步骤。

增加功能开关

当我们开发一个周期比较长的新功能时,通常会使用功能开关。

如果没有功能开关,当开发周期超过一周以上时,我们就不得不把开发中的功能放在一个"长命"功能分支下,直到整个功能完成后才合并到主分支,这往往会增加合并分支的难度。

另一种方法是延迟发布的时间,在功能完整开发出来后才进行发布。假如有多个团队一直在开发新功能,那么发布计划就可能一直在延迟。但如果我们使用了功能开关,就可以把未完成的功能一直隐藏着,直到通过完整的测试和产品验证后才把开关启动并进行发布。总之,有了功能开关,我们可以支持多个团队并行开发,并在此期间随时发布新版本的 App

下面我们看看如何为添加点赞功能增加一个功能开关,具体代码如下:

swift
enum InternalToggle: String, ToggleType {
    case isLikeButtonForMomentEnabled
}

首先,我们为枚举类型InternalToggle添加isLikeButtonForMomentEnabled来表示启动点赞功能的功能开关。

接着在InternalTogglesDataStore里把该值初始化为false表示默认关闭该功能,这样就能保证 App Store 版本的 App 都看不到这个功能,代码如下:

swift
struct InternalTogglesDataStore: TogglesDataStoreType {
    private init(userDefaults: UserDefaults) {
        self.userDefaults.register(defaults: [
            InternalToggle.isLikeButtonForMomentEnabled.rawValue: false
            ])
    }
}

最后一步是通过isLikeButtonForMomentEnabled初始化InternalMenuFeatureToggleItemViewModel,并添加到InternalMenuViewModelsections属性里面 ,代码如下:

swift
let featureTogglesSection = InternalMenuSection(
    title: L10n.InternalMenu.featureToggles,
    items: [
        InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.likeButtonForMomentEnabled, toggle: InternalToggle.isLikeButtonForMomentEnabled)
    ])
sections = .just([
    featureTogglesSection,
    ... // other sections
])

这样子就为内部隐藏菜单增加了启动点赞功能的功能开关。功能开关是其他模块的基础,你会看到我们在其他模块中也都会使用到该开关。

开发网络层

Moments App 使用了 BFF 来读取朋友圈信息,那我们也把点赞信息存储在 BFF 里面。因为 Moments App 的 BFF 使用了 GraphQL,要更新 BFF 上的数据,我们就需要使用 Mutation。和 Restful API 的 Post 操作不一样,在 GraphQL 的 Mutation 不仅能更新数据,还可以返回数据。

下面我们就来一起看看网络层的实现,首先定义一个名叫UpdateMomentLikeSessionType的协议来提供更新点赞信息的接口,具体代码如下:

swift
protocol UpdateMomentLikeSessionType {
    func updateLike(_ isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<MomentsDetails>
}

该协议只定义了一个updateLike()方法,该方法会接收以下的入口参数,并返回类型为MomentsDetails的 Observable 序列。

  • isLiked是一个布尔类型,用于表示是否点赞了。

  • momentID表示被点赞的那条朋友圈的 ID。

  • userID表示点赞的用户 ID。

接着我们定义了一个遵循UpdateMomentLikeSessionType协议的结构体,它名叫UpdateMomentLikeSessionUpdateMomentLikeSession的实现方法和GetMomentsByUserIDSession代码基本一致,我们已经在《18 | 网络层架构:如何设计网络访问与 JSON 数据解析?》那一讲中详细讲述了GetMomentsByUserIDSession的实现,如有需要你可以回去复习一下。

不同的地方是在query属性的定义里,UpdateMomentLikeSession使用了mutation而不是query,具体定义如下:

swift
private static let query = """
   mutation updateMomentLike($momentID: ID!, $userID: ID!, $isLiked: Boolean!) { 
       // the response for updateMomentLike 
   }
"""

这样子,我们就能往 BFF 发送一个 Mutation 请求并接收更新后的MomentsDetails信息了。

除了更新点赞信息 以外,我们还要修改GetMomentsByUserIDSession读取点赞朋友的列表信息

不过,点赞信息只有在功能开关开启的时候才能看到,因此在读取朋友圈信息的时候需要进行检查。要检查内部功能开关,需要使用到一个InternalTogglesDataStore的实例,因此我们在初始化GetMomentsByUserIDSession的时候可以将InternalTogglesDataStore.shared传递进去,代码如下:

swift
private let togglesDataStore: TogglesDataStoreType
init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: ...) {
    self.togglesDataStore = togglesDataStore
}

GetMomentsByUserIDSession接收到InternalTogglesDataStore的实例时,可以直接保存到togglesDataStore属性里面,这样我们就能使用togglesDataStore属性来检查点赞功能是否开启了。下面代码展示的是内嵌Session结构体的init()方法:

swift
init(userID: String, togglesDataStore: TogglesDataStoreType) {
    let variables: [AnyHashable: Encodable] = ["userID": userID,
                                               "withLikes": togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled)]
}

我们通过调用togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled)来判断是否开启了点赞功能,如果是,就把withLikes属性赋值为true,否则赋为false。这样就可以在 Query 里面使用withLikes属性了,代码如下:

swift
private static let query = """
   query getMomentsDetailsByUserID($userID: ID!, $withLikes: Boolean!) {
     getMomentsDetailsByUserID(userID: $userID) {
         // other fields
         createdDate
         isLiked @include(if: $withLikes)
         likes @include(if: $withLikes) {
           id
           avatar
         }
       }
     }
   }
"""

在定义query属性的地方,我们把withLikes传递给getMomentsDetailsByUserIDQuery,然后通过@include来控制是否读取isLikedlikes属性,从而保证只有当isLikeButtonForMomentEnabled开关开启时,才需要读取这两个属性。

到此为止,网络层的开发就完成了,下面我们再来看看 Repository 层的开发。

开发 Repository 层

在朋友圈功能里面,Respository 层的关键组件是MomentsRepo。当它要更新点赞信息时,就会用UpdateMomentLikeSessionType协议,因此我们在初始化的时候也注入对该协议的依赖,具体代码如下:

swift
private let updateMomentLikeSession: UpdateMomentLikeSessionType
static let shared: MomentsRepo = {
    return MomentsRepo(..., 
        updateMomentLikeSession: UpdateMomentLikeSession()
    )
}()
init(..., updateMomentLikeSession: UpdateMomentLikeSessionType) {
    self.updateMomentLikeSession = updateMomentLikeSession
}

我们把UpdateMomentLikeSession结构体的实例赋值给updateMomentLikeSession属性,当需要访问网络层时就可以使用该属性的方法,接着看一下updateLike()方法的实现:

swift
func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable<Void> {
    return updateMomentLikeSession
        .updateLike(isLiked, momentID: momentID, fromUserID: userID)
        .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
        .map { _ in () }
        .catchErrorJustReturn(())
}

我们通过调用updateMomentLikeSession属性的updateLike()方法来更新点赞信息,然后把返回的结果通过persistentDataStoresave()方法保存到本地数据存储中。

到这里,Repository 层的开发也完成了,我们接着修改 ViewModel 层的代码来支持点赞功能。

开发 ViewModel 层

因为点赞功能只使用在 UI 层的MomentListItemView里面,所以我们只需要更新该 View 所对应的 ViewModelMomentListItemViewModel即可。为此,我们增加了两个属性 :第一个是isLiked属性,用于表示用户是否已经点赞了该朋友圈信息;第二个是likes属性,用于显示点赞了朋友的头像列表。

有了这两个属性,我们就可以在init()方法里面把MomentsDetails.Moment数据映射到这两个属性中去,具体代码如下:

swift
isLiked = moment.isLiked ?? false
likes = moment.likes?.compactMap { URL(string: $0.avatar) } ?? []

isLiked属性的映射比较简单,只是简单的赋值即可。而likes属性则需要我们把 BFF 返回的 URL 字符串转换为用于呈现图片的URL类型。

当用户在页面中点击点赞按钮后,我们就需要调用MomentListItemViewModel来完成具体的操作,因此我们在MomentListItemViewModel也定义了两个方法,具体代码如下:

swift
func like(from userID: String) -> Observable<Void> {
    return momentsRepo.updateLike(isLiked: true, momentID: momentID, fromUserID: userID)
}
func unlike(from userID: String) -> Observable<Void> {
    return momentsRepo.updateLike(isLiked: false, momentID: momentID, fromUserID: userID)
}

可以看到,like(from userID: String)unlike(from userID: String)方法都调用了momentsRepo.updateLike()方法来更新点赞信息。至此,ViewModel 层也开发完毕了。

开发 UI/View 层

其他模块开发完毕以后,最后就是更新 UI/View 层了。因为点赞按钮在每一条朋友圈信息里面,所以我们只需要修改MomentListItemView就可以了。你可以从下面的这个示例图看到新加的组件:

从示例图可以看到,新加的组件主要有以下三个。

  • likesStakeView用于存放点赞朋友的列表。

  • likesContainerView是一个用来存放likesStakeView的容器视图,我们还可以使用它来设置背景颜色和配置圆角效果。

  • favoriteButton表示点赞按钮。

这些 UI 组件的属性定义如下:

swift
private let likesContainerView: UIView = configure(.init()) {
    $0.translatesAutoresizingMaskIntoConstraints = false
    $0.backgroundColor = UIColor.designKit.secondaryBackground
    $0.layer.cornerRadius = 4
}
private let likesStakeView: UIStackView = configure(.init()) {
    $0.translatesAutoresizingMaskIntoConstraints = false
    $0.spacing = Spacing.twoExtraSmall
}
private let favoriteButton: UIButton = configure(.init()) {
    $0.translatesAutoresizingMaskIntoConstraints = false
    $0.asHeartFavoriteButton()
}

有了这些属性以后,我们还需要把它们添加到 UI 里面,下面是setupUI()方法的代码:

swift
func setupUI() {
    if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {
        likesContainerView.addSubview(likesStakeView)
        verticalStackView.addArrangedSubview(likesContainerView)
        addSubview(favoriteButton)
    }
}

从上面的代码可以看到,只有当isLikeButtonForMomentEnabled开关开启时,才需要添加新的组件。添加新组件的逻辑相对比较简单,我们把likesStakeView添加到likesContainerView里面,然后把likesContainerView添加到verticalStackView,这样就可以把点赞的朋友列表放在父视图的底部,最后再把favoriteButton放到父视图里面。

接着我们为新的组件配置自动布局的约束条件,这就一起来看看setupConstraints()方法的实现:

swift
func setupConstraints() {
    if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {
        likesStakeView.snp.makeConstraints {
            $0.top.leading.equalToSuperview().offset(Spacing.twoExtraSmall)
            $0.bottom.trailing.equalToSuperview().offset(-Spacing.twoExtraSmall)
        }
        favoriteButton.snp.makeConstraints {
            $0.bottom.trailing.equalToSuperview().offset(-Spacing.medium)
        }
    }
}

Moments App 使用了 SnapKit 库来配置约束。在这个例子中,我们通过调用equalToSuperview().offset(Spacing.twoExtraSmall)likesStakeView添加填充(padding),然后把favoriteButton放置在父视图的右下角。

配置好布局以后,我们通过绑定的方式来处理点赞按钮的点击事件,具体代码如下:

swift
func setupBindings() {
    if togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled) {
        favoriteButton.rx.tap
            .bind(onNext: { [weak self] in
                guard let self = self else { return }
                if self.favoriteButton.isSelected {
                    self.viewModel?.like(from: self.userDataStore.userID).subscribe().disposed(by: self.disposeBag)
                } else {
                    self.viewModel?.unlike(from: self.userDataStore.userID).subscribe().disposed(by: self.disposeBag)
                }
            })
            .disposed(by: disposeBag)
    }
}

同样地,在进行绑定前,我们先检查isLikeButtonForMomentEnabled开关是否开启。当开关开启了,我们就使用 RxCocoa 中UIButton.rx.tap扩展属性来绑定favoriteButton的点击事件。当用户点击了点赞按钮时,就会调用viewModellike()或者unlike()方法来更新点赞状态。

到此为止,我们已经开发了一个完整的点赞功能。

总结

在这一讲中,我们以添加点赞功能为例讲解了如何快速开发一个新功能。因为 Moments App 使用了 MVVM 和 RxSwift 来进行架构,这就保证了每一层都有明确的责任与分工。

当你开发新功能时,就可以按照我今天讲解的这些步骤一层层来进行开发:添加功能开关,开发网络层、Repository 层、ViewModel 层和 View 层。这样能大大减低代码接手的难度,使得整个团队都遵循统一的步骤与规范,从而降低沟通成本,并同时保证代码的质量。

思考题

你可能已经注意到,当一个类型需要依赖其他类型时,例如当 GetMomentsByUserIDSession 使用 TogglesDataStoreType 时,我们都是通过 init() 方法进行注入的。那为什么我们不在 GetMomentsByUserIDSession 定义 togglesDataStore 属性时直接初始化呢?

你可以把自己的思考写到下面的留言区哦,这一讲就介绍到这里了,下一讲我将介绍如何使用 TDD 来保证功能模块的高质量。

源码地址

朋友圈点赞功能的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/Moments/Features/Moments