游戏助手技术细节:结合Shell Script快速建立大量相似iOS App
背景
游戏助手项目是由一系列的游戏助手App组成,需要在现有基础功能上,通过换皮肤、渠道识别以及新增功能,快速制作出小差异化的App,例如:炉石传说助手、迷你西游助手,大话西游2助手等。
如何复制?
目前,有两种可行方式:Targets(编译目标)与Subprojects(子工程)
- Targets - 同一项目工程里,通过复制多个target,利用target与scheme的配合编译不同的资源文件,得到多个App;
- Subprojects - 把主框架独立成库项目,再复制出多个子工程得到多个App。
下面,先从Targets方式说起,分别介绍一下两个方式在我们项目中的具体实践,并讲述为什么我们放弃使用targets的方式,以及使用子项目方式都有哪些难点和关键点。
Targets方式
话不多说,先直接上图给大家看看:
虽然xcode的文件组是虚拟的,但真实文件结构也差不多,就不截图上来了,下面直入实现细节。
实现细节:
- 每个target指定不同的Info.plist文件;
- Build Phases的Copy Bundle Resources中仅添加对应资料夹的资源;
- Build Settings -> Preprocessing添加Preprocessing Macros预处理宏常量以区分各App的其它实现细节;
- 复制同名带_TS的为测试服数据target,区别在于bundle id添加测试服标识,以及Build Settings -> Custom Compiler Flags设置预编译常量表明为测试服数据源;
- 预编译常量:每个资料夹下独立的AppBuilder.h(例如开放平台id与统计id等);
- 服务端通过渠道标识区分来自哪个助手App;
- 指定target对应的scheme进行编译,最终得到不同App。
使用Run Script
- Run Script脚本:放置bundle id等基础信息,每次跑都修改Info.plist,而不是直接修改Info.plist;
- Run Script处理AppBuilder.h的使用,通过拷贝至基础代码替换文件的方式实现xcode唯一引用,而非直接引用资料夹下的。
复制助手的步骤:
- 右击某个助手target,Duplicate(通过复制新建)这个target;
- 移除target内上一个助手的资源,例如几百个上个助手的皮肤图片,一些不可重用有代码等,但注意不要碰到基础代码内的东西;
- 建立资源资料夹,准备Info.plist,AppBuilder.h,以及相关资源;
- 资源文件仅添加进xcode的这个新建target;
- 修改AppBuilder.h,Run Script的内容;
- 修改xcode中新target的Product Name;
- 使用新的Info.plist为target的Info.plist
- 修改target内的其它预编译常量
- 通过对这个新的target 进行duplicate,修改类似相关的点得到测试服target
以上步骤缺一不可,当然,还是有进一步整理空间的,但主要问题不在这,请继续往下看。
Targets的优缺点:
优点是工程结构简单,清晰,统一,另外唐巧《使用多target来构建大量相似App》对此进行了很好的诠释,最初我们的项目工程也是这种方式,但当助手数量增加后,但缺点也越来越明显:
- 工程文件(xcodeproj)日益增大,如上面的Copy Bundle Resource就有1700+资源文件,compile的文件就300+,每一个资源文件的引用就是1行记录,14个助手App,就有28个target,共28x2000 = 56000行记录,单个文件就20MB;
- 因为上一条,导致xcode处理效率下降,甚至卡死,哪怕只修改target内的一个字母,2012年i5 MBA直接卡顿30秒以上;
- 在协作与版本管理上,会造成多个人多次反复修改同一项目核心文件project.pbxproj,出现冲突机率很大,而且该文件不适合人工修改,一旦冲突出现,如果修改得多,他人根本就无从下手,xcode也无法打开,只好无奈revert了。
在SNS工具上也为此向大牛唐巧请教过,他也无奈表示目前还没什么办法解决,于是我们决定用子项目的方式把target分出来。
子项目方式:利用脚本批处理复制
实现计划
- 确定基础功能明确,确立框架,最终基础库由Foundation与UI两库组成,以.a文件形式提供;
- 代码重构,把注入式的配置文件AppBuilder.h、预编译常量全部分离,脱出基础库,基础库不再预编译进任何助手信息;
- Run Script改造,不再耦合任何助手信息,转为读取另外配置文件,并独立成文件;
- 精简target,不再储存资源文件引用以外的信息
- 所有信息,包括需要预编译信息,统一由运行期设置
- 建立Template项目,使用xcode workspace组织各子项目
- 编写脚本,从Template生成新项目,目的把上面繁琐的复制步骤去人工化,减负并减少出错机率
执行难点
大量的预编译常量使用
因为AppBuilder.h使用预编译常量记录信息,在基础库里散落各地,需要逐一整理出来,并用运行时的单例来取代它们。这部分没什么办法,只能慢慢挑鱼骨头。
原代码:
#if !kOpenPlatformEnabled
self.shareButton.hidden = YES;
//codes...
#else
self.shareButton.hidden = NO;
//codes else...
#endif
修改为
if (![BuildInfo shareInstance].openPlatformEnabled]){
self.shareButton.hidden = YES;
//codes...
} else {
self.shareButton.hidden = NO;
//codes else...
}
因为#define
常量在编译期就固定值,不包含常量类型信息,xcode不能辅助识别和Refactor,不利于维护和debug,所以常量定义尽量使用const
而非#define
。
// File.h
extern NSString *const MyKey;
// File.m
NSString *const MyKey = @"MyKey";
预编译 vs 运行时
预编译可以选择性的编译代码或打包资源,例如正式包不会包含任何测试服信息,即使逆向工程也无法从中找到任何信息,在一定程序上起到数据保护作用。
#if BUILD_FOR_ONLINE_APP
return @"https://myonlinehost.163.com";
#else
return @"http://123.123.123.123/";
#endif
project.pbxproj文件
利用子工程的方式与target方式一样需要进行项目配置,但最大的问题还在于xcode工程文件本身的组织方式,
- xcode文件的组是虚拟组,文件引用并非扫描当下目录文件,不关心实体文件路径只关心文件名,并且隐藏了具体路径细节;
- 通过Scheme指定Target进行编译,target参数众多,人工复制修改需要好记性与细心,是个绣花活。
.xcodeproj是一个包,显示包内容后会发现最核心的是project.pbxproj文件,这个文件其实是一个类似JSON形式的文本,但并非JSON,我们希望从中找出规律,让脚本在复制的时候自动修改对应的内容。
文件内容非常庞大,但仔细观察,还是能发现一些规律的,例如:
/* Begin PBXBuildFile section */
94EDEA9C1A13500E00AAEA5F /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 94EDEA9B1A13500E00AAEA5F /* AppDelegate.m */; };
....
/* End PBXBuildFile section */
类似注释号的/* Begin PBXBuildFile section */
明确了需要Build的文件;而类似94EDEA9B1A13500E00AAEA5F
为索引key,xcode通过key来检索所以信息点。
当然,我们并不需要太过关心这些,毕竟这文件不是给人去修改的。
Hack掉project.pbxproj
- 首先,利用xcode做好一个Template工程,并把所有文件路径设置为Relative to Group,这样能简化路径搜寻;
- 准备好要引用的代码、资源、Info.plist、Framework等;
- 修改项目名字,scheme name,target name为好标记的字母,例如统一使用
Sample
为名字替换目标; - 准备target内其它需要的内容;
- 关于AppDelegate.m,上面说了,xcode并不关心文件放哪里,所以你可以用A项目的AppDelegate.m放到B项目里,只要没BUG,一样能运行起来,所以最终的子项目只是借用了原AppDelegate.m,而不是每次都用新的文件,.h文件也如是。换句话说,xcode工程文件只是一个虚拟文件组织器,任何认为不需要独立出来的文件都可以指向同一个,甚至是main.m文件!
- 让工程正确运行起来,通过测试;
自动脚本Shell Script
当上面的操作准备好后,再打开project.pbxproj,会发现一切都如此清晰,只需要用shell来替换埋设好的关键字即可。
在Mac,用来替换文件字符串,并拷贝到目标目录,可以用到sed命令:
sed -e "s/TemplateString/NewString/g" Template/template.xcodeproj/project.pbxproj > NewPath/NewNameFile/project.pbxproj
# 如果有多个要替换,可以多个集合多个替换字符串:
sed -e "s/TemplateString/NewString/g" -e "s/TemplateString2/NewString2/g" Template/template.xcodeproj/project.pbxproj > NewPath/NewNameFile/project.pbxproj
我们的脚本必须能接受几个基本的参数,然后把参数转成对应的信息:
例如名为create.sh,执行时这样:
./create.sh -n ZGMH -b com.abc.zgmh -c zgmh_channel -p 1004
脚本开头可以这样写:
while [ "$1" != "" ]; do
case $1 in
-n | --name ) shift
name=$1
;;
-b | -bundleid ) shift
bundleid=$1
;;
-c | --channel ) shift
channel=$1
;;
-p | --pushid ) shift
pushid=$1
;;
* ) usage
exit 1
esac
shift
done
这样就能接受参数了
更多设置:settingVars.txt
在前文,我们看到在Run Script中进行了Info.plist修改,我们把Run Script单独抽出来,并且用第三个文件来定义更多参数。
settingVars.txt:
# 配置信息 有空格必须用英文半角双引号括起来
CHANNEL=sampleChannel #渠道名与目录
# 以下为info.plist内对应的内容
APP_BUNDLE_ID=appBundleId #CFBundleIdentifier
CFBUNDLE_URL_NAME=zsUrlName #CFBundleURLName
CFBUNDLE_URL_SCHEMES=zsScmeme #CFBundleURLSchemes
CFBUNDLE_DISPLAY_NAME=某某游戏助手 #CFBundleDisplayName
如何读进去脚本里?
#read setting vars
varsContent=$(<settingVars.txt)
eval "$varsContent"
简单粗暴到连自己都不相信[捂脸]。
如何读写plist文件?
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ${CFBUNDLE_DISPLAY_NAME}" ${INFO_PLIST_FILE_PATH}
通过PlistBuddy
来设置和读取plist,把接下来的字段补充完成即可,全部代码就不贴出来了。
小结一下
- 在xcode中配置好一切,埋设标识符;
- 编写Shell Script,用
sed
命令替换标识符并输出成新的project.pbxproj文件, - 复制Template下所有文件到新目录;
- 编写统一runscript,读取新助手资料夹下的settingVars.txt内容,修改Info.plist;
- 复制助手准备完成
以上的步骤只需要一次准备,以后的助手复制只需要一条命令:
./create.sh -n ZGMH -b com.abc.zgmh -c zgmh_channel -p 1004
项目使用了workspace来管理,只在需要的助手才放进去:
总结
善于运用Shell Script,能帮人自动化执行一些重复劳动,结合xcode的run script,可以最大限度解放劳动力,提高效率减少人工错误率。上文内容包括了大部分助手复制技术实现关键点,事实上我们还可以给run script传参数,用来生成不同的内部测试包。下一步,还会把自动化打包部分脚本再完善,自动生成多个包,并进行签名。
【完】