Introduction
Dependency Injection is a technique in software development where the dependencies of an object are set from the outside. There are many benefits of using this technique, such as loose coupling between the objects, which enables us to easily replace an implementation in the future. With dependency injection, we can also make unit testing a lot easier – we can inject a mock implementation of other dependencies of that object, to focus only on the tests for that particular object.
In this post, we will see how we can effectively use dependency injection to support older versions of iOS. We will accomplish this using the dependency injection container from the Girders for Swift framework.
The problem
Let’s say we are building a PDF viewer app, which can generate a PDF from a list of images and display it in a web view. Also, we should be able to create a thumbnail from that PDF. One requirement is that our minimum supported version is iOS 10 (or even lower). We are implementing the app using the Core Graphics methods for PDF support. It’s a lower-level API, which requires a lot of code to implement it.
/// Class for handling PDF related tasks, such as merging images to PDF, generating thumbnails. class CGPDFService { /// Merges the provided images into a PDFData. func mergeToPDF(images: [UIImage]) -> PDFData? { let maxWidth = images.map { (image) -> CGFloat in return image.size.width }.max() let defaultWidth = UIScreen.main.bounds.size.width let pdfs = images.map { (image) -> PDFData in return self.createPDFDataFromImage(image: image, width: maxWidth ?? defaultWidth) } let out = NSMutableData() UIGraphicsBeginPDFContextToData(out, .zero, nil) guard let context = UIGraphicsGetCurrentContext() else { return out as Data } for pdf in pdfs { guard let dataProvider = CGDataProvider(data: pdf as CFData), let document = CGPDFDocument(dataProvider) else { continue } for pageNumber in 1...document.numberOfPages { guard let page = document.page(at: pageNumber) else { continue } var mediaBox = page.getBoxRect(.mediaBox) context.beginPage(mediaBox: &mediaBox) context.drawPDFPage(page) context.endPage() } } context.closePDF() UIGraphicsEndPDFContext() return out as Data } /// Converts an image to a PDFData. private func createPDFDataFromImage(image: UIImage, width: CGFloat) -> PDFData { let pdfData = NSMutableData() let imgView = UIImageView.init(image: image) let ratio: CGFloat = width / image.size.width let imageRect = CGRect(x: 0, y: 0, width: width, height: ratio * image.size.height) imgView.frame = imageRect UIGraphicsBeginPDFContextToData(pdfData, imageRect, nil) UIGraphicsBeginPDFPage() let context = UIGraphicsGetCurrentContext() imgView.layer.render(in: context!) UIGraphicsEndPDFContext() return pdfData as PDFData } /// Generates a thumbnail image of the PDFData. func thumbnail(from pdfData: PDFData) -> UIImage? { guard let provider = CGDataProvider(data: pdfData as CFData), let document = CGPDFDocument(provider), let page = document.page(at: 1) else { return nil } let pageRect = page.getBoxRect(.mediaBox) let renderer = UIGraphicsImageRenderer(size: pageRect.size) let img = renderer.image { ctx in UIColor.white.set() ctx.fill(pageRect) ctx.cgContext.translateBy(x: 0.0, y: pageRect.size.height) ctx.cgContext.scaleBy(x: 1.0, y: -1.0) ctx.cgContext.drawPDFPage(page) } return img } }
Then, iOS 11 comes out with the new PDFKit framework, which offers a higher-level API and an easier manipulation of the PDFs. We want to use it, because it’s simpler and better in performance, but we can’t, because of the lower iOS versions we have to support in our app. The only way to use it, is to provide different implementations for different iOS versions.
Solution 1
First idea might be to use the @available macro in the methods above. If it’s iOS 11 or higher, we will call a function that uses PDFKit, otherwise, we will call our old function.
However, there are several issues with this approach. First, we will have a lot of if-statements, which means a lot of test cases and scenarios. Second, we will not have a clean and maintainable code. When we decide that we are finally getting rid of the old implementation, we would have to go in the class and delete the old methods, which is error prone.
Solution 2
We can solve this in a more elegant way, using protocols and dependency injection. First, let’s extract the methods for merging images to PDF and creating a thumbnail in a protocol.
typealias PDFData = Data /// Protocol for the PDF manipulation tasks. protocol PDFService: class { /// Merges images to PDF. func mergeToPDF(images: [UIImage]) -> PDFData? /// Creates a thumbnail from the provided PDF data. func thumbnail(from pdfData: PDFData) -> UIImage? }
Next, we will provide new implementation of the two methods, with the new PDFKit framework.
import PDFKit @available(iOS 11, *) class PDFKitService: PDFService { /// Merges the provided images into a PDFData. func mergeToPDF(images: [UIImage]) -> PDFData? { let document = PDFDocument() var index = 0 for image in images { if let page = PDFPage(image: image) { document.insert(page, at: index) index += 1 } } return document.dataRepresentation() } /// Generates a thumbnail image of the PDFData. func thumbnail(from pdfData: PDFData) -> UIImage? { guard let page = PDFDocument(data: pdfData)?.page(at: 0) else { return nil } let pageSize = page.bounds(for: .mediaBox) let defaultWidth: CGFloat = 240 let pdfScale = defaultWidth / pageSize.width // Apply if you're displaying the thumbnail on screen let scale = UIScreen.main.scale * pdfScale let screenSize = CGSize(width: pageSize.width * scale, height: pageSize.height * scale) return page.thumbnail(of: screenSize, for: .mediaBox) } }
As you can see, this is much simpler implementation. It also doesn’t have strange bugs (like sometimes not generating a thumbnail for a PDF). We have added the @available macro at the class definition, so the compiler knows that it needs to load this class only in iOS 11 or higher. Next, we also make our old CGPDFService conform to that protocol.
Next, let’s create a DIService class, which will inject the dependencies for us, when the app is launched. Our app has only one dependency that will be injected, depending on the iOS version.
class DIService { static func injectDependencies() { Container.addSingleton { () -> PDFService in if #available(iOS 11, *) { return PDFKitService() } else { return CGPDFService() } } } }
Next, in the view controller that uses the PDF functionalities, we need to make it depend on an abstraction (protocol), instead of a concrete implementation. With this approach, we are able to switch implementations easily.
import GirdersSwift private lazy var pdfService: PDFService = Container.resolve() private func loadPDF() { let images = loadImages() if let pdfData = pdfService.mergeToPDF(images: images) { let baseURL = URL(string: "https://example.com")! pdfView.load(pdfData, mimeType: "application/pdf", characterEncodingName: "utf-8", baseURL: baseURL) } }
This functionality is enabled by the dependency injection container from Girders for Swift, an open-source framework developed by Netcetera, the company I work for.
Now, when we want to completely remove the old implementation, we will just update our DIService class. The view controller which uses this functionality, will not be changed at all. The same applies if we want to remove the PDFKit implementation in the future, with a new implementation. I find this approach much cleaner for managing dependencies, especially from third-party libraries.
What do you think about this approach? Would you use Girders for Swift DI container in your apps? Write your thoughts in the comments section.
The source code of this example can be found here.