Property List is a convenient and flexible format for saving data. It was originally defined by apple, to use in iOS devices and later extended to various apps. Plist internally is actually an XML file and can easily be converted to JSON file format as well.
Xcode provides a nice visual tool to edit and view contents of a .plist
file.
In the context of iOS, a plist file is used in many places. To store the basic information of an app, every target has a info.plist
file which contains information like bundle Id
, product name
, version no
, background modes
, etc. If your app has a settings bundle, you save the information in Root.plist
inside the bundle. So we can see Plist is a very popular format when it comes to iOS app development.
The best thing about it is, we can use Plist
for storing other custom information. It’s often a good practice to store various hard-coded key-value pairs in plist, for instance: API path name, screen toggle mode, app configuration, private tokens, etc. One thing, is if your plist file contains sensitive and private information, you should encrypt the file before shipping it out.
It’s easy to edit and save information in a plist file, but let’s talk about reading it from iOS app, because that’s what we are more focused on. The ease of using plist file. Generally, we don’t write to a plist file using code
, but we must read
from code.
Let’s see the logic of reading a plist file:
Easy but not so safe approach
A plist can be converted into a Dictionary. If you know the key of a dictionary, you can get the value of it.
if let path = Bundle.main.path(forResource: "Debug", ofType: "plist"),
let dict = NSDictionary(contentsOfFile: path) as? [String: Any] {
... // We have a dictionary now
}
Doesn’t it seem very easy? Yes, it does. But, why is it unsafe? Let’s imagine you have a plist whose JSON equivalent looks like this
{
key1: {
key11: {
key111: {
keyFinal: "result"
}
}
}
}
You’d need to know before hand, where your final value is. In this case key1 -> key11 -> key111 -> keyFinal
. You need to check for validity and type guarantee at every key. Let’s attempt to write, how we could read the final value result
// Assuming we already have dict from previous step
if let dict1 = dict["key1"] as? [String: Any],
let dict2 = dict1["key11"] as? [String: Any],
let dict3 = dict2["key111"] as? [String; Any],
let finalValue = dict3["keyFinal"] as? String {
... // We have the final value "value" here now.
}
Looks like the so-called easy step is not so easy after all. There must be a better way. There must be a safer way. Let’s have another look.
Alternative approach
If we look carefully in the above code, the problem lies, because we need to keep digging for all intermediate key
. We could probably write a generalized function that takes in all the keys, and it keeps going in to find the final value.
func valueFor(keys: String..., inDictionary dict: [String: Any]) -> Any? {
}
That helps! Can we do better? Writing key names one by one is too prone to mistakes. What if you have a typo.
What about, we generate the all possible keys combination out of plist, and read the key.
An interesting approach would be to generate a enum
whose case
are the key
of the plist. This way we can be sure, we won’t any unwarranted mistakes in providing the key name. There are several ways this could be done, the approach I would like to try out is write a script to generate the enum
from Plist.
Since we are talking about iOS here, why not try scripting in swift
. Swift is incredibly efficient for minimal tasks like this. There are several articles and talks about scripting in swift. You can checkout one of my talks here.
Scripting in swift
Let’s start the fun part now. To begin, create a new Xcode project (Commandline). You should see a main.swift
with a default Hello, World!
line. Go ahead try to build and run Cmd+B
and Cmd+R
. If it’s running fine, we can go about doing the real deal.
The whole part can be divided into four main parts:
- Read plist file from some [
input = .plist
] - Read all possible keys into a container
- Generate a enum of keys, and a method to read plist using this enum key
- Write the generated
.swift
file somewhere [output = .swift
]
A plist is like an XML (and that’s why a JSON like) file. It could be a dictionary or an array. So, before reading each items in plist, we should check whether it’s an array or dictionary type.
Let’s define a class named PListEnum
. It is going to be the heart and soul of converting plist to swift enum.
class PlistEnum {
let plistOutput: Any
required init(url: URL) {
plistOutput = File.readPlist(url: url)
}
}
You provide the fileUrl of plist file, we have a helper method on class File
that reads plist file.
class File {
class func readPlist(url: URL) -> Any {
if let dict = NSDictionary(contentsOf: url) as? [String: Any] {
return dict
} else if let array = NSArray(contentsOf: url) as? [Any] {
return array
} else {
return []
}
}
}
Reading Array and Dictionary
Now that we’ve converted plist into either an Array
of objects, or a Dictionary
containing objects, we’d like to read all the possible keys inside them. For a dictionary, it’s straight forward, it’s a key-value pair. Possibile key type is only String
and possible values are Array
, Number
, Date
, Boolean
or another Dictionary
.
For an array, the only way to access all items is to know their index (Int
type) in the array. If you know the index, accessing the value in an array is a very cheap(O(n)
) operation.
So we have two possible key
types if want to generalize both array and dictionary. String
and Int
.
Let’s define a protocol named Keypathable
and make String
and Int
conform to it.
protocol Keypathable {}
extension Int: Keypathable {}
extension String: Keypathable {}
Now we’re going to loop through each item of the dictionary and array in plist, as we loop through we save the combination of keys in an array. To hold the combination of keys that produces a value, we define a struct
called PlistKey
struct PlistKey: CustomStringConvertible {
let keys: [Keypathable]
func keyByAppending(_ key: Keypathable) -> PlistKey {
var myKeys = keys
myKeys.append(key)
return PlistKey(keys: myKeys)
}
var keyCase: String {
var keyArray: [String] = []
for k in keys {
keyArray.append("\(k)")
}
return keyArray.joined(separator: "_")
}
var description: String {
return keys.description
}
}
We also define a couple of helper methods that we would find helpful later.
Let’s write a method that loops through dictionary and array.
func run() {
var keys: [PlistKey] = []
// Reads a dictionary
func checkDict(_ dictionary: [String: Any], plistKey: PlistKey) {
for (key, value) in dictionary {
if let array = value as? [Any] {
checkArray(array, plistKey: plistKey.keyByAppending(key))
} else if let dict = value as? [String: Any] {
checkDict(dict, plistKey: plistKey.keyByAppending(key))
} else {
keys.append(plistKey.keyByAppending(key))
}
}
}
// Reads an array
func checkArray(_ array: [Any], plistKey: PlistKey) {
for (index, value) in array.enumerated() {
if let array = value as? [Any] {
checkArray(array, plistKey: plistKey.keyByAppending(index))
} else if let dict = value as? [String: Any] {
checkDict(dict, plistKey: plistKey.keyByAppending(index))
} else {
keys.append(plistKey.keyByAppending(index))
}
}
}
if let output = plistOutput as? [String: Any] {
checkDict(output, plistKey: PlistKey(keys: []))
} else if let output = plistOutput as? [Any] {
checkArray(output, plistKey: PlistKey(keys: []))
}
}
We’ve two methods defined inside run
. checkDict
and checkArray
are very similar, the only difference is one has String
key path type and the other has Int
key path type.
Once this finishes, the variable keys
contains combination of all possible key paths that result in a meaningful value. All we are left with now, is to write a .swift
file with enum cases
The final config file we’d like to generate would look something like this:
Let’s call it PlistConfig.swift
import Foundation
protocol PathType {}
extension Int: PathType {}
extension String: PathType {}
public enum PKeys {
//***************
case key1_0
case key2_key21
//***************
var keys: [PathType] {
switch self {
//***************
case .key1_0: return ["key1", 0]
case .key2_key21: return ["key2", "key21"]
//***************
}
}
}
class PlistConfig {
let plistOutput: Any
private init() {
if let url = Bundle.main.url(forResource: "example", withExtension: "plist") {
if let dict = NSDictionary(contentsOf: url) as? [String: Any] {
plistOutput = dict
} else if let array = NSArray(contentsOf: url) as? [Any] {
plistOutput = array
} else {
plistOutput = []
}
} else {
plistOutput = []
}
}
func valueFor(_ keyPath: [PathType]) -> Any? {
let count = keyPath.count
var finalValue: Any? = plistOutput
for i in 0..<count {
let path = keyPath[i]
if let dictPath = path as? String {
if let newValue = finalValue as? [String: Any] {
finalValue = newValue[dictPath]
} else {
return nil
}
} else if let arrayPath = path as? Int {
if let newValue = finalValue as? [Any], arrayPath < newValue.count {
finalValue = newValue[arrayPath]
} else {
return nil
}
}
}
return finalValue
}
}
The part commented between two //***************
indicates the part that needs to be updated from our script. Other than that, PlistConfig.swift
is pretty ready to be used.
The implementation part of the final part is a little bit long, so please bear with me.
let template: String = "\n" +
"import Foundation\n" +
"\n" +
"protocol PathType {}\n" +
"extension Int: PathType {}\n" +
"extension String: PathType {}\n" +
"\n" +
"\n" +
"public enum PKeys {\n" +
"{CASES}\n" +
"\n" +
"\tvar keys: [PathType] {\n" +
"\t switch self {\n" +
"{CASESRESULTS}\n" +
"\t\t }\n" +
"\t }\n" +
"\n" +
"}\n" +
"\n" +
"\n" +
"class PlistConfig {\n" +
"\n" +
"\t let plistOutput: Any\n" +
"\n" +
"\t private init() {\n" +
"\t\t if let url = Bundle.main.url(forResource: \"{FILENAME}\", withExtension: \"plist\") {\n" +
"\t\t\t if let dict = NSDictionary(contentsOf: url) as? [String: Any] {\n" +
"\t\t\t\t plistOutput = dict\n" +
"\t\t\t } else if let array = NSArray(contentsOf: url) as? [Any] {\n" +
"\t\t\t\t plistOutput = array\n" +
"\t\t\t } else {\n" +
"\t\t\t\t plistOutput = []\n" +
"\t\t\t }\n" +
"\t\t } else {\n" +
"\t\t\t plistOutput = []\n" +
"\t\t }\n" +
"\t }\n" +
"\n" +
"\n" +
"\t func valueFor(_ keyPath: [PathType]) -> Any? {\n" +
"\t\t let count = keyPath.count\n" +
"\t\t var finalValue: Any? = plistOutput\n" +
"\t\t for i in 0..<count {\n" +
"\t\t\t let path = keyPath[i]\n" +
"\t\t\t if let dictPath = path as? String {\n" +
"\t\t\t\t if let newValue = finalValue as? [String: Any] {\n" +
"\t\t\t\t\t finalValue = newValue[dictPath]\n" +
"\t\t\t\t } else {\n" +
"\t\t\t\t\t return nil\n" +
"\t\t\t\t }\n" +
"\t\t\t } else if let arrayPath = path as? Int {\n" +
"\t\t\t\t if let newValue = finalValue as? [Any], arrayPath < newValue.count {\n" +
"\t\t\t\t\t finalValue = newValue[arrayPath]\n" +
"\t\t\t\t } else {\n" +
"\t\t\t\t\t return nil\n" +
"\t\t\t\t }\n" +
"\t\t\t }\n" +
"\t\t }\n" +
"\t\t return finalValue\n" +
"\t\t }\n" +
"\n" +
"}\n" +
"\n"
This defines the template. We’d replace a few parameters inside it, and write this template
string into a file named PlistConfig.swift
.
The final part is rather straight forward:
var cases: [String] = []
var caseResult: [String] = []
for plistKey in keys {
cases.append("\t case \(plistKey.keyCase)")
caseResult.append("\t\t case .\(plistKey.keyCase): return \(plistKey.keys)")
}
let content = template.replacingOccurrences(of: "{FILENAME}", with: file)
let contentCases = content.replacingOccurrences(of: "{CASES}", with: cases.joined(separator: "\n"))
let finalContent = contentCases.replacingOccurrences(of: "{CASESRESULTS}", with: caseResult.joined(separator: "\n"))
let output = Bundle.main.bundlePath
let _ = File.write(path: output + "/PListConfig.swift", content: finalContent )
Like the previously defined read
method, we have a write method
on File
class.
class func write (path: String, content: String, encoding: String.Encoding = String.Encoding.utf8) -> Bool {
return ((try? content.write(toFile: path, atomically: true, encoding: encoding)) != nil) ?true :false
}
That’s it for generating the script.
………………
Where to go next?
You can check out the complete code at github PlistToEnum.
The next part would be, to make sure plist file and PlistConfig.swift
are always in sync. Ideally, we’d like to protect PlistConfig.swift
as readonly for user. And for iOS Project, we could write a Pre-build script to check if there’s any update on plist file, and if so, to update the PlistConfig.swift
file.
I might write my next post on that topic.
Till then, good day!!