Almost Netflix:一款使用 Appwrite 构建的 iOS 版 Netflix 克隆应用
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
欢迎来到“几乎是Netflix”系列的第四篇文章!我们将继续完善项目设置,为我们的Netflix克隆版构建另一个前端!在本文中,我们将深入探讨如何使用iOS构建克隆版。在本系列的下一篇文章中,我们将构建Android前端!
这次的内容全是关于 iOS 的,那么让我们开始吧!
本文不可能列出所有代码😬,但您将了解所有基本概念、组件以及与 Appwrite 的通信方式。如果您想全面了解我们这款“Almost Netflix for iOS”的每一个细节,可以查看包含完整应用的GitHub 源代码。
🤔 Appwrite是什么?
Appwrite是一个开源的后端即服务(BaaS),它通过提供一套REST API来满足您的核心后端需求,从而抽象化构建现代应用程序的所有复杂性。Appwrite可以处理用户身份验证和授权、数据库、文件存储、云函数、Webhook等等!如果缺少任何功能,您可以使用自己喜欢的后端语言来扩展Appwrite。
📃 要求
要继续学习本教程,您需要以下工具:
- 您需要拥有 Appwrite 项目访问权限或创建 Appwrite 项目的权限。如果您还没有 Appwrite 实例,可以按照我们的官方安装指南进行安装。
- 按照我们的“几乎是 Netflix服务器设置指南”设置 Appwrite 项目。
- 需要使用 Xcode 12 或更高版本。点击此处了解更多关于 Xcode 的信息。
- 掌握 iOS 开发和 Swift UI 的基础知识
🛠️ 创建 iOS 项目
我们将从创建一个新项目开始。打开 Xcode 并选择“新建项目”。在下一个屏幕上,选择iOS -> App,然后单击“下一步”。
在下一个屏幕上,给你的项目命名,输入组织 ID,对于界面,选择 SwiftUI,语言设置为 Swift,然后单击下一步。
在下一个屏幕上,选择要保存新项目的文件夹,然后单击“创建”。这将创建一个新项目并在 Xcode 中打开它。现在您应该会看到以下屏幕。
↪️ 依赖项
我们首先添加依赖项。本项目需要两个包。首先是图像包Appwrite SDK,然后Kingfisher是用于显示 URL 图片的图像包。要添加包,请转到“文件”->“添加包”。
在弹出的对话框中,点击右上角的搜索图标,输入以下 SDK 的 GitHub URL:https://github.com/appwrite/sdk-for-apple,然后按Enter 键。您应该会看到列出的sdk-for-apple软件包。
现在选择sdk-for-apple包,然后在右侧选择依赖规则为“最高至主版本”,并选择“使用version 0.3.0最新 SDK”。然后点击“添加包”按钮。Xcode 将下载Appwrite Apple SDK及其依赖项,并将其添加到您的项目中。
请确保在如上所示的对话框中,在“添加到目标”中选择了正确的目标,然后单击“添加包”按钮。该包应该已成功添加到您的项目中。现在,按照相同的步骤,这次使用 GitHub URL 搜索该Kingfisher包,例如https://github.com/onevcat/Kingfisher。
现在我们已经有了所有依赖项,接下来我们将创建一个AppwriteService类并初始化 Appwrite 客户端、数据库和身份验证服务。让我们开始吧。首先,添加一个新的 Swift 文件,并将其命名为 `appwrite.js`,AppwriteService.swift内容如下:
class AppwriteService {
let client: Client
let database: Database
let account: Account
let storage: Storage
let avatars: Avatars
static let shared = AppwriteService()
init() {
client = Client()
.setEndpoint("https://YOUR_ENDPOINT")
.setProject("PROJECT_ID")
.setSelfSigned() // Do not use in production
database = Database(client)
account = Account(client)
storage = Storage(client)
avatars = Avatars(client)
}
}
使用上述代码,我们将初始化客户端、数据库、账户、存储和头像服务。请将YOUR_ENDPOINT`<endpoint>` 和PROJECT_ID`<project ID>` 替换为您自己的端点。现在我们已经设置好了 Appwrite 客户端和服务,接下来我们将开始在应用程序中实现身份验证。
🔐 身份验证
我们将首先创建实现身份验证流程所需的基本视图。让我们从登录视图开始。首先创建一个新的SwiftUI 视图文件并将其命名为 `<view_name>` 。完整的视图定义可以在这里LoginView.swift找到,现在我们将重点关注按钮操作,这些操作会调用我们稍后创建的视图模型:
@State private var email = ""
@State private var password = ""
@EnvironmentObject var authVM: AuthVM
var body: some View {
VStack {
...
Button("Login") {
authVM.login(email: email, password: password)
}
...
}
}
}
创建一个名为 `<filename>` 的类似文件,并使用此处SignupView.swift的完整视图进行更新。此视图与原视图基本相同,但在单击“注册”按钮时会调用我们代码中的不同方法。AuthVM
现在我们需要一个视图,用于在登录和注册之间切换。创建一个名为 `.js` 的新文件TitleView.swift。这里我们将重点关注导航方面:
import Foundation
import SwiftUI
struct TitleView: View {
@State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
...
NavigationLink(destination: LoginView(), tag: "Sign In", selection: $selection) {}
NavigationLink(destination: SignupView(), tag: "Sign Up", selection: $selection) {}
Button("Sign In") {
self.selection = "Sign In"
}
Button("Sign Up") {
self.selection = "Sign Up"
}
...
}
}
}
}
这里我们使用导航视图中的导航链接来实现登录或注册功能。当用户点击其中一个按钮时,selection状态变量会被设置。这样,如果我们要添加更多按钮,只需要添加新的标签,而无需为每个按钮都创建一个新的状态变量。
我们现在有一个漂亮的标题视图,可以导航到登录或注册页面,如下所示:
现在我们需要一个页面,用于在用户成功登录或注册后跳转到该页面。创建一个名为 `<filename>` 的新文件HomeView.swift,并暂时用以下代码更新它,以创建一个虚拟的主页,确保身份验证流程完整且正确。
import SwiftUI
struct HomeView: View {
var body: some View {
NavigationView {
ZStack {
Color(.black).ignoresSafeArea()
}
}
}
}
我们还需要一个视图,能够根据身份验证状态显示启动画面、登录界面或主页。让我们创建一个名为 `<filename>` 的文件MainView.swift,并用以下代码更新它。
import SwiftUI
struct MainView: View {
@EnvironmentObject var authVM: AuthVM
var body: some View {
Group {
if !authVM.checkedForUser {
SplashView()
} else if authVM.user != nil {
HomeView().environmentObject(MoviesVM(userId: authVM.user!.id))
} else {
TitleView()
}
}
}
}
现在我们已经有了身份验证流程所需的所有视图,接下来我们将创建一个视图模型来处理身份验证逻辑。创建一个新的 Swift 文件并将其命名为AuthVM.swift。在该文件中创建以下类。
class AuthVM: ObservableObject {
@Published var checkedForUser = false
@Published var error: String?
@Published var user: User?
}
我们还定义了三个变量,用于跟踪和显示身份验证状态、错误信息和用户详细信息。现在,让我们添加一个用于创建帐户的函数。请将以下代码添加到AuthVM类中。
func create(name: String, email: String, password: String) {
error = ""
AppwriteService.shared.account.create(userId: "unique()", email: email, password: password, name: name) { result in
switch result {
case .failure(let err):
DispatchQueue.main.async {
print(err.message)
self.error = err.message
}
case .success:
self.login(email: email, password: password)
}
}
}
上面我们有一个 create 方法,它接受姓名、电子邮件和密码,并使用 Appwrite 的帐户服务创建一个新帐户。帐户创建成功后,我们还会调用 login 方法来为用户创建会话。现在让我们将以下login函数添加到我们的代码中AuthVM。
public func login(email: String, password: String) {
error = ""
AppwriteService.shared.account.createSession(email: email, password: password) { result in
switch result {
case .failure(let err):
DispatchQueue.main.async {
self.error = err.message
}
case .success:
self.getAccount()
}
}
}
登录函数接受电子邮件地址和密码,并使用 Appwrite 的帐户服务创建会话。会话创建完成后,我们调用该getAccount函数来获取当前用户的详细信息。接下来,我们添加一个getAccount方法来获取用户的帐户信息,并帮助我们检查用户是否已登录。
private func getAccount() {
error = ""
AppwriteService.shared.account.get() { result in
DispatchQueue.main.async {
self.checkedForUser = true
switch result {
case .failure(let err):
self.error = err.message
self.user = nil
case .success(let user):
self.user = user
}
}
}
}
要获取用户帐户,我们只需调用getAppwrite 帐户服务的函数。成功后,我们将接收到的用户详细信息设置到用户属性中。最后,我们向AuthVM类中添加一个初始化方法,该方法将getAccount在应用程序启动时调用,以检查用户的身份验证状态。
init() {
getAccount()
}
身份验证到此结束!我们的应用程序现在将显示启动画面,直到我们检查用户是否已登录;如果已登录,则显示主屏幕;否则,显示登录屏幕。
🎬 电影页面
让我们回到HomeView.swift文件,开始创建电影轮播视图。我们需要在垂直滚动视图内创建一个水平滚动视图。垂直滚动视图用于遍历电影类别,水平滚动视图用于遍历每个类别下的电影。我们首先按HomeView如下方式添加垂直滚动视图:
ScrollView(.vertical, showsIndicators: false) {
if(moviesVM.featured != nil) {
MovieItemFeaturedView(
movie: moviesVM.featured!,
isInWatchlist: moviesVM.watchList.contains(moviesVM.featured!.id),
onTapMyList: {
self.onTapMyList(moviesVM.featured!.id)
}
)
} else if(!((moviesVM.movies["movies"] ?? []).isEmpty)) {
let movie = (moviesVM.movies["movies"]!).first!
MovieItemFeaturedView(
movie: movie,
isInWatchlist: moviesVM.watchList.contains(movie.id),
onTapMyList: {
self.onTapMyList(movie.id)
}
)
}
VStack(alignment: .leading, spacing: 16) {
ForEach(appwriteCategories) { category in
MovieCollection(title: category.title, movies: moviesVM.movies[category.id] ?? [])
.frame(height: 180)
}
}.padding(.horizontal)
}
MovieItemFeaturedView我们来详细分析一下。在滚动视图中,我们首先检查是否有推荐影片。如果有,则会使用稍后添加的元素将其显示在页面顶部。
在主推电影下方,我们有一个垂直堆叠,它会遍历一些预定义的类别,并MovieCollectionView为每个类别显示一个内容。
现在我们可以创建子视图了。首先,创建一个名为 `<filename>` 的新文件MovieItemFeaturedView.swift,并添加以下内容:
import SwiftUI
import Kingfisher
struct MovieItemFeaturedView: View {
@State private var isShowingDetailView = false
let movie: Movie
let isInWatchlist: Bool
let onTapMyList: () -> Void
var body: some View {
ZStack{
KFImage.url(URL(string: movie.imageUrl))
.resizable()
.scaledToFill()
.clipped()
VStack {
HStack {
...
Button {
onTapMyList()
} label: {
VStack {
Image(systemName: self.isInWatchlist ? "checkmark" :"plus")
Text("My List")
}
}
NavigationLink(destination: MovieDetailsView(movie: movie), isActive: $isShowingDetailView) { EmptyView() }
Button {
self.isShowingDetailView = true
} label: {
VStack {
Text("Info")
}
}
}
}
}
}
}
我们的主打影片已设置完毕,接下来我们MovieCollectionView创建一个名为 `<field_name>` 的新字段MovieCollectionView.swift,并使用以下代码进行更新:
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(movies) { movie in
MovieItemThumbnailView(movie: movie)
.frame(width: itemWidth, height: itemHeight)
}
}
.frame(height: frameHeight)
}
这里我们有一个滚动视图,其中包含一个水平堆栈,它会遍历给定集合中的每部电影,并MovieItemThumbnailView为每部电影显示一个列表。现在我们可以创建一个新文件,MovieItemThumbnailView.swift并添加以下内容:
NavigationLink (destination: MovieDetailsView(movie: movie)) {
KFImage.url(URL(string: movie.imageUrl))
.resizable()
.scaledToFit()
.cornerRadius(4)
}
这里,我们的图片嵌套在一个导航链接中,所以当点击图片时,应用会跳转到电影详情页面。电影信息流就完成了。现在我们有了一个嵌套的滚动视图,按类别显示电影合集。接下来,我们需要为每部电影创建详情页面。
🕵️ 详情页
现在我们可以定义电影详情视图了。创建一个名为 `.details.view` 的新文件MovieDetailsView.swift。在详情视图中,我们有一个按钮,用于切换是否将当前电影添加到用户的观看列表:
Button {
self.addToMyList()
} label: {
VStack {
Image(systemName: moviesVM.watchList.contains(movie.id) ? "checkmark" : "plus")
Text("My List")
}
.padding()
}
func addToMyList() -> Void {
moviesVM.addToMyList(movie.id)
}
}
现在我们需要添加一个功能MovieGridView,用于显示与详情页上显示的电影类似的影片。让我们现在就把它添加为一个名为 `<filename>.js` 的新文件MovieGridView.swift:
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], alignment: .leading, spacing: 4) {
ForEach(movies) { movie in
MovieItemThumbnailView(movie: movie)
}
}
我们的影片详情视图现在将显示如下:
⚙️ 电影视图模型
我们需要添加用于获取电影并将其发布到用户界面的视图模型。此类包含管理电影源以及当前登录用户观看列表的大部分逻辑。请添加一个新文件,MoviesVM.swift内容如下:
class MoviesVM: ObservableObject {
@Published var featured: Movie?
@Published var movies: [String:[Movie]] = [:]
@Published var watchList: [String] = []
let userId: String
init(userId: String) {
self.userId = userId
getMovies()
getFeatured()
}
public func getMyWatchlist() {
AppwriteService.shared.database.listDocuments(
collectionId: "movies",
queries: [
Query.equal("$id", value: watchList)
]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.movies["watchlist"] = docs.convertTo(fromJson: Movie.from)
}
}
}
}
func addToMyList(_ movieId: String) {
if(self.watchList.contains(movieId)) {
removeFromMyList(movieId)
} else {
AppwriteService.shared.database.createDocument(collectionId: "watchlists", documentId: "unique()", data: ["userId": userId, "movieId": movieId, "createdAt": Int(NSDate.now.timeIntervalSince1970)], read: ["user:\(userId)"], write: ["user:\(userId)"]){ result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(_):
self.watchList.append(movieId)
self.getMyWatchlist()
print("successfully added to watchlist")
}
}
}
}
}
func removeFromMyList(_ movieId: String) {
AppwriteService.shared.database.listDocuments(collectionId: "watchlists", queries: [
Query.equal("userId", value: userId),
Query.equal("movieId", value: movieId)
], limit: 1) { result in
switch result {
case .failure(let err):
print(err.message)
case .success(let docList):
AppwriteService.shared.database.deleteDocument(collectionId: "watchlists", documentId: docList.documents.first!.id) {result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(_):
let index = self.watchList.firstIndex(of: movieId)
if(index != nil) {
self.watchList.remove(at: index!)
self.getMyWatchlist()
print("removed from watchlist")
}
}
}
}
}
}
}
func isInWatchlist(_ movieIds: [String]) {
AppwriteService.shared.database.listDocuments(
collectionId: "watchlists",
queries: [
Query.equal("userId", value: userId),
Query.equal("movieId", value: movieIds)
]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docList):
let docs = docList.convertTo(fromJson: Watchlist.from)
for doc in docs {
self.watchList.append(doc.movieId)
}
if(docs.count > 1) {
self.getMyWatchlist()
}
}
}
}
}
private func getFeatured() {
AppwriteService.shared.database.listDocuments(
collectionId: "movies",
limit: 1,
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.featured = docs.convertTo(fromJson: Movie.from).first
if(self.featured != nil) {
self.isInWatchlist([self.featured!.id])
}
}
}
}
}
private func getMovies() {
appwriteCategories.forEach {category in
AppwriteService.shared.database.listDocuments(collectionId: "movies", queries: category.queries, orderAttributes: category.orderAttributes, orderTypes: category.orderTypes) { result in
DispatchQueue.main.async {
switch result {
case .failure(let err):
print(err.message)
case .success(let docs):
self.movies[category.id] = docs.convertTo(fromJson: Movie.from)
self.isInWatchlist(docs.documents.map { $0.id });
}
}
}
}
}
}
🔖 关注列表页面
加分项:我们还实现了自选列表功能。要添加此功能,我们需要向另一个新视图添加内容WatchlistView.swift:
ScrollView(.vertical, showsIndicators: false) {
if(!(moviesVM.movies["watchlist"] ?? []).isEmpty) {
MovieGridView(movies: moviesVM.movies["watchlist"] ?? [])
} else {
Text("You have no items in your watchlist")
.foregroundColor(Color.white)
}
}
此视图将获取用户观看列表中的所有电影,并将它们显示在我们之前创建的列表中MovieGridView。
👨🎓 结论
锵锵!我们几乎完美地克隆了 Netflix,而且使用 Appwrite 作为后端,速度快、操作简便!想成为 Appwrite 社区的一员吗?欢迎加入我们的Discord服务器。我们期待看到你的作品,也期待看到你的精彩分享(加入我们的 Discord 服务器后!)
Appwrite 每次发布都会添加令人惊叹的新功能,因此,我们将回归我们的 Netflix 克隆版,使其不断发展壮大!







