Appearance
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
,并添加到InternalMenuViewModel
的sections
属性里面 ,代码如下:
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
协议的结构体,它名叫UpdateMomentLikeSession
。UpdateMomentLikeSession
的实现方法和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
传递给getMomentsDetailsByUserID
Query,然后通过@include
来控制是否读取isLiked
和likes
属性,从而保证只有当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()
方法来更新点赞信息,然后把返回的结果通过persistentDataStore
的save()
方法保存到本地数据存储中。
到这里,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
的点击事件。当用户点击了点赞按钮时,就会调用viewModel
的like()
或者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