• 赚钱入口【需求资源】限时招募流量主、渠道主,站长合作;【合作模式】CPS长期分成,一次推广永久有收益。主动打款,不扣量;

最佳实践:如何在Swift项目中获取远程图像

iOS cps12345 1年前 (2020-06-16) 386次浏览 0个评论

在调用程序时,iOS不仅(不仅是)要求开发人员执行其最常见的任务之一是获取和管理应显示给应用程序的远程图像。例如,假设您正在构建下一个出色的消息传递应用程序,该应用程序显然可以与用户建立联系。这些联系人具有驻留在服务器上的头像图像。当用户连接到应用程序时,必须下载化身以供应用程序使用。或者,再举一个例子,考虑一个管理全球会议事件的应用程序,其功能之一是所有与会者的列表。他们的图片必须由应用抓取并处理,并在必要时正确显示。

使事情变得复杂的是图像获取是一个异步过程的事实。当获取多个图像时,在某些情况下将错误的图像显示在错误的位置真的很容易。再次考虑联系人列表,它是最简单形式的带有文字的图像列表。根据获取的图像处理方式,将获取的头像图像显示给适当的联系人可能会陷入困境。看到应用在每个名称旁边显示错误的头像会真的很糟糕,并且当联系人在滚动时出现和消失时,将头像替换为其他头像会更糟。你看过这个吗?完美的混乱,根本没有一致性!

另一个麻烦点是一旦获取图像就在本地处理图像的方式。是否有关于命名或存储详细信息的规则?是否应遵循最佳和最差的做法?图像应该存储在文档中还是在缓存目录中?这些和更多的问题很可能在构建应用程序时出现,并且是时候处理远程图像了。但是,在构建实际应用程序时,不应给出和实施答案。这类问题应该已经解决,应该成为解决方案的一部分,而不是问题。

所以,我们来了。在本教程中,我将向您介绍一个简单而有效的食谱,该食谱使获取远程图像然后在本地进行处理是一项非常简单的任务。我将向您展示如何创建一个方便的工具,使您可以在需要时使用它,而您不必再为我在上面描述的所有问题而担心。即使您最终不会使用我们将在此处构建的内容,也可能会获得一些想法,将其应用于您自己的解决方案。因此,请继续阅读以找到一些真正有趣的东西。

我们将要建立的

我们将在本文中实现的主要“组件”是协议默认实现,它将使从远程源获取图像或从本地文件加载图像成为可能。只需创建协议,创建协议就可以非常轻松地在任何自定义类型上集成图像处理功能,因此,简而言之,我们即将实现的解决方案可以在任何地方使用。但是,如果我们只是创建一种将获取单个图像的方法,那么陷入该麻烦的意义何在?

事实是,我们不会止步于此。我们将添加更多的功能,我们将支持多个图像提取,以及单个和批量图像删除。作为奖励,我们将添加直接从应用程序本地保存新图像的功能,即使这听起来并没有太大关系。虽然拥有一个可读取,加载和删除图像的组件是很可惜的,但是它不支持保存新图像,而只是为了该操作需要其他工具或实现。

除了将成为主要和最重要的自定义类型的协议之外,还将有两个附加的自定义类型,两个结构

  1. 第一个将用于指定可选的一些设置。因此有必要使我们构建的解决方案尽可能灵活,您很快就会明白我的意思。
  2. 第二个结构将在后台运行,它将包含少量静态方法和属性,这些属性和属性对于协议方法的默认实现很有用。

我们将逐步构建所有内容,并全面讨论每个方面。为了看到我们努力的结果,尽管我们将使用…

一个用于获取远程图像的演示项目

在开始做任何事情之前,有必要下载一个启动程序项目。它包含一个基于SwiftUI的iOS应用程序,名为FetchableImageDemo。让我从视图开始快速描述其各个部分:

  • UserView:具有图像和文本的自定义视图,其中将显示头像图像和联系人姓名。它嵌入到其他两个视图,RandomContactViewContactDetailsView
  • RandomContactView:除了的视图UserView,还包含一个按钮,该按钮可随机触发联系人的选择。我们将使用它来测试单个图像的获取。
  • ContactListView:此处的主要组件是一个列表,它将显示联系人列表。我们将使用它来测试多个图像的获取和删除。该视图被嵌入到具有两个条形项目的导航视图中。一种用于启动获取过程,另一种用于删除所有获取的头像。点击任何联系人项时,ContactDetailsView将显示。此外,它还包含另一个自定义视图,该视图ProgressView将以可视方式显示所有头像的下载进度。
  • ContactDetailsView:另一个嵌入的视图,UserView它还包含一个按钮,使我们可以尝试删除单个图像(所选联系人的头像),并将其取回。
  • ProgressView:进度条的自定义实现。
  • TabsFetchableImageDemo是一个标签栏应用程序,而Tabs视图是创建标签的应用程序。

除了上述内容,您还会发现:

  • Contact结构:它表示一个单一的接触。它包含一个idnameavatarURL,和avatar。我们将使用avatarURL来获取要分配给的图片avatar。它符合Decodable协议,因此可以从项目中包含的两个JSON文件中加载和解码伪造的联系人数据,并且还符合Identifiable协议,以便在ContactListView使用ForEach循环中轻松迭代此类对象。
  • RandomContactModel班:我们将用它作为模型的RandomContactView。它是部分实现的,当我们完成它时,它将能够选择随机的联系人并使用接下来将要构建的协议来获取其头像图像。
  • ContactListModel班:为模型ContactListView类。我们将使用它来尝试将添加到协议中的大多数功能,例如获取和删除多张图片,以及删除和下载单张图片。

我们将使用的虚假联系人数据已嵌入到项目中。有两个JSON文件,分别为fakedata_small.jsonfakedata.json,其中第一个包含10个联系人的数据,第二个包含50个联系人的数据。我们将第一个与RandomContactModelRandomContactView类一起使用,另一个与联系人列表和一起使用ContactListModel。每个联系人数据都包含一个ID姓名头像图像URL

