Chapter 9 — Uploading & downloading files
Uploading with multipart/form-data
// Chirp/Networking/MultipartFormData.swift
import Foundation
struct MultipartFormData {
let boundary = "Boundary-\(UUID().uuidString)"
private var body = Data()
var contentType: String { "multipart/form-data; boundary=\(boundary)" }
mutating func addFile(name: String, filename: String, mimeType: String, data: Data) {
body.appendString("--\(boundary)\r\n")
body.appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
body.appendString("Content-Type: \(mimeType)\r\n\r\n")
body.append(data)
body.appendString("\r\n")
}
func finalized() -> Data {
var result = body
result.appendString("--\(boundary)--\r\n") // closing boundary
return result
}
}
private extension Data {
mutating func appendString(_ string: String) { append(Data(string.utf8)) }
}
// Chirp/Features/Profile/AvatarUploader.swift
func uploadAvatar(_ imageData: Data, client token: String) async throws -> ProfileDTO {
var form = MultipartFormData()
form.addFile(name: "file", filename: "avatar.jpg", mimeType: "image/jpeg", data: imageData)
var request = URLRequest(url: baseURL.appendingPathComponent("me/avatar"))
request.httpMethod = "POST"
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") // includes the boundary
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await session.upload(for: request, from: form.finalized())
try validate(response, data: data)
return try decoder.decode(ProfileDTO.self, from: data)
}
- Compress the image first. A photo from the camera can be 10 MB; that's slow to upload and the
- Use
upload(for:from:), not a JSON body. The upload variant streams the body efficiently and is
Reporting upload progress
final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate {
let onProgress: @Sendable (Double) -> Void
init(onProgress: @escaping @Sendable (Double) -> Void) { self.onProgress = onProgress }
func urlSession(_ session: URLSession, task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
guard totalBytesExpectedToSend > 0 else { return }
let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
onProgress(fraction) // 0.0 → 1.0
}
}
// Pass it to the specific upload:
let progress = UploadProgressDelegate { fraction in
Task { @MainActor in viewModel.uploadProgress = fraction } // drive a ProgressView
}
let (data, response) = try await session.upload(for: request, from: body, delegate: progress)
Downloading and caching images
URLCache(Chapter 7) — the raw bytes, on disk/memory, respecting HTTP headers. Shared by all- A decoded-image memory cache (
NSCache<NSURL, UIImage>) — the decoded image, so you skip the - In-flight deduplication — if two rows request the same URL at once, do one download, not two.
// Chirp/Networking/ImageLoader.swift
import UIKit
actor ImageLoader {
static let shared = ImageLoader()
private let cache = NSCache<NSURL, UIImage>()
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache.object(forKey: url as NSURL) { return cached } // decoded cache hit
if let existing = inFlight[url] { return try await existing.value } // dedup concurrent loads
let task = Task { () throws -> UIImage in
defer { inFlight[url] = nil }
let (data, _) = try await URLSession.shared.data(from: url) // URLCache underneath
guard let image = UIImage(data: data) else { throw APIError.decoding(URLError(.cannotDecodeContentData)) }
cache.setObject(image, forKey: url as NSURL)
return image
}
inFlight[url] = task
return try await task.value
}
}
struct CachedImage: View {
let url: URL?
@State private var image: UIImage?
var body: some View {
Group {
if let image { Image(uiImage: image).resizable() }
else { Color.gray.opacity(0.2) } // placeholder
}
.task(id: url) {
guard let url else { return }
image = try? await ImageLoader.shared.image(for: url)
}
}
}
Downloading larger files
let (tempURL, response) = try await session.download(for: request)
try validate(response, data: Data())
// Move it out of the temp location before the closure returns — the OS deletes temp files.
let destination = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("export.zip")
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: tempURL, to: destination)
Background transfers (for when the app is suspended)
let config = URLSessionConfiguration.background(withIdentifier: "com.chirp.uploads")
config.isDiscretionary = false // start promptly (true lets the OS wait for good conditions)
let backgroundSession = URLSession(configuration: config, delegate: myDelegate, delegateQueue: nil)
flowchart TB
subgraph Up_Upload___Upload__ ["Up_Upload ["Upload"]"]
Pick["pick + compress image"] --> Multi["build multipart body"] --> UpTask["session.upload (progress via delegate)"] --> Prof["→ ProfileDTO"]
end
subgraph Down_Download___Download__ ["Down_Download ["Download"]"]
Row["feed row needs image"] --> Loader["ImageLoader: NSCache → URLCache → network"] --> Img["decoded UIImage"]
end
What we built
- Built multipart/form-data upload for the avatar, with a reusable body builder, image
- Reported upload progress with a
URLSessionTaskDelegate(didSendBodyData) driving a - Built a caching
ImageLoader(decodedNSCache+ in-flight dedup overURLCache) and a - Used
download(for:)for large files (streamed to disk) and covered background sessions for
Mental model to take away
- Uploads use multipart/form-data (build the body carefully) and
upload(for:from:); compress - For images, cache at three levels:
URLCache(bytes), anNSCacheof decoded images, and - Use
data(...)for small in-memory responses,download(...)for big files streamed to disk. - Reach for a background
URLSessiononly for transfers that must outlive the foreground — it trades