Help us understand the problem. What is going on with this article?

PostProcessBuild で Info.plist を編集するための仕組みを作ってみた

More than 3 years have passed since last update.

このエントリは Unity Advent Calendar 2015 の9日目の記事になります。

前日の記事は @fakestarbaby さんの「Visual Studio Code を利用して Unity アプリをデバッグする」でした。

ログ仕込んでデバッグするのもアリですが、やっぱりちゃんとブレークポイント仕込んでデバッグできると楽ですよね!

私も最近 Visual Studio Code 遣いになったので、ガンガン活用していこうかと思います!

前置き

Unity で iOS 向けにビルドした後に、 Info.plist を編集する という作業が発生する人も多いのではないでしょうか?

例えば、

  • ATS (App Transport Security) 関連の設定
  • URL Schemes の設定
  • オリエンテーションに関する設定を書き換え

…などなど。

この辺、毎回手動で書き換えるのは面倒なので、自動化しちゃいましょうってなお話です。

plist って何よ?

本題に入る前に、まず plist という形式について理解しておく必要があります。

成り立ちとかそういう小難しいコトは Wikipedia に説明を譲るとして、端的に言えば XML で記述されるデータ定義ファイル のコトです。

こいつには、

  • そもそも XML である
  • 型に応じたノードが定義されている
  • Dictionary (連想配列) は <key> ノードと対になる型ノードを連続して記述する

といった、クセがあって、サックリ書き換えようと思っても、なかなか難しかったりします。

実際に、iOS 出力した際に生成される Info.plist をテキストエディタで開いてみれば、「嗚呼…面倒くさい…。」となるコト請け合いです。ええ。

汎用的に編集するための作戦

この面倒くさいフォーマットを攻略するための戦略を立ててみましょう。

  • System.Xml のクラスを使うコトを前提とする
  • いわゆるスカラーなノードは単一のテキストノードとして取り扱う
  • array ノードと dict ノードはちょっと頑張る必要あり
  • dict ノードに於けるキーの重複は上書きで対応する
    • ただし、再帰的に潜れる場合は、末端のノードでのみ上書きを行う
  • array ノードは原則的に追加のみ
  • ノードの削除も実装できるとオシャレ

この辺りを定義したパッチ的なファイルを読み込むことで、 plist ファイルを編集できたら楽ですよね?

PlistMods

と、言うわけで作ってみました。

変更定義ファイル

.plistmods という拡張子を持つ JSON ファイルとして変更方法を定義してみました。

要点としては以下の通り。

  • { "type": ..., "value": ... } という1つ分の plist ノードを表現するための Object ノードを持つ
  • type の値に応じて value ノードの型も変わる
    • array なら Array ノードを取る
    • dict なら plist 的な key ノードの値を、そのままキーとした Object ノードの連想配列とする
  • dict なノードのキーをハイフン始まりにすると、「該当のキーと、それに隣接する値ノードを削除する」とみなす

まぁ、サンプル見ればある程度理解はできるんじゃないかと。

sample.plistmods
{
  "type": "dict",
  "value": {
    "-CFBundleIconFiles": null,
    "-UISupportedInterfaceOrientations": null,
    "-UISupportedInterfaceOrientations~ipad": null,
    "UISupportedInterfaceOrientations": {
      "type": "array",
      "value": [
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeLeft"
        },
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeRight"
        }
      ]
    },
    "UISupportedInterfaceOrientations~ipad": {
      "type": "array",
      "value": [
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeLeft"
        },
        {
          "type": "string",
          "value": "UIInterfaceOrientationLandscapeRight"
        }
      ]
    },
    "NSAppTransportSecurity": {
      "type": "dict",
      "value": {
        "NSExceptionDomains": {
          "type": "dict",
          "value": {
            "kidsstar.tv": {
              "type": "dict",
              "value": {
                "NSExceptionAllowsInsecureHTTPLoads": {
                  "type": "bool",
                  "value": true
                },
                "NSExceptionRequiresForwardSecrecy": {
                  "type": "bool",
                  "value": false
                },
                "NSIncludesSubdomains": {
                  "type": "bool",
                  "value": true
                }
              }
            },
            "cloudfront.net": {
              "type": "dict",
              "value": {
                "NSExceptionAllowsInsecureHTTPLoads": {
                  "type": "bool",
                  "value": true
                },
                "NSExceptionRequiresForwardSecrecy": {
                  "type": "bool",
                  "value": false
                },
                "NSIncludesSubdomains": {
                  "type": "bool",
                  "value": true
                }
              }
            }
          }
        }
      }
    },
    "CFBundleURLTypes": {
      "type": "array",
      "value": [
        {
          "type": "dict",
          "value": {
            "CFBundleTypeRole": {
              "type": "string",
              "value": "Editor"
            },
            "CFBundleURLName": {
              "type": "string",
              "value": "tv.kidsstar.app.${PRODUCT_NAME:rfc1034identifier}"
            },
            "CFBundleURLSchemes": {
              "type": "array",
              "value": [
                {
                  "type": "string",
                  "value": "tv.kidsstar.app.${PRODUCT_NAME:rfc1034identifier}"
                }
              ]
            }
          }
        }
      ]
    }
  }
}

