Unity框架

本地化多语言框架基本构想

Spread the love
对于本地化而言需要做的事情有几个点:
  • 多地区(渠道)SDK的管理
  • 不同地域文本以及图片的抽出以及替换
  • 不同地域不同逻辑的分离

基本思想

在我们的工程当中拥有最终只有一个核心版本,也就是有一个主分支,而不同的地区版本则是由不同的主分支时间点切出去的。
对于不同的地区我们有独立的文件夹,而每一个地区文件夹中的内容都是对主分支内容的覆盖或者扩展。
例如:
我们启用了东南亚文件夹,那么在东南亚的一些逻辑则会进行启用,而且地区中的特定文件将会从我们的地区文件夹中进行读取,覆盖主分支的配置。
而SDK也是用相同的思路对主分支的逻辑进行覆盖。
例如:
我们使用了Dolphin的SDK,那么这个时候,我们不需要改动任何代码,即可将我们的普通热更新流程替换为Dolphin热更新流程。
再例如,我们使用了MSDK,那么将启用腾讯登陆,否则则使用我们自己的默认登陆流程,一切都是通过覆盖的方式进行的。
在版本升级的时候我们只需要将基础版本升级,对应的实际地区文件只需要做对新版本的适配即可升级整个游戏,合并的时候不会有任何的冲突,因为我们已经将所有的差异分离了。而在做地区开发的时候完全在地区文件夹之中,而不能修改任何基础版本的东西。
整个框架想要表达的就是“非侵入式的框架”,将每一个地区以及SDK都完美的封装成一个模块来看待,绝不允许地区以及SDK的代码污染任何的游戏代码。

覆盖的基本技术

对于地区以及SDK的逻辑以及配置覆盖基本上分成了几个部分。
  • 配置文件
  • 资源文件
  • 代码文件

资源文件的覆盖

其中资源文件以及配置文件的覆盖是比较简单的,但是必须修改游戏代码中的加载机制,在加载主分支的资源之前必须先对地区文件夹进行检查,如果地区文件夹中有对应的覆盖文件,则加载覆盖文件。

表格文件的覆盖

在表格这块的话可以更进一步,对于数据进行和增量的而不是全量的配置,也就是只会去替换或者添加主分支没有的配置,而不是将所有的配置都加入。
这样的好处是,我们在每次版本合并的时候我们只需要策划将旧的附加表格数据升级到新的表格格式(字段、含义等),也不需要去寻找到底哪些地方被修改了,因为文件夹中只存在差异,不存在任何的通用数据。

代码逻辑的覆盖

在一般项目中大家肯定经历过每一个地区的代码都写在了同一个地方,导致维护起来异常困难的问题,这个时候使用覆盖的方式可以大大减少逻辑比较的问题,而只是使用增量的方式去添加地区的特殊代码。
这个地方我会分两块来讲:
一块是C#代码的覆盖,另一块是lua代码的覆盖。

C#代码的覆盖方法

C#的覆盖方法我有两种思路,
一个是使用C# Hook的方式直接从二进制层去替换C#方法的调用。
这个方式的优点:
可以运行时实时替换,并且在执行时0消耗,直接跳转到对应的函数,不需要消耗。
这个方式的缺点:
这个方式非常依赖于C#的底层实现,如果C#在二进制层有略微实现的不同可能会导致游戏的
崩溃。而且iOS不允许你修改这块内存,所以限制比较大。
第二种方式是使用Cecil注入代码的方式,这种方式实际上和Xlua热更新的方式是一样的,需要自己去埋点。
优点:
比较稳定,基本上不会崩溃,只是改变上层的IL代码而已,实现起来比较简单,自己也可以轻易进行魔改。可以标记出哪些函数在哪些地区被覆盖(同时需要自己埋点其实也是缺点)。
缺点:
效率相对会有所影响,对于底层代码不能使用这种方式(不过一般也不会有变态需求去改底层)而且需要自己去埋点。无法再运行时修改注入点,不像Hook可以动态替换注入点。
两种方式权衡下来,还是使用了Cecil代码注入的方式,更加稳定,而且能够看出哪些函数已经被注入过,防止同一个函数被反复注入的风险。

Lua代码的覆盖方法

Lua的覆盖实际上比C#要简单地多,多亏了Lua能够灵活重写自身的函数,我们只需要简单重写require的函数即可。
在require的时候我们会先将主分支的代码加载到我们的内存当中,然后我们回去检查地区文件夹下是否存在同名的文件,如果有的话我们则会附加加载地区的lua文件,这个lua文件中我们可以主动覆盖主分支的函数,这样我们就可以做到,如果有这个文件夹就覆盖,没有这个文件夹就只执行主分支代码的效果。

多语言的分离

对于多语言这一块,实际的加载方式也和普通的配置文件一样,我认为无介绍赘述。
关键点在于如何将所有Prefab上的文字以及多语言资源抽离。
多语言包含了几块:
  • 表格中的文本
  • 资源中的文本以及图片以及声音(统称资源)
  • 代码中混入的文本
对于表格中的文本,在表格的设计当中就应该采用键值对的方式,保证所有的串在内存中只有一份。表格读取的时候实际上只是通过索引去找对应的串而已,几乎所有的现代编程语言底层都是有一个字符串池的,比如C#、Lua、Java这些常用语言就不必多说了。我们也这么做就好了。
对于代码中的文本,需要注意的是,在项目刚开始的时候就应该有简单的Localization类去通过索引获取字符串,而绝对不允许直接在代码中写中文字符串(报错等非对向玩家的字符串可以除外)。
对于资源中的文本,如果是在项目开始的时候就有对应工具自然是最好了,但是如果在后期才想起抽取字符串我觉得也没什么问题,可以自己写一个遍历所有prefab的工具,将所有的Text进行抽取。图片以及声音资源,我认为完全应该交给底层加载模块自行寻找是否有对应的资源,例如一些艺术字,我们都会做成图片,尽可能将有地区差异的图片放到同个图集当中,防止同时加载多余图集,通过我们上面讲的资源覆盖机制自动索引到相应地区的资源,声音亦如是,如果你使用的是Unity自带的音频系统,做好bundle管理,如果你使用的是FMod则可以通过Event的命名去自动切换地区声音。

字符串池的基本使用

其实这个字符串池的设计,一百个项目有一百个做法,我就只聊聊自己的想法。
字符串池实际上就是hash->string的这么一个东西,在取字符串的时候还是需要考虑到一些效率,最好的方式那其实还是把这个字符串池当做一个大数组,取的时候直接O1就完事儿了,而且内存紧凑占用内存很小,我们的一开始使用的hashmap,还是满占用内存的。id为自增方式进行,插入的时候不需要考虑效率,因为这一操作在运行时时不会有的。插入的时候检查数组中是否存在该字符串,如果有则返回现有id,如果没有则往最后插。
如果删除的话就使用特殊字符占位,下次插入的时候发现有占位符则替换掉。
基本也谈不上啥原理,保证集中类型的字符串能够公用一个池子,保证最大程度复用就好了。

SDK管理的基本思路

SDK的管理我认为比较关键的就是将游戏逻辑本身与SDK自身进行完全解耦,解耦无非适配层而已。
在目前的SDK设计当中,将SDK以及设备进行了抽象,通过事件的方式进行逻辑层与SDK层的关联。

SDK的抽象

所有的SDK都继承与同一个积累,游戏层通过全局配置中的一个SDK列表得知到底有哪些SDK需要被实例化出来,实例化之后,所有的操作,类似于初始化,更新都是通过遍历这些抽象来实现的,上层无需知道任何拥有的是什么SDK。
游戏层中SDK涉及的关键逻辑,使用代码覆盖的方式来将SDK应用到实际的游戏逻辑当中,也就是例如如果目前包含了SDK文件夹,我们可以通过注入来运行SDK自身的逻辑,否则则使用默认逻辑。对于比较小的逻辑,类似于打点等逻辑可以通过监听事件来实现,如果没有SDK的代码当然无从监听以及运行响应逻辑了,SDK被删掉也不会影响上层逻辑。
当你删除了所有的SDK文件夹,你会发现你会拿到一个纯净的游戏代码,没有任何的SDK逻辑,如果你放入的SDK代码,你依旧可以无感知地编写上层代码,SDK的逻辑自然地注入到了游戏当中。
整个上层只会调用仅有的一个发送事件的逻辑,以及一些函数中打上标签而已,不会包含其他任何的SDK逻辑。
在原生层亦是如此,在Java以及OC当中都有着反射的机制,通过列表实例化出每一个SDK的实例,一个基础库包含了所有SDK库需要的公共方法,常驻在游戏库中,而每一个SDK依赖这个基础SDK库,如果有的话则执行响应的逻辑。

SDK配置化

对于不同的SDK都会有自己的配置,这些配置绝对不要写死在代码里面,而是使用配置放在SDK对应的文件夹下面,一个是保证了SDK与逻辑层的分离,另一个是可配置化有助于持续集成的搭建。
特别的需要提一下的有Android的Manifest以及iOS的plist,这两个东西都需要支持自动合并,对于SDK,其他的东西,例如:库、配置文件等等,都可以分文件夹放入,唯独Manifest喊饿iOS他是唯一的,你可以借助Unity来合,也可以自己来合,但是这是一个需要注意的点。

设备的抽象

针对设备的抽象实际上要更加简单,也就是建立设备类,所有的设备相关逻辑调用设备类接口进行实现,而不使用宏进行区分,简单的策略模式的做法。减少类“switch”的代码。
在框架设计中我们也会尽可能减少大片的switch,而尽可能对逻辑进行抽象。

目前还会存在的问题

工程开发没有银弹,多地区管理亦如是。下面就列出目前能够想到的可能出现的问题,以及部分想到的解决方案。

版本升级以及维护

大部分的多地区框架存在的问题也就是当新版本的主分支要合入地区分支的时候会造成大量的冲突问题需要解决,这个过程异常地漫长和容易出错。
我们的框架有效避免了冲突的问题,因为所有的地区相关资源与逻辑被抽离到了单独的文件夹,但是差异解决的问题依旧没有完全被解决——如果表格字段被修改了怎么办?如果地区特定逻辑依赖的代码已经被修改了怎么办?
当然回答很简单,适配成新的不就完了?
但是问题是我们很难找到到底是哪些字段被修改,以及哪些被依赖的逻辑被修改了,这也是升级过程当中最容易出错的地方,我们的框架虽然大大缩小了需要比较差异的范围,但是这依旧是我们维护多地区是需要面对的问题。
但是实际上不同的项目面对的修改规模并不相同,在日本、韩国等需求较高的地方确实会存在大量新需求的情况,而在东南亚则基本不会有太大的修改需求,而且因为我们大大缩小了需要检查的范围,所以问题不太大,在每次修改代码的时候发现(特别是C#是可以直接看到哪些函数被注入的)对地区代码进行一些必要的注释可以有效帮助我们升级代码。

已覆盖资源以及配置的可知性

我们的框架最大的优点是非侵入式,但是非侵入式,不可察觉的缺点是我们可能根本不知道这个地方已经被覆盖了,从而修改了不应该修改的地方。
一些简单的解决方法有,在表头或者表工具中自动显示已经覆盖的地区,并且在开发过程中更好得维护表检查工具,减少字段修改带来的逻辑错误。
代码这块的话C#因为有Attribute做标记,所以完全没啥问题,Lua的话就需要我们自己注意一下了。

分支切换成本过大

在开发多地区版本的过程中,切分支是不可避免的,例如:我们的基础版本已经到了1.6版本,而日本版本的基础版本还停留在1.0版本,这个时候如果我们需要切换分支的话由于差异过大,切换分支会带来较大成本,另外,Unity需要大量更新Library缓存而造成了长时间的HoldOn,从龙之谷团队反馈,甚至可以达到一个小时之久。
在这个问题上我思考过两个方案:
一个是我们在切换版本的时候保存当时版本的bundle、dll以及所有的配置,在游戏运行的时候除了地区内容读取文件夹中资源以外,其他所有的资源都读取保存下来的bundle、dll以及config。这样的做法是需要开一个额外的git库保存bundle、dll以及config,不过在做热更新的时候本身就是需要做快照进行比较的,所以问题不是很大(有些团队可能是通过hash比较的吧,可能就需要加一个库来存bundle之类的东西了)
这种方式保证我们只需要切换一个bundle库以及游戏一个标记就可以即时切换地区版本了,没有了等待holdon的时间了。
但是其缺点就是打bundle之后比较不是所见即所得,如果我希望拷贝一个基础版本的prefab到地区文件夹这种事情就完全做不了。
第二个方案是将Library缓存下来,当打开工程的时候会自动将对应地区的Library重命名,这样我们就不需要反复重新生成Library了(实际上其实也可以把资源不压缩这个选项勾上,带来的问题就是打包的时候需要压缩,打包会变慢)。做这个方案的好处也是不需要holdon了,不过就比较而言的话比较bundle应该还是比比较资源要简单的,因为工程里面会有大量实际上游戏中不需要用的资源(一般情况,如果你有一个专门拿来打包用的工程就当我没说)不过就是需要程序写好批处理,然后做一个专门的快捷方式,打开的时候自动运行批处理,也不是那么麻烦。
上述其实还是个人想法,其实都还没投入工程,解决这个切分支的问题确实是一个痛点。

SDK中的冗余库

这个不管是我们的框架或者是其他人做SDK的时候都会遇到的问题,一个SDK用到的json就包了一个minijson或者是litjson,想ios方便unity管理就自己带了一个XUPort,最可气的是包了也就算了,自己还魔改了,搞得你在接的时候只能一点点比较几个库的区别,找恶心的SDK的bug。
我个人在接SDK的时候更倾向于通过原生层接入,而不是通过提供的Unity插件进行接入:
理由如下:
一个原因是第三方提供的Unity SDK往往大量有着冗余的库,例如Litjson或者是XUPort,之前就有同事遇到接入腾讯的SDK,好几个都自己带了XUPort然后各自魔改自己的,搞得只能一点点比对,或者直接改命名空间,冗余太大了。
另一个原因是,自己包装SDK往往有更大的灵活性,大部分第三方提供的Unity SDK自己带了一个适配层,然后你自己的工程又有一个适配层,多几个SDK你的工程就充斥了大量无用代码,如果直接接入原生SDK则可以直接由你自己的适配层接手,上层能够更好与SDK分离,有助于后续的管理,升级相对而言也更加容易。
不过特别重的SDK要另说了,你自己肯定也不可能去包一个FMod,人家是自己wrap了C++的库,不熟悉他们家C++库的人想几天内包完他们的库还是强人所难了一些……
这里提到的SDK库大部分还是不同渠道商提供的SDK。

总结

我个人对于SDK以及本地化的目标其实非常简单:“非侵入式”、“模块化”。我觉得如果能真正做好这两点的话,要写一个优秀的SDK库也不远了。

2 thoughts on “本地化多语言框架基本构想

    1. 保证每次只往文件的最后写即可,如果有冲突只有同时采用两者的数据,有重复则做去重就好了。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.