发布于 2026-01-06 0 阅读
0

Almost Netflix:一款使用 Appwrite DEV 的全球 Show and Tell Challenge 构建的 iOS Netflix 克隆应用,由 Mux 呈现:Pitch Your Projects!

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。

📃 要求

要继续学习本教程,您需要以下工具:

  1. 您需要拥有 Appwrite 项目访问权限或创建 Appwrite 项目的权限。如果您还没有 Appwrite 实例,可以按照我们的官方安装指南进行安装。
  2. 按照我们的“几乎是 Netflix服务器设置指南”设置 Appwrite 项目。
  3. 需要使用 Xcode 12 或更高版本。点击此处了解更多关于 Xcode 的信息。
  4. 掌握 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软件包。

Appwrite SDK 搜索

现在选择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)
    }
}


Enter fullscreen mode Exit fullscreen mode

使用上述代码,我们将初始化客户端、数据库、账户、存储和头像服务。请将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)
            }

            ...
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

创建一个名为 `<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"
                }

                ...
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

这里我们使用导航视图中的导航链接来实现登录或注册功能。当用户点击其中一个按钮时,selection状态变量会被设置。这样,如果我们要添加更多按钮,只需要添加新的标签,而无需为每个按钮都创建一个新的状态变量。

我们现在有一个漂亮的标题视图,可以导航到登录或注册页面,如下所示:

标题视图显示

现在我们需要一个页面,用于在用户成功登录或注册后跳转到该页面。创建一个名为 `<filename>` 的新文件HomeView.swift,并暂时用以下代码更新它,以创建一个虚拟的主页,确保身份验证流程完整且正确。



import SwiftUI

struct HomeView: View {

    var body: some View {
        NavigationView {
            ZStack {
                Color(.black).ignoresSafeArea()
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

我们还需要一个视图,能够根据身份验证状态显示启动画面、登录界面或主页。让我们创建一个名为 `<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()
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

现在我们已经有了身份验证流程所需的所有视图,接下来我们将创建一个视图模型来处理身份验证逻辑。创建一个新的 Swift 文件并将其命名为AuthVM.swift。在该文件中创建以下类。



class AuthVM: ObservableObject {
    @Published var checkedForUser = false
    @Published var error: String?
    @Published var user: User?
}


Enter fullscreen mode Exit fullscreen mode

我们还定义了三个变量,用于跟踪和显示身份验证状态、错误信息和用户详细信息。现在,让我们添加一个用于创建帐户的函数。请将以下代码添加到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)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

上面我们有一个 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()
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

登录函数接受电子邮件地址和密码,并使用 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
            }

        }
    }
}


Enter fullscreen mode Exit fullscreen mode

要获取用户帐户,我们只需调用getAppwrite 帐户服务的函数。成功后,我们将接收到的用户详细信息设置到用户属性中。最后,我们向AuthVM类中添加一个初始化方法,该方法将getAccount在应用程序启动时调用,以检查用户的身份验证状态。



init() {
    getAccount()
}


Enter fullscreen mode Exit fullscreen mode

身份验证到此结束!我们的应用程序现在将显示启动画面,直到我们检查用户是否已登录;如果已登录,则显示主屏幕;否则,显示登录屏幕。

🎬 电影页面

信息流视图

让我们回到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)
    }


Enter fullscreen mode Exit fullscreen mode

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")
                        }
                    }
                }
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

我们的主打影片已设置完毕,接下来我们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)
    }


Enter fullscreen mode Exit fullscreen mode

这里我们有一个滚动视图,其中包含一个水平堆栈,它会遍历给定集合中的每部电影,并MovieItemThumbnailView为每部电影显示一个列表。现在我们可以创建一个新文件,MovieItemThumbnailView.swift并添加以下内容:



    NavigationLink (destination: MovieDetailsView(movie: movie)) {
        KFImage.url(URL(string: movie.imageUrl))
            .resizable()
            .scaledToFit()
            .cornerRadius(4)
    }


Enter fullscreen mode Exit fullscreen mode

这里,我们的图片嵌套在一个导航链接中,所以当点击图片时,应用会跳转到电影详情页面。电影信息流就完成了。现在我们有了一个嵌套的滚动视图,按类别显示电影合集。接下来,我们需要为每部电影创建详情页面。

🕵️ 详情页

现在我们可以定义电影详情视图了。创建一个名为 `.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)
    }
}


Enter fullscreen mode Exit fullscreen mode

现在我们需要添加一个功能MovieGridView,用于显示与详情页上显示的电影类似的影片。让我们现在就把它添加为一个名为 `<filename>.js` 的新文件MovieGridView.swift



    LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], alignment: .leading, spacing: 4) {
        ForEach(movies) { movie in
            MovieItemThumbnailView(movie: movie)
        }
    }


Enter fullscreen mode Exit fullscreen mode

我们的影片详情视图现在将显示如下:

影片详情视图

⚙️ 电影视图模型

我们需要添加用于获取电影并将其发布到用户界面的视图模型。此类包含管理电影源以及当前登录用户观看列表的大部分逻辑。请添加一个新文件,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 });
                    }

                }
            }
        }
    }
}



Enter fullscreen mode Exit fullscreen mode

🔖 关注列表页面

加分项:我们还实现了自选列表功能。要添加此功能,我们需要向另一个新视图添加内容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)
        }
    }


Enter fullscreen mode Exit fullscreen mode

此视图将获取用户观看列表中的所有电影,并将它们显示在我们之前创建的列表中MovieGridView

👨‍🎓 结论

锵锵!我们几乎完美地克隆了 Netflix,而且使用 Appwrite 作为后端,速度快、操作简便!想成为 Appwrite 社区的一员吗?欢迎加入我们的Discord服务器。我们期待看到你的作品,也期待看到你的精彩分享(加入我们的 Discord 服务器后!)

Appwrite 每次发布都会添加令人惊叹的新功能,因此,我们将回归我们的 Netflix 克隆版,使其不断发展壮大!

🔗了解更多

文章来源:https://dev.to/appwrite/almost-netflix-an-ios-netflix-clone-built-with-appwrite-5b7