.plistmods の適用

んで、 .plistmods な定義ファイルを読み込んで、 Info.plist を編集する感じの動きになります。

まぁ、実際のコードを見てもらうのが早いです。

PlistMods.cs
using UnityEngine;
using System.IO;
using System.Xml;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using MiniJSON;

namespace KidsStar.Editor.iOS {

    public class PlistMods {

        private XmlDocument plist;

        public PlistMods(string path) {
            this.plist = new XmlDocument();
            this.plist.Load(path);
        }

        public void Apply(string pathPlistMods) {
            IDictionary mods = (IDictionary)Json.Deserialize(File.ReadAllText(pathPlistMods));

            // 第1階層が dict ノードであることを前提としている
            IDictionary rootItem = (IDictionary)mods["value"];
            XmlNode rootNode = this.plist.SelectSingleNode("/plist/dict");
            foreach (object key in rootItem.Keys) {
                this.Apply(rootNode, (string)key, (IDictionary)rootItem[key]);
            }
        }

        public void Apply(string[] pathPlistModsList) {
            foreach (string pathPlistMods in pathPlistModsList) {
                this.Apply(pathPlistMods);
            }
        }

        public void Apply(List<string> pathPlistModsList) {
            this.Apply(pathPlistModsList.ToArray());
        }

        public void Save(string path) {
            XmlDocumentType _documentType = this.plist.CreateDocumentType("plist", "-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd", null);
            if (null != this.plist.DocumentType) {
                this.plist.RemoveChild(this.plist.DocumentType);
            }
            this.plist.InsertAfter(_documentType, this.plist.FirstChild);
            this.plist.Save(path);
        }

        private void Apply(XmlNode parent, string key, IDictionary item) {
            // キーがハイフンで始まる場合、削除とみなす
            if (!string.IsNullOrEmpty(key) && Regex.IsMatch(key, "^-")) {
                key = Regex.Replace(key, "^-", string.Empty);
                if (parent.HasKeyNode(key)) {
                    parent.RemoveChild(parent.GetKeyNode(key).NextSibling);
                    parent.RemoveChild(parent.GetKeyNode(key));
                }
                return;
            }

            if (null == item["type"] || null == item["value"]) {
                Debug.LogError("処理対象のノードに type か value が含まれていません");
                return;
            }
            switch ((string)item["type"]) {
                case "bool":
                    this.ApplyScalar(parent, key, (bool)item["value"] ? "true" : "false");
                    break;
                case "integer":
                    this.ApplyScalar(parent, key, "integer", (int)item["value"]);
                    break;
                case "real":
                    this.ApplyScalar(parent, key, "real", (float)item["value"]);
                    break;
                case "string":
                    this.ApplyScalar(parent, key, "string", (string)item["value"]);
                    break;
                case "date":
                    this.ApplyScalar(parent, key, "date", (string)item["value"]);
                    break;
                case "data":
                    this.ApplyScalar(parent, key, "data", (string)item["value"]);
                    break;
                case "array":
                    this.ApplyArray(parent, key, (IList)item["value"]);
                    break;
                case "dict":
                    this.ApplyDict(parent, key, (IDictionary)item["value"]);
                    break;
            }
        }

        private void ApplyScalar(XmlNode parent, string key, string type, object value = null) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                if (null == value) {
                    parent.AppendChild(this.plist.CreateElement(type));
                } else {
                    parent.AppendChild(this.plist.CreateSimpleTextNode(type, value.ToString()));
                }
                return;
            }