注意:伪数据是使用Mockaroo生成的。

花时间在入门项目中导航。当您准备就绪时,请继续阅读并做好准备开始在项目中添加缺少的部分。

入门

让我们从创建一个新的Swift文件开始,在其中添加将在此处执行的实现。在键盘上按Cmd + N,在“源”类别下选择Swift文件模板,然后继续创建新文件。将其命名为FetchableImage.swift。准备就绪后,如果它没有自动打开,请选择在“项目浏览器”中将其打开。

为了使我们将来将要使用的协议以及其他开发人员更轻松地进行操作,我们将基于以下主要思想:要使用远程图像的URL来获取图像从远程源获取,并作为文件名(在获取图像后本地保存图像时)。要执行后者,尽管我们首先需要在URL中进行一些处理。我们不能使用URL作为文件名。无论如何,当图像以本地方式存在时,不必增加处理自定义文件名的负担。两种情况都将使用已知的原始URL。

我们将详细介绍所有内容,但对于初学者而言,让我们定义所需的自定义类型。让我们从协议及其扩展开始。我们将其命名为FetchableImage

protocol FetchableImage {
 
}
 
 
extension FetchableImage {
 
}

现在,让我们声明两个可以帮助我们构建它的结构:

struct FetchableImageOptions {
 
}
 
 
fileprivate struct FetchableImageHelper {
 
}

请注意,该FetchableImageHelper结构被标记为文件专用。这是因为我们希望它仅在此文件中可见,并且只能由接下来将要实现的方法访问。

指定可用选项

现在让我们谈谈FetchableImageOptions结构将包含的内容。最初,让我告诉您,我们将只添加一些存储的属性,而没有方法。您可以自行充实它,并添加可能适合的任何方法。

现在,让我们专注于将获取的图像存储到的目录。这里有两个选项;可以将它们存储在文档中缓存目录中。最佳做法是,应将它们保存在缓存目录中,因为如果系统删除了它们,则可以再次获取它们(例如,在设备上的可用空间不足的情况下)。但是,在某些情况下,将下载的图像存储在文档目录中更为合适。由于没有具体的规则,而且这完全取决于所要使用的情况FetchableImage,因此这是我们允许通过的第一个设置FetchableImageOptions

struct FetchableImageOptions {
    var storeInCachesDirectory: Bool = true
}

看到我们将true其设置为该storeInCachesDirectory属性的默认值,因此图像会自动保存到缓存目录中。可以通过创建一个新FetchableImageOptions对象并将该标志设置为来更改此设置false

接下来,让我们参考另一种情况。在大多数情况下,我们将把获取的图像保存在本地。在某些情况下,尽管我们可能不希望这种情况发生;我们可能只想获取而不是本地存储。为了解决这个问题,我们再添加一个选项,在其中指定是否允许将获取的图像存储在本地:

struct FetchableImageOptions {
    ...

    var allowLocalStorage: Bool = true
}

我们将其设置true为默认值,因为默认情况下,最常见的情况是我们需要将提取的图像保存在本地。

最后,即使我们可以不用它,也可以拥有另一个属性,我们以后肯定会需要它,以便能够将新图像直接从应用程序保存到本地目录。那就是图像的文件名:

struct FetchableImageOptions {
    ...
 
    var customFileName: String?
}

除非您使用FetchableImage协议保存新图像,否则很有可能不需要使用该customFileName属性。虽然有点预测性并提供了我们将来可能需要的解决方案,但这并没有什么坏处。

FetchableImageHelper前几位

就在上面,我们只是在理论层面上讨论了文档和缓存目录。由于我们将需要很快访问它们,因此现在是为两者指定URL的好时机。

FetchableImageHelper结构中添加以下两个静态属性:

fileprivate struct FetchableImageHelper {
    static var documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    static var cachesDirectoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}

将它们声明为静态可以使它们无需创建FetchableImageHelper对象就可以使用,就像这样:FetchableImageHelper.cachesDirectoryURL

添加第一个协议方法

完成第一步后,让我们开始实现该FetchableImage协议。通常,您应该期望从负责获取远程映像的方法开始,但这不是我们要从那里开始的地方。首先,我们将实现一个将URL返回到本地图像的方法。我们需要紧挨着它,以便确定是从远程源中获取图像还是从本地目录中加载图像。

首先,让我们在FetchableImage协议中对其进行声明:

func localFileURL(for imageURL: String?, options: FetchableImageOptions?) -> URL?

此方法需要两个参数值:第一个是图像的远程URL。请不要在这里感到困惑;imageURL是应从中获取图像的URL。如果该文件存在,则该方法会将URL返回到本地文件。

请注意,它被声明为可选,并且只能nil在一种情况下使用:如果图像的来源不是远程站点,但是图像是直接从应用程序创建和本地存储的。在那种情况下,customFileName必须通过一个FetchableImageOptions对象来提供,这将我们带到第二个参数值。

如果我们在FetchableImageOptions属性中指定的默认值符合我们的需要(存储获取的图像,保存到缓存目录,不使用自定义文件名),则传递nil就可以了。如果应更改选项之一,则FetchableImageOptions必须给新对象,并为其设置适当的值。

综上所述,让我们传递该方法的默认实现:

extension FetchableImage {
 
    func localFileURL(for imageURL: String?, options: FetchableImageOptions? = nil) -> URL? {
 
    }
 
}

即使在向协议声明方法时不允许声明默认参数值,也请注意上面(在FetchableImage扩展名内)我们设置niloptions参数的默认值。

我们要做的第一件事是确定文件的包含目录。文档或缓存目录。如果不是,则可以通过提供的options参数确定nil。如果是,nil则缓存目录为存储目录。但是我们可以更一般和最佳地解决这个问题。如果options参数值为nil,则可以初始化一个新FetchableImageOptions对象并“读取”指定的默认设置:

let opt = options != nil ? options! : FetchableImageOptions()

我现在可以告诉您,以上是我们接下来需要多次执行的操作。因此,我们不必在FetchableImageHelper结构中创建一个新方法来返回正确的对象,而不必一遍又一遍地写上述条件:

static func getOptions(_ options: FetchableImageOptions?) -> FetchableImageOptions {
    return options != nil ? options! : FetchableImageOptions()
}

确保在FetchableImageHelper结构中添加了上述方法。足够简单的是,options如果不是,则由方法返回给定的参数值nil。否则,将FetchableImageOptions返回一个新对象。

返回localFileURL(for:options:)方法实现,用以下内容替换之前添加的第一行:

let opt = FetchableImageHelper.getOptions(options)

现在很容易确定包含的目录:

let targetDir = opt.storeInCachesDirectory ?
    FetchableImageHelper.cachesDirectoryURL :
    FetchableImageHelper.documentsDirectoryURL

知道目标目录后,让我们指定图像的文件名。首先,让我们确保imageURL参数值不是nil

guard let urlString = imageURL else {
 
}

如果是nil,并且代码执行属于else上面的子句,那么我们将检查参数中是否提供了自定义文件名options。如果没有,我们nil从该方法返回;否则,我们从该方法返回。没有文件名,别无其他。否则,我们会将自定义文件名附加到目标目录,然后将其作为URL对象一起返回:

guard let customFileName = opt.customFileName else { return nil }
return targetDir.appendingPathComponent(customFileName)

上面处理了imageURL参数值为的​​情况nil。如果不是nil,那么我们将根据URL生成文件名,最后将其作为完整URL以及目标目录返回:

guard let imageName = FetchableImageHelper.getImageName(from: urlString) else { return nil }
return targetDir.appendingPathComponent(imageName)

getImageName(from:)是我们接下来要实现的方法。可以返回一个nil值,因此guard这里需要一个语句(或使用if-let替代方法)。

在开始之前,这里是一个完整的localFileURL(for:options:)方法:

func localFileURL(for imageURL: String?, options: FetchableImageOptions? = nil) -> URL? {
    let opt = FetchableImageHelper.getOptions(options)
    let targetDir = opt.storeInCachesDirectory ?
        FetchableImageHelper.cachesDirectoryURL :
        FetchableImageHelper.documentsDirectoryURL
 
    guard let urlString = imageURL else {
        guard let customFileName = opt.customFileName else { return nil }
        return targetDir.appendingPathComponent(customFileName)
    }
 
    guard let imageName = FetchableImageHelper.getImageName(from: urlString) else { return nil }
    return targetDir.appendingPathComponent(imageName)
}

远程图像URL包含特殊字符,例如列和斜杠,因此在本地存储获取的图像时,它不能由文件名组成。这里可以采用多种方法。例如,我们可以基于URL路径创建一个哈希,但是缺点是我们需要导入加密框架。或者,我们可以删除所有特殊字符并仅保留URL的字母数字部分。我们甚至可以提出一种自定义算法,根据原始URL生成文件名。

在此实现中,我们将做一些不同的事情。我们将URL的路径转换为Base64编码的字符串,然后删除所有非字母数字字符。这将是我们将用于每个图像的文件名。

跳转到该FetchableImageHelper结构,并定义以下方法:

static func getImageName(from urlString: String) -> String? {
 
}

为了从参数值中获取Base64编码的字符串,我们需要先将其转换为Data对象,如下所示:

guard var base64String = urlString.data(using: .utf8)?.base64EncodedString() else { return nil }
base64String = base64String.components(separatedBy: CharacterSet.alphanumerics.inverted).joined()
return base64String

如果首先创建一个数据对象,然后再创建Base64编码表示形式的任务没有失败,那么我们在第二行中删除所有非字母数字字符。乍一看可能看起来并不简单,但是它所做的只是将基于非字母数字字符的Base64编码的字符串拆分为一个数组(我们通过反转字母数字字符集来获取它们),然后重新加入分隔的块再次回到一串。

上面的小实现很好,但是有一个陷阱。根据URL的长度,我们可能会得到很长的Base64字符串值。

要解决此问题,我们将在方法中添加一个检查。如果base64String长度超过50个字符,那么我们将删除第一个字符,仅保留最后50个字符。我们不会保留前几个字符,因为所有图像URL的开始方式几乎相同(“ https://some.url/some_path/…”)。我们保留50个字符而不是10个或20个字符的事实确保了在结果Base64值中将有不同的字符。

因此,在return上面的语句之前添加以下内容:

guard base64String.count < 50 else {
    return String(base64String.dropFirst(base64String.count - 50))
}

现在是整个方法:

static func getImageName(from urlString: String) -> String? {
    guard var base64String = urlString.data(using: .utf8)?.base64EncodedString() else { return nil }
    base64String = base64String.components(separatedBy: CharacterSet.alphanumerics.inverted).joined()
 
    guard base64String.count < 50 else {
        return String(base64String.dropFirst(base64String.count - 50))
    }
 
    return base64String
}

获取图像

是时候实施FetchableImage协议的核心方法之一,也许是最重要的一种。在FetchableImage协议中添加以下声明:

func fetchImage(from urlString: String?, options: FetchableImageOptions?, completion: @escaping (_ imageData: Data?) -> Void)

在协议的扩展中,让我们开始实现它:

extension FetchableImage {
    ...
 
    func fetchImage(from urlString: String?, options: FetchableImageOptions? = nil, completion: @escaping (_ imageData: Data?) -> Void) {
 
    }
}

尽管我们将其称为“ fetchImage”,但该方法不会仅获取远程图像。它也会从本地文件加载图像。因此,根据上下文的不同,从现在开始说获取时,我们将指的是仅远程图像获取,或者既是远程获取又是从本地文件加载。

让我们看一下它的参数。第一个是作为字符串值的远程图像的URL。声明它是可选的,以涵盖不是从远程站点获取而是由应用程序本地创建和保存的图像的情况;在这种情况下,将没有远程URL。

第二个参数值再次是一个FetchableImageOptions可选对象。默认值为nil,因为大多数情况下默认值都将包含所需的配置。

最后,还有完成处理程序。请记住,获取远程图像必须是一个异步过程,因为有许多因素会影响下载过程(例如图像大小,网络速度等),因此不能实时返回图像。所需的获取时间不确定。完成处理程序(imageData)的参数值是可选的Data对象。如果从远程源成功获取图像,则将其数据传递给完成处理程序。如果失败,我们将通过nil

注意#1:我们可以将Swift 5中引入的Result类型用作完成处理程序的参数值,并且在无法通过实际错误的情况下:Result<Data, Error>。但是,为了简单起见,我们将坚持使用上述方法。

注意#2:我们会将获取(或加载)的图像作为Data对象传递给完成处理程序。取而代之的是,如果要讨论macOS,我们可以有一个UIImage对象,一个CGImage对象,甚至一个NSImage对象。但是,我们正在尝试在此处实施通用解决方案,其灵活性足以与UIKit,SwiftUI甚至macOS上的AppKit一起使用。将数据对象传递给完成处理程序是最通用的解决方案,因为之后可以将数据转换为最合适的对象。

综上所述,该方法实现的第一步是确保我们正在后台线程上工作。这很重要,因为只要图像获取正在进行,我们就不想阻塞主线程

DispatchQueue.global(qos: .background).async {
 
}

接下来的两个步骤涉及获取选项(从options参数或从新FetchableImageOptions对象(如果是nil)),以及图像本地文件的URL:

let opt = FetchableImageHelper.getOptions(options)
let localURL = self.localFileURL(for: urlString, options: options)

您可以在此处看到我们getOptions(_:)再次使用了之前实现的方法,并且localFileURL(for:options:)从此方法开始就开始使用even。

接下来要做什么很重要。首先,我们将检查图像是否在本地存在。如果是这样,那么我们将尝试从本地文件加载它,并将其数据传递给完成处理程序。如果图像在本地不存在,或者参数的allowLocalStorage属性options已设置为false(意味着具有给定URL的图像不允许存储在本地),那么我们将使用URL来获取图像。所以,我们开始:

if opt.allowLocalStorage,
    let localURL = localURL,
    FileManager.default.fileExists(atPath: localURL.path) {
 
} else {
 
}

if主体内部(这是本地存在的图像的情况),我们将调用另一个方法,该方法将使用localURL值从文件执行实际加载:

let loadedImageData = FetchableImageHelper.loadLocalImage(from: localURL)

我们将很快实施loadLocalImage(from:)loadedImageData将包含实际的图像作为数据对象,或者nil包含出现问题的图像,并且无法从文件中读取图像数据。无论如何,我们可以loadedImageData在上一行之后将传递给完成处理程序:

completion(loadedImageData)

else现在的身体,让我们在图像不存在于本地,并且必须从给定的远程URL抓取的情况下照顾。请记住,尽管URL是作为字符串值给出的,所以我们必须URL使用它创建一个对象:

guard let urlString = urlString, let url = URL(string: urlString) else {
    completion(nil)
    return
}

如果代码执行以else大小写结束并且urlString参数值为nil,或者它不是有效的URL并且url对象无法初始化,那么我们只需传递nil给完成处理程序即可;没什么可做的了。

另一方面,如果url对象初始化成功,那么我们可以从远程URL下载图像。实际的下载代码将与一样实现为另一种方法loadLocalImage(from:),但是现在让我们使用它,我们将在一段时间内处理丢失的方法。

FetchableImageHelper.downloadImage(from: url) { (imageData) in
 
}

downloadImage(from:completion:)我们即将实现的方法将接受应该从中下载图像的URL,并在下载完成后调用完成处理程序。完成处理程序包含成功时或nil发生错误时的实际图像数据。

在完成处理程序的主体内部,我们必须执行两个不同的任务:

  1. 如果允许,将获取的图像数据保存在本地。
  2. imageData数据(或nil)传递给完成处理程序。

两者都是:

FetchableImageHelper.downloadImage(from: url) { (imageData) in
    if opt.allowLocalStorage, let localURL = localURL {
        try? imageData?.write(to: localURL)
    }
 
    completion(imageData)
}

至此fetchImage(from:options:completion:)方法完成。我们在这里使用了两种缺少的方法,接下来将要添加,但是在此之前,这里是实现的整个方法:

func fetchImage(from urlString: String?, options: FetchableImageOptions? = nil, completion: @escaping (_ imageData: Data?) -> Void) {
    DispatchQueue.global(qos: .background).async {
 
        let opt = FetchableImageHelper.getOptions(options)
        let localURL = self.localFileURL(for: urlString, options: options)
 
        // Determine if image exists locally first.
        if opt.allowLocalStorage,
            let localURL = localURL,
            FileManager.default.fileExists(atPath: localURL.path) {
 
            // Image exists locally!
            // Load it using the composed localURL.
            let loadedImageData = FetchableImageHelper.loadLocalImage(from: localURL)
            completion(loadedImageData)
 
        } else {
            // Image does not exist locally!
            // Download it.
 
            guard let urlString = urlString, let url = URL(string: urlString) else {
                completion(nil)
                return
            }
 
            FetchableImageHelper.downloadImage(from: url) { (imageData) in
                if opt.allowLocalStorage, let localURL = localURL {
                    try? imageData?.write(to: localURL)
                }
 
                completion(imageData)
            }
 
        }
    }
}

我们将从最新的方法开始执行缺失的方法,即执行实际图像下载的方法。在其中,我们将简单地初始化一个URLSession对象,然后,我们将使用数据任务以从指定的URL提取图像数据。如果您曾经使用URLSession过,则以下实现看起来会很熟悉。只要确保在FetchableImageHelper结构中添加下一个实现即可:

static func downloadImage(from url: URL, completion: @escaping (_ imageData: Data?) -> Void) {
    let sessionConfiguration = URLSessionConfiguration.ephemeral
    let session = URLSession(configuration: sessionConfiguration)
    let task = session.dataTask(with: url) { (data, response, error) in
        completion(data)
    }
    task.resume()
}

请注意,我们不在这里响应或从服务器返回的潜在错误。但是,如果它们对您很重要,您可以随意更新上面的代码并进行处理。我们在这里所做的唯一一件事就是调用完成处理程序,将获取的数据传递给它。

加载本地图像文件

随着downloadImage(from:completion:)到位,让我们通过其他失踪当前的方法; 在loadLocalImage(from:)中,我们已经利用了fetchImage(from:options:completion:)以前的方法。

在其内部,我们将执行一个简单的操作;我们将尝试使用给定URL指定的文件内容来初始化Data对象。成功后,我们将返回初始化的数据对象,该对象不过是图像数据。如果失败,我们将返回nil。再一次,我们将此实现添加到FetchableImageHelper结构中:

static func loadLocalImage(from url: URL) -> Data? {
    do {
        let imageData = try Data(contentsOf: url)
        return imageData
    } catch {
        print(error.localizedDescription)
        return nil
    }
}

试用图像获取

是时候尝试我们到目前为止所做的事情了!在下载的入门项目中,您将找到一个名为RandomContactModel.swift的文件。在“项目浏览器”中发现它,然后单击以将其打开。

RandomContactModel该类要做的第一件事是从fakedata_small.json文件加载假联系人,解码JSON数据,然后将解码后的数据填充到Contact称为的对象集合中contacts

它还包含一个称为的方法pickRandomContact(),该方法Contact从联系人集合中随机选择一个对象。所选联系人将显示在UserView视图中,该视图嵌入RandomContactView。对于每个联系人,将从远程或本地来源获取化身图像,并且当化身图像可用时,UI将会更新以显示它。

当前,picker方法没有任何花哨的功能,只不过是随机选择一个联系人,如您在pickRandomContact()方法实现中所看到的:

func pickRandomContact() {
    let random = Int.random(in: 0..<10)
    guard random < contacts.count else { return }
    contact = contacts[random]
}

由于我们要在FetchableImage此处使用该协议,因此我们必须要做的第一步是使RandomContactModel类符合该协议:

class RandomContactModel: ObservableObject, FetchableImage {
    ...
}

在该pickRandomContact()方法中,我们现在可以调用该fetchImage(from:options:completion:)方法,以获取avatarURL与随机选取的联系人对象的属性匹配的图像:

func pickRandomContact() {
    ...
 
    fetchImage(from: contact.avatarURL, options: nil) { (avatarData) in
 
    }
}

目前,我们没有传递任何选项,因此将使用默认选项(将图像存储到缓存目录,允许存储远程图像,并且不使用任何自定义文件名)。

根据我们之前应用的逻辑,上述方法将首先尝试从本地文件加载图像。如果不存在,那么它将尝试获取它,然后将其存储到缓存目录中以备将来使用。在该方法的后续调用中,将从本地文件中加载图像,因此显示时间将显着缩短。我们将看到所有这些动作。

由于在SwiftUI CGImage中使用Image控件中的图像很方便,因此我们首先确保avatarData不是nil,然后尝试UIImage从该数据初始化一个对象。然后,从UIImage对象获得所需的CGImage表示形式。需要注意的重要一点是,我们必须使用主线程才能执行我刚刚描述的操作。请记住,图像伪装是在后台进行的,并且仅允许从应用程序的主线程更改UI。

func pickRandomContact() {
    ...
 
    fetchImage(from: contact.avatarURL, options: nil) { (avatarData) in
        if let data = avatarData {
            DispatchQueue.main.async {
                self.contact.avatar = UIImage(data: data)?.cgImage
            }
        }
    }
}

化身CGImage表示被分配给avatar随机选择的联系人的属性。由于@Published已将其标记为,因此更改将触发在UserView视图中更新UI 。

现在,打开RandomContactView并添加以下行作为按钮的操作:

Button("Random contact") {
    self.randomDataModel.pickRandomContact()
}

然后…运行该应用程序以查看结果!默认情况下,每个假联系人都有一个默认头像,指示缺少适当的头像。单击“ 随机联系人”按钮以选择一个随机联系人,然后看到几秒钟内将提取图像并将其显示在Image控件上。继续单击该按钮,以便获取更多图像以获取更多联系人。然后,停止并再次运行该应用程序,以便avatar每个联系人的属性再次变为空。您会看到,获取已经下载的图像几乎是即时的;它们是从本地文件加载的!

如果要查看存储的图像,请转到pickRandomContact()方法,然后在fetchImage方法调用之前添加以下打印命令:

func pickRandomContact() {
    ...
 
    print(localFileURL(for: contact.avatarURL))
 
    fetchImage(from: contact.avatarURL, options: nil) { (avatarData) in
        ...
    }
}

再次运行并开始点击随机接触按钮。这次,您将在控制台中看到本地图像文件的URL。复制任何显示的URL,但确保包括前缀“ file://”和文件名(在“ Caches /”中停止)。转到Finder,然后按Shift + Cmd + G,然后粘贴复制的URL,然后按Return键。这也是查看生成的图像名称的好机会。

最佳实践:如何在Swift项目中获取远程图像

您也可以使用这些选项。删除所有下载的文件,并在调用该fetchImage方法之前创建以下FetchableImageOptions对象。将其作为fetchImage调用中的第二个参数传递:

let options = FetchableImageOptions(storeInCachesDirectory: true, allowLocalStorage: false, customFileName: nil)
 
func pickRandomContact() {
    ...
 
    let options = FetchableImageOptions(storeInCachesDirectory: true, allowLocalStorage: false, customFileName: nil)
 
    fetchImage(from: contact.avatarURL, options: options) { (avatarData) in
        ...
    }
}

再次运行,您将发现本地不再存储任何图像,并且每次您请求随机联系时,都会再次获取其头像。

让我们尝试其他事情。使用以下命令更新上面的代码:

func pickRandomContact() {
    ...
 
    // let options = FetchableImageOptions(storeInCachesDirectory: true, allowLocalStorage: false, customFileName: nil)
    let options = FetchableImageOptions(storeInCachesDirectory: false, allowLocalStorage: true, customFileName: nil)
 
    fetchImage(from: contact.avatarURL, options: options) { (avatarData) in
        ...
    }
}

在此配置中,我们允许本地存储,但我们将其设置falseFetchableImageOptions初始化程序的第一个参数值。图像将存储到文档目录,而不是缓存目录。

运行,看到这次联系人化身确实存储在documents目录中。

获取批处理图像

让我们继续前进,让FetchableImage协议也能够获取多个图像。好消息是,我们将基于到目前为止已构建的内容,因此获取批处理图像不应令人生畏。最初,我们将在FetchableImage协议中声明一个新方法(切换到FetchableImage.swift文件):

protocol FetchableImage {
    func fetchBatchImages(using urlStrings: [String?], options: FetchableImageOptions?,
         partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
         completion: @escaping () -> Void)
}

在协议扩展中开始实现它之前,让我们看一下我们在此处设置的参数值。首先,它是URL的字符串值集合。我们想获取多个图像,因此我们需要多个URL。接下来,我们有到目前为止已经讨论过很多次的选项。

这里的新特性和有趣之处是第三个参数值partialFetchHandler。您可能也将其视为进度处理程序。想象一下,您必须下载大量图像,并且该任务将花费一些时间。您很可能希望通过显示进度栏或相关消息来向用户通知下载进度。该处理程序使这成为可能。每次获取图像时,其数据将作为该处理程序的第一个参数给出,第二个参数是所有图像集合中图像的索引。这样一来,我们便可以立即使用每张图像,并且有了索引,我们将能够显示在任何给定时刻所取得的进展。

最后,最后一个参数值是完成过程,当整个过程完成时将调用该完成。注意,这里我们没有传递图像数据。发生在partialFetchHandler

现在转到实现部分。在FetchableImage扩展名中添加方法定义:

extension FetchableImage {
    func fetchBatchImages(using urlStrings: [String?], options: FetchableImageOptions?,
         partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
         completion: @escaping () -> Void) {
 
 
 
    }
}

现在,进行重要的澄清。我们将以递归方式获取多张图片,这意味着我们将调用相同的方法来一次又一次地获取图片,直到获取完所有图片为止。该方法将不是我们刚刚定义的方法。为此,我们将实施一个新的。

仍在FetchableImage扩展中,添加下一个私有方法。在扩展名中看不到它,但是它将帮助我们实现目标:

private func performBatchImageFetching(using urlStrings: [String?], currentImageIndex: Int,
    options: FetchableImageOptions?,
    partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
    completion: @escaping () -> Void) {
 
}

您会注意到,参数几乎与先前方法的参数相同。此处的新内容是第二个参数,它是urlStrings集合中应使用的URL的索引。

实现非常简单。首先,必须确保currentImageIndex处于urlStrings计数范围内,如果仅如此,则继续。否则,我们将调用完成处理程序并从方法返回:

private func performBatchImageFetching(using urlStrings: [String?], currentImageIndex: Int,
    options: FetchableImageOptions?,
    partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
    completion: @escaping () -> Void) {
 
    guard currentImageIndex < urlStrings.count else {
        completion()
        return
    }
}

下一步是获取指向的图像currentImageIndex。为此,我们将使用fetchImage(from:options:completion:)已经完成的方法:

private func performBatchImageFetching(using urlStrings: [String?], currentImageIndex: Int,
    options: FetchableImageOptions?,
    partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
    completion: @escaping () -> Void) {
 
    ...
 
    fetchImage(from: urlStrings[currentImageIndex], options: options) { (imageData) in
 
    }
}

在上述完成处理程序的主体内,我们将做两件事。首先,我们将调用部分处理程序,以将刚刚获取的图像数据(或nil如果不存在数据的话)以及当前图像的索引传递给调用者:

private func performBatchImageFetching(using urlStrings: [String?], currentImageIndex: Int,
    options: FetchableImageOptions?,
    partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
    completion: @escaping () -> Void) {
 
    ...
 
    fetchImage(from: urlStrings[currentImageIndex], options: options) { (imageData) in
        partialFetchHandler(imageData, currentImageIndex)
    }
}

第二件事是调用相同的方法并递归获取下一个图像。在这里要注意的重要一点是第二个论点。我们将currentImageIndex增加的1传递给下一张图像。最后,我们将调用完成处理程序:

private func performBatchImageFetching(using urlStrings: [String?], currentImageIndex: Int,
    options: FetchableImageOptions?,
    partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
    completion: @escaping () -> Void) {
 
    ...
 
    fetchImage(from: urlStrings[currentImageIndex], options: options) { (imageData) in
        partialFetchHandler(imageData, currentImageIndex)
 
        self.performBatchImageFetching(using: urlStrings,
            currentImageIndex: currentImageIndex + 1,
            options: options, partialFetchHandler: partialFetchHandler) {
            completion()
        }
    }
}

实施该方法后,我们可以返回fetchBatchImages(using:options:partialFetchHandler:completion:)开始制作的方法,但从未完成。很简单,在其中我们将调用上面的方法来指示第一个URL的索引;0(零)。在被调用方法的完成处理程序的主体处,我们将调用此方法的完成处理程序,以便调用者知道整个过程已完成。

func fetchBatchImages(using urlStrings: [String?],
                      options: FetchableImageOptions? = nil,
                      partialFetchHandler: @escaping (_ imageData: Data?, _ index: Int) -> Void,
                      completion: @escaping () -> Void) {
 
    performBatchImageFetching(using: urlStrings, currentImageIndex: 0,
        options: options, partialFetchHandler: { (imageData, index) in
 
        partialFetchHandler(imageData, index)
 
    }) {
        completion()
    }
 
}

至此,我们完成了必要的工作,以便能够进行多个图像读取。如您所知,所有必需的东西都已经制作好了。在这里,我们仅关注递归和使用已经起作用的代码。

试用获取多个图像

因此,现在FetchableImage可以提取批处理图像,让我们尝试一下,看看它是否确实有效。首先,打开ContactListModel.swift文件,您将在其中找到ContactListModel该类。此处已经实现的是伪造的联系数据的加载和解码,这次是通过演示项目中存在的fakedata.json文件读取的。

请注意,这里观察到的三个属性标记为@Published

  1. contactsContact包含伪造联系数据的对象数组。
  2. progress:Double值,将保持下载进度。此属性的任何更改都将导致进度条的可视更新(在中实现ProgressView,但嵌入在中ContactListView)。
  3. isFetching:一个标志,指示是否正在进行下载过程。我们将需要它来显示或隐藏进度,并防止在另一个正在进行的下载过程中开始新的下载过程。

让我们现在获取批处理图像。首先,采用FetchableImage协议:

class ContactListModel: ObservableObject, FetchableImage {
    ...
}
func fetchAvatars() {
    guard !isFetching else { return }
    isFetching = true
    progress = 0.0
}

接下来,让我们创建所有头像URL的集合:

func fetchAvatars() {
    ...
 
    let allAvatarURLs = contacts.map { $0.avatarURL }
}

是时候调用该fetchBatchImages(using:options:partialFetchHandler:completion:)方法以启动批量图像获取:

func fetchAvatars() {
    ...
 
    fetchBatchImages(using: allAvatarURLs, options: nil, partialFetchHandler: { (imageData, index) in
 
        DispatchQueue.main.async {
            guard let data = imageData else { return }
            self.contacts[index].avatar = UIImage(data: data)?.cgImage
 
            self.progress = Double(((index + 1) * 100) / self.contacts.count)
        }
 
    }) {
        print("Finished fetching avatars!")
 
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: {
            self.isFetching = false
        })
    }
}

查看每次调用部分处理程序时会发生什么:

  • 首先,我们一定要切换到主线程,因为图像获取是在后台进行的。
  • 其次,我们确保已获取图像数据,并且返回的值不是nil
  • 第三,也是最重要的一点,我们使用图像的索引来访问contacts数组中匹配的对象并更新其化身。该操作将使用户界面得到更新并显示化身图像。
  • 最后,我们将进度计算为百分比。这将更新进度条。

完成所有下载后,将调用完成处理程序。除了print仅用于验证目的的非重要语句外,我们还将isFetching属性的值切换回false。这是在主线程上进行的,因为这会影响UI。它将使进度条变为隐藏状态。0.4秒的小延迟只是为了让您眼神愉悦,因此进度条不会在最后一次获取图像后立即消失。

现在打开ContactListView.swift文件,并找到用作第一个导航项的按钮。在那里,我们将调用该fetchAvatars()方法:

.navigationBarItems(
    leading:
    Button(action: {
 
        // Add this line:
        self.contactList.fetchAvatars()
 
    }) {
        Image(systemName: "arrow.clockwise")
    },

运行该应用程序,然后打开第二个选项卡。您会看到一长串具有默认头像图像的虚假联系人。点击刷新按钮,然后看到化身逐渐被更新。在下一次运行中,您会发现化身几乎会立即显示;那是因为它们存储在本地。

注意:我们可能会自动获取化身,但为了更轻松地尝试构建内容,我们将其保留为手动操作,该操作由刷新栏按钮项触发。

最佳实践:如何在Swift项目中获取远程图像

删除单个本地图像文件

现在,获取图像已经不可行了,所以让我们暂时关注相反的操作。让我们从本地目录中删除单个或批处理图像文件成为可能,让我们从第一部分开始。

回到FetchableImage.swift文件,让我们声明以下新方法:

protocol FetchableImage {
    ...
 
    func deleteImage(using imageURL: String?, options: FetchableImageOptions?) -> Bool
}

无论我们讨论的有关图像URL和选项的内容都适用于此,因此,我跳过了关于它们的任何进一步讨论。但是,请注意,这一次这里没有完成处理程序。取而代之的是,删除结果作为布尔值从方法中立即返回。成功删除后,它将返回true,否则将返回false

现在让我们跳到它的实现。在FetchableImage扩展程序中,我们将从找到本地图像的URL开始:

extension FetchableImage {
    ...
 
    func deleteImage(using imageURL: String?, options: FetchableImageOptions? = nil) -> Bool {
        guard let localURL = localFileURL(for: imageURL, options: options),
            FileManager.default.fileExists(atPath: localURL.path) else { return false }
    }
}

如果本地图像的URL是nil,或者找不到文件,我们将返回false并在此处停止。否则,我们尝试在do-catch语句中删除图像文件,如下所示:

func deleteImage(using imageURL: String?, options: FetchableImageOptions? = nil) -> Bool {
    ...
 
    do {
        try FileManager.default.removeItem(at: localURL)
        return true
    } catch {
        print(error.localizedDescription)
        return false
    }
}

就这样。现在,我们可以尝试删除单个图像。

试用删除单个图像

使用演示应用程序时,然后点击列表中联系人的行(第二个选项卡),您将导航到详细信息页面。这不过是联系人的头像,姓名和一个允许删除头像图像的按钮而已。实际上,您很快就会发现该按钮具有双重作用。当联系人的头像存在时,它允许删除它。在相反的情况下,它将用于再次获取它。

因此,这是我们测试图像删除的地方。但是,在进入按钮之前,请打开ContactListModel.swift文件,在该文件中必须添加几个新方法。在第一个例子中,我们将调用deleteImage(using:options:)上面实现的方法:

func deleteAvatar(for contact: Contact) {
    guard let index = contacts.firstIndex(where: { $0.id == contact.id }) else { return }
 
    if deleteImage(using: contacts[index].avatarURL, options: nil) {
        contacts[index].avatar = nil
    }
}

全新的deleteAvatar(for:)方法获取一个Contact对象作为参数,并以此为基础尝试Contactcontacts数组中所有对象之间找到其索引。然后,它调用该协议的deleteImage(using:options:)方法FetchableImage,并成功设置nil为相应的属性,以删除实际的头像图像。

我们要在ContactListModel类中添加的下一个方法仅在使按钮ContactDetailsView具有前面提到的双重角色时才是必需的。删除并提取。我们没有讨论过什么新内容,因此我将立即进行介绍:

func fetchAvatar(for contact: Contact, completion: @escaping (_ avatar: CGImage?) -> Void) {
    guard let index = contacts.firstIndex(where: { $0.id == contact.id }) else { return }
 
    fetchImage(from: contacts[index].avatarURL) { (imageData) in
        DispatchQueue.main.async {
            guard let data = imageData else { return }
            self.contacts[index].avatar = UIImage(data: data)?.cgImage
            completion(self.contacts[index].avatar)
        }
    }
}

立即打开ContactDetailsView.swift,并更新以下内容:

Button(contact.avatar != nil ? "Delete Avatar" : "Fetch Avatar") {
 
}

有了这个:

Button(contact.avatar != nil ? "Delete Avatar" : "Fetch Avatar") {
    if self.contact.avatar != nil {
        self.contact.avatar = nil
        self.contactList.deleteAvatar(for: self.contact)
    } else {
        self.contactList.fetchAvatar(for: self.contact) { (fetchedAvatar) in
            guard let avatar = fetchedAvatar else { return }
            self.contact.avatar = avatar
        }
    }
}

最后,运行该应用程序。转到联系人的详细信息,然后使用在那里可以找到的按钮。如果头像已经存在,则将其删除,否则将获取并显示该头像。

最佳实践:如何在Swift项目中获取远程图像

删除多张图像

现在可以删除单个图像,我们可以轻松地在FetchableImage协议中添加一个新方法,该方法将删除多个图像。我说的很容易,因为我们将基于之前实现的单一删除方法。

打开FetchableImage.swift文件,并在FetchableImage协议中添加以下方法声明:

protocol FetchableImage {
    ...
 
    func deleteBatchImages(using imageURLs: [String?], options: FetchableImageOptions?)
}

然后,在协议的扩展中实现它。它非常简单:

extension FetchableImage {
    ...
 
    func deleteBatchImages(using imageURLs: [String?], options: FetchableImageOptions? = nil) {
        DispatchQueue.global().async {
            imageURLs.forEach { _ = self.deleteImage(using: $0, options: options) }
        }
    }
}

首先,我们将全局后台线程用于我们的任务。然后,使用forEach高阶函数浏览第一个参数值中提供的所有图像URL,然后逐个删除所有图像文件。

注意:在此处阅读有关高阶函数的更多信息。

请注意,以上实现基于以下假设:imageURLs参数值包含应删除的图像的URL。万一我们想删除多个文件,而这些文件的自定义文件名提供给对象customFileName数量相等的属性,它将无法正常工作FetchableImageOptions。在这种情况下,我们需要创建一个新方法:

protocol FetchableImage {
    ...
 
    func deleteBatchImages(using multipleOptions: [FetchableImageOptions])
}

以及协议扩展中的实现:

如您所知,数组中的每个FetchableImageOptions项目multipleOptions都将与具有自定义文件名的单个文件匹配。

试用批量图像删除

我们将在此处尝试基于URL批量删除图像,因此请打开ContactListModel.swift文件,在该文件中需要在ContactListModel类中添加一个新方法:

func deleteAllAvatars() {
 
}

在其中,我们将创建一个包含所有头像URL的数组,然后将调用刚刚实现的方法以删除所有本地图像文件,最后我们将遍历contacts数组中的所有联系人对象以将其设置nil为全部头像对象:

func deleteAllAvatars() {
    let allAvatarURLs = contacts.map { $0.avatarURL }
    deleteBatchImages(using: allAvatarURLs, options: nil)
    for (index, _) in contacts.enumerated() {
        contacts[index].avatar = nil
    }
}

最后,转到ContactListView.swift文件,并找到第二个栏按钮。在结束动作中调用deleteAllAvatars()

.navigationBarItems(
    leading:
    ...,
 
    trailing:
    Button(action: {
 
        // Add this line:
        self.contactList.deleteAllAvatars()
 
    }) {
        Image(systemName: "trash")
    }
)

运行应用程序,然后在联系人列表中点击垃圾桶按钮(如果您已获取远程头像图像)。所有的头像都会被删除,您需要点击刷新按钮以再次获取它们。

最佳实践:如何在Swift项目中获取远程图像

保存自定义图像

还有最后一件事要做,那就是在FetchableImage协议中添加最后一个方法,这将使我们可以轻松地直接从应用程序本地保存图像,而不是从远程源获取图像。最后一种方法不是我们使用FetchableImage协议所需的东西。但是,它存在会很不错,因此我们可以在必要时保存自定义图像,而在开发应用程序时不必提出新的解决方案。

因此,最后一次打开FetchableImage.swift文件,并在协议中添加下一个方法声明:

protocol FetchableImage {
    ...
 
    func save(image data: Data, options: FetchableImageOptions) -> Bool
}

注意这里:

  1. 该方法接受我们要保存的图像数据。
  2. 这里的options参数值不是可选的;我们需要它来获取文件名!

现在在协议扩展中实现它:

extension FetchableImage {
    ...
 
    func save(image data: Data, options: FetchableImageOptions) -> Bool {
        guard let url = localFileURL(for: nil, options: options) else { return false }
        do {
            try data.write(to: url)
            return true
        } catch {
            print(error.localizedDescription)
            return false
        }
    }
}

如果无法形成实际文件名的URL或写入文件失败,则返回false。如果将图像数据写入文件成功,则返回true

我将最后一个功能留给您测试。

摘要

FetchableImage协议现已完成,这使我们到了本文的结尾。我希望您喜欢这个真正方便的工具的分步实现,希望该工具可以适合您自己的项目。

如果您尚未实施自己的解决方案来处理远程图像,则可以考虑从此处阅读的内容开始,并对其进行扩展或修改,以满足您的特定需求。

作为参考,您可以在GitHub上下载完整的项目。

如果您想进一步发展,可以使其成为可重用的库并将其包装在Swift包中。谢谢阅读!

喜欢 (0)

您必须 登录 才能发表评论!