            if (parent.HasKeyNode(key)) {
                parent.RemoveChild(parent.GetKeyNode(key).NextSibling);
            } else {
                parent.AppendChild(this.plist.CreateKeyNode(key));
            }
            if (null == value) {
                parent.InsertAfter(this.plist.CreateElement(type), parent.GetKeyNode(key));
            } else {
                parent.InsertAfter(this.plist.CreateSimpleTextNode(type, value.ToString()), parent.GetKeyNode(key));
            }
        }

        private void ApplyArray(XmlNode parent, string key, IList itemList) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                foreach (object item in itemList) {
                    this.Apply(parent, null, (IDictionary)item);
                }
                return;
            }

            if (!parent.HasKeyNode(key)) {
                // キーがないなら、キーノードと array ノードを作る
                parent.AppendChild(this.plist.CreateKeyNode(key));
                parent.AppendChild(this.plist.CreateElement("array"));
            }
            foreach (object item in itemList) {
                this.Apply(parent.GetKeyNode(key).NextSibling, null, (IDictionary)item);
            }
        }

        private void ApplyDict(XmlNode parent, string key, IDictionary item) {
            // キーが空の場合、Array ノードへの挿入と見なす
            if (string.IsNullOrEmpty(key)) {
                XmlNode dictNode = parent.AppendChild(this.plist.CreateElement("dict"));
                foreach (object k in item.Keys) {
                    this.Apply(dictNode, (string)k, (IDictionary)item[k]);
                }
                return;
            }

            if (!parent.HasKeyNode(key)) {
                // キーがないなら、キーノードと dict ノードを作る
                parent.AppendChild(this.plist.CreateKeyNode(key));
                parent.AppendChild(this.plist.CreateElement("dict"));
            }
            foreach (object k in item.Keys) {
                this.Apply(parent.GetKeyNode(key).NextSibling, (string)k, (IDictionary)item[k]);
            }
        }
    }

    internal static class XmlExtension {

        public static bool HasChildNode(this XmlNode self, string name, bool ignoreCase = true) {
            return null != self.GetChildNode(name, ignoreCase);
        }

        public static XmlNode GetChildNode(this XmlNode self, string name, bool ignoreCase = true) {
            foreach (XmlNode childNode in self.ChildNodes) {
                if (childNode.Name == name || (ignoreCase && childNode.Name.ToLower() == name.ToLower())) {
                    return childNode;
                }
            }
            return null;
        }

        public static bool HasKeyNode(this XmlNode self, string key) {
            return null != self.GetKeyNode(key);
        }

        public static XmlNode GetKeyNode(this XmlNode self, string key) {
            return self.SelectSingleNode(string.Format("./key[.=\"{0}\"]", key));
        }

        public static XmlNode CreateSimpleTextNode(this XmlDocument xml, string name, string text) {
            XmlElement _node = xml.CreateElement(name);
            _node.InnerText = text;
            return _node;
        }

        public static XmlNode CreateKeyNode(this XmlDocument xml, string key) {
            return xml.CreateSimpleTextNode("key", key);
        }

    }
}

MiniJSON を使っていますが、昨日 (2015/12/08) リリースされた Unity 5.3 であれば標準で JSON Serializer を搭載しているので、それ使ってあげるように修正しても良いかもです。

Save() メソッドで DOCTYPE ノードを再構築していますが、普通に保存すると何故か [] という記号が最後に挿入されてしまって、ビルドエラーをぶちかましてくれるので、泣く泣く強引な処理を噛ましています。

なお、現時点で、 data ノードが正しく取り扱われない というバグがあります!

「対応したぜ!」って人が居れば、後述のサンプルプロジェクトにプルリク飛ばしてくださいw

PostProcessBuild での適用

この辺は、本題じゃないんで、サラッと流します。

PostProcessBuild.cs
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
using System.IO;
using System.Collections.Generic;

namespace Sample.Editor {

    public class PostProcessBuild {

        [PostProcessBuild(200)]
        public static void OnPostProcessBuild(BuildTarget target, string path) {
            if (target != BuildTarget.iOS) {
                return;
            }

            // 適用する .plistmods ファイルを掻き集める
            List<string> files = new List<string>();
            if (Directory.Exists(System.IO.Path.Combine(Application.dataPath, "Sample/PlistMods"))) {
                files.AddRange(System.IO.Directory.GetFiles(System.IO.Path.Combine(Application.dataPath, "Sample/PlistMods"), "*.plistmods", System.IO.SearchOption.AllDirectories));
            }

            // .plistmods ファイルを適用する
            string plistPath = Path.Combine(path, "Info.plist");
            KidsStar.Editor.iOS.PlistMods plistMods = new KidsStar.Editor.iOS.PlistMods(plistPath);
            plistMods.Apply(files);
            plistMods.Save(plistPath);
        }

    }

}

PlistMods.Apply() の引数として、

  • string として、単一の .plistmods へのパス
  • string[] として、複数の .plistmods へのパス
  • List<string> として、複数の .plistmods へのパス

というオーバーロードを用意してありますので、お好きな方法でどーぞ。

あ、ちなみに、 Info.plist に限らず、 plist 形式ならどんなファイルでも行けるようにしてあるので、独自プロパティリストを使うような SDK とかをアレコレしたい場合なんかにも使えるんじゃないかな?

サンプルプロジェクト

github にて公開してあります。

こいつをそのまま取り込んで iOS 向けビルドしていただければ Assets/Sample/PlistMods/sample.plistmods の変更定義に従って Info.plist が書き換わるハズです。

Unity のバージョンは 5.2.3f1 で確認済となります。

まとめ

実は、Unity 5 からは、 UnityEditor.iOS.Xcode なるネームスペースの下に PlistDocument というクラスが生えていて、こいつを駆使してあげれば同じようなことは実現出来るんですが、追加・変更・削除の処理そのものは、個別に実装する必要があるため、こんなモノを作ってみた次第です。

同じような感じで Android の AndroidManifest.xml の編集とか、 iOS の .pbxproj (Xcode の Build Settings とかの設定が定義されているファイル) の編集とかも実装してみたら面白いんじゃないでしょーか?

むしろ、誰か作って!w

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした