前言

写这篇博客的目的不再是为了一个一个单独去学习了解四人帮的设计模式(以前看四人帮的设计模式时就是这样,并没有结合游戏开发深入思考这些设计模式的真正用处和好处)。

本篇博客的侧重点是针对实战游戏开发过程中用到的设计模式进行深入学习了解(能够运用在实际项目中的东西才是真正能发挥价值的)。

下面几个问题,是本篇博客需要解答的疑问:

  1. 为什么需要****设计模式?
  2. ***设计模式会给游戏开发带来什么好处?
  3. 什么情况下适合在实际项目中使用***模式?

接下来本文将结合《Game-Programming-Patterns》书籍以及项目实战开发过程中遇到的问题就游戏编程模式而言进行深入学习和分析理解。

《Game-Programming-Patterns》的作者是Bob Nystrom,一个在EA工作了8年的游戏程序开发者。
下面给出官方网站链接:
Game-Programming-Patterns

Note:
后面加上””号的内容表示是从书里截取的内容。

游戏架构

为什么这里要提游戏架构了?
作为程序员在写代码的过程中,会发现整个游戏有很多模块,这些模块各自负责不同的功能,所有模块整合到一起才组成了完整的游戏框架。

一个游戏并不是把所有的代码都编写在一个main函数里就能完成的,这样的代码既不具有可读性也不具备扩展性以及维护性。

而游戏架构就是为了让游戏开发变得高度可扩展,可读性高,降低维护成本等。在一个好的游戏架构上编写实现一个功能可能只需要修改很少几处或者添加很少几处代码即可完成。而不好的游戏架构可能会导致你编写了上千行代码去实现一个小功能(这里无论是维护成本还是理解成本都是不能接受的)。

“好的设计意味着当我作出改动,整个程序就好像正等着这种改动。我可以加使用几个函数调用完成任务,而代码库本身无需改动。”

解耦对于上面提到的扩展性和维护成本起到了关键作用,后面会详细学习了解。

下面再引用一句作者对于软件架构的目标的话:
“最小化在编写代码前需要了解的信息。”

落实到真正的游戏开发过程中,我们往往要考虑开发周期,开发成本,游戏设计复杂度等,不是说任何游戏开发都往复杂的好的游戏架构上去设计就是正确的,也要结合实际情况分析。(但对于大型游戏开发,好的架构一般来说是必不可少的)

下面是作者给出的几个建议;

  1. “抽象和解耦让扩展代码更快更容易,但除非确信需要灵活性,否则不要在这上面浪费时
    间。”
  2. “在整个开发周期中考虑并为性能设计,但是尽可能推迟那些底层的,基于假设的优化,
    那会锁死代码。”
  3. “快速地探索游戏的设计空间,但不要跑的太快,在身后留下烂摊子。毕竟,你总得回来
    打扫。”
  4. “如果打算抛弃这段代码,就不要尝试将其写完美。摇滚明星将旅店房间弄得一团糟,因
    为他们知道明天他们就走人了。”
  5. “如果你想要做出让人享受的东西,那就享受做它的过程。”

游戏设计模式

接下来我们将结合游戏开发,实战分析学习设计模式带来的好处和实际运用的地方。

命令模式

首先看看作者是如何定义命令模式的:
“命令是具现化的方法调用。”

再看看四人帮是如何定义命令模式的:
“Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations”(将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化; 对请求排队或记录请求日志,以及支持可撤销的操作。)

看完定义后更加糊涂了,让我们还是结合实例来学习理解。
假设我们通过不同的按键点击去控制玩家的行为,不考虑任何扩展性的前提下我们可能写出下面的代码:

1
2
3
4
5
6
7
8
9
10
11
void InputHandler::handleInput()
{
if(Input.GetKeyDown(KEY_A))
{
Attack();
}
else if(Input.GetKeyDown(KEY_SPACE))
{
Jump();
}
}

上面的硬编码确实实现了我们的功能需求,但当我们想改变按键所代表的行为时(比如玩家配置修改按钮功能),我们会发现上面的代码没法满足需求,因为我们硬编码写死了指定按钮的行为。

我们需要支持按钮动态绑定行为的功能,这时命令模式就起作用了,命令模式把游戏行为封装成对象,通过动态绑定不同的命令可以实现同一按钮实现不同的行为。
首先我们需要定义一个命令基类:

1
2
3
4
public absctract class Command
{
public abstract void execute();
}

然后实现对应行为的命令类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AttackCommand : Command
{
public override void execute()
{
Attack();
}
}

public class JumpCommand : Command
{
public override void execute()
{
Jump();
}
}

此时我们响应输入不再是直接执行特定行为,而是执行绑定在按钮上的命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class InputHandler
{
private Command Key_A;
private Command Key_Space;

// 绑定命令的方法(用于支持动态修改按钮行为绑定)
// **********

public void handleInput()
{
if(Input.GetKeyDown(KEY_A))
{
Key_A.execute();
}
else if(Input.GetKeyDown(KEY_SPACE))
{
Key_Space.execute();
}
}
}

玩家配置按钮行为,在代码里的表现就只是对于Key_A和Key_Space进行不同的行为命令绑定即可,因为我们把请求对象化了(四人帮里提到的定义),所以可以通过动态绑定对象来绑定不同的命令实现动态绑定行为。

上面的代码还有个问题就是特定命令里的行为并不知道需要表现指定行为的对象,上面的写法是属于全局访问的一种形式,这样做耦合度太高且不灵活。
还记得四人帮定义里提到请求对象化后可以对客户参数化,这里的客户就是我们要服务要表现的行为对象。

因为我们把请求对象化了,那么我们对于请求的执行添加一些额外的信息也就是对于命令进行传参的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class Command
{
public abstract void execute(GameActor actor);
};

public class JumpCommand : Command
{
public override void execute(GameActor actor)
{
actor.Jump();
}
}

Command* command = inputHandler.handleInput();
if (command)
{
command->execute(actor);
}

这样一来我们的命令请求就知道应该是对谁做行为操作了。

通过命令模式,我们在按钮和角色操作之间增加了一层重定向,使我们可以通过按钮操作任何角色的行为(只需向命令传入不同的角色即可)。

在真正的实战游戏开发中,角色的AI会使用命令模式,这样一来可以对于所有的角色重用命令行为,而且通过模拟命令队列,我们可以实现AI行为队列的功能(一个行为接一个行为)。这个后续会结合Behavior Designer插件学习来进一步深入了解:

待续……

Note:

  1. 我们不止可以通过定义类(Command)实现请求对象化,我们也可以通过闭包的形式把请求转化成可返回的函数对象来实现请求对象化。这里需要了解第一公民函数等概念。
  2. 命令模式可以让我们轻易做到撤销和重做的功能。

框架模式

关于框架模式最初了解过MVC,只知道是为了解耦数据,显示以及控制逻辑的一种模式。
后来陆陆续续了解到还有MVP,MVVM等模式。
为什么会有这么多MV**的框架模式了?
核心思想是通过解决M和V的耦合问题来实现界面分离。

接下来我们重点要学习了解MVC和MVVM模式以及实战运用,MVP会简单带过。

参考博客 :
谈谈对MVC、MVP和MVVM的理解
谈 MVC、MVP 和 MVVM 架构模式
Unity 应用架构设计—— MVVM 模式的设计和实施 (Part 1)
mvc的实现

MVC

MVC模式(Model–view–controller)是软件工程中的一种软件架构模式,把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。

MVC只是一种框架模式,针对不同的平台环境的实现方式会有些区别,这里本人的理解如下:
MVCRelationship

模型(Model): 数据结构以及数据处理等。

视图(View):专注于显示,比如前端UI显示

控制器(Controller):连接(解耦)模型和视图,如处理视图的请求,更新模型数据,通知视图变化等

这里我们结合实例动手实现一个简单的MVC模式来加深理解:
需求:
用户通过点击按钮触发一个随机数显示在文本上。

分析事例中的MVC:
视图(View):
MVC_View
负责显示最终结果。

模型(Model):
负责存储随机数数据。

控制器(Controller):
负责响应View的点击事件,处理随机数逻辑,修改Model里的随机数数据,通知View更新显示。

这里Model和View之间的更新通知我们会用到监听者模式,Model是发布者,视图是订阅者。
接下来看下实际的代码设计:

监听者模式部分:
ObserverBase.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 监听者模式的监听者接口
/// </summary>
public interface ObserverBase {

/// <summary>
/// 用于更新监听者状态
/// </summary>
/// <param name="subject"></param>
void update(SubjectBase subject);
}

SubjectBase.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 监听者模式,发布者父类
/// </summary>
public class SubjectBase {

/// <summary>
/// 监听者列表
/// </summary>
private List<ObserverBase> mObserverList;

public SubjectBase()
{
mObserverList = new List<ObserverBase>();
}

/// <summary> 添加监听者 /// </summary>
/// <param name="observer"></param>
public virtual void addObserver(ObserverBase observer)
{
mObserverList.Add(observer);
}

/// <summary> 删除监听者 /// </summary>
/// <param name="observer"></param>
public virtual void deleteObserver(ObserverBase observer)
{
mObserverList.Remove(observer);
}

/// <summary> 通知所有监听者 /// </summary>
public virtual void notifyAllObserver()
{
foreach(var obesrver in mObserverList)
{
obesrver.update(this);
}
}
}

MVC部分:
UIView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// MVC中的View,负责UI显示
/// </summary>
public class UIView : MonoBehaviour, ObserverBase {

public Text mTxtRandomNumber;

public Button mBtnRandomNumber;

// Use this for initialization
void Start () {
// 添加UIController对UI点击事件的响应
mBtnRandomNumber.onClick.AddListener(UIController.Instance.OnBtnRandomNumberClick);
}

public void update(SubjectBase subject)
{
// View层被通知监听对象改变了
// 这里是UIModel改变了
refreshView(subject);
}

/// <summary>
/// 刷新视图显示
/// </summary>
private void refreshView(SubjectBase subject)
{
// 获取UIModel数据刷新显示
var uimodel = subject as UIModel;
mTxtRandomNumber.text = uimodel.mRandomNumber.ToString();
}
}

UIModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVC里的Model,负责存储随机数数据
/// </summary>
public class UIModel : SubjectBase {

public static UIModel Instance
{
get
{
if (mInstance == null)
{
mInstance = new UIModel();
}
return mInstance;
}
}
private static UIModel mInstance;

/// <summary>
/// 随机数数据
/// </summary>
public int mRandomNumber;

/// <summary>
/// 随机一个数字
/// </summary>
public void randomNumber()
{
mRandomNumber = Random.Range(0, 100);
// 通知UIView更新显示
notifyAllObserver();
}
}

UIController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVC中的Controller,负责连接(解耦)模型和视图,如处理视图的请求,更新模型数据,通知视图变化等
/// 逻辑部分都在这里
/// </summary>
public class UIController {

public static UIController Instance
{
get
{
if(mInstance == null)
{
mInstance = new UIController();
}
return mInstance;
}
}
private static UIController mInstance;

private UIController()
{

}

/// <summary>
/// UIView按钮点击事件响应
/// </summary>
public void OnBtnRandomNumberClick()
{
// 响应UIView按钮点击,
// 修改UIModel数据,
UIModel.Instance.randomNumber();
}
}

初始化相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameProgrammingPattern : MonoBehaviour {

/// <summary> MVC里的模型 /// </summary>
private UIModel mModel;

/// <summary> MVC里的视图 /// </summary>
private UIView mView;

void Awake()
{
// 初始化MVC里的各个模块
mModel = UIModel.Instance;
mView = this.transform.GetComponentInChildren<UIView>();
}

void Start ()
{
// 添加UIView对UIModel的监听
mModel.addObserver(mView);
}
}

可以看到我们把UIView和UIModel设计成了监听者模式用于UIModel变化时通知UIView刷新显示。
通过划分V和M,把视图和数据严格分离开,视图只关心显示,模型只关心数据,其他所有的操作变化都是通过控制器去处理。

分析:

  1. 上面的MVC里V和M并没有完全解耦,视图依然依赖于模型的数据来显示(当然我们可以通过回调注册替换监听者模式的形式去实现V和M的完全解耦)。(参考:
    Unity3D用MVC框架思想实现的小例子
    )
  2. V和M的解耦,让我们可以重复利用M和V,只要控制器处理好V和M逻辑即可。(比如View不变,改变Model数据来源,我们重新编写一个新的Contoller去处理View和新的Model之间的逻辑关系就能实现对View的重用。反之Model重用同理。)
  3. 正因为V和M的解耦,所有逻辑相关处理都在C里,使得C看起来过于臃肿。

问题:

  1. UI操作和数据原本就是大量交互在一起的,使用MVC使V和M分离,使得代码显的过于复杂,Controller这一层过于臃肿,对于后期维护并不友好。
  2. 游戏开发中,大部分时候UI变化很大,View层的重用不太现实。

下面给出知乎上对于游戏里使用MVC模式的讨论(本人比较认同flashyiyi的分析):
如何在Unity中实现MVC模式?

结论:
游戏开发需要根据项目需求制定合理的框架设计,并非一种MVC就能包治百病,过度的设计有时候会带来可读性和维护性上的大大降低,Pure MVC并不适用于大部分游戏开发。

MVP

MVPRelationship
MVP用展示器代替了控制器,而展示器是可以直接更新视图,所以MVP中展示器可以处理视图的请求并递送到模型又可以根据模型的变化更新视图,实现了视图和模型的完全分离。

MVVM

MVVM是MVP更进一步的发展,把软件系统分为三个基本部分:模型(Model)、视图(View)和视图模型(ViewModel),关系如下图所示:
MVVMRelationship

模型(Model): 数据结构,基础数据等(不关心业务逻辑)。

视图(View):专注于显示,比如前端UI显示

视图模型(ViewModel):连接模型和视图,暴露视图所关心的数据和属性,负责修改Model数据,视图模型和视图是双向绑定的

从上面的关系图以及介绍,可以看出MVVM里V和M通过VM完全隔离开来,V的更新通过V和VM的Data Bidning(数据绑定)以及Command模式等形式触发更新显示响应等。M的修改是通过VM来操作,V只关心和VM绑定的数据用于显示,真正的数据是在M里(比如M里存的是一个玩家的名字,VM暴露给V的数据可能是一个地址+名字格式的数据形式)。

MVVM优缺点(来源:MVC、MVP、MVVM架构分析与比较):
优点:
1、低耦合。View可以独立于Model变化和修改,一个ViewModel可以绑定到不同的”View”上,当View变化的时候Model可以不变,当Model变化的时候View也可以不变。
2、可重用性。你可以把一些视图逻辑放在一个ViewModel里面,让很多view重用这段视图逻辑。
3、独立开发。开发人员可以专注于业务逻辑和数据的开发(ViewModel),设计人员可以专注于页面设计,生成xml代码。
4、ViewModel解决MVP中View(Activity)和Presenter相互持有对方应用的问题,界面由数据进行驱动,响应界面操作无需由View(Activity)传递,数据的变化也无需Presenter调用View(Activity)实现,使得数据传递的过程更加简洁,高效。

缺点:
1、ViewModel中存在对Model的依赖。
2、数据绑定使得 Bug 很难被调试。你看到界面异常了,有可能是你 View 的代码有 Bug,也可能是 Model 的代码有问题。
3、IDE不够完善(修改ViewModel的名称对应的xml文件中不会自动修改等)。

关键词:

  1. Data Binding(数据绑定)
  2. Event Based Programming
  3. Command
  4. Code Behind

后面我们会实战实现一套MVVM里深入理解里面的相关概念,主要参考博客:
Unity 应用架构设计—— MVVM 模式的设计和实施 (Part 1)

Unity MVVM实现

要是在Unity里实现MVVM架构,那么我们必须先实现一套Unity里的Data Binding。

Data Binding in Unity

如何在Unity里实现Data Bidning?
Data Binding的核心是在修改数据时能触发回调通知,能将数据和回调绑定起来。
明白了这一点,回顾C#里设置数据一般是通过方法或者属性或者直接访问public成员变量,要想实现回调触发,我们可以通过C#里的Property设置(set)的形式去确保回调相应。

基础实现

实现一个可触发回调的Property属性抽象:
BindableProperty.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
* Description: BindableProperty.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// BindableProperty.cs
/// 可触发回调的属性抽象
/// </summary>
public class BindableProperty<T> {

/// <summary>
/// 属性值变化回调委托定义
/// </summary>
/// <param name="oldvalue"></param>
/// <param name="newvalue"></param>
public delegate void ValueChangedDelegate(T oldvalue, T newvalue);

/// <summary>
/// 属性值变化回调
/// </summary>
public ValueChangedDelegate OnValueChanged;

/// <summary>
/// 属性
/// </summary>
public T Value
{
get
{
return mValue;
}
set
{
T oldvalue = mValue;
mValue = value;
ValueChanged(oldvalue, mValue);
}
}
private T mValue;

/// <summary>
/// 属性值变化时
/// </summary>
/// <param name="oldvalue"></param>
/// <param name="newvalue"></param>
private void ValueChanged(T oldvalue, T newvalue)
{
if(OnValueChanged != null)
{
OnValueChanged(oldvalue, newvalue);
}
}
}

这样一来我们定义VM的Property时就可以按如下方式定义,然后让绑定V里的View到VM里对应的Property上即可:

1
2
public readonly BindableProperty<string> Name = new BindableProperty<string>();
public readonly BindableProperty<int> AgeDetail = new BindableProperty<int>();

按照VM负责和V的数据绑定以及负责M的修改,我们可以定义ViewModel如下:
MVVMViewModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/*
* Description: MVVMViewModel.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVVMViewModel.cs
/// MVVM里的VM
/// </summary>
public class MVVMViewModel {

/// <summary> 与View里Name绑定的名字属性 /// </summary>
public readonly BindableProperty<string> Name = new BindableProperty<string>();

/// <summary> 与View里Age绑定的年纪属性 /// </summary>
public readonly BindableProperty<int> Age = new BindableProperty<int>();

/// <summary> Model数据 /// </summary>
private MVVMModel Model;

/// <summary>
/// 初始化Model数据
/// </summary>
/// <param name="m"></param>
public void initializeModel(MVVMModel m)
{
Model = m;
Name.Value = Model.Name;
Age.Value = Model.Age;
}

/// <summary>
/// 保存Model数据
/// </summary>
public void saveModel()
{
Model.Name = Name.Value;
Model.Age = Age.Value;
}
}

按照V的定义只关心视图,我们可以定义View如下:
MVVMView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/*
* Description: MVVMView.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// MVVMView.cs
/// MVVM里的View
/// </summary>
public class MVVMView : MonoBehaviour {

/// <summary> Name输入文本(用于View输入改变Name) /// </summary>
public InputField mInputFieldName;

/// <summary> Name值展示文本 /// </summary>
public Text mTxtName;

/// <summary> Age值展示文本 /// </summary>
public Text mTxtAge;

/// <summary> Model数据保存按钮 /// </summary>
public Button mBtnSave;

/// <summary> View所绑定的ViewModel /// </summary>
private MVVMViewModel mViewModel;

/// <summary>
/// 绑定V到指定VM
/// </summary>
/// <param name="vm"></param>
public void bindViewModel(MVVMViewModel vm)
{
mViewModel = vm;
}

/// <summary>
/// 初始化View(主要是V到VM之间的绑定)
/// </summary>
public void initialize()
{
#region Bind VM to V Part
mViewModel.Name.OnValueChanged = onNameChanged;
mViewModel.Age.OnValueChanged = onAgeChanged;
#endregion

#region Bind V To VM
mInputFieldName.onValueChanged.AddListener(onInputNameChanged);
mBtnSave.onClick.AddListener(onSaveClick);
#endregion
}

#region Bind VM to V Part
private void onNameChanged(string oldname, string newname)
{
mInputFieldName.text = newname;
mTxtName.text = newname;
}

private void onAgeChanged(int oldvalue, int newvalue)
{
mTxtAge.text = newvalue.ToString();
}
#endregion

#region Bind V To VM
private void onInputNameChanged(string newname)
{
mViewModel.Name.Value = newname;
}

private void onSaveClick()
{
mViewModel.saveModel();
}
#endregion
}

按照M的定义只关心数据结构定义,我们可以定义Model如下:
MVVMModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* Description: MVVMModel.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVVMModel.cs
/// MVVM里的Model
/// </summary>
public class MVVMModel {

/// <summary> 名字 /// </summary>
public string Name { get; set; }

/// <summary> 年纪 /// </summary>
public int Age { get; set; }

public MVVMModel()
{
Name = "TonyTang";
Age = 28;
}
}

接下来测试MVVM里的Data Binding:
GameProgrammingPattern.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/*
* Description: GameProgrammingPattern.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameProgrammingPattern : MonoBehaviour {

#region MVC Part
/// <summary> MVC里的模型 /// </summary>
private UIModel mModel;

/// <summary> MVC里的视图 /// </summary>
private UIView mView;
#endregion

#region MVVM Part
/// <summary> MVVM里的模型M /// </summary>
private MVVMModel mMVVMModel;

/// <summary> MVVM里的视图V /// </summary>
private MVVMView mMVVMView;

/// <summary> MVVM里的视图VM /// </summary>
private MVVMViewModel mMVVMViewModel;

/// <summary> 动态变化Age属性值频率 /// </summary>
private WaitForSeconds mAgeChangeFrequency;
#endregion

void Awake()
{
initMonoScripts();

#region MVC Part
// 初始化MVC里的各个模块
mModel = UIModel.Instance;
mView = this.transform.GetComponentInChildren<UIView>();
#endregion

#region MVVM Part
mMVVMViewModel = new MVVMViewModel();
mMVVMModel = new MVVMModel();
mMVVMView = this.transform.GetComponentInChildren<MVVMView>();

mAgeChangeFrequency = new WaitForSeconds(5.0f);
#endregion
}

void Start ()
{
#region MVC Part
// 添加UIView对UIModel的监听
mModel.addObserver(mView);
#endregion

#region MVVM Part
// 初始化ViewModel里的Model数据
mMVVMViewModel.initializeModel(mMVVMModel);
// 绑定View到VM上
mMVVMView.bindViewModel(mMVVMViewModel);
// 初始化View和ViewModel的双向绑定
mMVVMView.initialize();
// 通过携程动态使用VM修改值Age触发View变化显示
CoroutineManager.Singleton.startCoroutine(changeViewModelAgeCoroutine());
#endregion
}

/// <summary>
/// 初始化需要挂在的脚本
/// </summary>
private void initMonoScripts()
{
this.gameObject.AddComponent<CoroutineManager>();
}

/// <summary>
/// 修改ViewModel的Age属性值携程
/// </summary>
/// <returns></returns>
IEnumerator changeViewModelAgeCoroutine()
{
while(true)
{
yield return mAgeChangeFrequency;
mMVVMViewModel.Age.Value = Random.Range(0, 100);
}
}
}

我们的测试UI如下:
MVVMPatternTestUI
InputField里动态输入文字,会看到右边的文本动态跟新了,同时下方的Age文本因为携程动态修改数据刷新显示:
MVVMPatternExample
上面的表现可以看出,我们通过绑定V和VM里的数据实现了动态修改VM数据后,V自动跟着刷新显示,V变化VM自动更新,做到了数据驱动刷新显示,完全隔离了V和M。

解耦V和VM

上面的实现有一个比较严重的问题,就是V和VM严重的耦合了,V里通过保存一个指定VM类型的实例对象来访问VM,如果我们想将V绑定到另一个VM的话,就发现代码需要改动才能支持,同时上面的代码是没有处理动态绑定VM时对原始VM动态绑定部分的解除。这里为了改进这一点,我们需要了解几个概念:

  1. 依赖倒置原则(DIP)
    高层模块不应依赖于低层模块,两者应该依赖于抽象。 抽象不不应该依赖于实现,实现应该依赖于抽象。
  2. 控制反转(IoC)
    控制反转(IoC),它为相互依赖的组件提供抽象,将依赖(低层模块)对象的获得交给第三方(系统)来控制,即依赖对象不在被依赖模块的类中直接通过new来获取。
  3. 依赖注入(DI)
    依赖注入(DI),它提供一种机制,将需要依赖(低层模块)对象的引用传递给被依赖(高层模块)对象。

引用的文章里对几个核心概念做了如下总结,这里直接搬过来:
依赖倒置原则(DIP):一种软件架构设计的原则(抽象概念)。
控制反转(IoC):一种反转流、依赖和接口的方式(DIP的具体实现方式)。
依赖注入(DI):IoC的一种实现方式,用来反转依赖(IoC的具体实现方式)。
IoC容器:依赖注入的框架,用来映射依赖,管理对象创建和生存周期(DI框架)。

上面的核心概念是高层模块不应该直接依赖底层模块,而是依赖于抽象,然后通过依赖注入的形式实现反转依赖,解耦高层模块和底层模块。

针对解耦V和VM,让我们修改上面的实现:
为了解耦V和VM,根据依赖倒置的原则,我们需要让V依赖于VM的接口而非具体的类,同时通过依赖注入的形式传入实现控制反转。
IMVVMView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
* Description: IMVVMView.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// IMVVMView.cs
/// MVVM里的V的抽象接口(抽象出V和VM之间的绑定关系)
/// </summary>
public interface IMVVMView {

/// <summary>
/// MVVM里V的VM上下文
/// Note:
/// 通过定义VM的Interface接口类成员,实现依赖倒置
/// </summary>
IMVVMViewModel mMVVMViewModelContext
{
get;
set;
}
}

IMVVMViewModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* Description: IMVVMViewModel.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// IMVVMViewModel.cs
/// MVVM里的VM抽象接口(实现依赖倒置,避免V和VM的高度耦合)
/// </summary>
public interface IMVVMViewModel {


}

为了实现ViewModel可动态绑定修改,我们还需定义一个View的父类,用于实现动态响应ViewModel的绑定修改:
MVVMBaseView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/*
* Description: MVVMBaseView.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVVMBaseView.cs
/// MVVM里的View的基类(含ViewModel的动态绑定定义以及ViewModel的接口成员定义(依赖倒置))
/// </summary>
public class MVVMBaseView : MonoBehaviour, IMVVMView
{
/// <summary>
/// 可绑定的ViewModel属性
/// 用于实现V动态绑定VM
/// </summary>
protected readonly BindableProperty<IMVVMViewModel> ViewModelProperty = new BindableProperty<IMVVMViewModel>();

/// <summary>
/// V的VM上下文属性接口
/// </summary>
public IMVVMViewModel mMVVMViewModelContext
{
get
{
return ViewModelProperty.Value;
}
set
{
if(!mIsInitialized)
{
OnInitialize();
mIsInitialized = true;
}
ViewModelProperty.Value = value;
}
}

/// <summary>
/// View是否初始化过
/// 用于避免逻辑依赖于显示(比如绑定VM上下文属性变化回调时,依赖于View的显示)
/// </summary>
protected bool mIsInitialized;

public MVVMBaseView()
{
// 绑定VM上下文修改回调
// 避免这种写法,会导致绑定VM上下文属性变化回调依赖于显示
//ViewModelProperty.OnValueChanged += OnBindingContextChanged;
}

/// <summary>
/// 第一次初始化初始化方法
/// </summary>
protected void OnInitialize()
{
// 绑定VM上下文修改回调
ViewModelProperty.OnValueChanged += OnBindingContextChanged;
}

/// <summary>
/// VM动态绑定回调
/// </summary>
/// <param name="oldvm"></param>
/// <param name="newvm"></param>
protected virtual void OnBindingContextChanged(IMVVMViewModel oldvm, IMVVMViewModel newvm)
{

}
}

修改原来的MVVMView实现:
MVVMView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/*
* Description: MVVMView.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// MVVMView.cs
/// MVVM里的View
/// </summary>
public class MVVMView : MVVMBaseView{

/// <summary> Name输入文本(用于View输入改变Name) /// </summary>
public InputField mInputFieldName;

/// <summary> Name值展示文本 /// </summary>
public Text mTxtName;

/// <summary> Age值展示文本 /// </summary>
public Text mTxtAge;

/// <summary> Model数据保存按钮 /// </summary>
public Button mBtnSave;

/// <summary>
/// 真实绑定的VM对象访问入口
/// </summary>
public MVVMViewModel ViewModel
{
get
{
return (MVVMViewModel)mMVVMViewModelContext;
}
}

/// <summary>
/// 响应VM绑定变化回调
/// 在这里做V和VM绑定相关的事
/// </summary>
/// <param name="oldvm"></param>
/// <param name="newvm"></param>
protected override void OnBindingContextChanged(IMVVMViewModel oldvm, IMVVMViewModel newvm)
{
base.OnBindingContextChanged(oldvm, newvm);
// 解除老的VM相关绑定
if(oldvm != null)
{
#region Bind VM to V Part
MVVMViewModel ovm = oldvm as MVVMViewModel;
ovm.Name.OnValueChanged -= onNameChanged;
ovm.Age.OnValueChanged -= onAgeChanged;
#endregion

#region Bind V To VM
mInputFieldName.onValueChanged.RemoveListener(onInputNameChanged);
mBtnSave.onClick.RemoveListener(onSaveClick);
#endregion
}

// 添加新的VM相关绑定
if (ViewModel != null)
{
#region Bind VM to V Part
ViewModel.Name.OnValueChanged += onNameChanged;
ViewModel.Age.OnValueChanged += onAgeChanged;
#endregion

#region Bind V To VM
mInputFieldName.onValueChanged.AddListener(onInputNameChanged);
mBtnSave.onClick.AddListener(onSaveClick);
#endregion
}
}

#region Bind VM to V Part
private void onNameChanged(string oldname, string newname)
{
mInputFieldName.text = newname;
mTxtName.text = newname;
}

private void onAgeChanged(int oldvalue, int newvalue)
{
mTxtAge.text = newvalue.ToString();
}
#endregion

#region Bind V To VM
private void onInputNameChanged(string newname)
{
ViewModel.Name.Value = newname;
}

private void onSaveClick()
{
ViewModel.saveModel();
}
#endregion
}

MVVMViewModel只需要修改来实现IMVVMViewModel即可:
MVVMViewModel.cs

1
2
3
4
public class MVVMViewModel : IMVVMViewModel{

******
}

测试代码也要跟着修改:
GameProgrammingPattern.cs

1
2
3
4
5
6
7
8
9
10
11
void Start ()
{
#region MVVM Part
// 初始化ViewModel里的Model数据
mMVVMViewModel.initializeModel(mMVVMModel);
// 绑定View到VM上
mMVVMView.mMVVMViewModelContext = mMVVMViewModel;
// 通过携程动态使用VM修改值Age触发View变化显示
CoroutineManager.Singleton.startCoroutine(changeViewModelAgeCoroutine());
#endregion
}

结果依然是正常双向绑定V和VM,实现V和M隔离,但现在我们的V不在和VM强耦合了,可以动态绑定到同类型不同的VM实例对象:
MVVMPatternExampleRefactor

VM如何通知V

V和VM在MVVM里是通过数据绑定实现的双向绑定,但假设V里点击一个按钮后修改VM里的数据后并不需要更新View刷新显示,而是需要VM通知View数据处理完毕并将最新数据返回做后续处理了(比如弹窗口提示之类)?

这时候我们需要的是一种通知数据处理完毕后回调通知的形式,我们很容易想到的一种实现方案是传递回调的形式:
伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class View : BaseView
{
******

private void onBtnClick()
{
mViewModel.ChangeName(newname, ChangeNameCallback);
}

private void ChangeNameCallback(string name)
{
// VM处理完毕后回调做后续处理
}
}

但这样在View里编写Code Behind代码会导致View层被污染,导致View层包含逻辑代码。同时也打断了MVVM里ViewModel的Unit Test特性。

我们可以通过绑定按钮点击到ViewModel的一个Command(包含操作的数据对象以及检查是否可执行以及最终逻辑回调等)里,把逻辑部分搬到ViewModel层实现Zero Code Behind并不破坏ViewModel的Unit Test。也可以考虑直接调用VM方法在方法里写逻辑。如果是涉及到View层表现,可以考虑绑定属性,在VM写逻辑去修改对应属性触发View层表现。

这里并没有太弄懂Command存在的意义(和直接在VM层写逻辑的区别是什么?),所以这里没有去实现这一套东西。

简化V和VM绑定代码

从前面的事例可以看到,V和VM的绑定都是成对出现,属于有规律可循的代码(后续可以自动化代码生成来解决),但这里我们希望解决的不是自动化代码的问题,而是提供一套更方便的代码绑定方案,解决大量复杂代码的编写。

实现思考:

  1. 通过统一的一个类来实现简化V和VM的绑定。
  2. 因为要访问VM的属性对象,需要通过反射访问(没想到更好的方案)

因为涉及到反射实现,这里觉得并不好(一是效率方面,二是直观上查找不到属性引用(维护方面)),不如单纯的实现自动化代码生成的形式。所以这里就不去实现这一套方案了,详细参考下文作者实现:
Unity应用架构设计(1)—— MVVM 模式的设计和实施(Part 2)

VM和VM之间通信

现实开发过程中,我们的View和View有些时候是需要通信的,虽然View实现了和ViewModel的双向绑定,但并没有解决View和View之间的通信问题。

实现方便来说我们会去写如下代码实现View与View之间的访问:

1
2
3
4
5
6
7
public class ViewA
{
public void notifyViewB()
{
ViewB.UpdateView();
}
}

但这样的代码是的View与View之间强耦合了,并不推荐这样实现,我们使用MVVM模式的目的是为了解耦V和M,同时实现V和VM之间数据驱动刷新。这里我们需要解决View和View之间ViewModel与ViewModel之间通信的问题。

首先V和VM是一对一的,V和VM是双向绑定的,所以要实现View和View之间的沟通交流,其实只要实现VM和VM之间的沟通交流即可。VM和VM是一对多的,为了解耦,我们可以通过一个中介者来解耦并统一管理ViewModel之间的沟通交流,然后使用订阅者模式实现消息订阅的形式模拟实现ViewModel之间一对多的沟通交流。

首先核心的消息统一订阅分发类:
MessageDispatcher.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/*
* Description: MessageDispatcher.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MessageDispatcher.cs
/// 消息分发单例类(这里是为了解耦类似ViewModel和ViewModel之间的交流)
/// 类似于EventDispather,只不过这里是支持对任意string的消息监听而非EventId枚举
/// </summary>
public class MessageDispatcher : SingletonTemplate<MessageDispatcher>{

/// <summary>
/// 消息回调定义
/// </summary>
/// <param name="sender">发送消息对象</param>
/// <param name="paras">消息参数</param>
public delegate void MessageDelegate(object sender, params object[] paras);

/// <summary>
/// 消息映射类
/// Key为订阅的消息字符串
/// Value为注册的回调
/// </summary>
private readonly Dictionary<string, MessageDelegate> mMessageMap = new Dictionary<string, MessageDelegate>();

/// <summary>
/// 注册监听消息
/// </summary>
/// <param name="message">监听的消息字符串</param>
/// <param name="handler">监听的消息响应回调</param>
public void subscribe(string message, MessageDelegate handler)
{
if (!string.IsNullOrEmpty(message))
{
if (!mMessageMap.ContainsKey(message))
{
mMessageMap.Add(message, handler);
}
else
{
mMessageMap[message] += handler;
}
}
else
{
Debug.LogError("不能订阅空的字符串消息!");
}
}

/// <summary>
/// 注销消息监听
/// </summary>
/// <param name="message"></param>
/// <param name="handler"></param>
public void unSubscribe(string message, MessageDelegate handler)
{
if (!string.IsNullOrEmpty(message))
{
if (mMessageMap.ContainsKey(message))
{
mMessageMap[message] -= handler;
}
}
else
{
Debug.LogError("不能取消空的字符串消息!");
}
}

/// <summary>
/// 分发消息
/// </summary>
/// <param name="message">分发的消息字符串</param>
/// <param name="sender">发送消息对象</param>
/// <param name="paras">消息参数</param>
public void publish(string message, object sender, params object[] paras)
{
if (!string.IsNullOrEmpty(message))
{
if (mMessageMap.ContainsKey(message))
{
mMessageMap[message](sender, paras);
}
}
else
{
Debug.LogError("不能分发空的字符串消息!");
}
}
}

修改ViewModel1的代码,添加消息监听:
MVVMViewModel.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* Description: MVVMViewModel.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVVMViewModel.cs
/// MVVM里的VM
/// </summary>
public class MVVMViewModel : IMVVMViewModel{

******

/// <summary>
/// 注册消息监听
/// </summary>
public void registerMessageListener()
{
MessageDispatcher.Singleton.subscribe("ChangeName", onChangeNameMessageHandler);
}

/// <summary>
/// 取消小心监听注册
/// </summary>
public void unregisterMessageListener()
{
MessageDispatcher.Singleton.unSubscribe("ChangeName", onChangeNameMessageHandler);
}

/// <summary>
/// 保存Model数据
/// </summary>
public void saveModel()
{
Model.Name = Name.Value;
Model.Age = Age.Value;
}

/// <summary>
/// 响应ChangeName消息回调
/// </summary>
/// <param name="sender"></param>
/// <param name="paras"></param>
private void onChangeNameMessageHandler(object sender, object[] paras)
{
Name.Value = paras[0] as string;
}
}

添加View 2和ViewModel2相关代码:
MVVMView2.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/*
* Description: MVVMView2.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// MVVMView2.cs
/// 第二个View
/// </summary>
public class MVVMView2 : MVVMBaseView {

/// <summary> 通知ViewModel1改变Name属性按钮 /// </summary>
public Button mBtnChangeViewModel1Name;

/// <summary>
/// 真实绑定的VM对象访问入口
/// </summary>
public MVVMViewModel2 ViewModel
{
get
{
return (MVVMViewModel2)mMVVMViewModelContext;
}
}

/// <summary>
/// 响应VM绑定变化回调
/// 在这里做V和VM绑定相关的事
/// </summary>
/// <param name="oldvm"></param>
/// <param name="newvm"></param>
protected override void OnBindingContextChanged(IMVVMViewModel oldvm, IMVVMViewModel newvm)
{
base.OnBindingContextChanged(oldvm, newvm);
// 解除老的VM相关绑定
if (oldvm != null)
{
#region Bind VM to V Part
#endregion

#region Bind V To VM
mBtnChangeViewModel1Name.onClick.RemoveListener(onNotifyViewModel1Click);
#endregion
}

// 添加新的VM相关绑定
if (ViewModel != null)
{
#region Bind VM to V Part
#endregion

#region Bind V To VM
mBtnChangeViewModel1Name.onClick.AddListener(onNotifyViewModel1Click);
#endregion
}
}

#region Bind VM to V Part

#endregion

#region Bind V To VM
private void onNotifyViewModel1Click()
{
ViewModel.notifyViewModel1NameChange();
}
#endregion
}

MVVMViewModel2.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* Description: MVVMViewModel2.cs
* Author: TONYTANG
* Create Date: 2018/09/16
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MVVMViewModel2.cs
/// 第二个ViewModel
/// </summary>
public class MVVMViewModel2 : IMVVMViewModel {

/// <summary>
/// 通知ViewModel1的名字修改
/// </summary>
public void notifyViewModel1NameChange()
{
MessageDispatcher.Singleton.publish("ChangeName", this, "Hello World!");
}
}

初始化View2和ViewModel2相关的绑定:
GameProgrammingPattern.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* Description: GameProgrammingPattern.cs
* Author: TONYTANG
* Create Date: 2018/09/15
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameProgrammingPattern : MonoBehaviour {

#region MVVM Part
******

/// <summary> 第二个ViewModel /// </summary>
private MVVMViewModel2 mMVVMViewModel2;

/// <summary> 第二个View /// </summary>
private MVVMView2 mMVVMView2;
#endregion

void Awake()
{
initMonoScripts();

#region MVVM Part
******

mMVVMViewModel2 = new MVVMViewModel2();
mMVVMView2 = this.transform.GetComponentInChildren<MVVMView2>();
#endregion
}

void Start ()
{
#region MVVM Part
// 初始化ViewModel里的Model数据
mMVVMViewModel.initializeModel(mMVVMModel);
// ViewModel注册消息监听
mMVVMViewModel.registerMessageListener();
// 绑定View到VM上
mMVVMView.mMVVMViewModelContext = mMVVMViewModel;
// 通过携程动态使用VM修改值Age触发View变化显示
CoroutineManager.Singleton.startCoroutine(changeViewModelAgeCoroutine());

// 绑定View2到ViewModel2上
mMVVMView2.mMVVMViewModelContext = mMVVMViewModel2;
#endregion
}

******
}

运行点击ViewModel2的按钮可以看到我们成功通过消息订阅和分发达到ViewModel和ViewModel之间交流的目的,成功实现了解耦ViewModel和ViewModel之间的通信:
MessageDispatcher

优化
  1. 自动化生成View和ViewModel属性绑定部分代码
    待续……
MVVM使用思考

可以看到MVVM相比传统MVC模式,将V和M完全解耦,V和M不需要关心对方,通过ViewModel去实现V和M的桥接,通过双向绑定实现View的数据驱动刷新显示。通过消息分发注册等类似机制实现ViewModel与ViewModel之间的沟通交流,实现低耦合的MVVM框架。
View可以绑定不同的ViewModel,快速实现不同数据刷新显示。
同时因为V和M完全分离,开发人员可以专注于业务逻辑和数据的开发(ViewModel),设计人员可以专注于页面设计。

MVVM应用范围思考:

  1. 独立开发,方便测试,MVVM比较适合多人合作项目,设计人员和开发人员可以同时开工,
  2. 代码重用性,开发人员之间的代码有很高的重用性(重用V或者重用VM都有可能),开发人员之间的代码不会互相污染。
  3. 适用于数据驱动的不复杂的游戏UI部分(数据驱动刷新显示)。

待续……

ECS

待续……

引用

网站

谈谈对MVC、MVP和MVVM的理解
谈 MVC、MVP 和 MVVM 架构模式
Unity 应用架构设计—— MVVM 模式的设计和实施 (Part 1)
mvc的实现
如何在Unity中实现MVC模式?
跟踪数据结构的变更
MVC、MVP、MVVM架构分析与比较

书籍

《Game-Programming-Patterns》 – Robert.Nystrom
《Design Pattern》 – GoF(四人帮)

前言

本篇文章是为了记录学习2D像素画方面的技巧以及知识而写,希望有一天能做自己的2D像素独立游戏。

电子游戏史

在学习了解像素画之前,先了解下电子游戏史,有助于学习了解像素画游戏的由来,同时也可以加深对游戏发展史的了解。

电子游戏在1970年代开始以商业娱乐媒体的姿态出现,成为1970年代末日本、美国和欧洲一个重要娱乐工业的基础。

街机时代

关键词: 街机 & 70年代初发展 & 70年代末至90年代中期盛行
在街机上运行的游戏为街机游戏。街机游戏发端于70年代初而盛行于70年代末至90年代中期。90年代后期,虽然SNK的拳皇和合金弹头依然颇受欢迎,但整个产业已经开始衰退。进入21世纪后,街机迅速衰弱,日益远离大众的视线。
小时候90年代,街机很流行,从小就混迹街机室,不得不说有很深的感情。
这里放几张图留念:
ArcadeGame
小时候比较喜欢的几款街机游戏,拳皇97,三国战纪,恐龙快打,合金弹头等。
KingOfFighters
SanGuoZhanJi
KongLongKuaiDa
MetalSlug

掌机时代

关键词: 掌机 & 任天堂 & 索尼 & 70年代初发展 & 80年代以后开始盛行
虽然掌机游戏发展史70年代初,但掌机游戏真正发光是任天堂的掌机系列。
任天堂掌机系列:

  1. GB(Game Body) – 第一代
    Game Boy(日语:ゲームボーイ,简称GB)是任天堂公司在1989年发售的第一代便携式掌上游戏机。
    这里截取一段GB主机的技术参数信息:
    GameBoyParameters
    不得不说以前掌机的内存,容量等都相当苛刻,8 KByte RAM放到今天真的是难以想象。
    掌机小时候玩的并不算多,印象中还是用下面的掌机玩俄罗斯方块之类的:
    GameBoyTetris
  2. GBA(Game Boy Advance) – 第二代
    2001年初,任天堂发布了Game Boy后续机型Game Boy Advance(GBA)。
  3. NDS(Nintendo Dual screen) – 第三代
    2004年11月,任天堂DS系列掌机产品上市。NDS采用了上下两块的3英寸屏幕,其下屏幕的触控设计一举颠覆了过往游戏掌机的操控概念。
    可以看出NDS在显示操作上针对GBA做出了很大改变:
    NintendoDualScreen
  4. NDSL
  5. NDSi
  6. NDSiLL
  7. 3DS
    任天堂于2011年2月26日发布的次世代掌机
    这里再看看3DS的部分技术参数信息:
    3DS
    可以看出从Game Boy到3DS,经历了20年的发展,内存和容量都得到了大大的提高。
  8. 3DSLL
  9. Switch
    Nintendo Switch近日首度亮相,集电视游戏主机与掌上游戏机“双机一体”
    Switch不同于普通的掌机,还结合了游戏主机的功能。但当初了解到Switch令我眼前一亮的是他的游戏设计创意上(瓦楞纸游戏):
    通过纸盒做出游戏所需的零部件,在这些纸板上会有一些反光部件,IR 摄像头就是通过计算这些反光,来触发相应的功能,从而实现游戏性。
    这里就放一个链接,个人可以去感受下,创意真的很好:
    Switch瓦楞纸游戏视频

索尼掌机系列;

  1. PSP(PlayStation Portable)
    2003年5月13日,SCE在E3大展5月11日的例行新闻发布会上首次披露了携带游戏主机PSP,社长久多良木健将之称许为“21世纪 的WALKMAN”
  2. PSP2
  3. PSP3
  4. PSV(Play Station Vita)
  5. PS4

家用机时代

关键词: 家用机 & 任天堂 &

  1. FC(Family Computer) – 第一代
    大家熟悉的红白机指的就是这个,小时候在家玩的插卡的魂斗罗,超级玛丽就是在FC上玩的。
    FamilyComputer
    小时候家里有卖这个,直接拿回家里就可以玩了,真的是童年里很快乐的时光。
    很多红白机经典游戏,现在都记忆犹新:
    超级玛丽
    马戏团
    魂斗罗
  2. SFC – 第二代
    SFC是「FAMICOM」后任天堂推出的第2代家用机。 [1] 采用全新16位元架构,搭配强化的图形与音效处理功能,成功延续FC的霸业。SFC手柄最大的改进之处在于第一次加入了肩部按键L/R,并形成了ABXY四个按键的手柄布局、和satellaview。
  3. N64(Nintendo 64) – 第三代
    N64手柄堪称游戏史上最重要的输入设备,它有3个堪称划时代的设计:类比摇杆,扳机按键,震动包。 N64上诞生了塞尔达传说:时之笛和超级马里奥64等超级大作。N64下一代产品为任天堂NGC。
    塞尔达传说和超级玛丽相比也不逊色游戏大作,这里本人并没有玩过,但可以看下简单的游戏版本信息截图来感受下:
    TheLegendOfZelda
  4. NGC(Nintento GameCube) – 第四代
    2001年9月4日,NGC在日本本土发售。
  5. Wii – 第五代
    Wii是任天堂公司2006年11月19日推出的家用游戏机。是NGC的后续机种。Wii第一次将体感引入了电视游戏主机。
    Wii
  6. WiiU
    Wii U是任天堂继Wii之后所推出的家用游戏主机,是Wii的后继机种。于2011年6月7日首次公布

电脑时代

关键词: 60年代初诞生 & 斯蒂夫.拉塞尔 & 80,90年代开始盛行 & 21世纪鼎盛时期
电脑游戏(英语:PC games,或称computer games,全写personal computer games)是一个相对于主机游戏和街机游戏的概念,指在个人电脑上运行的游戏软件,是一种本身提供娱乐功能的电脑软件。
电脑游戏的出现与1960年代电子计算机出现在美国大学校园有密切的联系。当年的环境培养出了一群编程的高手。在1962年,一位叫斯蒂夫·拉塞尔的大学生在美国DEC公司生产的PDP-1型电子计算机上编制的电脑游戏就是在当时很有名的《宇宙战争》(Space War)。游戏业界人士一般认为,斯蒂夫·拉塞尔就是发明电脑游戏的人

小时候90年代初,电脑游戏已经开始盛行,只是当时处于硬件和技术等限制,还没有那么多3A大作游戏。小时候当初影响最深要数传奇,暗黑2,星际争霸和半条命了。
传奇算是当初非常火的MMORPG(大型多人在线角色扮演类游戏):
传奇
当时还在读小学,玩传奇玩的不亦乐乎,对于这种大型多人在线游戏以及打怪升级,杀人爆装备的设定,感觉很有意思。当时玩的最多的是法师,最喜欢去招宝宝(暗黑神殿的巨型多角虫)带去练级刷怪了。
暗黑2(暴雪出品)算是Isometric Projection(等角投影)用2D模拟3D显示典型的游戏:
暗黑2
暗黑2利用Isometric Projectection使用2D来制作2.5D的游戏,看起来特别舒服,同时里面的装备系统,难度模式,剧情以及各种职业特点都很有特点,让人即使是网吧局域网都玩的不亦乐乎。
星际争霸(暴雪出品)算是典型的RTS(Real Time Strategy即时战略类)游戏:
StarCraft1
半条命(Valve公司出品)算是典型的FPS(First Person Shooting第一人称射击)游戏:
半条命

电脑游戏虽然受到手机游戏的冲击,但目前看来电脑游戏依然还是主流。(2018/06/02)

手机时代

关键词: 21世纪 & 智能机普及
21世纪,伴随着智能机的普及,手机已经是人们生活中必不可少的工具了。为移动端游戏的开发和繁荣奠定了基础。
这里就说太多了,这里主要想提下,移动游戏里自己最喜欢的游戏COC(部落冲突),由芬兰Supercell公司开发(后来被腾讯斥资86亿美元收购了)。这一款游戏让我被移动游戏开发深深吸引,原来移动游戏可以做到如此的好玩,也激发了我做独立移动游戏的思想。
这里不厌其烦的再次放张自己玩COC以前的截图作为纪念:
My COC

虽然现在(2018/06/02)移动游戏已经不再像几年前,百花齐放,已经趋于稳定,基本剩下的都是大厂或者有实力的公司。但独立游戏的梦想让我并不想放弃做移动游戏,因此走上了Unity的移动游戏开发之路。

像素画

还是按What,Why,How的顺序来学习理解像素画。
什么是像素画?(What)
像素画是一种以“像素”(Pixel)为基本单位来制作的电脑绘图表现形式。

这里让我们了解两个概念,位图向量图
位图(Bitmap),又称栅格图(英语:Raster graphics)或点阵图,是使用像素阵列(Pixel-array/Dot-matrix点阵)来表示的图像。

矢量图形是计算机图形学中用点、直线或者多边形等基于数学方程的几何图元表示图像。矢量图形与使用像素表示图像的位图不同。

我个人简单的理解就是位图是存储的原始像素信息,矢量图存的是图形描述信息然后通过计算机栅格化显示出来。两则很重要的一个区别就是前者放大缩小会失真,后者因为是根据图形描述信息动态栅格化计算显示放大缩小出来的不会失真。

为什么会有像素画?(Why)
像素风格起源于电子游戏,是硬件和显示器技术不发达的时期用来表现画面的一种解决方案。后来随着技术的发展,延伸出越来越多的变种,界限也越来越宽泛。
像素风格伴随50年代电子游戏出现而诞生,到了家用机大行其道的FC(Family Computer)时代,更是诞生了大量大家熟知的优秀像素风格游戏作品。
正由于这个原因,像素风格往往让人联想到游戏的起源,并且常常被视为游戏文化和复古潮流的象征之一。

题外话:
个人在做独立游戏的时,美术是个很大的问题,同时游戏开发出来后要想跟大厂的游戏去比画质比游戏性基本是不太可能的,而像素画是一个门槛相对低,对于像素风格复古风格的游戏始终有着自己的一小部分受众,所以像素画是独立游戏的一个不错选择。以前在独立游戏大电影里,了解到了独立游戏FEZ(像素风)一个人花费了大量的时间完成且取得了很大成功的游戏,既有感动,又感受到独立游戏开发之路辛酸。

这里贴一张图以示敬意:
PixelArtGameFEZ

如何学好像素画?(How)
这是一个比较大的问题,这里暂时只提及个人认为从入门到精通需要经历的步骤:

  1. 找到讲解介绍像素画的网站(找到入门点)
  2. 明确自己想学习的方向(想做什么样的像素画,需要学习那些知识)
  3. 欲善其事,必先利其器(找到合适的工具)
  4. 花费时间去学习了解像素画里的知识和技巧(熟能生巧)
  5. 实战运用

像素画知识

像素画分类

下面的像素画分类信息来源:
二维平面像素介绍

  1. 黑白画风
    BlackAndWhitePixelArt
  2. 复古画风
    FuGuPixelArt
  3. 等距视角画风
    IsometricProjectionPixelArt
  4. 极简画风
    ExtramSimplePixelArt
  5. 计繁画风
    ComplicatedPixelArt
  6. 写实画风
    RealisticPixelArt
  7. Q萌画风
    QStylePixelArt
  8. 油画式画风
    YouHuaStylePixelArt
  9. 光影渲染画风
    LightShadowStylePixelArt
    从上面别人的总结讲解来看,考虑到个人美术功底比较差,复古画风是一个入门学习的不错选择。

像素画技巧

绘制方法

下面的绘制方法信息来源:
二维平面像素介绍

  1. 绘制线稿法
    HuiZhiXianGaoFaPixelArt
  2. 色块构图法
    SeKuaiGouTuFaPixelArt
  3. 缩图修改法
    SuoTuXiuGaiFaPixelArt
    从上面别人的总结分享来看,初学入门选择绘制线稿法来练习像素画比较全面的学习像素画的各方面技巧。

像素画工具

欲善其事,必先利其器。
PC端:

  1. PhotoShop
    相对来说属于功能很强大,但不适合非专业美术人员用于学习像素画,对于像素画比如Animation,Tile等方面支持都并不友好。
  2. Aseprite(Open Source这一点比较吃惊,付费版$14.99,免费版需要自己编译)
    Aseprite Github Link
  3. Pyxel Edit(付费 $9.00)
    参考了网上的观点和对应官方网站的教学视频。个人觉得Asesprite能满足我做像素画以及像素动画的需求,同时官方网站做的比较完善,资料相对完整些,最终决定Asesprite作为像素画入门工具。

移动端:
Android:
8bit Painter
IOS:
8bit Painter

Note:
因为是移动端版本,只适合画一些简单的像素画(工具丰富层度和操作形式限制了),专业的还是需要在PC端操作(Aseprite软件)。

Aseprite学习

本人直接购买的付费版,先来张软件截图:
Aseprite

快捷键

通过Edit -> Keyboard Shotcuts修改自定义快捷键
Tab – Layer & Frame面板快捷键

可以设置前景色和背景色,左键是前景色,右键是背景色
View -> Symetriy Options可以自动对称绘制

工具

这里的工具很多跟PS里很像,这里就不一一讲解了。详细参考教程学习:Aseprite Tutorial

Tips

What is the best size to draw pixel art?
根据需要绘制的像素画各部位表现所需的大小来决定最终的画布大小。

Common mistakes in Pixel Art:

  1. Doubles
    DoublesInPixelArt
    纠正方法:
    • 启用Pixel-Perfect or 手动Pixel-Perfect
      PixelPerfectPixelArt
    • 2px
    • AA
      DoublesSolutions
  2. Jaggies(锯齿)
    JaggiesLine
    纠正方法:
    • 连续的图像像素连续数量增加减少要有规律(比如:43211234)
    • 连续的图像像素连续数量有限取小数量的(比如:43211235)
      NotJaggiesLine
  3. Outline
    确认哪些部分是需要突出的,哪些是不需要突出的。
    突出部分可以多加一点像素outline来凸显,不需要突出的部分紧贴着outline即可。

像素画实战学习

前面的一部分学习是参考一下学习网站:
Mort的系列视频

接下来主要根据下面这个网站,一步一步的学习绘制像素画:
教你画像素画

像素素材网站:
像素艺术
pixeljoint

工具:
Asperite

自定义快捷键

画像素画,快捷键很重要,因为我们需要不断在各种画笔,橡皮檫,取色工具等之间不断的切换。

Edit -> Keyboard Shortcuts自定义快捷键
以下几个是比较常见的,为了方便我把默认的Ctrl + Alt形式改成了Shift + Alt的形式。

  • Shift + Alt + B – 画笔
  • Shift + Alt + E – 橡皮檫
  • Shift + Alt + C(默认是I,按键隔太远了我改成了C) – 吸取颜色
  • Shift + Alt + G – 填充整色
  • Shift + Alt + M – 矩形选中区
  • Shift + Alt + U – 矩形框选区
  • Shift + Alt + Z – Undo
  • Shift + Alt + Y – Redo
  • Space + 左键 – 移动画布
  • 滚动鼠标 – 放大缩小
    更多的快捷键根据自己的需求修改,这里就不一一列出了

AA(Anti-Aliasing)

AA(Anti-Aliasing)即抗锯齿。
前面简单的提过如何去通过手动添加绘制完成AA,但为什么像素画需要AA了?
像素画不支持半透明,因此抗锯齿的工作只能有画家手工完成。

下面通过对比大写的字母A,一个没有AA一个有AA来对比看下效果:
AAUsing
可以看到没有添加AA的因为没有半透明的过度,边缘斜线过度区域很明显很生硬,添加AA后边的柔和不再那么生硬。

AA不仅在于手工的添加上,好的AA颜色选择也是关键所在。
AA颜色的选择记住下面几条准则:

  1. 确定添加AA的地方两侧的颜色信息
  2. 中间色选取两侧颜色信息中间的部分
    AAColorChoosen

Note:

  1. 中间色不能过多,太多的话像素画会模糊。
  2. 在AA手工抗锯齿的时候,应当尽量避免平行像素(这里的平行像素是指AA自身像素平行)。平行像素会强化锯齿感,缺乏视觉美感。

实战绘画步骤

  1. 想好要画什么(最好能找到图片模板对照着)
  2. 决定像素画画布大小(初期学习绘制最好控制在32X32大小以内)
  3. 选择好颜色(考虑好冷色暖色颜色选择以及中间过度的AA颜色),设置好调色板(palette)
  4. 利用基础线条勾勒出基本轮廓(像素画像素是有限的,讲求的是神似而非写实,所以只要勾勒出基本轮廓即可,化繁为简)
  5. 填充颜色
  6. AA绘制
  7. 勾勒高亮和暗部区域

实战画动物

结合上面几个步骤,接下来实战绘制一次:

  1. 想好要画什么
    这一步,考虑到初期学习,这里决定画参考文章尝试绘制动物
    像素画初级教程:鸡的画法
    原图:
    Chicken
  2. 决定好像素画布大小
    这里考虑初学像素画,决定按32X32的大小来绘制
  3. 决定好颜色选择
    这一步因为有原图,直接吸取原图几个主要颜色作为调色板颜色即可,然后把AA中间色等准备好即可:
    ChikenPalette
  4. 勾勒基础轮廓
    LunKuo
  5. 填充基本色
    TiuanChongJiChuSe
  6. 手动AA
    ChickenAfterAA
  7. 勾勒高亮和暗部区域
    ChikenFinish

调色板设计与管理

参考学习文章:
像素画高级教程:像素画调色板指南
像素画高级教程:像素画调色板设计和管理
像素画颜色基础基本配色方法介绍

初学像素画,要想画出的效果好看,除了画的好,好的颜色选择也很关键。
这一节主要学习像素画调色板的准备和颜色相关知识,为画好的像素画打好基础。

色相 & 饱和度 & 亮度(HSB)

平时我接触关于颜色的概念,更多是RGB三原色的概念(R - 红色 G - 绿色 B - 蓝色)。
接下来让我们学习理解下HSB颜色模式:
H(Hue)色相 – 实际颜色(0 - 360)
S(Saturation)饱和度 - 颜色的强度或纯度(0 - 100%)
B(Brightness)亮度 - 混合颜色的黑色或白色的量(0 - 100%)

这里HSB和之前的RGB是完全不一样的概念,颜色的组成不在是三个颜色的混合结果,而是通过定义色相,饱和度,亮度来决定最终的颜色。

色相可以理解成我们选择的基准颜色,这个基准颜色可以是RGB任何一种组合结果,比如我们选取纯红(RGB - 255,0,0)作为基准色。
HueBasicColor
可以看到虽然我们选取了红色作为基准色,但最终的颜色还是纯黑的,这是因为我们设置的B亮度是0导致的。

亮度决定了黑白的颜色量,这里可以理解成白色颜色的占比比重((255,255,255) X 0-1(亮度)),从而影响最终颜色的颜色占比,比如我们设置B亮度50%:
BasicColorWithHalfBrightness
可以看到,我们的色相颜色红色(255,0,0)因为B亮度50%的设置,变成了RBG颜色信息是128,128,128,这真是因为50%的亮度决定了整体颜色的颜色基础值。

看到这里,读者可能会和我有一个相同的疑问,既然亮度决定了颜色值,那么我们设置色相又有什么作用了?
H色相是配合S饱和度来起作用的,亮度决定了基调颜色信息,色相和饱和度决定了我们非色相颜色信息的占比。

色相只是决定了我们的基准色,但这个颜色的饱和度是多少了,会决定RGB里我们的非色相颜色的占比。比如此时我们把S饱和度信息设置成50%,我们会看到如下结果:
BasicColorWithHalfBAndHalfS
颜色(128,128,128)因为饱和度50%的设置使得非色相颜色的占比降低了一半,也就是颜色(128,64,64)。

因此我们最终得到的颜色信息是128,64,64。

总结:

  1. B亮度决定了基础颜色信息。
  2. H色相决定了主色颜色信息。
  3. S饱和度决定了非主色颜色信息的占比。
  4. 三者共同影响颜色的计算得出最终颜色。

Note:

  1. 色相的值范围0-360可以理解成颜色圆盘信息从纯红开始绕360度
  2. 饱和度是从外往内递减
    HSBCircle
  3. 亮度第二章图下面的横条
    HSBCircle2
颜色选择

颜色有区分冷色和暖色之分:

  1. 冷色
    冷色给人冰冷的感觉。
    蓝绿、蓝青、蓝、蓝紫
  2. 暖色
    暖色给人愉悦和温暖的感觉。
    红紫、红、橘、黄橘、黄。
  3. 中性色
    紫、绿、黑、白、灰。

WarmAndColdeColor

颜色选择建议:

  1. 在一件作品上创建阴影时,最好使用偏冷的颜色。对于最亮的区域,包含更多暖色调显然会更好。
  2. 选色时要考虑角色个性,场景属性,场景光照颜色等。
  3. 新手同一个物品绘制颜色选择不要太多(初期建议控制在10个及以内,最多最好控制在32个以内,颜色越多在颜色选择和管理上花费的时间也就越多)。
  4. 选好两个基础色(亮度色和暗部色,然后通过添加两个颜色之间的过渡色完成调色板基础色准备)
  5. 过渡色除了亮度和饱和度上的变化,最好还有色相上的变化,相邻颜色过度会比较自然。
  6. 颜色饱和度不要过高,高了眼睛看久了会累。
自制调色板

有了前面的颜色概念和选色意见,接下来我们去制作一个用于真正绘制像素画的调色板,实战演练。

  1. 定两个基准颜色(红色和蓝色)
  2. 亮度偏黄色(暖色),暗部偏青色(冷色)
  3. 分析光照(假设光照是白色光)

开始制作我们的调色板:
CustomPalette
可以看到主色红色和蓝色有饱和度和明暗度以及色相上的颜色变化。

明暗知识

参考文章:
画明暗和配色Tips
笨办法学像素画:像素画明暗画法指南

上明暗的时候要兼顾色调变化。最简单的规则就是,亮部的颜色偏暖,暗部的颜色偏冷。

文章里明暗画法提到下面3点基础:
1、有光源(发出光的东西叫光源)才有明暗,影子位置与光源相反;
2、光是一种粒子,会被物体吸收和反射;
3、光直射的区域是高光;阴影里面稍亮的区域是反光;

接下来结合文章里绘制立方体来学习绘制明暗。
步骤一:
准备调色板

  1. 以土黄色单色为主
  2. 明暗上的变化以饱和度和亮度变化为主(单纯绘制明暗,暂时不考虑色相变化)
    MingAnPalette

步骤二:
绘制基础形状外观
BasicOutline

步骤三:
确定光源位置(假设光源在左侧),会出暗部和亮部以及影子
MingAnWhithLight

步骤四:
调整颜色以及细节明暗。
MingAnFinal

Reference

Conception Part

像素画
位图
矢量图形

Background knowledge Part

电子游戏史
RPGMaker官方像素画教程
二维平面像素介绍
街机
Game Boy
Game Boy Advanced
SFC
N64
NGC
Wii
WiiU
Play Station Portable
PS4
电脑游戏

Pixel Art Part

什么是像素画
教你画像素画
Mort的系列视频
像素艺术
pixeljoint

Pixel Art Tutorial

像素画高级教程:像素画调色板指南
像素画高级教程:像素画调色板设计和管理
像素画颜色基础基本配色方法介绍
画明暗和配色Tips

Other Part







前言

本篇文章只是为了记录一些使用学习Unity过程中的一些能够帮助我们自动化快速完成一些重复工作的小技巧。

Script Template

需求

使用Unity的时候我们难以避免需要创建脚本,平时都是通过Priject -> Right CLick -> Create -> C# Script的形式创建默认的脚本。

默认脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class NewBehaviourScript : MonoBehaviour {

// Use this for initialization
void Start () {

}

// Update is called once per frame
void Update () {

}
}

需求决定功能,但默认的脚本模本不足以提供我们想要的结果,比如我们希望自动创建如下的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Description: NewBehaviourScript类
* Author: tanghuan
* Create Date: 2018/04/01
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// NewBehaviourScript类
/// </summary>
public class NewBehaviourScript : MonoBehaviour {

}

这样我们就不需要每次都自己去编写自动化描述相关的内容了。

原理

如何实现,先让我们来看看,Unity是如何自动化生成NewBehaviourScript.cs脚本的。

让我们打开%EDITORPATH%/Data/Resources/ScriptTemplates/路径一探究竟:
UnityScriptTemplate

可以看到Unity默认的脚本模板是定义在这个路径下的,让我们打开81-C# Script-NewBehaviourScript.cs.txt看看默认的脚本模板是如何定义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class #SCRIPTNAME# : MonoBehaviour {

// Use this for initialization
void Start () {
#NOTRIM#
}

// Update is called once per frame
void Update () {
#NOTRIM#
}
}

关于Unity脚本模板的定义规则,这里没有找到官方的介绍,下面的规则参考了网上的说法:
Custom Unity Script Template

但Unity提供的脚本模板Keyword数量有限,所以我们想要创建自己的脚本模本需要分两步走:

  1. 根据Unity脚本模板规则创建自定义脚本
  2. 监听脚本Asset修改保存回调去自动化修改替换自定义Keyword

实现

  1. 根据Unity脚本模板规则创建自定义脚本,编写自定义脚本模板
    81-C# Custom Mono Script Template-NewBehaviourScript.cs.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Description: #SCRIPTNAME#.cs
* Author: #AUTHOR#
* Create Date: #CREATEDATE#
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// #SCRIPTNAME#.cs
/// </summary>
public class #SCRIPTNAME# : MonoBehaviour {

}

可以看到通过添加Unity脚本模板,右键创建时已经多了一个模板脚本选择。
CustomScriptTemplateUI

  1. 监听脚本Asset创建回调去自动化修改替换自定义Keyword
    ScriptKeywordProcesser.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/*
* Description: 脚本模板自定义Keyword处理脚本
* Author: tanghuan
* Create Date: 2018/04/01
*/

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Security.Principal;
using UnityEditor;
using UnityEngine;

/// <summary>
/// 脚本模板自定义Keyword处理脚本
/// </summary>
public class ScriptKeywordProcesser : UnityEditor.AssetModificationProcessor {

/// <summary>
/// 非用户导入的Asset创建回调(e.g. .meta文件)
/// </summary>
/// <param name="assetpath"></param>
public static void OnWillCreateAsset(string assetpath)
{
assetpath = assetpath.Replace(".meta", string.Empty);
int index = assetpath.LastIndexOf(".");
if(index <= 0)
{
return;
}
//判定是否是cs脚本
string filepostfix = assetpath.Substring(index);
if(!filepostfix.Equals(".cs"))
{
return;
}
//判定脚本文件是否存在
index = Application.dataPath.LastIndexOf("Assets");
assetpath = Application.dataPath.Substring(0, index) + assetpath;
if(!File.Exists(assetpath))
{
return;
}

var filecontent = File.ReadAllText(assetpath);
filecontent = replaceKeywords(filecontent);
File.WriteAllText(assetpath, filecontent);
AssetDatabase.Refresh();
}

/// <summary>
/// 替换文本Keyword
/// Note:
/// 自定义Keyword替换规则写在这里
/// </summary>
/// <param name="filecontent"></param>
/// <returns></returns>
private static string replaceKeywords(string filecontent)
{
string author = WindowsIdentity.GetCurrent().Name;
// 只取最终用户名
var splashindex = author.IndexOf("\\");
if(splashindex > 0)
{
author = author.Substring(0, splashindex);
}

filecontent = filecontent.Replace("#AUTHOR#", author);
filecontent = filecontent.Replace("#CREATEDATE#", DateTime.Now.ToString("yyyy//MM/dd"));
return filecontent;
}
}

最终自动化创建一份MyCustomMonoScript.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Description: MyCustomMonoScript.cs
* Author: TONYTANG
* Create Date: 2018/04/01
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// MyCustomMonoScript.cs
/// </summary>
public class MyCustomMonoScript : MonoBehaviour {

}

至此我们成功创建并支持了自定义关键词的自动化脚本创建,以后再也不用每次都去编写这些固定的文件头了。

Asset Import Cache(资源导入缓存)

在Unity里,我们每次导入或者修改资源都会资源被重新导入。在大的项目里,当大量的资源被其他人修改或者切换平台的时候会导致资源导入的时间很长,这一点是不能接受的。

Unity官方出的Cache Server正式为了减少资源导入,充分利用缓存以及共享导入资源的方式减少导入资源的时间。

先让我们看看,什么是Cache Server。

Cache Server

接下来还是按照What,Why,How的方式循序渐进了解学习Cache Server.

What

What is Cache Server?(什么是Cache Server?)
When you enable the Cache Server (see How to set up a Cache Server as a user, below), you can even share Asset imports across multiple projects (that is, the work of importing is done on one computer and the results are shared with others).
从官方的介绍可以看出,Cache Server是为了共享资源导入的一个工具。

Why

Why do we use Cache Server?(为什么需要用Cache Server?)
Unity has a completely automatic Asset
pipeline. Whenever a source Asset like a .psd or an .fbx file is modified, Unity detects the change and automatically re-imports it. The imported data from the file is subsequently stored by Unity in an internal format.

因为Unity会把外部资源根据导入设置导入成一个内部的资源格式来使用,每次资源导入设置或者资源有变化都会导致资源被重新导入一次。

Caching the imported Assets data on the Cache Server drastically reduces the time it takes to import Assets.
缓存并共享前面说到的资源导入则可以有效的减少资源导入的时间,提高团队工作效率,这也是我们为什么需要Cach Server的主要原因。

How

How to use Cache Server?(如何使用Cache Server)
在了解如何使用Cach Server之前,让我们先了解下Asset在什么情况下会被重新导入:

  1. The Asset file itself(Asset自身)
  2. The import settings(导入设置)
  3. Asset importer version(Asset导入版本-这里应该是指Unity版本)
  4. The current platform(资源平台-平时我们切换的Android,IOS,PC这些)
    当上述4点任何一点被改变的话都会导致资源被重新导入。否则就直接使用Cache Server上缓存的资源导入。

如何高效的使用Cach Server了?

  1. 足够的硬盘空间
  2. 高速的读写硬盘(SSD)
  3. 足够的网络带宽(最好用于局域网内)
  4. 尽量在Linux或者MacOS上使用Cache Server

第四个点参考官网描述:
he Cache Server runs optimally on a Linux or Mac OS X computer. The Windows file system is not particularly well-optimized for how the Cache Server stores data, and problems with file locking on Windows can cause issues that don’t occur on Linux or Mac OS X.

接下来让我们实战看看如何设置Cache Server:
第一步:
下载对应Unity版本的Cache Server

Unity对应版本官方的Cache Server链接:
Unity 下载存档
CacheServerDownloadPage
最新Cache Server链接是在Github上:
unity-cache-server

第二步:
运行Cache Server
这里我是安装在MacOS上的,所以运行RunOSX.command命令行即可:
RunCacheServer

第三步:
查看Cache Server所在电脑IP(最好能固定Cache Server电脑的IP地址,避免每次去设置),配置需要使用Cache Server的Unity
CacheServerConnect
看到Connection Successful就代表我们链接成功了。

接下来我们就可以重用团队或者已经缓存过的Asset导入资源了。

Note:
使用Cache Server有哪些需要注意的点了?

  1. 修改已存在的Material
  2. 脚本和一些原始资源(Maya,3D Max直接使用的格式)不会被缓存

待续

Reference

Conceptions Part

Cache Server

Other Part

Custom Unity Script Template

Introduction

游戏开发过程中,避免不了和数据打交道,数据是游戏最基础也是很重要的一部分。本篇文章正式为了学习了解游戏开发过程中关于数据配置自动化导表生成代码等一系列自动化相关的知识而写。

Data Config

What

数据配置 – 策划通过类似Excel或者其他方式配置出游戏里需要的数据,程序通过读取这些数据让游戏根据数据驱动运行起来,这正是我们所谓的数据配置。

Why

那么为什么需要数据配置了?

  1. 数据是游戏最基本的单位 – 无论是炫酷的特效还是惊人的AI都是通过读取底层数据最终得出结论的。
  2. 协同工作 – 这里说的数据配置更多的是游戏逻辑层的数据,即类似游戏里的对话显示,背包单位格子数量上限等配置。有了数据配置,程序和策划很容易协同工作,程序负责数据读取实现功能,策划负责具体的数据配置实现想要的效果。
  3. 多语言显示 – 数据配置用于语言包显示也帮助我们很方便的实现多语言的版本(读取不同语言版本的数据配置即可)。

How

实现数据配置读取原理上是把数据序列化导出保存在本地(.xml, .json .bytes……),然后通过反序列化读取回来。
这里我们不单单是针对个别数据进行序列化反序列化。这里要实现的是一个针对表格配置,可以动态生成支持反序列化读取(非反射机制)读取到程序里的工具。

序列化相关知识学习参考Runtime Serialization
PC端Excel读取库选择参考:Excel数据读取

Note:
这里注意反射和反序列化不是一回事,反序列化可以用反射也可以不用反射来实现。

Functional Requirement(功能需求)

  1. 支持int,float,string基础数据配置
  2. 支持大于一个维度的数据读取(多余一个的数据)快速读取(比如配置:1;2;3;4分别代表物体随机的生命值选项)(这个算是基础需求功能)
  3. 支持列表数据自定义数据结构快速读取(高阶功能需求,方便列表数据复杂的时候能够抽象成数据结构快速读取。)
    实际需求举列:
    我们配置一个道具不同等级的属性加成数据:1+200|2+300;3+400|4+500|5+600 表示第一级加属性id为1的属性加200并且,属性id为2的加300。第二季加属性id为3,4,5的分别加400,500,600)
    如果我们不能动态创建自定义结构数据,那么我们就需要在代码里写string.split()这种形式的分割数据去判定读取,不论是从性能开销还是代码编写都是很不划算的。
    如果我们能自动创建自定义结构,生成如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Property{
public int Id
{
public get;
private set;
}

public int Value
{
public get;
private set;
}
}

// Property[0].Length : 2
// Proeprty[1].Length : 3
Property[2][] mPropertyArray; // 存储表格数据的成员

那么我们就能方便的编写代码去访问特定等级所加成的属性了:

1
2
//快速访问特定等级所加成的属性值
mPropertyArray[1][2]表示第二级所加成的第二个属性数据
扩展内嵌多层的话(这样的需求应该很少,这里暂时就不考虑了),此方案需要扩展多维数组来支持,并不是很友好
  1. 支持自动生成读表相关代码
  2. 内存开销要控制,读取速度要快
  3. 跨平台
  4. 跨语言
  5. 独立于Unity的工具(后期优化部分)
  6. 前后端表格数据区分(后期优化部分)(因为前后端往往是读不同的数据,并不需要导同一套表格数据)

原理思考

流程:

  1. 定义excel表格格式规则(这一步会影响后面所有步骤)
  2. 读取excel数据
  3. 生成可跨平台跨语言的序列化代码
  4. 序列化写入excel数据(这一步涉及跨语言问题)
  5. 反序列化读取数据
  • 定义excel表格格式规则
    这一步要决定我们excel长什么样子,决定不同数据不同的行号字段代表什么。

  • 读取excel数据
    通过第三方库(比如PC端跨平台的ExcelRead)可以解决读取问题。

  • 生成跨平台跨语言的序列化反序列化代码
    这一步的重点在于跨平台和
    跨平台这一点肯定不用说,要想在PC端序列化的数据在移动端使用,这一点是必须的也并不是难点。
    跨语言这一点上,试想如果前后端用的语言不同,但前后端想共用一份导表工具来生成需要的表格数据,那么跨语言这一点就是必须的了。
    序列化数据和反序列化数据时,我们需要考虑跨语言的问题,后续的方案中就会强调这一点。

  • 序列化写入excel数据
    写入数据也就是序列化的过程,解决了前面提到的跨语言生成序列化代码问题后,这个问题也就迎刃而解。

  • 反序列化读取数据
    读数据就是反序列化的过程,同理序列化,解决了跨语言生成序列化代码的问题,这个问题依然不是问题。

方案比较

  1. C# .Net BinaryFormatter(不夸语言,序列化后数据相对大,反序列化费时且内存开销大)
  2. ProtoBuff(跨语言,序列化数据相对小,反序列化费时和内存开销相对好点。工具成熟,社区完善,自定义数据结构方便)
  3. FlatBuff(跨语言,序列化数据小,反序列化费时少,编写自定义序列化和反序列化数据结构不方便)
  4. XBuffer(之前一位同事写的基于C#的简化版FlatBuff,高效且高度支持扩展和配置)

接下来针对各个方案进行简单学习,理解各自的优缺点。

C# .Net BinaryFormatter

参考这篇文章:Unity C#配置表工具

我们可以看到,我们需要做的事情有以下几步:

  1. 定义excel表格格式规则
  2. 从excel中读取数据
  3. 根据数据类型,动态生成每个表的C#类(用于反序列化读取数据)
  4. 动态编译C#类,输出一个动态库以及相关代码
  5. 实例化C#类,并且把数据填入到实例化对象中,序列化数据,保存在Untiy本地Resources目录中
  6. 运行时,通过生成的C#类反序列化加载并创建对应实例化对象去存储数据

Utilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using UnityEngine;

using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;

using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

/// <summary>
/// 工具静态类
/// </summary>
public static class Utilities
{
/// <summary>
/// 本机打开特定目录(暂时只用于Windows)
/// </summary>
/// <param name="folderPath"></param>
public static void OpenFolder(string folderPath)
{
if (Directory.Exists(folderPath))
{
ProcessStartInfo startInfo = new ProcessStartInfo(folderPath, "explorer.exe");
Process.Start(startInfo);
}
else
{
UnityEngine.Debug.LogError(string.Format("{0} Directory does not exist!", folderPath));
}
}

/// <summary>
/// 检查指定目录是否存在,不存在创建一个
/// </summary>
public static void checkOrCreateSpecificFolder(string folderpath)
{
if (!Directory.Exists(folderpath))
{
Directory.CreateDirectory(folderpath);
}
}

/// <summary>
/// 无论目录是否存在都删除所有文件重新创建一个目录
/// </summary>
public static void recreateSpecificFolder(string folderpath)
{
if (Directory.Exists(folderpath))
{
Directory.Delete(folderpath, true);
}
Directory.CreateDirectory(folderpath);
}

/// <summary>
/// 获取文件的目录名字
/// </summary>
/// <param name="filepath"></param>
/// <returns></returns>
public static string getFileFolderName(string filepath)
{
return Path.GetFileName(Path.GetDirectoryName(filepath));
}
}

PathUtilites.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 路径静态工具类
/// </summary>
public static class PathUtilties {

#region Excel Data
/// <summary>
/// Excel数据文件夹目录路径
/// </summary>
public static string ExcelFileFolderPath = Application.dataPath + "/../ExcelDatas/Game/";

/// <summary>
/// Excel数据生成的对应代码目录
/// Note:
/// 因为序列化反序列化时要求Assemble信息一致,所以如果反序列化使用Unity 编译后的C#无法成功反序列化
/// BinderToType低版本.Net可以用但BinderToName要求.Net 4.0
/// 所以这里放弃了直接生成新的cs表代码通过Unity编译使用的方案,选择依然采用直接编译表代码成dll来使用
/// 表代码依然生成,但放到Asset外部作为非有效代码存储方便查看
/// 参考:
/// https://stackoverflow.com/questions/505611/binary-deserialization-with-different-assembly-version
/// https://www.codeproject.com/Tips/1101106/How-to-serialize-across-assemblies-with-the-Binary
/// </summary>
public static string ExcelScriptOutputFolderPath = Application.dataPath + "/../DataConfigScripts/";

/// <summary>
/// Excel数据序列化后的文件名
/// </summary>
public static string ExcelDataFileName = "dataconfig.byte";

/// <summary>
/// Excel数据序列化后的数据文件存储目录
/// </summary>
public static string ExcelDataFileOutputFolderPath = Application.dataPath + "/Resources/DataConfig/";

/// <summary>
/// Excel数据序列化后的数据文件相对于Resource目录的相对目录
/// </summary>
public static string ExcelDataFileOutputFolderRelativePath = "DataConfig/";

/// <summary>
/// Excel数据生成的对应Assembly&Script临时文件临时目录
/// </summary>
public static string ExcelScriptAssemblyFilePath = Application.dataPath + "/Scripts/Core/DataConfig/";

/// <summary>
/// Excel数据生成的对应Assembly临时文件名
/// </summary>
public static string ExcelScriptAssemblyFileName = "dataconfig.dll";

/// <summary>
/// 表格代码文件后缀
/// </summary>
public static string ExcelScriptFilePostFix = ".cs";

/// <summary>
/// 表格生成数据管理读取代码文件名
/// </summary>
public static string ExcelScriptDataManagerFileName = "DataManager.cs";

/// <summary>
/// 表代码Container后缀名
/// </summary>
public static string ExcelTableContainerPostfix = "Container";
#endregion
}

ExportExcelData.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using UnityEditor;

using Excel;
using System.IO;
using System.Data;
using System.Reflection;
using System.Text;
using Microsoft.CSharp;
using System.CodeDom.Compiler;
using System;
using System.Runtime.Serialization.Formatters.Binary;

/// <summary>
/// 表格数据抽象
/// </summary>
public class ConfigData
{
/// <summary>
/// 数据类型
/// </summary>
public string Type;

/// <summary>
/// 字段名字
/// </summary>
public string Name;

/// <summary>
/// 数据
/// </summary>
public string Data;
}

/// <summary>
/// 导表工具
/// </summary>
public class ExportExcelData {

/// <summary>
/// 字段名行号
/// </summary>
private const int NameLineNumber = 1;

/// <summary>
/// 字段类型行号
/// </summary>
private const int TypeLineNumber = 2;

/// <summary>
/// 数据开始行号
/// </summary>
private const int DataLineNumber = 3;

/// <summary>
/// 有效的数据类型
/// </summary>
private static List<string> ValideTypesList = new List<string>(new string[]{"int", "float", "string"});

[MenuItem("Tools/DataConfig/导出所有表格数据", false, 100)]
public static void exportAllExcelData()
{
string[] excelfiles = Directory.GetFiles(PathUtilties.ExcelFileFolderPath, "*.xlsx");
// 表格数据映射Map
// Key为表格名字
// Value为表格对应的数据列表,List的每一个元素代表一行的数据数组
Dictionary<string, List<ConfigData[]>> datadic = new Dictionary<string, List<ConfigData[]>>();
// 字段信息map
// Key为表格名字
// Value为对应表格的字段数据
Dictionary<string, string[]> namedic = new Dictionary<string, string[]>();
// 字段类型信息map
// Key为表格名字
// Value为对应表格的字段类型数据
Dictionary<string, string[]> typedic = new Dictionary<string, string[]>();
foreach (var excelfile in excelfiles)
{
if(!readAllDataFromExcelFile(excelfile, ref datadic, ref typedic, ref namedic))
{
return;
}
}

// 表格代码映射map
// Key为类名字,Value为对应代码数据
Dictionary<string, string> codemap = new Dictionary<string, string>();
//根据刚才记录的每一张表格数据生成对应代码数据
foreach (var data in datadic)
{
string classname = data.Key;
DataConfigScriptGenerator dcsg = new DataConfigScriptGenerator(classname, typedic[classname], namedic[classname]);
codemap.Add(classname, dcsg.generateCode());
}

//删除前一次所有相关文件
Utilities.recreateSpecificFolder(PathUtilties.ExcelScriptOutputFolderPath);
Utilities.recreateSpecificFolder(PathUtilties.ExcelDataFileOutputFolderPath);
Utilities.recreateSpecificFolder(PathUtilties.ExcelScriptAssemblyFilePath);

//生成dll,Assemble文件用于序列化数据
string[] scripts = new string[codemap.Values.Count];
codemap.Values.CopyTo(scripts, 0);
var assemblyfilepath = PathUtilties.ExcelScriptAssemblyFilePath + PathUtilties.ExcelScriptAssemblyFileName;
Assembly assembly = compileCode(scripts, assemblyfilepath);

if (assembly == null)
{
Debug.LogError("编译dll失败!");
return;
}

//使用Assemble序列化数据
foreach (var data in datadic)
{
object container = assembly.CreateInstance(data.Key + "Container");
Type classtype = assembly.GetType(data.Key);
if (!serialize(container, classtype, data.Value, PathUtilties.ExcelDataFileOutputFolderPath))
{
return;
}
}

// 生成对应Manager加载管理的cs代码文件
createDataManager(assembly, PathUtilties.ExcelScriptAssemblyFilePath);

// 生成对应cs代码文件(如果删除dll,使用cs编译的Assembly-CSharp.dll会导致反序列化出问题。猜测是因为Assemly名字改变后,默认的序列化和反序列化的部分信息(e.g. 比如assembly name)对不上。)
// 所以这里生成的代码仅供查看,释放到Asset上层目录的
foreach (var code in codemap)
{
using (FileStream fs = File.Create(PathUtilties.ExcelScriptOutputFolderPath + code.Key + PathUtilties.ExcelScriptFilePostFix))
{
byte[] info = new UTF8Encoding(true).GetBytes(code.Value);
fs.Write(info, 0, info.Length);
}
}

//删除Assemble文件
//File.Delete(assemblyfilepath);

AssetDatabase.Refresh();
}

/// <summary>
/// 读取excel里所有数据
/// </summary>
/// <param name="excelfile">excel文件</param>
/// <param name="datadic">表格数据</param>]
/// <param name="names">字段数据</param>
/// <param name="typs">类型数据</param>
/// <returns></returns>
private static bool readAllDataFromExcelFile(string excelfile, ref Dictionary<string, List<ConfigData[]>> datadic, ref Dictionary<string, string[]> typesmap, ref Dictionary<string, string[]> namesmap)
{
Debug.Log(string.Format("excelfile: {0}", excelfile));
FileStream fs = File.Open(excelfile, FileMode.Open, FileAccess.Read);
IExcelDataReader excelreader = ExcelReaderFactory.CreateOpenXmlReader(fs);
if (!excelreader.IsValid)
{
Debug.LogError(string.Format("Excel文件:{0}读取失败!", excelfile));
return false;
}
else
{
Debug.Log(string.Format("Excel文件.Name:{0}", excelreader.Name));
var dataset = excelreader.AsDataSet();
if (dataset.Tables.Count > 1)
{
Debug.LogError(string.Format("Excel文件:{0},不允许一个Excel多张Table!", excelreader.Name));
return false;
}
else if (datadic.ContainsKey(excelreader.Name))
{
Debug.LogError(string.Format("有同名的Excel Table存在!同名Excel:{0}!", excelreader.Name));
return false;
}
else
{
int currentlinenumber = 1;
string[] types = null;
string[] names = null;
datadic.Add(excelreader.Name, new List<ConfigData[]>());
while(excelreader.Read())
{
//读取每一行的数据
string[] datas = new string[excelreader.FieldCount];
for (int i = 0; i < excelreader.FieldCount; i++)
{
datas[i] = excelreader.GetString(i);
}

// 字段信息
if (currentlinenumber == NameLineNumber)
{
names = datas;
if(checkRepeatedNameString(names))
{
Debug.LogError(string.Format("Excel Table:{0}", excelreader.Name));
return false;
}
namesmap.Add(excelreader.Name, names);
}
// 字段类型信息
else if(currentlinenumber == TypeLineNumber)
{
types = datas;
if (checkInvalideType(types))
{
Debug.LogError(string.Format("Excel Table:{0}", excelreader.Name));
return false;
}
typesmap.Add(excelreader.Name, types);
}
else if(currentlinenumber >= DataLineNumber)
{
// 记录每一行所有数据的字段名,字段类型,字段数据
ConfigData[] configdatas = new ConfigData[datas.Length];
for(int m = 0; m < datas.Length; m++)
{
ConfigData cd = new ConfigData();
cd.Type = types[m];
cd.Name = names[m];
cd.Data = datas[m];
if(string.IsNullOrEmpty(cd.Type))
{
Debug.LogError(string.Format("数据第{0}行,第{1}列字段类型不能为空!", currentlinenumber, m));
return false;
}
else if(string.IsNullOrEmpty(cd.Name))
{
Debug.LogError(string.Format("数据第{0}行,第{1}列字段名字不能为空!", currentlinenumber, m));
return false;
}
configdatas[m] = cd;
}
datadic[excelreader.Name].Add(configdatas);
}
else
{
Debug.LogError(string.Format("无效的行号:{0}", currentlinenumber));
return false;
}
currentlinenumber++;
}
return true;
}
}
}

/// <summary>
/// 检查是否有重复的字段名
/// </summary>
/// <param name="names"></param>
/// <returns></returns>
private static bool checkRepeatedNameString(string[] names)
{
var tempdic = new Dictionary<string, string>();
foreach (var name in names)
{
if (tempdic.ContainsKey(name))
{
Debug.LogError(string.Format("同名字段:{0}!", name));
return true;
}
else
{
tempdic.Add(name, name);
}
}
return false;
}

/// <summary>
/// 检查是否有无效的字段类型
/// </summary>
/// <param name="types"></param>
/// <returns></returns>
private static bool checkInvalideType(string[] types)
{
foreach(var type in types)
{
if(!ValideTypesList.Contains(type))
{
Debug.LogError(string.Format("无效类型:{0}", type));
return true;
}
}
return false;
}

/// <summary>
/// 编译代码到dll
/// </summary>
/// <param name="scripts"></param>
/// <param name="outputfile"></param>
/// <returns>返回Assembly</returns>
private static Assembly compileCode(string[] scripts, string outputfile)
{
//编译参数
CSharpCodeProvider codeprovider = new CSharpCodeProvider();
CompilerParameters objcompilerparameters = new CompilerParameters();
objcompilerparameters.ReferencedAssemblies.AddRange(new string[] { "System.dll" });
objcompilerparameters.OutputAssembly = outputfile;
objcompilerparameters.GenerateExecutable = false;
objcompilerparameters.GenerateInMemory = true;

//开始编译脚本
CompilerResults cr = codeprovider.CompileAssemblyFromSource(objcompilerparameters, scripts);
if (cr.Errors.HasErrors)
{
Debug.LogError("编译错误:");
foreach (CompilerError err in cr.Errors)
Debug.LogError(err.ErrorText);
return null;
}
return cr.CompiledAssembly;
}

/// <summary>
/// 序列化数据
/// </summary>
/// <param name="container">数据容器对象</param>
/// <param name="type">数据Class类型</param>
/// <param name="datalist">数据列表</param>
/// <param name="outputpath">输出目录</param>
private static bool serialize(object container, Type type, List<ConfigData[]> datalist, string outputpath)
{
FieldInfo dictInfo = container.GetType().GetField("Dict");
object dict = dictInfo.GetValue(container);
//读取每一行数据并填充到实例对象里
foreach (var datas in datalist)
{
object instance = type.Assembly.CreateInstance(type.FullName);
//填充实例对象数据
foreach (var data in datas)
{
FieldInfo fieldinfo = type.GetField(data.Name);
fieldinfo.SetValue(instance, parseValue(data.Type, data.Data));
}
//将实例对象数据添加到Container Map里,后续用于序列化数据
object id = type.GetField("id").GetValue(instance);
bool isExist = (bool)dict.GetType().GetMethod("ContainsKey").Invoke(dict, new object[] { id });
if (isExist)
{
Debug.LogError("Repetitive key " + id + " in " + container.GetType().Name);
return false;
}
dict.GetType().GetMethod("Add").Invoke(dict, new object[] { id, instance });
}

//将数据序列化到本地文件
var bf = new BinaryFormatter();
var s = new FileStream(outputpath + type.Name + ".bytes", FileMode.CreateNew, FileAccess.Write);
bf.Serialize(s, container);
s.Close();
return true;
}

/// <summary>
/// 创建生成反序列化加载接口代码
/// </summary>
/// <param name="assembly"></param>
/// <param name="outputpath">输出路径</param>
/// <returns></returns>
private static bool createDataManager(Assembly assembly, string outputpath)
{
StringBuilder source = new StringBuilder();
source.Append("/*\n");
source.Append("\tAuto create\n");
source.Append("\tDon't Edit it\n");
source.Append("*/\n");
source.Append("\n");

source.Append("using System;\n");
source.Append("using UnityEngine;\n");
source.Append("using System.Runtime.Serialization;\n");
source.Append("using System.Runtime.Serialization.Formatters.Binary;\n");
source.Append("using System.IO;\n\n");
source.Append("[Serializable]\n");
source.Append("public class DataManager : SingletonTemplate<DataManager>\n");
source.Append("{\n");

//定义所有Container的成员变量
var types = assembly.GetTypes();
foreach(var type in types)
{
if(!type.Name.EndsWith(PathUtilties.ExcelTableContainerPostfix))
{
continue;
}
source.Append("\tpublic " + type.Name + " " + type.Name.Remove(0, 2) + ";\n");
}
source.Append("\n");

//加载所有配置表
source.Append("\tpublic void loadAll()\n");
source.Append("\t{\n");
foreach (var type in types)
{
if (!type.Name.EndsWith("Container"))
{
continue;
}
string typeName = type.Name.Remove(type.Name.IndexOf("Container"));
source.Append("\t\t" + type.Name.Remove(0, 2) + " = Load(" + '"' + typeName + '"' + ") as " + type.Name + ";\n");
}
source.Append("\t}\n\n");

//反序列化接口方法
source.Append("\tprivate System.Object Load(string name)\n");
source.Append("\t{\n");
source.Append("\t\tvar bf = new BinaryFormatter();\n");
source.Append("\t\tTextAsset text = Resources.Load<TextAsset>(" + '"' + PathUtilties.ExcelDataFileOutputFolderRelativePath + '"' + " + name);\n");
source.Append("\t\tStream s = new MemoryStream(text.bytes);\n");
source.Append("\t\tSystem.Object obj = bf.Deserialize(s);\n");
source.Append("\t\ts.Close();\n");
source.Append("\t\treturn obj;\n");
source.Append("\t}\n");

// 数据加载接口
foreach (var type in types)
{
if (type.Name.EndsWith("Container"))
{
continue;
}
var classname = type.Name;
source.Append("\t\n");
source.Append("\tpublic " + classname + " get" + classname.Substring(2) + "Data(int id)\n");
source.Append("\t{\n");
source.Append("\t\t" + classname + " data = null;\n");
source.Append("\t\t" + classname.Substring(2) + "Container.getMap().TryGetValue(id, out data);\n");
source.Append("\t\tif(data == null)\n");
source.Append("\t\t{\n");
source.Append("\t\t\tDebug.LogError(string.Format(\"表格:{ 0}找不到id: { 1}的数据!\", data.GetType().ToString(), id));\n");
source.Append("\t\t}\n");
source.Append("\t\treturn data;\n");
source.Append("\t}\n");
}

source.Append("}\n");

//保存脚本
StreamWriter sw = new StreamWriter(outputpath + PathUtilties.ExcelScriptDataManagerFileName);
sw.WriteLine(source.ToString());
sw.Close();
return true;
}

/// <summary>
/// 根据字符串解析类型(暂时只支持基础类型)
/// </summary>
/// <param name="type"></param>
/// <param name="data"></param>
/// <returns></returns>
private static object parseValue(string type, string data)
{
if (type.Equals("string"))
{
return data;
}
else if(type.Equals("int"))
{
return int.Parse(data);
}
else if(type.Equals("float"))
{
return float.Parse(data);
}
else
{
Debug.LogError(string.Format("类型字符串:{0}不支持该类型数据解析!", type));
return data;
}
}
}

DataConfigScriptGenerator.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

/// <summary>
/// 导表代码生成工具
/// </summary>
public class DataConfigScriptGenerator {

/// <summary>
/// 类名
/// </summary>
public string ClassName
{
get;
private set;
}

/// <summary>
/// 成员字段类型数据
/// </summary>
public string[] TypeDatas
{
get;
private set;
}

/// <summary>
/// 成员字段名字数据
/// </summary>
public string[] TypeNameDatas
{
get;
private set;
}

public DataConfigScriptGenerator(string classname, string[] typedatas, string[] typenamedatas)
{
ClassName = classname;
TypeDatas = typedatas;
TypeNameDatas = typenamedatas;
}

/// <summary>
/// 生成对应表代码外部接口
/// </summary>
/// <returns></returns>
public string generateCode()
{
if(string.IsNullOrEmpty(ClassName) || TypeDatas == null || TypeNameDatas == null)
{
return null;
}
return generateRealCode();
}

/// <summary>
/// 生成表代码
/// </summary>
/// <returns></returns>
private string generateRealCode()
{
//生成类文件头
StringBuilder classsource = new StringBuilder();
classsource.Append("/*\n");
classsource.Append("\tAuto create\n");
classsource.Append("\tDon't Edit it\n");
classsource.Append("*/\n");
classsource.Append("\n");
classsource.Append("using System;\n");
classsource.Append("using System.Reflection;\n");
classsource.Append("using System.Collections.Generic;\n");
classsource.Append("\n");
classsource.Append("[Serializable]\n");
classsource.Append("public class " + ClassName + "\n");
classsource.Append("{\n");

//生成成员声明数据
for(int i = 0; i < TypeDatas.Length; i++)
{
classsource.Append(filedString(TypeDatas[i], TypeNameDatas[i]));
}
classsource.Append("}\n");

//生成Container
classsource.Append("\n");
classsource.Append("[Serializable]\n");
classsource.Append("public class " + ClassName + PathUtilties.ExcelTableContainerPostfix + "\n");
classsource.Append("{\n");
classsource.Append("\tpublic " + "Dictionary<int, " + ClassName + ">" + " Dict" + " = new Dictionary<int, " + ClassName + ">();\n");
classsource.Append("\tpublic " + "Dictionary<int, " + ClassName + ">" + " getMap()\n");
classsource.Append("\t{\n");
classsource.Append("\t\treturn Dict;\n");
classsource.Append("\t}\n");
classsource.Append("}\n");

return classsource.ToString();
}

/// <summary>
/// 成员声明数据
/// </summary>
/// <param name="type">成员类型</param>
/// <param name="field">成员名</param>
/// <returns></returns>
private string filedString(string type, string field)
{
if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(field))
return null;

StringBuilder sbProperty = new StringBuilder();
sbProperty.Append("\tpublic " + type + " " + field + ";\n");
return sbProperty.ToString();
}
}

让我们来看看最终的结果:
game.xlsx
ExcelDataConfig

DataConfigData

t_game.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
Auto create
Don't Edit it
*/

using System;
using System.Reflection;
using System.Collections.Generic;

[Serializable]
public class t_game
{
public int id;
public string author;
public string sex;
public int age;
public float money;
}

[Serializable]
public class t_gameContainer
{
public Dictionary<int, t_game> Dict = new Dictionary<int, t_game>();
public Dictionary<int, t_game> getMap()
{
return Dict;
}
}

DataManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
Auto create
Don't Edit it
*/

using System;
using UnityEngine;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;

[Serializable]
public class DataManager : SingletonTemplate<DataManager>
{
public t_gameContainer gameContainer;

public void loadAll()
{
gameContainer = Load("t_game") as t_gameContainer;
}

private System.Object Load(string name)
{
var bf = new BinaryFormatter();
TextAsset text = Resources.Load<TextAsset>("DataConfig/" + name);
Stream s = new MemoryStream(text.bytes);
System.Object obj = bf.Deserialize(s);
s.Close();
return obj;
}

public t_game getgameData(int id)
{
t_game data = null;
gameContainer.getMap().TryGetValue(id, out data);
if(data == null)
{
Debug.LogError(string.Format("表格:{ 0}找不到id: { 1}的数据!", data.GetType().ToString(), id));
}
return data;
}
}

最后在Unity里测试读取和打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GUILayout.BeginHorizontal();
if (GUILayout.Button("加载所有表格数据", GUILayout.MaxWidth(250.0f), GUILayout.MaxHeight(50.0f)))
{
Debug.Log("加载所有表格数据");
DataManager.Singleton.loadAll();
}
if (GUILayout.Button("打印所有表格数据", GUILayout.MaxWidth(250.0f), GUILayout.MinHeight(50.0f)))
{
Debug.Log("打印所有表格数据");
for (int i = 1; i <= DataManager.Singleton.gameContainer.Dict.Count; i++)
{
var data = DataManager.Singleton.getgameData(i);
Debug.Log(string.Format("data.id : {0}, data.author : {1}, data.age : {2}, data.sex : {3}, data.money : {4}", data.id, data.author, data.age, data.sex, data.money));
}
}
GUILayout.EndHorizontal();

DataConfigResult

就这样我们成功的通过读取表格数据,生成对应代码,序列化数据,然后通过生成的对应表代码反序列化读取出数据就基本完成了。

遇到的问题:
第三步动态编译dll进行序列化反序列化,这一步本来想最终不使用dll而是跟着Unity参与编译的代码来反序列化,但是反序列化出现了问题。

错误信息:
SerializationExeption: could not find type ‘System.Collections.Generic.Dictionary’ **

个人猜测因为序列化反序列化时要求Assemble信息一致,所以如果反序列化使用Unity 编译后的C#无法成功反序列化

网上相关问题:
SerializationException: Could not find type ‘System.Collections.Generic.List’1 in c# untiy3d
How to Serialize Across Assemblies with the BinaryFormatter

但考虑到BinderToType低版本.Net可以用但BinderToName要求.Net 4.0,所以这里放弃了直接生成新的cs表代码通过Unity编译使用的方案,选择依然采用直接编译表代码成dll来使用。
表代码依然生成,但放到Asset外部作为非有效代码存储方便查看

问题
  1. C#反序列化默认是反射实现的,速度会比较慢,可以考虑实现自定义反序列化加快读取速度,参考:Runtime_Serialization
    测试方式:
  • 序列化同一个类型对象500次并反序列化500次
  • 打印记录反序列化的内存和时间开销
    测试代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
private static long mHeapMemorySize;
/// <summary>
/// 显示堆内存使用准备工作(在调用ShowHeapMemoryUsing打印堆内存分配之前调用此方法确保计算显示的堆内存数据申请正确)
/// </summary>
private static void TonyTangShowHeapMemoryUsingPrepration()
{
// 确保得到正确的起始Heap Memory Size
GC.Collect();
mHeapMemorySize = GC.GetTotalMemory(true);
}

/// <summary>
/// 显示堆内存使用情况
/// </summary>
/// <param name="postfix"></param>
private static void TonyTangShowHeapMemoryUsing(string postfix)
{
var newestheapmemorysize = GC.GetTotalMemory(false);
var heapmemoryoffset = newestheapmemorysize - mHeapMemorySize;
Console.WriteLine(string.Format("Heap Memory Size = {0} Pre Memory Size = {1} Memory Offset = {2} -- {3}", newestheapmemorysize, mHeapMemorySize, heapmemoryoffset, postfix));
mHeapMemorySize = newestheapmemorysize;
}

//Note:
//注释掉的部分就是自定义序列化和反序列化的代码部分
[Serializable]
public class Map //: ISerializable, IDeserializationCallback
{
//Special construct(required by ISerializable) to control deserialization
//[SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter = true)]
//protected Map(SerializationInfo info, StreamingContext context)
//{
// m_SiInfo = info;
//}

//[SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter = true)]
//public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
//{
// info.AddValue("mID", mID);
// info.AddValue("mMapName", mMapName);
//}

//void IDeserializationCallback.OnDeserialization(Object sender)
//{
// if (m_SiInfo == null)
// {
// return;
// }

// mID = m_SiInfo.GetInt32("mID");
// mMapName = m_SiInfo.GetString("mMapName");
//}

//private SerializationInfo m_SiInfo;

public Map()
{
mID = 0;
mMapName = "DefaultMap";
}

public int ID
{
get
{
return mID;
}
set
{
mID = value;
}

}
private int mID;

public string MapName
{
get
{
return mMapName;
}
set
{
mMapName = value;
}
}
private string mMapName;
}

//Serialization
string mMapSavePath = "./mapInfo.dat";
for(int id = 0; id < 500; id++)
{
Map map = new Map();
map.ID = id;
map.MapName = id.ToString();
BinaryFormatter bf = new BinaryFormatter();
var mapsavepath = mMapSavePath + id;
if (!File.Exists(mapsavepath))
{
FileStream fsc = File.Create(mapsavepath);
fsc.Close();
}

FileStream fs = File.Open(mapsavepath, FileMode.Open);
bf.Serialize(fs, map);
fs.Close();
}

//Deserialization
ShowHeapMemoryUsingPrepration();
TimeCounter.Singleton.Restart("Deserialization");
Map mDSMap;
BinaryFormatter dsbf = new BinaryFormatter ();
for (int id = 0; id < 500; id++)
{
var mapsavepath = mMapSavePath + id;
if (File.Exists(mapsavepath))
{
FileStream dsfs = File.Open(mapsavepath, FileMode.Open);
mDSMap = (Map)dsbf.Deserialize(dsfs);
dsfs.Close();
}
}
TimeCounter.Singleton.End();
ShowHeapMemoryUsing("Serialization");

自定义序列化反序列化开销:
CustomSerializationPerformance
默认序列化开销:
DefaultSerializationPerformance
Note:
实际测试以后发现,自定义并没有加快反序列化的速度,反而增加了内存开销
从上面的测试可以看出反序列化的速度没有加快,反倒是反序列化的内存开销增加了。
这里的测试结果也和Performance Test - BinaryFormatter vs Protobuf-Net里的结论一致了。但至于为什么反序列化速度没有提升,这里不太明白,理论上避免了反射应该是有所提升才对。希望知道的朋友忘告知。

  1. 序列化的数据仅仅是通过C#自身可以反序列化出来,不支持跨语言(比如服务器端使用Java的话,并不能直接使用同样的序列化数据)。

ProtoBuff

ProtoBuff学习了解

Protocol Buffers是Google发布出来的开源项目。
Protocol Buffers - a language-neutral, platform-neutral, extensible way of serializing structured data for use in communications protocols, data storage, and more.
从官网介绍可以看出Google Protocol Buffers(平台无关,语言无关)是针对序列化数据的存储和传输协议等。
既然都是用于数据存储,那么Protocol buffers相比传统的二进制和XML序列化有什么优势了?
相比XML序列化,Protocol buffers更高效,更灵活,更快,更小,更简单。
更方便简单在于 – PB只需定义一次data的数据结构就能自动生成对应语言的解析该数据结构的代码类。
更高效更小在于 – XML存储的数据量大,XML在解析的时候,空间开销大,内容多速度慢。(XML的好处 – 具有可读性)
更灵活在于 – 只需更新数据结构定义然后编译出对应语言的代码就能无缝衔接以前的版本。
既然Protocol buffers这么好,那么让我们看看他是怎么工作的?

  1. 定义出我们需要序列化的数据结构,保存在.proto文件里。
  2. 运用Protocol buffer编译器编译出我们对应语言的解析该数据结构的代码类。
    首先下载对应语言的Protocol buffer编译器
    “There are two NuGet packages: Google.Protobuf (the support library) and Google.Protobuf.Tools (containing protoc)”
    根据上面写的,C#现在貌似有两个版本支持Protocol Buffer,但原生的Protobuf版本支持的.NET版本很高,所以不适合Unity(Unity是基于Mono的,Mono现在对.NET的支持还停留在.NET 2.0和.NET 2.0 subset)。

—————————–2018/5/6(新增)———————————–
UntiyDotNetVersion
虽然表面上Unity(5.6.4p4)写的.Net 2.0和.Net 2.0 Subnet,其实支持的算是.Net 2.0 + .Net 3.5
详情参考扒一扒.net、.net framework、mono和Unity
Unity2017DotNetVersion
到了Unity 2017,Unity已经支持到.Net 3.5和测试版的.Net 4.6了(支持C# 6语法)
Mono逐渐被IL2CPP取代,MonoDevelop工具在Unity2018开始也正式废弃不再支持。Replacing MonoDevelop-Unity with Visual Studio Community starting in Unity 2018.1
——————————2018/5/6(新增)———————————–

所以通过Google,我找到了以下两个开源项目:
1. Protobuf-csharp-port
2. Protobuf-net
前者在Git上的描述不多,所以这里就以下面引用的话来了解两者之间的区别。
Protobuf-csharp-port is written by Jon Skeet and is a faithful port of Google’s java implementation that uses similar command-line tooling. You create the data definitions in text-based .proto files and use a code generation tool to create the C# classes.
Protobuf-csharp-port是由Jon Skeet编写,利用原生工具去port的版本。在编译创建上都沿用了Protocol buffer的原始方式(通过编译.proto文件生成解析定义的Message数据结构的C#代码类)
Protobuf-net is written by Marc Gravell and will be more familiar to .Net developers. The implementation uses .Net classes and attributes to define data rather than .proto files. Overall, the code resembles existing .Net serializers such as the DataContractSerializer.
Protobuf-net是由Marc Gravell编写,支持.NET的类和attributes去定义数据结构,也支持.proto文件预编译生成对应代码类。可以说是.NET风格的Protobuf。
同时Github写明了支持:
.net 2.0/3.0/3.5/4.0
Mono 2.x
所以这里就决定使用Protobuf-net作为Unity C#序列化数据的存储和协议传输的库来学习。
1. 安装Protobuf-net
VS -> Tools -> Nuget Package Manager -> Package Manager Console
在Package Manager Console窗口输入下列命令就会自动去下载Protobuf-net包并安装了。

1
Install-Package protobuf-net
    也可以直接下载[Down load link of Protobuf-net](https://code.google.com/archive/p/protobuf-net/downloads)
    把Protobuf-net.dll放到Assets/Plugins目录下
    2. 定义可序列化的类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using UnityEngine;
using System.Collections;
using ProtoBuf;

[ProtoContract]
public class PlayerData {
[ProtoMember(1)]
public string SceneName
{
get;
set;
}

[ProtoMember(2)]
public int Scores
{
get;
set;
}

[ProtoMember(3)]
public int SpeedLevel
{
get;
set;
}
}
  1. 通过C#(对应语言)protocol buffer API去读写存储的数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public void Awake()
{
......

mPlayerData = new PlayerData();

mProfilePath = Application.persistentDataPath + "/PlayerProile.bin";
Debug.Log("mProfilePath = " + mProfilePath);
}

public void SavePlayerData()
{
//Do serialization for player data
FileStream profile;
if(File.Exists(mProfilePath))
{
profile = File.Open(mProfilePath, FileMode.Open);
Serializer.Serialize(profile, mPlayerData);
}
else
{
profile = File.Create(mProfilePath);
Serializer.Serialize(profile, mPlayerData);
}
}

private void LoadPlayerData()
{
FileStream profile;
if(File.Exists(mProfilePath))
{
profile = File.OpenRead(mProfilePath);
mPlayerData = Serializer.Deserialize<PlayerData>(profile);
}
else
{
mPlayerData = new PlayerData();
mPlayerData.SceneName = Application.productName;
mPlayerData.Scores = 0;
mPlayerData.SpeedLevel = 1;
SavePlayerData();
}
}

但只是这样的话,会报如下错误:
NotSupportException: …… System.Reflection.Emit is not supported
这里出现了Emit类的使用,显然是用到了动态生成代码,根据我们之前讲到的IOS是在Full-AOT模式下运行不允许动态生成代码的。
所以直接使用.NET风格的Attribute会触发动态代码生成,那么我们就需要想办法提前生成,这里就需要使用.proto文件然后通过预编译的方式生成对应的代码类。
所以现在我们需要先定义.proto文件(Protobuf-net只支持Proto2),所以编写proto2参考

1
2
3
4
5
6
7
8
9
10
//PlayerData.proto
syntax = "proto2";

package NGUI2DGame;

message PlayerData{
optional string SceneName = 1;
optional int32 Scores = 2;
optional int32 SpeedLevel = 3;
}

然后通过Protogen.exe帮我们生成对应解析该数据结构所需要的代码类

1
protogen -i:PlayerData.proto -o:PlayerData.cs -ns:NGUI2DGame

-i是指明输入,这里可以通过-i指定多个输入proto文件。-o是指定输出文件,最终生成的对应信息的代码类。-ns是指定namespace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//PlayerData.cs
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

// Generated from: PlayerData.proto
namespace NGUI2DGame
{
[global::System.Serializable, global::ProtoBuf.ProtoContract(Name=@"PlayerData")]
public partial class PlayerData : global::ProtoBuf.IExtensible
{
public PlayerData() {}

private string _SceneName = "";
[global::ProtoBuf.ProtoMember(1, IsRequired = false, Name=@"SceneName", DataFormat = global::ProtoBuf.DataFormat.Default)]
[global::System.ComponentModel.DefaultValue("")]
public string SceneName
{
get { return _SceneName; }
set { _SceneName = value; }
}
private int _Scores = default(int);
[global::ProtoBuf.ProtoMember(2, IsRequired = false, Name=@"Scores", DataFormat = global::ProtoBuf.DataFormat.TwosComplement)]
[global::System.ComponentModel.DefaultValue(default(int))]
public int Scores
{
get { return _Scores; }
set { _Scores = value; }
}
private int _SpeedLevel = default(int);
[global::ProtoBuf.ProtoMember(3, IsRequired = false, Name=@"SpeedLevel", DataFormat = global::ProtoBuf.DataFormat.TwosComplement)]
[global::System.ComponentModel.DefaultValue(default(int))]
public int SpeedLevel
{
get { return _SpeedLevel; }
set { _SpeedLevel = value; }
}
private global::ProtoBuf.IExtension extensionObject;
global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing)
{ return global::ProtoBuf.Extensible.GetExtensionObject(ref extensionObject, createIfMissing); }
}
}

接下来把我们生成的代码通过VS编译成DLL

  1. 新建C# Library工程
  2. 删掉原始cs添加刚才生成的PlayerData.cs
  3. 添加Protobuf-net库引用(用CoreOnly/ios/protobuf-net.dll)
  4. 编译出dll
    最后我们需要通过上一步生成的信息类dll来编译出专门序列化该信息的类库。(添加precompile.exe的路径到环境变量确保找得到,这里要注意的是dll项目的.NET target设置成2.0,因为Unity Mono现在只支持到2.0)
    在之前生成的PlayerData.dll目录下执行下列命令:
1
precompile PlayerData.dll -o:ProtobufSerializer.dll -t:com.TonyTang.ProtobufSerializer

-o指明输出文件 -t指明生成的序列化类的类名
最后利用生成的PlayerData.dll和ProtobufSerializer.dll到Unity里进行测试,编写对应代码即可,写法和之前的区别就是用我们生成好的类来完成序列化和反序列化。

1
2
3
4
5
6
7
//Serialization
ProtobufSerializer serializer = new ProtobufSerializer();
profile = File.Open(mProfilePath, FileMode.Open);
serializer.Serialize(profile, mPlayerData);
//Deserialization
profile = File.OpenRead(mProfilePath);
mPlayerData = serializer.Deserialize(profile,null,typeof(PlayerData)) as PlayerData;

因为需要编写.proto文件后编译生成对应的Serialization类的dll,所以最好通过shell脚本写成自动化。
还有一种方式是使用Probobuf-net源码的形式(貌似需要设定-unsafe和.NET 2 subset),这里我没有尝试,下面给出链接:
在ios android设备上使用 Protobuf (使用源码方式)

了解了什么(What)是Protobuf,如何(How)使用Protobuf,那么我们也应该大概了解下为什么(Why)Protobuf高效,更小,更快,更简单…..
参考下面两篇文章:
Google Protocol Buffer 的使用和原理
官网讲到的Protocol buffer的Encode技术
从上面可以看出,更小更快是因为Protocol buffer采用了巧妙的Encoding方法。
而这个Encoding方法利用了Varint技术,那么什么是Varint了?
“Varints are a method of serializing integers using one or more bytes. Smaller numbers take a smaller number of bytes. “
Varints通过更少的字节来表达准确的数据,比如0-127只需要1个字节就能表示。
当需要多个字节表达的时候,通过利用1byte里的first bit来区分,如果first bit是1表示后续的7个bit都表示数据,如果first bit是0表示这是最后一个字节用于表示数据。
同时Protocol buffer在Encode的时候通过key和value来存储数据。key只包含filed的名字和类型,value包含数据。
类型数据映射
ProtocolBufferMessageValueType
当Decode的时候,如果遇到不识别的key值,只需要skip即可,保证了程序的老版本兼容。
更方便在于,我们只需通过编写简单易懂的.proto文件然后通过ProtoGen,Precompile工具就能生成对应的Message,Encode和Decode类。
结合前面的事例,让我们窥探一下到底Protocol-net是如何实现编写.proto文件结合编译生成Message类和序列化类来实现数据的Encode和Decode的。
首先反编译的ProtovufSerializer.dll,查看Serialize方法
ProtobufSerializerSerialize
可以看到ProtobufSerializer调用了Write方法,接下来查看Write方法
ProtobufSerializerWrite
可以看到我们ProtobufSerializer实际是通过调用ProtoBuf.ProtoWriter::WriteFieldHeader(Protobuf-net库)实现数据的Encode的。同时因为ProtobufSerializer是根据PlayerData.dll编译而成,所以Write方法里的实现针对每一个需要Encode的Message成员进行数据Encode写入。
这样一来就完成了通过编写.proto文件定义Message,然后生成对应的Message类和序列化类再结合Protocol-net库实现了自定义Message的Encode和Decode了,而最终数据的Encode,Decode实现就落实到Protocol-net的实现了。

那么为什么要用Protocol buffer了?
参考文章:
使用 Protocol Buffers 代替 JSON 的五个原因
Protocol buffer因为是存储在二进制文件,相比XML,Jason等技术不具可读性。

Note:
最初的Protocol Buffers只开发了针对Java,C++,Python的版本。后来开园后有了C#,Go等语言的支持。
Protocol Buffer存储的数据是以二进制的形式。
Protocol-net只支持proto2的语言编写规范

从之前的学习可以知道的是,ProtoBuf通过自定义了Encode和Decode,使用Varint技术,把数据成功压缩更小,实现了更小的数据量存储。

——————————-2018/04/22———————————–
关于ProtoBuf原理深度解析,这里找到一篇好文:Protocol Buffer 序列化原理大揭秘 - 为什么Protocol Buffer性能这么好?——————————-2018/04/22———————————–

前面提到的是Protocol Buffer 2的使用,那么我们能否集成使用Protocol Buffer 3吗?
答案是可以的,接下来我们学习Protocol Bufffer 3的集成使用。

Protocol Buffer 3 in Unity

Protocok Buffer(高效的跨语言序列化反序列化数据以及数据结构向后扩展兼容)

问题:

  1. PB3对.Net版本的要求?
    在前面的学习中,我们使用了Protobuf-net版本(支持.Net 2.0,3.0,4.0,但只支持PB2),这里是学习了解PB3是否能在Unity中使用起来。

Probuf官方Git链接:
protobuf
Probuf最新压缩包链接:
protobuf latest
从Git上下载的最新PB3要求.Net4.5,这是从Git上Protobuf CS源代码工程的Google.Protobuf.csproj文件里看出来的:
Protobuf3DotNetTargetNumber

  1. Unity支持的.Net版本?
    Unity2017DotNetVersion
    到了Unity 2017,Unity已经支持到.Net 3.5和测试版的.Net 4.6了(支持C# 6语法)

  2. Unity支持的PB版本?
    通过前面两个问题可以知道,Unity要想支持PB3,我们需要把PB3编译成基于.Net 3.5(因为.Net 4.6还处于测试阶段,所以没选择.Net 4.6)来使用。看网上都有兼容PB3的版本了,理论上Unity是有办法支持PB3的。
    兼容PB3的版本链接:
    protobuf3-for-unity

  3. Python对应需要的PB版本?
    选择和前面CS版本相同版本的Python版本的PB3即可(PB3 3.6.1)

  4. Protoc的选择?
    直接下载编译好的对应版本的Protoc(3.6.1)压缩包即可

流程:
Protobuf CSharp篇:

  1. 下载新版Protobuf Csharp版
  2. 自己编译基于.Net 3.5版本Protobuf Csharp版dll(我的理解这一步的dll是Protobuf核心的模块,用于支持对数据编码存储)
    打开下载的Protobuf Csharp源码里的Google.Protobuf.sln,修改里面Google.Protobuf.csproj工程的TargetFrameworks编译生成我们想要的基于.Net 3.5的Csharp版Protobuf-Csharp.dll
    CompileProtobufCsharpForDoNet35
    DoNet35ProtobufCsharpDll
    复制基于.Net 3.Google.Protobuf.dll到我们的Unity脚本目录下。

Note:

  • Protobuf 3.6.1的Google.Protobuf.sln工程需要VS2017才能正确打开,VS2015本人打开显示加载.csproj的C#项目都失败了
  1. 使用Protoc.exe对*.proto文件进行解析生成对应的代码(我的理解,这一步是为了支持指定定义的数据格式的序列化和反序列化相关代码)
    第一步需要添加Protoc.exe到环境变量里,方便快速访问调用Protoc.exe(这个就不细说了)
    编写测试GameConfig.proto文件:
1
2
3
4
5
6
7
8
9
10
11
// 使用proto3
syntax = "proto3";
// 指明namespace
package GameData;

// 游戏配置信息
message GameConfig {
string difficultyLevel = 1; // 游戏难度
int32 versionNumber = 2; // 游戏版本号
int32 resourceNumber = 3; // 游戏资源版本号
}

使用protoc.exe生成用于序列化反序列化GameConfig的代码:

1
protoc.exe --csharp_out=CsharpCode -I=Proto GameConfig.proto

–csharp_out指明CS脚本输出目录
-I指明Proto文件查找目录
GameConfig.proto表示protoc.exe编译该文件
最终得到我们Csharp序列化和反序列化GameConfig所需的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
// <auto-generated>
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: GameConfig.proto
// </auto-generated>
#pragma warning disable 1591, 0612, 3021
#region Designer generated code

using pb = global::Google.Protobuf;
using pbc = global::Google.Protobuf.Collections;
using pbr = global::Google.Protobuf.Reflection;
using scg = global::System.Collections.Generic;
namespace GameData {

/// <summary>Holder for reflection information generated from GameConfig.proto</summary>
public static partial class GameConfigReflection {

#region Descriptor
/// <summary>File descriptor for GameConfig.proto</summary>
public static pbr::FileDescriptor Descriptor {
get { return descriptor; }
}
private static pbr::FileDescriptor descriptor;

static GameConfigReflection() {
byte[] descriptorData = global::System.Convert.FromBase64String(
string.Concat(
"ChBHYW1lQ29uZmlnLnByb3RvEghHYW1lRGF0YSJUCgpHYW1lQ29uZmlnEhcK",
"D2RpZmZpY3VsdHlMZXZlbBgBIAEoCRIVCg12ZXJzaW9uTnVtYmVyGAIgASgF",
"EhYKDnJlc291cmNlTnVtYmVyGAMgASgFYgZwcm90bzM="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { },
new pbr::GeneratedClrTypeInfo(null, new pbr::GeneratedClrTypeInfo[] {
new pbr::GeneratedClrTypeInfo(typeof(global::GameData.GameConfig), global::GameData.GameConfig.Parser, new[]{ "DifficultyLevel", "VersionNumber", "ResourceNumber" }, null, null, null)
}));
}
#endregion

}
#region Messages
/// <summary>
/// 游戏配置信息
/// </summary>
public sealed partial class GameConfig : pb::IMessage<GameConfig> {
private static readonly pb::MessageParser<GameConfig> _parser = new pb::MessageParser<GameConfig>(() => new GameConfig());
private pb::UnknownFieldSet _unknownFields;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public static pb::MessageParser<GameConfig> Parser { get { return _parser; } }

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public static pbr::MessageDescriptor Descriptor {
get { return global::GameData.GameConfigReflection.Descriptor.MessageTypes[0]; }
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
pbr::MessageDescriptor pb::IMessage.Descriptor {
get { return Descriptor; }
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public GameConfig() {
OnConstruction();
}

partial void OnConstruction();

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public GameConfig(GameConfig other) : this() {
difficultyLevel_ = other.difficultyLevel_;
versionNumber_ = other.versionNumber_;
resourceNumber_ = other.resourceNumber_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public GameConfig Clone() {
return new GameConfig(this);
}

/// <summary>Field number for the "difficultyLevel" field.</summary>
public const int DifficultyLevelFieldNumber = 1;
private string difficultyLevel_ = "";
/// <summary>
/// 游戏难度
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public string DifficultyLevel {
get { return difficultyLevel_; }
set {
difficultyLevel_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}

/// <summary>Field number for the "versionNumber" field.</summary>
public const int VersionNumberFieldNumber = 2;
private int versionNumber_;
/// <summary>
/// 游戏版本号
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public int VersionNumber {
get { return versionNumber_; }
set {
versionNumber_ = value;
}
}

/// <summary>Field number for the "resourceNumber" field.</summary>
public const int ResourceNumberFieldNumber = 3;
private int resourceNumber_;
/// <summary>
/// 游戏资源版本号
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public int ResourceNumber {
get { return resourceNumber_; }
set {
resourceNumber_ = value;
}
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public override bool Equals(object other) {
return Equals(other as GameConfig);
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public bool Equals(GameConfig other) {
if (ReferenceEquals(other, null)) {
return false;
}
if (ReferenceEquals(other, this)) {
return true;
}
if (DifficultyLevel != other.DifficultyLevel) return false;
if (VersionNumber != other.VersionNumber) return false;
if (ResourceNumber != other.ResourceNumber) return false;
return Equals(_unknownFields, other._unknownFields);
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public override int GetHashCode() {
int hash = 1;
if (DifficultyLevel.Length != 0) hash ^= DifficultyLevel.GetHashCode();
if (VersionNumber != 0) hash ^= VersionNumber.GetHashCode();
if (ResourceNumber != 0) hash ^= ResourceNumber.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
return hash;
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public override string ToString() {
return pb::JsonFormatter.ToDiagnosticString(this);
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public void WriteTo(pb::CodedOutputStream output) {
if (DifficultyLevel.Length != 0) {
output.WriteRawTag(10);
output.WriteString(DifficultyLevel);
}
if (VersionNumber != 0) {
output.WriteRawTag(16);
output.WriteInt32(VersionNumber);
}
if (ResourceNumber != 0) {
output.WriteRawTag(24);
output.WriteInt32(ResourceNumber);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public int CalculateSize() {
int size = 0;
if (DifficultyLevel.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(DifficultyLevel);
}
if (VersionNumber != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(VersionNumber);
}
if (ResourceNumber != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(ResourceNumber);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
return size;
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public void MergeFrom(GameConfig other) {
if (other == null) {
return;
}
if (other.DifficultyLevel.Length != 0) {
DifficultyLevel = other.DifficultyLevel;
}
if (other.VersionNumber != 0) {
VersionNumber = other.VersionNumber;
}
if (other.ResourceNumber != 0) {
ResourceNumber = other.ResourceNumber;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}

[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
public void MergeFrom(pb::CodedInputStream input) {
uint tag;
while ((tag = input.ReadTag()) != 0) {
switch(tag) {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
break;
case 10: {
DifficultyLevel = input.ReadString();
break;
}
case 16: {
VersionNumber = input.ReadInt32();
break;
}
case 24: {
ResourceNumber = input.ReadInt32();
break;
}
}
}
}

}

#endregion

}

#endregion Designer generated code
  1. 使用生成的代码进行序列化和反序列化数据测试
    UIDebugMonoScript.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/*
* Description: UIDebugMonoScript.cs
* Author: TONYTANG
* Create Date: 2018/08/19
*/

using GameData;
using Google.Protobuf;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// UIDebugMonoScript.cs
/// 快速访问UI组件测试挂在脚本
/// </summary>
public class UIDebugMonoScript : MonoBehaviour {

/// <summary>
/// 创建GameConfig按钮
/// </summary>
public Button mBtnCreateGameConfig;

/// <summary>
/// 读取GameConfig按钮
/// </summary>
public Button mBtnReadGameConfig;

private void Start()
{
mBtnCreateGameConfig.onClick.AddListener(onCreateGameConfigClick);
mBtnReadGameConfig.onClick.AddListener(onReadGameConfigClick);
}

private void OnDestroy()
{
mBtnCreateGameConfig.onClick.RemoveAllListeners();
mBtnReadGameConfig.onClick.RemoveAllListeners();
}

/// <summary>
/// 创建GameConfig按钮点击
/// </summary>
private void onCreateGameConfigClick()
{
Debug.Log("onCreateGameConfigClick()");
Debug.Log("PathUtilities.ProtobufDataFolderPath : " + PathUtilities.ProtobufDataFolderPath);
PathUtilities.checkAndCreateFolder(PathUtilities.ProtobufDataFolderPath);
GameConfig gameconfig = new GameConfig();
gameconfig.DifficultyLevel = "Easy";
gameconfig.VersionNumber = 1;
gameconfig.ResourceNumber = 1;
using (var output = File.Create(PathUtilities.ProtobufDataFolderPath + "GameConfig.data"))
{
gameconfig.WriteTo(output);
}
}

/// <summary>
/// 读取GameConfig按钮点击
/// </summary>
private void onReadGameConfigClick()
{
Debug.Log("onReadGameConfigClick()");
Debug.Log("PathUtilities.ProtobufDataFolderPath : " + PathUtilities.ProtobufDataFolderPath);
GameConfig gameconfig;
using (var intput = File.OpenRead(PathUtilities.ProtobufDataFolderPath + "GameConfig.data"))
{
gameconfig = GameConfig.Parser.ParseFrom(intput);
}
Debug.Log("gameconfig.DifficultyLevel : " + gameconfig.DifficultyLevel);
Debug.Log("gameconfig.VersionNumber : " + gameconfig.VersionNumber);
Debug.Log("gameconfig.ResourceNumber : " + gameconfig.ResourceNumber);
}
}

PathUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/*
* Description: 路径静态工具类
* Author: tanghuan
* Create Date: 2018/03/12
*/

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

/// <summary>
/// 路径静态工具类
/// </summary>
public static class PathUtilities
{
//******

#region Protobuf
#if UNITY_EDITOR
/// <summary>
/// Protobuf生成数据目录
/// </summary>
public static string ProtobufDataFolderPath = Application.dataPath + "/Resources/ProtoData/";
#elif UNITY_ANDROID
public static string ProtobufDataFolderPath = Application.temporaryCachePath + "/ProtoData/";
#elif UNITY_IOS
public static string ProtobufDataFolderPath = Application.temporaryCachePath + "/ProtoData/";
#endif
#endregion

#region 公用方法
/// <summary>
/// 检查并确保目录存在
/// </summary>
/// <param name="folderpath"></param>
public static void checkAndCreateFolder(string folderpath)
{
if(!Directory.Exists(folderpath))
{
Directory.CreateDirectory(folderpath);
}
}
#endregion
}

PC:
ProtobufSerialization
Android:
ProtobufSerializationAndroidPlatform
可以看到我们在PC和Android都成功使用Protobuf3进行了数据的序列化和反序列化。
接下来我们要完成的是测试同样的二进制序列化文件,通过Python版的Protobuf3是否能够正确的反序列化读取(验证跨语言数据存储读取)。

Protobuf Python篇:

  1. 安装python
  2. 下载Protoc.exe用于安装生成python版proto文件对应的解析所需文件
  3. 下载对应版本的Protobuf 3 Python版本(这里我下的和Csharp版本的Protobuf3一样的版本3.6.1)
  4. 安装Protobuf 3 Python所需环境(在下载的Protobuf3 Python的Python原代码下运行python setup.py install)
1
python setup.py install

安装完成后会发现Protobuf 3 For Python相关的Package环境都被下载到了site-packages目录:
Protobuf3ForPythonSetup

  1. 编译Proto文件生成解析所需文件
    依然使用protoc.exe,但这一次指定的输出代码是针对python的:
1
protoc.exe --python_out=PythonCode -I=Proto GameConfig.proto

GameConfig_pb2.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: GameConfig.proto

import sys
_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor.FileDescriptor(
name='GameConfig.proto',
package='GameData',
syntax='proto3',
serialized_options=None,
serialized_pb=_b('\n\x10GameConfig.proto\x12\x08GameData\"T\n\nGameConfig\x12\x17\n\x0f\x64ifficultyLevel\x18\x01 \x01(\t\x12\x15\n\rversionNumber\x18\x02 \x01(\x05\x12\x16\n\x0eresourceNumber\x18\x03 \x01(\x05\x62\x06proto3')
)




_GAMECONFIG = _descriptor.Descriptor(
name='GameConfig',
full_name='GameData.GameConfig',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='difficultyLevel', full_name='GameData.GameConfig.difficultyLevel', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=_b("").decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='versionNumber', full_name='GameData.GameConfig.versionNumber', index=1,
number=2, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
_descriptor.FieldDescriptor(
name='resourceNumber', full_name='GameData.GameConfig.resourceNumber', index=2,
number=3, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=30,
serialized_end=114,
)

DESCRIPTOR.message_types_by_name['GameConfig'] = _GAMECONFIG
_sym_db.RegisterFileDescriptor(DESCRIPTOR)

GameConfig = _reflection.GeneratedProtocolMessageType('GameConfig', (_message.Message,), dict(
DESCRIPTOR = _GAMECONFIG,
__module__ = 'GameConfig_pb2'
# @@protoc_insertion_point(class_scope:GameData.GameConfig)
))
_sym_db.RegisterMessage(GameConfig)


# @@protoc_insertion_point(module_scope)
  1. 利用Python的Proto代码序列化(或者反序列化C#序列化的)数据
    反序列化C#序列化的数据事例:
    GameConfigSerialization.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/python
# -*- coding: utf-8 -*-

# File Name: GameConfigSerialization.py
# Description: This file is used to Serialize GameConfig Data
# Author: TangHuan
# Create Date: 2018/08/20

import GameConfig_pb2
import sys

gameconfig = GameConfig_pb2.GameConfig()

# Read the existing GameConfig.data
f = open("GameConfig.data", "rb")
gameconfig.ParseFromString(f.read())
f.close()

print("gameconfig.difficultyLevel = {}".format(gameconfig.difficultyLevel))
print("gameconfig.versionNumber = {}".format(gameconfig.versionNumber))
print("gameconfig.resourceNumber = {}".format(gameconfig.resourceNumber))

PythonPB3DeserilizationFileStructure
PythonPB3Deserialization
可以看到我们成功的反序列化了C#序列化的数据(*证明了PB3跨语言的序列化反序列化支持)

Python PB3序列化数据事例:
GameConfigSerialization.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python
# -*- coding: utf-8 -*-

# File Name: GameConfigSerialization.py
# Description: This file is used to Serialize GameConfig Data
# Author: TangHuan
# Create Date: 2018/08/20

import GameConfig_pb2
import sys

gameconfig = GameConfig_pb2.GameConfig()
gameconfig.difficultyLevel = "Hard"
gameconfig.versionNumber = 2
gameconfig.resourceNumber = 2

# Write the new GmaeConfig back to disk.
f = open("GameConfig_Py.data", "wb")
f.write(gameconfig.SerializeToString())
f.close()

CSharp PB3反序列化:

1
2
3
4
5
6
7
8
GameConfig gameconfig;
using (var intput = File.OpenRead(PathUtilities.ProtobufDataFolderPath + "GameConfig_Py.data"))
{
gameconfig = GameConfig.Parser.ParseFrom(intput);
}
Debug.Log("gameconfig.DifficultyLevel : " + gameconfig.DifficultyLevel);
Debug.Log("gameconfig.VersionNumber : " + gameconfig.VersionNumber);
Debug.Log("gameconfig.ResourceNumber : " + gameconfig.ResourceNumber);

CSharpPB3DeserilizationFileStructure
CSharpPB3Deserialization
可以看到我们也成功的通过C#的PB3反序列化了Python序列化的数据。这也验证了Protocol Buffer 3跨语言的特性

Note:
Protocol Buffer很适合跨平台跨语言通信,用于网络通信定义数据结构。(待学习)

FlatBuff

在介绍FlatBuff前,让我们来看一张令人惊艳的各序列化方案测试性能对比图:
图1来源至XBuffer的性能测试:
SerializationPerformanceComparision

图2来源至FlatBuff官方性能Benchmark:
FlatBuffPerformanceBenchmark

从上面两张图可以看到,令人惊讶的FlatBuff性能,无论是序列化还是反序列化,FlatBuff都相当高效,反序列化的时间居然几乎为0。

接下来我们深入学习了解FlatBuff,洞察其中的奥妙。

介绍

官网FlatBuffer
Represents hierachical data in a flat binary buffer in such a way that it can still be acecssed directly without parsing/unpacking, while also still supporting data structure evolution(forwards/backwards compatibility)
根据官方的介绍,FlatBuff把数据存储在一个扁平化的二进制Buffer里,这样一来FlatBuff访问和解析数据时可以向访问数据结构一样快速不需要解析或者解包(这应该就是为什么FlatBuff的反序列化时间几乎为0的原因)

这里简单列举下官方给出的FlatBuffer的好处:

  1. Access to serialized data without parsing/unpacking(访问序列化数据块)
  2. Memory efficiency and speed(内存和速度都很高效)
  3. Flexible(向前向后兼容)
  4. Tiny code footprint(生成代码量少且无依赖)
  5. Strongly typed
  6. Convenient to use(使用简单?)
  7. Cross platform code with no dependencies(跨平台无依赖)

详情参考官网介绍

实战使用

接下来先按教程学习使用一波,再来看看底层的实现和优缺点:
使用步骤:

  1. 编写schema文件(定义数据结构)
    GameConfigFB.fbs
1
2
3
4
5
6
7
8
9
namespace GameData;

table GameConfigFB{
difficultyLevel:string; // 游戏难度
versionNumber:int; // 游戏版本号
resourceNumber:int; // 游戏资源版本号
}

root_type GameConfigFB;

schema文件编写参考

  1. 使用Flatc.exe(可以自己编译源码,也可以下载现成的)编译解析schema文件生成序列化和反序列化数据所需代码
1
./../flatc.exe --csharp .\GameConifgFB.fbs

GameConfigFB.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// <auto-generated>
// automatically generated by the FlatBuffers compiler, do not modify
// </auto-generated>

namespace GameData
{

using global::System;
using global::FlatBuffers;

public struct GameConfigFB : IFlatbufferObject
{
private Table __p;
public ByteBuffer ByteBuffer { get { return __p.bb; } }
public static GameConfigFB GetRootAsGameConfigFB(ByteBuffer _bb) { return GetRootAsGameConfigFB(_bb, new GameConfigFB()); }
public static GameConfigFB GetRootAsGameConfigFB(ByteBuffer _bb, GameConfigFB obj) { return (obj.__assign(_bb.GetInt(_bb.Position) + _bb.Position, _bb)); }
public void __init(int _i, ByteBuffer _bb) { __p.bb_pos = _i; __p.bb = _bb; }
public GameConfigFB __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; }

public string DifficultyLevel { get { int o = __p.__offset(4); return o != 0 ? __p.__string(o + __p.bb_pos) : null; } }
public ArraySegment<byte>? GetDifficultyLevelBytes() { return __p.__vector_as_arraysegment(4); }
public int VersionNumber { get { int o = __p.__offset(6); return o != 0 ? __p.bb.GetInt(o + __p.bb_pos) : (int)0; } }
public int ResourceNumber { get { int o = __p.__offset(8); return o != 0 ? __p.bb.GetInt(o + __p.bb_pos) : (int)0; } }

public static Offset<GameConfigFB> CreateGameConfigFB(FlatBufferBuilder builder,
StringOffset difficultyLevelOffset = default(StringOffset),
int versionNumber = 0,
int resourceNumber = 0) {
builder.StartObject(3);
GameConfigFB.AddResourceNumber(builder, resourceNumber);
GameConfigFB.AddVersionNumber(builder, versionNumber);
GameConfigFB.AddDifficultyLevel(builder, difficultyLevelOffset);
return GameConfigFB.EndGameConfigFB(builder);
}

public static void StartGameConfigFB(FlatBufferBuilder builder) { builder.StartObject(3); }
public static void AddDifficultyLevel(FlatBufferBuilder builder, StringOffset difficultyLevelOffset) { builder.AddOffset(0, difficultyLevelOffset.Value, 0); }
public static void AddVersionNumber(FlatBufferBuilder builder, int versionNumber) { builder.AddInt(1, versionNumber, 0); }
public static void AddResourceNumber(FlatBufferBuilder builder, int resourceNumber) { builder.AddInt(2, resourceNumber, 0); }
public static Offset<GameConfigFB> EndGameConfigFB(FlatBufferBuilder builder) {
int o = builder.EndObject();
return new Offset<GameConfigFB>(o);
}
public static void FinishGameConfigFBBuffer(FlatBufferBuilder builder, Offset<GameConfigFB> offset) { builder.Finish(offset.Value); }
public static void FinishSizePrefixedGameConfigFBBuffer(FlatBufferBuilder builder, Offset<GameConfigFB> offset) { builder.FinishSizePrefixed(offset.Value); }
};


}

  1. 使用FlatBufferBuilder(位于FlatBuff源码的/net/FlatBuffers目录下)根据schema生成代码序列化数据到二进制文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using FlatBuffers;
using GameData;

namespace FlatBufferStudy
{
class Program
{
/// <summary>
/// GameConfig数据全路径
/// </summary>
public const string GameConfigDataFullPath = "./GameConfig.data";

/// <summary>
/// 存储GameConfig数据
/// </summary>
public static void SaveGameConfigData()
{
// Create a `FlatBufferBuilder`, which will be used to create our
// GameConfigFB' FlatBuffers.
// 定义FlatBufferBulder对象用于我们构建数据对象
var builder = new FlatBufferBuilder(4);
var difficultylevel = builder.CreateString("Hard-困难");

// Use the `CreateGameConfigFB()` helper function to create the GameConfigFB, since we set every field.
// 往GameConfigFB里填充数据
var gameconfig = GameConfigFB.CreateGameConfigFB(builder, difficultylevel, 1, 1);

// Call `Finish()` to instruct the builder that this GameConfigFB is complete.
// 指定GameConfigFB数据构建完成
builder.Finish(gameconfig.Value);

// This must be called after `Finish()`.
// 包含GameConfigFB的二进制数据的数据对象
var buf = builder.DataBuffer;
// GameConfigFB的二进制数据
byte[] bufbytes = builder.SizedByteArray();

//写入GameConfig二进制数据得到本地
using (var filestream = new FileStream(GameConfigDataFullPath, FileMode.Create))
{
filestream.Write(bufbytes, 0, bufbytes.Length);
}
}

/// <summary>
/// 读取GameConfig数据
/// </summary>
public static void ReadGameConfigData()
{
//写入GameConfig二进制数据得到本地
using (var filestream = new FileStream(GameConfigDataFullPath, FileMode.Open))
{
byte[] bufbytes = new byte[filestream.Length];
filestream.Read(bufbytes, 0, bufbytes.Length);

// Get an accessor to the root object inside the buffer.
// 通过字节流数据构建FlatBuff所需的Buf对象,然后使用该Buf对象通过FlatBuff构建GameConfigFB对象
var buf = new ByteBuffer(bufbytes);
var gameconfig = GameConfigFB.GetRootAsGameConfigFB(buf);

Console.WriteLine("gameconfig.DifficultyLevel : " + gameconfig.DifficultyLevel);
Console.WriteLine("gameconfig.VersionNumber : " + gameconfig.VersionNumber);
Console.WriteLine("gameconfig.ResourceNumber : " + gameconfig.ResourceNumber);
}
}

static void Main(string[] args)
{
SaveGameConfigData();

ReadGameConfigData();

Console.WriteLine("结束!");

Console.ReadKey();
}
}
}

FlatBuffGameConfigDataOutput
FlatBuffOutput
可以看到我们成功通过FlatBuff序列化反序列化出了我们想要的数据。

通过上面的使用,可以看出FlatBuff在使用灵活度方面还是比较欠缺,对于数据的填充构建都是通过FlatBufferBuilder按指定填充方式构建数据并填充进去。

这里暂时没有对性能进行测试,具体性能对比参考前面给出过的对比图。

Note:
当我把FlatBuffer的.Net代码放进Unity时,我发现里面用到了C# 6.0的高阶语言特性,导致Unity里无法编译通过,所以上面的测试是脱离Unity基于.Net C#工程来测试的。

深入探究

这一步,让我们结合上面的学习用例来探究下FlatBuffer是如何实现高效的序列化和反序列化读取的。

通过学习了解FlatBuffer下面几个核心的类来理解学习:

  1. FlatBufferBuilder.cs(FlatBuffer构建填充数据的对外接口)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// <summary>
/// Responsible for building up and accessing a FlatBuffer formatted byte
/// array (via ByteBuffer).
/// </summary>
public class FlatBufferBuilder
{
private int _space;
private ByteBuffer _bb;
private int _minAlign = 1;

// The vtable for the current table (if _vtableSize >= 0)
private int[] _vtable = new int[16];
// The size of the vtable. -1 indicates no vtable
private int _vtableSize = -1;
// Starting offset of the current struct/table.
private int _objectStart;
// List of offsets of all vtables.
private int[] _vtables = new int[16];
// Number of entries in `vtables` in use.
private int _numVtables = 0;
// For the current vector being built.
private int _vectorNumElems = 0;

/// <summary>
/// Create a FlatBufferBuilder with a given initial size.
/// </summary>
/// <param name="initialSize">
/// The initial size to use for the internal buffer.
/// </param>
public FlatBufferBuilder(int initialSize)
{
if (initialSize <= 0)
throw new ArgumentOutOfRangeException("initialSize",
initialSize, "Must be greater than zero");
_space = initialSize;
_bb = new ByteBuffer(initialSize);
}

public void AddInt(int o, int x, int d)
{
if (ForceDefaults || x != d)
{
AddInt(x); Slot(o);
}
}

******
}
这里只截图了FlatBufferBuilder成员变量定义和部分函数定义。
  1. ByteBuffer.cs(定义不同数据类型的byte数据分配以及填充,读取)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public class ByteBuffer : IDisposable
{
private ByteBufferAllocator _buffer;
private int _pos; // Must track start of the buffer.

public ByteBuffer(ByteBufferAllocator allocator, int position)
{
_buffer = allocator;
_pos = position;
}

public ByteBuffer(int size) : this(new byte[size]) { }

public ByteBuffer(byte[] buffer) : this(buffer, 0) { }

public ByteBuffer(byte[] buffer, int pos)
{
_buffer = new ByteArrayAllocator(buffer);
_pos = pos;
}

public void Dispose()
{
if (_buffer != null)
{
_buffer.Dispose();
}
}

public int Position {
get { return _pos; }
set { _pos = value; }
}

public int Length { get { return _buffer.Length; } }

public void Reset()
{
_pos = 0;
}

// Increases the size of the ByteBuffer, and copies the old data towards
// the end of the new buffer.
public void GrowFront(int newSize)
{
_buffer.GrowFront(newSize);
}

public void PutStringUTF8(int offset, string value)
{
AssertOffsetAndLength(offset, value.Length);
Encoding.UTF8.GetBytes(value, 0, value.Length,
_buffer.ByteArray, offset);
}
#endif

public void PutInt(int offset, int value)
{
PutUint(offset, (uint)value);
}

protected void WriteLittleEndian(int offset, int count, ulong data)
{
if (BitConverter.IsLittleEndian)
{
for (int i = 0; i < count; i++)
{
_buffer.Buffer[offset + i] = (byte)(data >> i * 8);
}
}
else
{
for (int i = 0; i < count; i++)
{
_buffer.Buffer[offset + count - 1 - i] = (byte)(data >> i * 8);
}
}
}

******
}
  1. ByteBufferAllocator.cs(抽象二进制byte数据的分配管理)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public abstract class ByteBufferAllocator : IDisposable
{
#if UNSAFE_BYTEBUFFER
public unsafe byte* Buffer
{
get;
protected set;
}
#else
public byte[] Buffer
{
get;
protected set;
}
#endif

public int Length
{
get;
protected set;
}

public abstract void Dispose();

public abstract void GrowFront(int newSize);

#if !ENABLE_SPAN_T
public abstract byte[] ByteArray { get; }
#endif
}
  1. Table.cs(抽象FlatBuffer自定义结构的数据存储以及读取)
1
2
3
4
5
6
7
8
9
public struct Table
{
public int bb_pos;
public ByteBuffer bb;

public ByteBuffer ByteBuffer { get { return bb; } }

*******
}

数据序列化存储部分:
可以看出我们所有的数据最终都被写入在一个一维的byte数组里,而Flatbuffer通过按指定方式往byte数组里填充数据实现对数据的序列化存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class FlatBufferBuilder
{
private ByteBuffer _bb;

*******
}

public class ByteBuffer : IDisposable
{
private ByteBufferAllocator _buffer;

public void PutStringUTF8(int offset, string value)
{
******
}

public void PutInt(int offset, int value)
{
PutUint(offset, (uint)value);
}

protected void WriteLittleEndian(int offset, int count, ulong data)
{
******
}

******
}

public class ByteArrayAllocator : ByteBufferAllocator
{
private byte[] _buffer;

public void GrowFront(int newSize)
{
_buffer.GrowFront(newSize);
}

******
}

数据反序列化读取部分;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public struct GameConfigFB : IFlatbufferObject
{
private Table __p;

public ByteBuffer ByteBuffer
{
get { return __p.bb; }
}

public static GameConfigFB GetRootAsGameConfigFB(ByteBuffer _bb)
{
return GetRootAsGameConfigFB(_bb, new GameConfigFB());
}

public static GameConfigFB GetRootAsGameConfigFB(ByteBuffer _bb, GameConfigFB obj)
{
return (obj.__assign(_bb.GetInt(_bb.Position) + _bb.Position, _bb));
}

public void __init(int _i, ByteBuffer _bb)
{
__p.bb_pos = _i; __p.bb = _bb;
}

public GameConfigFB __assign(int _i, ByteBuffer _bb)
{
__init(_i, _bb); return this;
}

public string DifficultyLevel
{
get { int o = __p.__offset(4); return o != 0 ? __p.__string(o + __p.bb_pos) : null; }
}


******
}

可以看到当我们通过以下代码读取二进制数据构建我们自定义的GameConfigFB对象时,我们已经把二进制数据按FlatBuffer存储的方式读取进来:

1
2
var buf = new ByteBuffer(bufbytes);
var gameconfig = GameConfigFB.GetRootAsGameConfigFB(buf);

然后通过Table按自定义的数据结构读取指定的偏移把数据成功读取出来(这应该也是为什么FlatBuffer反序列化读取能这么高效的原因):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public struct GameConfigFB : IFlatbufferObject
{
public ArraySegment<byte>? GetDifficultyLevelBytes()
{
return __p.__vector_as_arraysegment(4);
}

public int VersionNumber
{
get { int o = __p.__offset(6); return o != 0 ? __p.bb.GetInt(o + __p.bb_pos) : (int)0; }
}

public int ResourceNumber
{
get { int o = __p.__offset(8); return o != 0 ? __p.bb.GetInt(o + __p.bb_pos) : (int)0; }
}

******
}

这里放一张其他博客上给出的一张FlatBuffer数据存储图解加深印象,下图来源:
FlatBufferBytesStructure

Xbuffer

公司一前同事写的基于C#的简化版的Flatbuffer。
这里重复再放一次前面放过的性能对比图:
SerializationPerformanceComparision
可以看到Xbuffer序列化和反序列化性能也相当优秀。

接下来我们通过实战使用和深入探究来学习其中的奥妙。

实战使用

使用流程:

  1. 定义数据结构文件
    GameConfigXB.xb
1
2
3
4
5
6
7
// 游戏版本信息
class GameConfigXB
{
difficultyLevel:string; // 游戏难度
versionNumber:int; // 游戏版本号
resourceNumber:int; // 游戏资源版本号
}
  1. 使用xbuffer_parser.exe根据模板文件解析数据结构文件生成对应类文件以及对应类Xbuffer序列化反序列化所需的代码
1
2
3
xbuffer_parser.exe input=GameConfigXB.xb template="templates/csharp_class.ftl" output_dir="output/csharp/csharp_class/" suffix=".cs"
xbuffer_parser.exe input=GameConfigXB.xb template="templates/csharp_buffer.ftl" output_dir="output/csharp/csharp_buffer/" suffix="Buffer.cs"
pause

GameConfigXB.cs(对应数据结构文件)

1
2
3
4
5
6
7
8
9
// 游戏版本信息
public partial class GameConfigXB
{
public string difficultyLevel; // 游戏难度

public int versionNumber; // 游戏版本号

public int resourceNumber; // 游戏资源版本号
}

GameConfigXBBuffer.cs(对应数据结构Xbuffer序列化反序列化所需文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
namespace xbuffer
{
public static class GameConfigXBBuffer
{
public static GameConfigXB deserialize(byte[] buffer, ref int offset)
{
// null
bool _null = boolBuffer.deserialize(buffer, ref offset);
if (_null) return null;

// difficultyLevel

string _difficultyLevel = stringBuffer.deserialize(buffer, ref offset);

// versionNumber
int _versionNumber = intBuffer.deserialize(buffer, ref offset);

// resourceNumber
int _resourceNumber = intBuffer.deserialize(buffer, ref offset);

// value
return new GameConfigXB() {
difficultyLevel = _difficultyLevel,

versionNumber = _versionNumber,

resourceNumber = _resourceNumber,
};
}

public static void serialize(GameConfigXB value, XSteam steam)
{
// null
boolBuffer.serialize(value == null, steam);
if (value == null) return;

// difficultyLevel
stringBuffer.serialize(value.difficultyLevel, steam);

// versionNumber
intBuffer.serialize(value.versionNumber, steam);

// resourceNumber
intBuffer.serialize(value.resourceNumber, steam);
}
}
}

上面生成默认的模板生成的GameConfigXBBuffer.cs貌似跟源码测试使用的接口对不上,需要把模板改成如下:
charp_buffer.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
namespace xbuffer
{
public static class #CLASS_NAME#Buffer
{
public static #CLASS_NAME# deserialize(byte[] buffer, ref uint offset)
{
#IF_DESERIALIZE_CLASS#
// null
bool _null = boolBuffer.deserialize(buffer, ref offset);
if (_null) return null;
#END_DESERIALIZE_CLASS#
#DESERIALIZE_PROCESS#
// #VAR_NAME#
#IF_SINGLE#
#VAR_TYPE# _#VAR_NAME# = #VAR_TYPE#Buffer.deserialize(buffer, ref offset);
#END_SINGLE#
#IF_ARRAY#
int _#VAR_NAME#_length = intBuffer.deserialize(buffer, ref offset);
#VAR_TYPE#[] _#VAR_NAME# = new #VAR_TYPE#[_#VAR_NAME#_length];
for (int i = 0; i < _#VAR_NAME#_length; i++)
{
_#VAR_NAME#[i] = #VAR_TYPE#Buffer.deserialize(buffer, ref offset);
}
#END_ARRAY#
#DESERIALIZE_PROCESS#

// value
return new #CLASS_NAME#() {
#DESERIALIZE_RETURN#
#VAR_NAME# = _#VAR_NAME#,
#DESERIALIZE_RETURN#
};
}

public static void serialize(#CLASS_NAME# value, XSteam steam)
{
#IF_SERIALIZE_CLASS#
// null
boolBuffer.serialize(value == null, steam);
if (value == null) return;
#END_SERIALIZE_CLASS#
#SERIALIZE_PROCESS#
// #VAR_NAME#
#IF_SINGLE#
#VAR_TYPE#Buffer.serialize(value.#VAR_NAME#, steam);
#END_SINGLE#
#IF_ARRAY#
intBuffer.serialize(value.#VAR_NAME#.Length, steam);
for (int i = 0; i < value.#VAR_NAME#.Length; i++)
{
#VAR_TYPE#Buffer.serialize(value.#VAR_NAME#[i], steam);
}
#END_ARRAY#
#SERIALIZE_PROCESS#
}
}
}
  1. 把Xbuffer核心代码xbuffer_runtime.dll放到项目目录下。
  2. 使用Xbuffer生成的对应Buffer类序列化和反序列化数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/*
* Description: UIDebugMonoScript.cs
* Author: TONYTANG
* Create Date: 2018/08/19
*/

using GameData;
using Google.Protobuf;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using xbuffer;

/// <summary>
/// UIDebugMonoScript.cs
/// 快速访问UI组件测试挂在脚本
/// </summary>
public class UIDebugMonoScript : MonoBehaviour {

******

/// <summary>
/// Xbuffer内存存储抽象对象
/// </summary>
private XSteam mXbufferStream;

/// <summary>
/// 创建GameConfig按钮点击
/// </summary>
private void onCreateGameConfigClick()
{
Debug.Log("onCreateGameConfigClick()");
Debug.Log("PathUtilities.XbufferDataFolderPath : " + PathUtilities.XbufferDataFolderPath);
PathUtilities.checkAndCreateFolder(PathUtilities.XbufferDataFolderPath);
//固定一个内存分配,把数据往这里面填充,不够的画动态扩展
mXbufferStream = new XSteam(1, 1024 * 1024 * 1000);
GameConfigXB gameconfigxb = new GameConfigXB();
gameconfigxb.difficultyLevel = "Hard";
gameconfigxb.versionNumber = 1;
gameconfigxb.resourceNumber = 1;
GameConfigXBBuffer.serialize(gameconfigxb, mXbufferStream);
var bytes = mXbufferStream.getBytes();
using (var output = File.Create(PathUtilities.XbufferDataFolderPath + "GameConfigXB.data"))
{
output.Write(bytes, 0, bytes.Length);
}
}

/// <summary>
/// 读取GameConfig按钮点击
/// </summary>
private void onReadGameConfigClick()
{
Debug.Log("onReadGameConfigClick()");
Debug.Log("PathUtilities.XbufferDataFolderPath : " + PathUtilities.XbufferDataFolderPath);
using (var intput = File.OpenRead(PathUtilities.XbufferDataFolderPath + "GameConfigXB.data"))
{
var bytes = new byte[intput.Length];
intput.Read(bytes, 0, bytes.Length);
uint offset = 0;
var gameconfig = GameConfigXBBuffer.deserialize(bytes, ref offset);
Debug.Log("gameconfig.difficultyLevel : " + gameconfig.difficultyLevel);
Debug.Log("gameconfig.versionNumber : " + gameconfig.versionNumber);
Debug.Log("gameconfig.resourceNumber : " + gameconfig.resourceNumber);
}
}
}

XbufferData
XbufferOutput
可以看到我们成功通过Xbuffer序列化反序列了数据。

深入探究

接下来结合源码,让我们深入Xbuffer内部去看看是如何实现高效的序列化和反序列以及挂镀可配置的。
Xbuffer提供了一下两个核心的部件:

  1. xbuffe_parser.exe
  2. xbuffer_runtime.dll
xbuffer_parser

xbuffer_parser负责根据模板自动化生成对应类以及Xbuffer序列化反序列化所需文件代码。

  1. Config.cs(负责参数的检查 – 这个就不详细介绍了)
  2. Proto.cs(抽象数据结构文件的各个组成部分)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/*
* File Name: Proto.cs
*
* Description: 原型语法解析工具
* Author: lisiyu <576603306@qq.com>
* Create Date: 2017/10/25
*/

using System.Text.RegularExpressions;

namespace xbuffer
{
public class Proto
{
public Proto_Class[] class_protos;

public Proto(string proto)
{
var matchs = Regex.Matches(proto, @"//\s*(\S*)\s*((class)|(struct))\s*(\w+)\s*{\s*((\w+):([\[|\]|\w]+);\s*//\s*(\S*)\s*)*}");
class_protos = new Proto_Class[matchs.Count];
for (int i = 0; i < matchs.Count; i++)
{
class_protos[i] = new Proto_Class(matchs[i]);
}
}
}

/// <summary>
/// 变量结构
/// </summary>
public class Proto_Variable
{
public string Var_Type; // 变量类型
public string Var_Name; // 变量名
public bool IsArray; // 是否是数组
public string Var_Comment; // 变量注释

public Proto_Variable(string name, string type, string comment)
{
Var_Name = name;
if (type.Contains("["))
{
Var_Type = type.Substring(1, type.Length - 2);
IsArray = true;
}
else
{
Var_Type = type;
IsArray = false;
}
Var_Comment = comment;
}
}

/// <summary>
/// 类结构
/// </summary>
public class Proto_Class
{
public string Class_Comment; // 注释
public string Class_Type; // 类型 例如 class struct
public string Class_Name; // 类名
public Proto_Variable[] Class_Variables; // 变量列表

public Proto_Class(Match match)
{
Class_Comment = match.Groups[1].Value;
Class_Type = match.Groups[2].Value;
Class_Name = match.Groups[5].Value;

var varNames = match.Groups[7].Captures;
var varTypes = match.Groups[8].Captures;
var varComments = match.Groups[9].Captures;
Class_Variables = new Proto_Variable[varNames.Count];
for (int i = 0; i < Class_Variables.Length; i++)
{
Class_Variables[i] = new Proto_Variable(varNames[i].Value, varTypes[i].Value, varComments[i].Value);
}
}
}
}
核心思想是通过正则匹配文本内容后,分别将对应内容填充到抽象的Proto类对象里。
  1. Parser.cs(负责将抽象后的数据结构数据结合模板文件转换成对应代码) & Xtemplate.cs(负责模板与代码生成部分的逻辑处理)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/*
* File Name: Parser.cs
*
* Description: 将类对象转化成代码文本
* Author: lisiyu <576603306@qq.com>
* Create Date: 2017/10/25
*/

namespace xbuffer
{
public class Parser
{
/// <summary>
/// 将类对象转化成代码文本
/// </summary>
/// <param name="proto_class">类结构</param>
/// <param name="template_str">模板文本</param>
/// <returns></returns>
public static string parse(Proto_Class proto_class, string template_str, bool showHead)
{
var template = new XTemplate(template_str);

template.setCondition("HEAD", showHead);
template.setValue("#CLASS_TYPE#", proto_class.Class_Type);
template.setValue("#CLASS_NAME#", proto_class.Class_Name);
template.setValue("#CLASS_COMMENT#", proto_class.Class_Comment);

template.setCondition("DESERIALIZE_CLASS", proto_class.Class_Type == "class");
template.setCondition("SERIALIZE_CLASS", proto_class.Class_Type == "class");

if (template.beginLoop("#VARIABLES#"))
{
foreach (var item in proto_class.Class_Variables)
{
template.setCondition("SINGLE", !item.IsArray);
template.setCondition("ARRAY", item.IsArray);
template.setValue("#VAR_TYPE#", item.Var_Type);
template.setValue("#VAR_NAME#", item.Var_Name);
template.setValue("#VAR_COMMENT#", item.Var_Comment);
template.nextLoop();
}
template.endLoop();
}

if (template.beginLoop("#DESERIALIZE_PROCESS#"))
{
foreach (var item in proto_class.Class_Variables)
{
template.setCondition("SINGLE", !item.IsArray);
template.setCondition("ARRAY", item.IsArray);
template.setValue("#VAR_TYPE#", item.Var_Type);
template.setValue("#VAR_NAME#", item.Var_Name);
template.setValue("#VAR_COMMENT#", item.Var_Comment);
template.nextLoop();
}
template.endLoop();
}

if (template.beginLoop("#DESERIALIZE_RETURN#"))
{
foreach (var item in proto_class.Class_Variables)
{
template.setValue("#VAR_TYPE#", item.Var_Type);
template.setValue("#VAR_NAME#", item.Var_Name);
template.setValue("#VAR_COMMENT#", item.Var_Comment);
template.nextLoop();
}
template.endLoop();
}

if (template.beginLoop("#SERIALIZE_PROCESS#"))
{
foreach (var item in proto_class.Class_Variables)
{
template.setCondition("SINGLE", !item.IsArray);
template.setCondition("ARRAY", item.IsArray);
template.setValue("#VAR_TYPE#", item.Var_Type);
template.setValue("#VAR_NAME#", item.Var_Name);
template.setValue("#VAR_COMMENT#", item.Var_Comment);
template.nextLoop();
}
template.endLoop();
}

return template.getContent();
}
}
}
核心依然是通过正则匹配替换模板文本内容。

上面几个文件就是解析模板文件以及根据数据结构定义文件生成对应Xbuffer所需代码的核心逻辑。可以看到模板的高自由度配置,让Xbuffer具备了对多语言的快速支持以及灵活配置的能力。

xbuffer_runtime

xbuffer_runtime.dll是xbuffer对数据进行序列化和反序列化的核心类。

  1. XSteam.cs(Xbuffer核心字节流序列化反序列化的关键类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
/*
* File Name: XSteam.cs
*
* Description: 一个简单的内存流实现 用于精准的控制内存管理
* Author: lisiyu <576603306@qq.com>
* Create Date: 2018/04/09
*/

namespace xbuffer
{
public class XSteam
{
public uint index_group; // 当前组别序号 行
public uint index_cell; // 当前单元序号 列

public uint capacity_group; // 行的数量
public uint capacity_cell; // 列的数量
public byte[][] contents; // 内容列表
public uint[] wastes; // 浪费列表 对应每一行

public XSteam(uint capacity_group, uint capacity_cell)
{
this.capacity_group = capacity_group;
this.capacity_cell = capacity_cell;

contents = new byte[capacity_group][];
for (int i = 0; i < capacity_group; i++)
{
contents[i] = new byte[capacity_cell];
}
wastes = new uint[capacity_group];
}

/// <summary>
/// 申请空间
/// 基本策略是不够了就申请一倍
/// </summary>
/// <param name="size"></param>
public void applySize(uint size)
{
if (index_cell + size > capacity_cell)
{
wastes[index_group] = capacity_cell - index_cell;
index_cell = 0;
index_group++;
if (index_group >= capacity_group)
{
var nCapacity_Group = capacity_group + 1;

var nContents = new byte[nCapacity_Group][];
for (uint i = 0; i < nCapacity_Group; i++)
{
if (i < capacity_group)
nContents[i] = contents[i];
else
nContents[i] = new byte[capacity_cell];
}
contents = nContents;

var nWastes = new uint[nCapacity_Group];
for (uint i = 0; i < capacity_group; i++)
{
nWastes[i] = wastes[i];
}
wastes = nWastes;

capacity_group = nCapacity_Group;
}
}
}

/// <summary>
/// 返回输出字节流
/// </summary>
/// <returns></returns>
public byte[] getBytes()
{
var len = index_group * capacity_cell + index_cell;
for (int i = 0; i < index_group; i++)
{
len -= wastes[i];
}

var ret = new byte[len];
var idx = 0;
for (int i = 0; i < index_group; i++)
{
for (int j = 0; j < capacity_cell - wastes[i]; j++)
{
ret[idx++] = contents[i][j];
}
}
for (int i = 0; i < index_cell; i++)
{
ret[idx++] = contents[index_group][i];
}

return ret;
}
}
}
从上面源码可以看出,Xbuffer是采用了一个二维的byte数组来存储原始数据的(*实现固定内存大小的数据分配*)。空间容量不够的时候在扩容处理,这一点和Flatbuffer是类似的(Flatbuffer貌似是个一维数组)。当现有定义的空间不足时,Xbuffer采取的是翻倍处理(数据存储上是采用扩展二维数组的第二维度实现扩容)。这里可以想成,

举个例子,一开始是byte[1][1024]的byte数组容量。当不够的时候,会扩展成byte[2][1024]的容量,然后从byte[2][]开始填充新的数据,而byte[1][]里未填充完的部分,就当做浪费的部分不再使用。

*但最后返回Xstream的byte数据时,是返回的一个一维byte数组,并且是剔除了wast部分的byte分配,详情见Xstream的getBytes方法*
  1. boolBuffer.cs & byteBuffer.cs & floatBuffer.cs & intBuffer.cs & longBuffer.cs & stringBuffer.cs & uintBuffer.cs(负责对各数据类型的数据序列化写入和反序列化读取进行实现)
    下面以intBuffer.cs为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* File Name: intBuffer.cs
*
* Description: 基本类型处理
* Author: lisiyu <576603306@qq.com>
* Create Date: 2017/10/25
*/

using System;

namespace xbuffer
{
public class intBuffer
{
private static readonly uint size = sizeof(int);

public unsafe static int deserialize(byte[] buffer, ref uint offset)
{
fixed (byte* ptr = buffer)
{
var value = *(int*)(ptr + offset);
offset += size;
return BitConverter.IsLittleEndian ? value : (int)utils.toLittleEndian((uint)value);
}
}

public unsafe static void serialize(int value, XSteam steam)
{
steam.applySize(size);
fixed (byte* ptr = steam.contents[steam.index_group])
{
*(int*)(ptr + steam.index_cell) = BitConverter.IsLittleEndian ? value : (int)utils.toLittleEndian((uint)value);
steam.index_cell += size;
}
}
}
}
从上面可以看出,底层的数据写入是根据数据类型大小,通过fixed使用非托管的内存分配方式进行指定内存位置指定数据byte的写入和读取。

举个例子,一开始分配了byte[1][1024*1024]也就是1M的内存,当我们写入一个int数据时,因为int类型的byte大小是4bytes也就是32bits,也就是说byte[1][0] - byte[1][4]的内存数据时用于存储这个int的值。其他类型同理。
  1. utils.cs(负责一些细节处理,比如大小端问题,这里就不放源码了)
  2. Serializer.cs(提供模板化的序列化反序列化读取加载接口)
  3. ***Buffer.cs(自动化生成的序列化反序列化所需的类代码)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
namespace xbuffer
{
public static class GameConfigXBBuffer
{
public static GameConfigXB deserialize(byte[] buffer, ref uint offset)
{
// null
bool _null = boolBuffer.deserialize(buffer, ref offset);
if (_null) return null;

// difficultyLevel
string _difficultyLevel = stringBuffer.deserialize(buffer, ref offset);

// versionNumber
int _versionNumber = intBuffer.deserialize(buffer, ref offset);

// resourceNumber
int _resourceNumber = intBuffer.deserialize(buffer, ref offset);

// value
return new GameConfigXB()
{
difficultyLevel = _difficultyLevel,

versionNumber = _versionNumber,

resourceNumber = _resourceNumber,
};
}

public static void serialize(GameConfigXB value, XSteam steam)
{
// null
boolBuffer.serialize(value == null, steam);
if (value == null) return;

// difficultyLevel
stringBuffer.serialize(value.difficultyLevel, steam);

// versionNumber
intBuffer.serialize(value.versionNumber, steam);

// resourceNumber
intBuffer.serialize(value.resourceNumber, steam);
}
}
}
从上面可以看到,根据数据结构定义文件生成对应的序列化和反序列化代码核心是通过各成员数据按循序依次以各自类型的写入方式将数据写入XStream里。
反序列化时同理依次从byte数据里读取出来。
不难看出,数据存储确实和Flatbuffer类似,存储在一个固定的内存byte[]数组里,只不过是一维和二维数组的区别(*但实际上最后返回Xstream的byte数据时,是剔除了wast部分的一个一维的byte数组*)。数据序列化和反序列化都是读取指定内存位置按指定数据类型读取出来即可,这应该就是Xbuffer和Flatbuffer序列化和反序列化高效的原因吧。

方案比较

不同的序列化反序列化库的优劣比较:

  1. 自定义数据填充和读取
    优点:
  • 实现简单
    缺点:
  • 序列化和反序列化速度慢,内存开销大
  • 不适合数据量大和频繁使用的情况

适用于数据量不大且不需要跨语言的情况,不适合对性能要求高的场合。

  1. ProtoBuff
    优点:
  • 序列化和反序列化速度和内存开销都很不错
  • 数据定义向后兼容
  • 高度自定义配置数据结构(proto文件)
  • 数据序列化反序列化代码简介,接口简单
  • 社区强大
    缺点:
  • 相比FlatBuffer和Xbuffer速度和内存开销上不占优势

很适合作为网络数据传输方案(向后兼容)。

  1. FlatBuff
    优点:
  • 序列化和反序列化速度快,内存开销小
  • 支持自定义配置数据结构(schema文件)
    缺点:
  • 序列化反序列化代码编写过于面向内存结构,不符合平时面向对象的编写风格,编写复杂(这一层被Xbuffer封装成自动化生成***Buffer.cs的代码类)
  1. XBuffer
    优点:
  • 序列化和反序列化速度快,内存开销小
  • 高度自由配置(基于模板配置且自定义配置文件配置)
  • 接口简单方便使用,支持泛型和非泛型序列化反序列化
  • 根据需求可以快速修改来符合自身需求(比如要做导表工具,只需在上层再封一层即可)
    缺点:
  • 向后不兼容(因为数据是严格按照数据结构按顺序读取的,所以当已经存储进数据库的数据,在扩展字段内容后是无法正常反序列化读取出来的)

不适合作为网络序列化数据方案,更适合表格数据序列化反序列化方案。

实战

方案选择

学习了解了那么多序列化和反序列化的库,根据我现有的需求是制作一个导表工具(速度和内存开销以及配置灵活度都要求比较高)来看,个人认为基于Xbuffer来编写是比较适合的一种方案。

原因如下:

  1. 速度快
  2. 内存开销小
  3. 高度可配置

基于Xbuffer的导表工具

流程:

  1. 定义导表工具基础功能需求
  2. XML配置导表相关路径数据(比如表格路径配置,输出目录配置等)
  3. 定义excel表格格式规则
  4. 读取excel数据(Window可以使用ExcelRead库)
  5. 创建excel对应Xbuffer数据结构文件(这一步需要自己去写)
  6. 使用Xbuffer_parser解析新生成的数据结构文件,生成所需类文件以及序列化相关文件(使用Xbuffer_parser解析即可)
  7. 使用生成的代码序列化excel数据到二进制数据流(这一步需要自己去写)
  8. 封装一层表格数据读取快速读取管理类,负责统一管理表格数据的加载和读取(这一步需要自己去写)
  9. 通过统一管理类使用Xbuffer去加载读取序列化的二进制数据(结合自身代码,使用Xbuffer_runtime.dll里的核心代码去反序列化)

相关语言及工具:
C#(Unity客户端使用的语言)
Xbuffer(第三方序列化反序列化库)

接下将来针对每一步的实现进行实战学习。

导表功能需求

  1. 支持int,float,long, string, bool基础数据配置
  2. 支持一维和多维数据配置(进阶功能,理论上,对于xbuffer而言,数组的维度扩展就能支持多维数据)
  3. 自动生成表格数据以及表格读取相关代码
  4. 工具独立于Unity存在使用
  5. 支持跨语言(xbuffer理论上通过模板生成不同语言版本的相关代码即可)
  6. 支持XML配置相关工具配置(比如表格路径配置,输出目录配置等。)

支持导表路径配置

因为工具是独立于Unity所以不受Unity .Net版本的限制,这里决定使用.Net自身的XML解析库System.XML。

ExportConfig.xml

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="utf-8"?>  
<ExportConfig>
<ExcelInputPath>./Excels/</ExcelInputPath>
<TemplatePath>./Templates/</TemplatePath>
<DesFileOutputPath>./DesFiles/</DesFileOutputPath>
<ByteDataOutputPath>./../Assets/Resources/DataBytes/</ByteDataOutputPath>
<CSClassCodeOutputPath>./../Assets/Scripts/Data/CSOutput/ClassCode/</CSClassCodeOutputPath>
<CSBufferCodeOutputPath>./../Assets/Scripts/Data/CSOutput/BufferCode/</CSBufferCodeOutputPath>
<CSTemplateOutputPath>./../Assets/Scripts/Data/CSTemplateOutput/</CSTemplateOutputPath>
<OtherLanguageCodeOutputPath>./OtherOutput/</OtherLanguageCodeOutputPath>
</ExportConfig>

ExportConfig.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/*
* Description: 导表工具导出配置数据抽象
* Author: tanghuan
* Create Date: 2018/09/01
*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace XbufferExcelToData
{
public class ExportConfig
{
/// <summary>
/// 表格数据路径
/// </summary>
public string ExcelInputPath { get; set; }

/// <summary>
/// Xbuffer模板路径
/// </summary>
public string TemplatePath { get; set; }

/// <summary>
/// 序列化数据输出路径
/// </summary>
public string ByteDataOutputPath { get; set; }

/// <summary>
/// CS代码输出路径
/// </summary>
public string CSCodeOutputPath { get; set; }

/// <summary>
/// 其他语言代码输出路径
/// </summary>
public string OtherLanguageCodeOutputPath { get; set; }

/// <summary>
/// 打印所有信息
/// </summary>
public void printOutAllInfo()
{
Console.WriteLine(string.Format("ExcelInputPath : {0}", ExcelInputPath));
Console.WriteLine(string.Format("TemplatePath : {0}", TemplatePath));
Console.WriteLine(string.Format("DesFileOutputPath : {0}", DesFileOutputPath));
Console.WriteLine(string.Format("ByteDataOutputPath : {0}", ByteDataOutputPath));
Console.WriteLine(string.Format("CSCodeOutputPath : {0}", CSCodeOutputPath));
Console.WriteLine(string.Format("OtherLanguageCodeOutputPath : {0}", OtherLanguageCodeOutputPath));
}
}
}

XbufferExcelToDataConfig.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/*
* Description: 导表工具相关配置单例类
* Author: tanghuan
* Create Date: 2018/09/01
*/

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml;

namespace XbufferExcelToData
{
/// <summary>
/// 导表工具相关配置单例类
/// </summary>
public class XbufferExcelToDataConfig : SingletonTemplate<XbufferExcelToDataConfig>
{
/// <summary>
/// XML配置文件全路径
/// </summary>
public string XMLConfigFileFullPath { get; private set; }

/// <summary>
/// 导出配置数据
/// </summary>
public ExportConfig ExportConfigInfo { get; private set; }

public XbufferExcelToDataConfig()
{
XMLConfigFileFullPath = "./ExportConfig.xml";
ExportConfigInfo = null;
}

/// <summary>
/// 加载导出配置数据
/// </summary>
public bool LoadExportConfigData()
{
if(File.Exists(XMLConfigFileFullPath))
{
XmlDocument xmldoc = new XmlDocument();
xmldoc.Load(XMLConfigFileFullPath);
var rootnode = xmldoc.DocumentElement;
var rootnodename = rootnode.Name;
var reflecttype = this.GetType().Assembly.GetType("XbufferExcelToData." + rootnodename);
if(reflecttype == typeof(ExportConfig))
{
ExportConfigInfo = new ExportConfig();
var childnodes = rootnode.ChildNodes;
for(int i = 0, length = childnodes.Count; i < length; i++)
{
var property = ExportConfigInfo.GetType().GetProperty(childnodes[i].Name);
if(property != null)
{
property.SetValue(ExportConfigInfo, childnodes[i].InnerText);
}
else
{
Console.WriteLine(string.Format("不支持的属性配置 : {0}", childnodes[i].Name));
}
}
ExportConfigInfo.printOutAllInfo();
return true;
}
else
{
Console.WriteLine(string.Format("找不到配置的类型数据信息 : {0}", rootnodename));
Console.WriteLine("当前只支持ExportConfig类型信息配置");
return false;
}
}
else
{
Console.WriteLine(string.Format("导出配置文件不存在 : {0}", XMLConfigFileFullPath));
return false;
}
}
}
}

LoadXMLConfig
可以看到我们成功的读取了XML里配置的信息(后续的导表工具会用到)。

定义表格规则

  1. 第一行定义字段
  2. 第二行写字段注释
  3. 第三行定义字段类型
  4. 第四行表示分割符(只对一维数组类型有用)
  5. 第五和第六行保留为未来扩展使用
  6. 第七行及以后正式填写数据(数据不填采用对应类型的默认值,比如int为0,bool为false,int[]为null)

读取excel数据

Windows PC端决定使用ExcelRead库来解决excel读取问题。

核心代码是下面两个文件:
ExcelDataManager.cs
ExcelData.cs

考虑到博客暂时不支持代码折叠,这里就不直接放源代码了,整个工程源代码会在博客最后给出链接。
主要实现了以下功能:

  1. 按表格规则解析
  2. 检查表格配置是否正确(1. 不允许同名excel sheet 2. 字段名不允许重复 3. 支持的数据类型检查 4. id第一列必须为int类型且不允许不填且同一个表格id不能重复 5. 支持的分隔符检查配置)

这里直接验证下读取Excel数据的结果:
ExcelDatOutput

创建对应数据结构文件

这一步,我们需要自动化生成Xbuffer用于自动化生成相关序列化代码的数据结构定义文件。

还记的我们前面学习Xbuffer时定义的GameConfigXB.xb吗?
GameConfigXB.xb

1
2
3
4
5
6
7
// 游戏版本信息
class GameConfigXB
{
difficultyLevel:string; // 游戏难度
versionNumber:int; // 游戏版本号
resourceNumber:int; // 游戏资源版本号
}

我们现在的目标就是将我们的Excel定义的数据内容转换成对应的数据结构定义文件。

实现方案思考:
数据结构定义文件不需要考虑跨平台,真正跨平台的实现是在序列化模板文件那一步完成的,所以这里打算简单的通过字节流形式按顺序文本写入即可。

代码很简单,就是利用之前存储的表格数据信息,一次写入字符串信息,最后通过文件流写入文件,这里就不放源代码了。

核心类是:
XbufferExcelToDesFile.cs

直接来看下最终生成的结果:
t_AuthorInfo.xb

1
2
3
4
5
6
7
8
9
10
11
// t_AuthorInfo的注释
class t_AuthorInfo
{
id:int; //唯一id
author:string; //作者
age:int; //年龄
money:float; //拥有金钱
hashouse:bool; //拥有房子
pbutctime:long; //出版utc时间
luckynumber:[int]; //幸运数字
}

这一步,我们成功自动化生成了Xbuffer需要的Excel对应的数据结构定义文件。

生成序列化所需文件

为了便于修改Xbuffer满足我们新的需求,这里直接把Xbuffer的两个核心工程(xbuffer_parser & xbuffer_runtime)集成到我们的导表工具里来:
IntegrateXbuffer

然后修改Xbuffer两个核心工程的输出路径和我们主工程一致(便于主工程使用):
ModifyXbufferOutputPath

然后编译出两个工程各自的核心文件:
CompileXbufferCSProjects

为了不修改原有Xbuffer的使用方式,这里采用通过跨程序调用exe的方式来使用Xbuffer。
核心类是:
XbufferDesFileToCSCode.cs

成功生成序列化相关代码:
t_AuthorInfoBuffer.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
namespace xbuffer
{
public static class t_AuthorInfoBuffer
{
public static t_AuthorInfo deserialize(byte[] buffer, ref uint offset)
{
// null
bool _null = boolBuffer.deserialize(buffer, ref offset);
if (_null) return null;

// id
int _id = intBuffer.deserialize(buffer, ref offset);

// author
string _author = stringBuffer.deserialize(buffer, ref offset);

// age
int _age = intBuffer.deserialize(buffer, ref offset);

// money
float _money = floatBuffer.deserialize(buffer, ref offset);

// hashouse
bool _hashouse = boolBuffer.deserialize(buffer, ref offset);

// pbutctime
long _pbutctime = longBuffer.deserialize(buffer, ref offset);

// luckynumber
int _luckynumber_length = intBuffer.deserialize(buffer, ref offset);
int[] _luckynumber = new int[_luckynumber_length];
for (int i = 0; i < _luckynumber_length; i++)
{
_luckynumber[i] = intBuffer.deserialize(buffer, ref offset);
}

// value
return new t_AuthorInfo() {
id = _id,
author = _author,
age = _age,
money = _money,
hashouse = _hashouse,
pbutctime = _pbutctime,
luckynumber = _luckynumber,
};
}

public static void serialize(t_AuthorInfo value, XSteam steam)
{
// null
boolBuffer.serialize(value == null, steam);
if (value == null) return;

// id
intBuffer.serialize(value.id, steam);

// author
stringBuffer.serialize(value.author, steam);

// age
intBuffer.serialize(value.age, steam);

// money
floatBuffer.serialize(value.money, steam);

// hashouse
boolBuffer.serialize(value.hashouse, steam);

// pbutctime
longBuffer.serialize(value.pbutctime, steam);

// luckynumber
intBuffer.serialize(value.luckynumber.Length, steam);
for (int i = 0; i < value.luckynumber.Length; i++)
{
intBuffer.serialize(value.luckynumber[i], steam);
}
}
}
}

t_AuthorInfo.cs

1
2
3
4
5
6
7
8
9
10
11
// t_AuthorInfo的注释
public partial class t_AuthorInfo
{
public int id; // 唯一id
public string author; // 作者
public int age; // 年龄
public float money; // 拥有金钱
public bool hashouse; // 拥有房子
public long pbutctime; // 出版utc时间
public int[] luckynumber; // 幸运数字
}

Note:
因为xbuffer_parser默认只支持一个一个文件解析,保留原始使用方法会导致弹过多的弹窗,所以这里把xbuffer_parser改成指定目录形式的自动化生成。

序列化数据

Xbuffer序列化相关的代码自动生成完成后,下一步就是把Excel的数据序列化到我们指定的二进制文件流了。

实现方案思考:

  • 这一步主要依赖于最初存储的Excel数据以及前面生成的Xbuffer相关的序列化代码来进行序列化数据。
  • 但序列化的代码是动态生成的没有被编译到工具代码里的,我们无法利用现成的序列化代码来进行数据序列化。
  • 只能根据表格数据以及类型信息通过Xbuffer的基础数据类型写入对应数据,反序列化时再利用反序列化代码进行数据读取。
  • 除了写入数据信息,我们还需要在写入数据信息之前,写入两个关键信息(1. 数据数量(行数) 2. 数据长度(字节数)),用于我们后面去读取加载存储数据时使用,后者如果完全是一个byte对应一个excel数据的话倒是可以不用存储,因为加载后就已经知道总长度了,但如果是所有byte通过zip压缩到一起的话就需要知道每个byte的数据长度。

序列化的核心代码文件:
XbufferExcelDataToBytes.cs

Note:

  1. 暂时是一个Excel文件对应一个bytes数据的方式进行存储。(2022/1/22完成了单Excel多Sheet导出的支持)
  2. 如果想对数据进行压缩或者加密验证,都可以在序列化这一步或者压缩之后写入数据,然后在加载时反向操作判定对比即可。(TODO:优化)

统一表格数据加载和读取

这一步是属于加载一测的了,是需要运用在Unity里的代码,统一表格加载管理是为了对于表格数据进行快速的访问和管理(一般都会有个GameDataManager之类的单例类负责管理)。
因为表格数据是动态生成的,所以加载的代码也需要动态生成。

实现方案思考:

  • 这一步因为涉及到自动化的加载代码生成,会涉及到多语言支持问题,所以如果想要支持多语言最好通过模板一类的方式来动态生成代码。
  • 虽然Xbuffer里有一套根据模板生成代码的方案,但想自己尝试实现一下,所以不准备直接拿过来用,准备参考Xbuffer模板替换思路自己模仿写一份(尝试写了这个才发现正则的强大之处)。

模板功能需求:

  • 支持模板内容基于占位符替换
  • 支持模板内容基于占位符循环替换

实现方案跟Xbuffer的一样,支持如下功能:

  1. 支持两种替换规则(1. 占位符单次替换 2. 限定模板内容多次替换累加)
  2. 前者作用范围整个模板(不包含多次替换模板内容部分 – 通过定义不重复的占位符来实现),后者允许指定局部内容作为模板用于多次替换累加
  3. 多次替换模板内容允许跨行
  4. 模板占位符可自定义名字,但格式必须是:#名字#
  5. 多次替换模板内可定义占位符,支持占位符单次替换

比如如下定义方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* Auto generated, do not edit it
*/
using Xbuffer;

namespace Data
{
public class GameDataManager
{
#循环标签名1#
public #循环占位符名1#Container #循环占位符名1#Container = new #循环占位符名1#Container();
#循环标签名1#
}

public void loadAll()
{
#循环标签名2#
#循环占位符名2#Container.loadDataFromBin();
#循环标签名2#
}

private GameDataManager()
{
}

public static readonly GameDataManager Instance = new GameDataManager();
}

模板文件定义分析:

  1. 整个文件内容是作为完整的模板内容。
  2. 在非循环占位符标签内的#占位符名#会用于作为单次替换占位符
  3. 循环标签名1和循环标签名2定义一个局部模板内容作为多次替换的模板(循环标签必须配对使用,且不允许同一个模板里重复使用)
  4. 循环占位符名1和循环占位符名2表示多次替换模板里的占位符(注意不能和单次占位符名字重复,不然会被单次替换掉)

这里主要需要以下几个文件的模板:
先手写一版模板初稿

  1. GameDataManager.ftl – GameDataManager.cs(表数据统一加载管理类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Auto generated, do not edit it
*/
using System.Collections.Generic;
using xbuffer;

namespace Data
{
public class GameDataManager
{
public static readonly GameDataManager Singleton = new GameDataManager();

#CONTAINER_MEMBER_LOOP#
private #CLASS_NAME#Container m#CLASS_NAME#Container = new #CLASS_NAME#Container();
#CONTAINER_MEMBER_LOOP#

private GameDataManager()
{

}

public void loadAll()
{
#CONTAINER_LOAD_LOOP#
m#LOOP_CLASS_NAME#Container.loadDataFromBin();
#CONTAINER_LOAD_LOOP#
}

#CONTAINER_GET_LOOP#
public List<#LOOP_CLASS_NAME#> Get#LOOP_CLASS_NAME#List()
{
return m#LOOP_CLASS_NAME#Container.getList();
}

public Dictionary<#ID_TYPE#, #LOOP_CLASS_NAME#> Get#LOOP_CLASS_NAME#Map()
{
return m#LOOP_CLASS_NAME#Container.getMap();
}
#CONTAINER_GET_LOOP#
}
}
  1. excelContainer.ftl – excelContainer.cs(表格数据存储类)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* Auto generated by XbufferExcelToData, do not edit it
* 表格名字
*/
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using xbuffer;

namespace Data
{
public class #CLASS_NAME#Container
{
private List<#CLASS_NAME#> list = null;
private Dictionary<#ID_TYPE#, #CLASS_NAME#> map = null;

public List<#CLASS_NAME#> getList()
{
if (list == null || list.Count <= 0)
loadDataFromBin();
return list;
}

public Dictionary<#ID_TYPE#, #CLASS_NAME#> getMap()
{
if (map == null || map.Count <= 0)
loadDataFromBin();
return map;
}

public void ClearList()
{
if (list != null && list.Count > 0)
list.Clear();
if (map != null && map.Count > 0)
map.Clear();
}

public void loadDataFromBin()
{
Stream fs = ConfLoader.Singleton.getStreamByteName(typeof(#CLASS_NAME#).Name);
if(fs != null)
{
BinaryReader br = new BinaryReader(fs);
uint offset = 0;
bool frist = true;
try{
while (fs.Length - fs.Position > 0)
{
if (frist)
{
frist = false;
ClearList();
var count = br.ReadInt32();
list = new List<#CLASS_NAME#>(count);
map = new Dictionary<#ID_TYPE#, #CLASS_NAME#>(count);
}

var length = br.ReadInt32();
var data = br.ReadBytes(length);
var obj= #CLASS_NAME#Buffer.deserialize(data, ref offset);
offset = 0;
list.Add(obj);
map.Add(obj.#ID_NAME#, obj);
}
}catch (Exception ex)
{
Debug.LogError("import data error: " + ex.ToString());
}
br.Close();
fs.Close();
}
}
}
}
  1. ConfLoader.cs(二进制配置数据文件加载类 - 这个不需要自动化生成)

这里只支持了一维数组维度的配置:
来看下自动生成后的相关代码:
GameDataManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* Auto generated, do not edit it
*/
using System.Collections.Generic;
using xbuffer;

namespace Data
{
public class GameDataManager
{
public static readonly GameDataManager Instance = new GameDataManager();


private t_AuthorInfoContainer mt_AuthorInfoContainer = new t_AuthorInfoContainer();

******

private t_language_cnContainer mt_language_cnContainer = new t_language_cnContainer();

private t_uiContainer mt_uiContainer = new t_uiContainer();


private GameDataManager()
{

}

public void loadAll()
{

mt_AuthorInfoContainer.loadDataFromBin();

******

mt_language_cnContainer.loadDataFromBin();

mt_uiContainer.loadDataFromBin();

}


public List<t_AuthorInfo> Gett_AuthorInfoList()
{
return mt_AuthorInfoContainer.getList();
}

public Dictionary<int, t_AuthorInfo> Gett_AuthorInfoMap()
{
return mt_AuthorInfoContainer.getMap();
}

******

public List<t_ui> Gett_uiList()
{
return mt_uiContainer.getList();
}

public Dictionary<string, t_ui> Gett_uiMap()
{
return mt_uiContainer.getMap();
}

}
}

t_AuthorInfoContainer.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* Auto generated by XbufferExcelToData, do not edit it
* 表格名字
*/
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using xbuffer;

namespace Data
{
public class t_AuthorInfoContainer
{
private List<t_AuthorInfo> list = null;
private Dictionary<int, t_AuthorInfo> map = null;

public List<t_AuthorInfo> getList()
{
if (list == null || list.Count <= 0)
loadDataFromBin();
return list;
}

public Dictionary<int, t_AuthorInfo> getMap()
{
if (map == null || map.Count <= 0)
loadDataFromBin();
return map;
}

public void ClearList()
{
if (list != null && list.Count > 0)
list.Clear();
if (map != null && map.Count > 0)
map.Clear();
}

public void loadDataFromBin()
{
Stream fs = ConfLoader.Singleton.getStreamByteName(typeof(t_AuthorInfo).Name);
if(fs != null)
{
BinaryReader br = new BinaryReader(fs);
uint offset = 0;
bool frist = true;
try{
while (fs.Length - fs.Position > 0)
{
if (frist)
{
frist = false;
ClearList();
var count = br.ReadInt32();
list = new List<t_AuthorInfo>(count);
map = new Dictionary<int, t_AuthorInfo>(count);
}

var length = br.ReadInt32();
var data = br.ReadBytes(length);
var obj= t_AuthorInfoBuffer.deserialize(data, ref offset);
offset = 0;
list.Add(obj);
map.Add(obj.id, obj);
}
}catch (Exception ex)
{
Debug.LogError("import data error: " + ex.ToString());
}
br.Close();
fs.Close();
}
}
}
}

Note:

  1. 占时我ConfLoad.cs加载二进制文件是通过放在Resources目录下以TextAsset形式加载进来,所以加载的时候是没带后缀的,具体ConfLoad代码根据不同的存储位置和加载方式会稍作改动。

读取序列化数据

终于走到最后一步了,完成这一步,导表工具的工具链基本就算打通了。

ConfLoader.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
* Description: 配置表加载辅助单例类
* Author: tanghuan
* Create Date: 2018/09/05
*/

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;

/// <summary>
/// 配置表加载辅助单例类
/// </summary>
public class ConfLoader : SingletonTemplate<ConfLoader> {

/// <summary>
/// Excel表格数据存储目录
/// </summary>
public const string ExcelDataFolderPath = "DataBytes/";

public ConfLoader()
{

}

/// <summary>
/// 获取表格配置数据的二进制流数据
/// </summary>
/// <param name="bytefilename"></param>
/// <returns></returns>
public Stream getStreamByteName(string bytefilename)
{
var textasset = Resources.Load(ExcelDataFolderPath + bytefilename) as TextAsset;
var memorystream = new MemoryStream(textasset.bytes);
return memorystream;
}
}

结合前面的GameManager.cs和所有对应的***Container.cs文件,完成我们对于序列化数据的加载。

激动人心的一刻,PC上成功加载表格数据代码并打印出来:
ExcelDataLoadAndPrint

遇到个严峻的问题,真机上读取float数据时出了空引用:
?大写的问号脸,PC成功了但是真机Android报空?
问题猜想1:
是不是Xbuffer对于float的序列化或者反序列化没写对?
猜想1思考:
但仔细一想PC都是对的,这个猜测自然不成立了。

问题猜想2:
大小端数据存储的问题?
猜想2思考:
Windows PC(Intel)和Android Mix2手机(ARM)都是小端,同时真机不是数据错误而是报空,这个猜想也不成立。

问题猜想3:
根据报错是在var value = (float)(ptr + offset);时报空,那肯定跟字节数据float解析有关。
猜想3思考:
结合万能的Google,貌似总算找到关键点了。[Google问答](https://stackoverflow.com/questions/28436327/monotouch-floating-point-pointer-throws-nullreferenceexception-when-not-4-byte-a]

原因:
上面那个问题提到了ARM设备上对于浮点数(比如floating,double)的值进行dereference要求内存地址必须是4-bytes对齐的形式才能正确解析。

相关知识储备:
浅谈字节序(Byte Order)及其相关操作
Data alignment: Straighten up and fly right

分析当前情况:
当前使用Xbuffer对于表格数据的存储只是单纯的按顺序填充数据,没有考虑任何的对齐问题,float数据也是前面的数据填到哪个字节数就在后面填入4bytes的float数据。

解决方案:
固定分配一个常驻的byte[4]作为中间缓冲区,在内存地址不满足4byte对齐时进行赋值byte数据然后对满足4byte对齐的常驻byte[4]对象进行解析。

floatBuffer.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
******

using System;

namespace xbuffer
{
public class floatBuffer
{
private static readonly uint size = sizeof(float);

//-------------------------------------------------
// float在ARM机器上dereferencing浮点数(float,double等)强制要求内存地址4字节对齐,不然会报空
// 参考链接:
// https://stackoverflow.com/questions/28436327/monotouch-floating-point-pointer-throws-nullreferenceexception-when-not-4-byte-a
//-------------------------------------------------

// 修复方案,定义一个全局的4字节byte数组,用于不满足内存地址4字节对齐时赋值用于解析float
private static readonly byte[] fourByteAlginedArray = new byte[4];

public unsafe static float deserialize(byte[] buffer, ref uint offset)
{
fixed (byte* ptr = buffer)
{
float value;
if ((int)(ptr + offset) % 4 == 0)
{
value = *(float*)(ptr + offset);
}
else
{
for (int i = 0; i < 4; i++)
{
fourByteAlginedArray[i] = (ptr + offset)[i];
}
fixed (byte* ptr2 = fourByteAlginedArray)
{
value = *(float*)(ptr2);
}
}
offset += size;
return BitConverter.IsLittleEndian ? value : utils.toLittleEndian((uint)value);
}
}

******
}
}

真正激动人心的时刻,真机运行反序列化数据:
AndroidDeviceExcelDataLoadAndPrint
可以看到我们成功反序列化了表格数据,同时解决了float在真机上dereference问题。

性能和内存开销

工具链虽然打通了,但是我们我不只是要考虑能不能用,更多的我们还要关心内存开始以及序列化反序列化的速度性能问题。

接下来就是通过大数据配置来测试内存分配和序列化性能问题。

测试用例:
两张表(author_info.xlsx和global_config.xlsx),各配置了994行数据,然后复制两张表9次并改名(文件名和Excel内部sheet名都得改)(总计20张表 X 994行数据)。

测试平台:
PC Windows

导表耗时:
XbuuferExcelToDataTimeConsume

导表后的二进制文件大小(未压缩):
二进制数据总大小我统计了下未压缩是1.3M。

表格数据读取内存以及反序列化时间开销:
我通过Unity的Profiler.GetMonoUsedSizeLong()统计计算堆内存的开销:
统计类:
MonoMeoryProfiler.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/*
* Description: MemoryProfiler.cs
* Author: TONYTANG
* Create Date: 2018/08/08
*/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Profiling;

/// <summary>
/// MemoryProfiler.cs
/// 简陋的内存统计工具(统计托管的Mono内存)
/// </summary>
public class MonoMemoryProfiler : SingletonTemplate<MonoMemoryProfiler> {

/// <summary>
/// 内存Profile类型
/// </summary>
public enum MemoryProfilerType
{
CSharp_GC = 1, // CS GC统计
Unity_Profiler = 2 // Unity Profiler接口统计
}

/// <summary>
/// 当前内存统计类型
/// </summary>
private MemoryProfilerType mCurrentMemoryProfilerType = MemoryProfilerType.Unity_Profiler;

/// <summary>
/// 内存标签名
/// </summary>
private string mTagName;

/// <summary>
/// 开始统计时总共使用的Mono内存
/// </summary>
private long mTotalUsedMonoMemory_Begin;

/// <summary>
/// 结束统计时总共使用的Mono内存
/// </summary>
private long mTotalUsedMonoMemory_End;

/// <summary>
/// 设置当前内存统计类型
/// </summary>
/// <param name="mpt"></param>
public void setMemoryProfilerType(MemoryProfilerType mpt)
{
mCurrentMemoryProfilerType = mpt;
}

/// <summary>
/// 开启内存统计Tag
/// </summary>
/// <param name="tag"></param>
public void beginMemorySample(string tag)
{
if(!tag.IsNullOrEmpty())
{
mTagName = tag;
if (mCurrentMemoryProfilerType == MemoryProfilerType.CSharp_GC)
{
// 确保得到正确的起始Heap Memory Size
mTotalUsedMonoMemory_Begin = GC.GetTotalMemory(true);
}
else if (mCurrentMemoryProfilerType == MemoryProfilerType.Unity_Profiler)
{
GC.Collect();
mTotalUsedMonoMemory_Begin = Profiler.GetMonoUsedSizeLong();
}
}
else
{
Debug.LogError("MonoMemoryProfiler的Tag不能为空!");
}
}

/// <summary>
/// 结束内存统计
/// </summary>
public void endMemorySample()
{
if(!mTagName.IsNullOrEmpty())
{
if (mCurrentMemoryProfilerType == MemoryProfilerType.CSharp_GC)
{
mTotalUsedMonoMemory_End = GC.GetTotalMemory(false);
}
else if (mCurrentMemoryProfilerType == MemoryProfilerType.Unity_Profiler)
{
GC.Collect();
mTotalUsedMonoMemory_End = Profiler.GetMonoUsedSizeLong();
}

var heapmemoryoffset = mTotalUsedMonoMemory_End - mTotalUsedMonoMemory_Begin;
Debug.Log(string.Format("内存统计标签 : {0}", mTagName));
Debug.Log(string.Format("当前Mono内存大小 = {0} Bytes", mTotalUsedMonoMemory_End));
Debug.Log(string.Format("之前Mono内存大小 = {0} Bytes", mTotalUsedMonoMemory_Begin));
Debug.Log(string.Format("总共Mono内存占用 = {0} Bytes == {1} KB == {2} M", heapmemoryoffset, heapmemoryoffset / 1024 , heapmemoryoffset / (1024 * 1024)));
mTagName = string.Empty;
}
}
}

PC:
Profiler_GetMonoUsedSizeLong_MemoryUsing

Android真机:
AndroidDevice_Profiler_GetMonoUsedSizeLong_MemoryUsing

可以看到两种方式统计有不少误差,在5M左右的堆内存开销。
时间开销PC在100ms左右,Android真机在200ms左右。

从20张表,每张表大概4-7个字段,各1000行数据来看,内存和序列化,反序列化速度都还是相当可观的。这里因为没有集成支持其他序列化方式,所以没法做详细的对比,详细各序列化库性能对比参考Xbuffer作者在Github上的对比Xbuffer

优化点

未来需要支持和优化的工作点:

  1. 支持多维数据的配置(放弃支持)
    方案1:
    - 扩展到多维不定长数组
    问题:
    - 每一个维度上的数组长度都是不定长的,用多维数组存储会伴随多维数组的维度以及各维度的数量级增长,导致各维度各数量级上的长度信息存储过多(占用过多的二进制字节数据)。
    - 同时会需要不定长的多维数组来支持,序列化和反序列化都很复杂
    - 需要扩展Xbuffer的维度支持
    方案2:
    - 扩展到多维定长数组
    结论:
    不太可行,空间不浪费,各维度数组长度信息过于复杂,实现过于复杂
    问题:
    - 只需要存储各个维度上的长度信息,维度长度信息和维度成正比,数量级小易存储
    - 在配置复杂的不定长数据时,定长数组存储会造成大量的数据空间浪费
    - 序列化代码依然复杂,反序列化代码相对简单
    - 需要扩展Xbuffer的维度支持
    结论:
    可行,空间比较浪费,多维度数组信息存储简单,实现相对复杂
    方案3:
    - 多维数据依然存储在一个一维数组里,通过一维数组索引去访问
    问题:
    - 数据存储空间不存在浪费
    - 不需要扩展Xbuffer多维度支持
    - 序列化和反序列化代码简单
    - 无法像多维数组形式方便快速索引数据,只能计算好对应索引值去访问索引数据(数据量配置一旦大了,访问复杂度成指数级成长)
    - 对于数据配置的抽象不友好们无法快速访问指定部分数据(相当于一维数组的单个字符分割,这样一来没有意义了)
    结论:
    可行,空间不浪费,实现简单,访问和理解都不友好,配置复杂度影响访问复杂度。但变相成了一维数组的单个字符分割,没有意义了。
    最终结论:
    没有想到好的方式支持,最终放弃了支持多维数据的解析和快速访问。
    多维数据配置建议:
    - 可以采用配置多个一维数组映射来支持。
    - 获取一维数组配置后,自己去split分割访问解析。
  2. 支持单列允许填写notation数据类型作为注释类型,单纯作为excel可查看的注释不序列化到数据里。(完成)
    • 修改导表工具支持notation数据类型配置作为注释类型
    • 修改导表生成二进制数据那里不序列化注释类型数据
  3. 支持个数据类型不配置,直接使用各类型默认值的方式(完成)
    • 修改导表公安局二进制数据序列化时判定书否有数据,没有数据用各类型默认数据
  4. 二进制数据的压缩(暂时未做)
    • 对序列化好的二进制数据可以进行一些压缩格式的压缩后然后运行时解压的形式实现数据压缩
  5. 支持单Excel多Sheet导出(2022/1/20完成)
  6. 支持Sheet黑名单,blacklist开头的Sheet名不参与导表(2022/1/20完成)
  7. 第一列字段名不限,同时类型支持int和string(2022/1/20完成)

Github

最后给出工具的Github链接:
XbufferExcelToData
上面写了导表工具的详细支持和测试信息。

学习总结

  1. 高效的数据序列化和反序列化不是语言自带的序列化和反序列化方式(很有可能默认使用了反射之类的)而是直接对于内存的数据的快速访问解析(比如flatbuffer,Xbuffer最终都是采用一个扁平化的字节数组对数据进行存储访问)
  2. 更进一步的数据压缩是对于基础数据类型的存储格式与数据解析定义(比如Protobuf里Varint数据存储方式)
  3. 跨语言的序列化反序列化只要定义统一的字节流写入和读取即可。
  4. 数据向后兼容问题(这里需要向Flatbuffer和Protobuf对于数据的存储做更多的处理才能做到)
  5. 完整的工具链要考虑的不仅仅是数据的存储和加载,还要关注自动化相关代码的生成(比如通过模板定义做到对多语言工具链的支持)。
  6. 二进制数据的处理需要关注大小端问题(不同CPU数据存储方式不一样),内存对齐问题(不同架构比如ARM和Intel对于浮点数的dereference就有内存4字节对齐的要求)。
  7. 强大的正则表达式在做模板问题处理时,相当优秀。

Reference

Conception Part

protobuf
FlatBuff
Xbuffer

Knowlodge Part

FlatBuffers 体验
Flatc
Protocol Buffer 序列化原理大揭秘 - 为什么Protocol Buffer性能这么好?
Microsoft .NET Framework 版本定义
Data alignment: Straighten up and fly right
详解大端模式和小端模式
理解字节序
浅谈字节序(Byte Order)及其相关操作

Other Part

Unity C#配置表工具
Unity3D游戏开发之当游戏开发遇上Excel
Unity3D研究院之MAC&Windows跨平台解析Excel(六十五)
ExcelReader
FlatBuffers 体验

Lua

结合以前的初步学习了解,这一次要对Lua进行进一步的深入学习。

首先我们要知道Lua是脚本语言,是有自己的虚拟机,是解析执行而非像C#,C++,Java这些编译执行。同时Lua是弱类型语言。

这里不更多的讲解Lua虚拟机相关概念,详情参考[http://blog.sina.com.cn/s/blog_8c7d49f20102uzsx.html](Scripting System)
Virtual_Machine_Working_Levels
这里只要知道Lua的虚拟机(程序模拟线程,堆栈等)是运行在程序里而非物理CPU上,因此只要虚拟机把Lua脚本解析成对应平台的机器码就能支持跨平台(好比Java的JVM)。

Lua Study

以前的部分学习参考:
Scripting System & Lua Study
Lua – some important concept and knowledge
Lua - C API
Lua - C call Lua & Lua call C
Lua - Object Oriented Programming
Lua - Userdata
Lua - Managing Resources & Threads and States

Weak Tables And Finalizers

“and Finalizers Lua does automatic memory management. Programs create objects (tables, threads, etc.), but there is no function to delete objects. Lua automatically deletes objects that become garbage, using garbage collection. “

Weak Table
“Weak tables allow the collection of Lua objects that are still accessible to the program”

“A weak reference is a reference to an object hat is not considered by the garbage collector”

“In weak tables,, both keys and values can be weak”(three kinds of week table:1. weak key 2. weak value 3. weak key and value)

Weak Table Using:

  1. 释放使用不再使用的缓存数据(memorizing)
    Auxiliary table(辅助表),Lua里有类似于C#里的internal hash table用于重用使用过的string和访问结果(C#里主要是使用过的string重用。Lua还能将访问过的string的table结果缓存返回)

但Auxiliary table有个缺点就是使用不频繁的string和result会一直被缓存无法释放。weak table正是用于解决这一问题的方案之一。(因为weak table的weak reference一旦不再被使用就会被下一次GC释放)

  1. Object Attributes Implemetation
    Solution: use external table to associate objects with attributes
    Drawback: external table reference prevent objects from being collected
    Final solution: use weak keys for objects in external table

  2. Tables with Default Values
    这里有两种各有优缺点的实现方案:
    方案1:

1
2
3
4
5
6
7
local defaults = {}
setmetatable(defaults, {__mode = "k"})
local mt = {__index = function (t) return defaults[t] end}
function setDefault (t, d)
defaults[t] = d
setmetatable(t, mt)
end

方案2:

1
2
3
4
5
6
7
8
9
10
local metas = {}
setmetatable(metas, {__mode = "v"})
function setDefault (t, d)
local mt = metas[d]
if mt == nil then
mt = {__index = function () return d end}
metas[d] = mt -- memorize
end
setmetatable(t, mt)
end

前者针对每一个不同的Table都会分配一个defaults入口,table作为key,default value作为value。这样的缺点就是当table数量很大的时候会需要分配很多内存去存储不同table的default value。

后者针对不同的default value分配了更多的内存(比如mt,entry on metas, closure……),但优点是同样的default value只需要分配一次即可,所以后者更适用于table数量多但default value大都相同的情况。

  1. Ephemeron Tables(声明短暂的table Lua 5.2里提供)
    Ephemeron Table:
    “In Lua 5.2, a table with weak keys and strong values is an ephemeron table. In an ephemeron table, the accessibility of a key controls the accessibility of its corresponding value.(只当有strong
    reference to key时value才是strong的)”
    e.g. constant-function factory

Note:
“Only objects can be collected from a weak table. Values, such as numbers and booleans, are not collectible”(只有Objects在weak table里能被gc回收,number和booleans这种Value类型不能在weak table里被gc回收)

“strings are collectible, but string is not removed from weak table(unless its associated value is collected)”

Finalizers
“Finalizers allow the collection of externa objects that are not directly under control of the garbage collector”

“Finalizer is a function associated with an object that is called when that object is about to be collected.”(相当于C#里的Finalize,在GC object的时候会被调用。但只有一开始设置Metamethod的__gc时才能mark该object为可finalization的,否则就算后面在复制__gc也不会在调用该object的finalizer)

Lua里是通过Metamethod里的__gc实现。

1
2
3
4
5
-- 测试环境要求至少Lua 5.2
o = {x = "hi"}
setmetatable(o, {__gc = function (o) print(o.x) end})
o = nil
collectgarbage() --> hi

The order of finalization called:
“When the collector finalizes several obejcts in the same cycle, it calls their finalizers in the reverse order that the objects were marked for finalization”(先声明__gc被mark为可finalization的object的finalizer后被调用)

1
2
3
4
5
6
7
8
9
10
11
-- 测试环境要求至少Lua 5.2
mt = {__gc = function (o) print(o[1]) end}
list = nil
for i = 1, 3 do
list = setmetatable({i, link = list}, mt)
end
list = nil
collectgarbage()
--> 1
--> 2
--> 3

对于被finalize的object,Finlization会使被finalize的object处于两个特殊的状态:

  1. transient resurrection(短暂的复苏)
  2. permanent resurrection(永久的复苏)
    前者因为在调用__gc的metamethod时我们会得到finalize的object,这样一来使得该object alive again,然后我们可以通过该object用于访问里面的对象。
    finalizer被调用时,该alive again的object被存储在了global table里,导致该object在finalize后依然存在。要想使用finalize也真正回收obejct,我们需要调用两次gc,第一次用于回收原始object,第二次用于回收alive again的object。
1
-- 因为当前测试环境是Lua 5.1(基于LuaTo#插件学习的,JIT支持到5.1的版本)所以不支持Talbe的__gc Metamethod,这里暂时没有写测试程序。

Note:
“In lua 5.1 the only lua values that work with __gc metamethod is userdata. “(Lua 5.1不支持table的__gc metamethod,只支持userdata的__gc metamethod)

Lua里的位运算

在了解Lua里的位运算之前我们需要了解原码,反码,补码相关的概念,详情参考:
原码, 反码, 补码 详解

这里直接说结论:
机器的数字存储采用补码的方式来编码存储的。

正数的反码 = 自身
负数的反码 = 符号位不变 + 其他位取反

正数的补码 = 自身
负数的补码 = 反码 + 1

移码 = 2^n + 补码(真值为排开非符号位的值,n为真值的位数) = 补码符号位取反

为什么会需要原码,反码,补码?
个人总结原因有以下几点:

  1. 计算机运算设计为了简化让符号位直接参与运算,同时只有加法没有减法,减法用加一个负数的表示
  2. 直接用原码相加无法解决符号位相加问题,导致结果不正确
  3. 直接用反码相加无法解决0的准确表达,即10和00都表示0但区分了正负之分(有效范围127到-127)
  4. 用补码相加不仅解决了正负0的问题,同时1*0还能表示-128(扩展有效范围为127到-128。这也是为什么我们的int32的有效范围为2^31 - 1到(-2)^31的原因)
  5. 移码解决补码不能快速比较两个数大小的问题

了解了基础的理论知识,让我们再来看看Lua里的位运算。

取反

1
2
3
4
5
6
7
for i = 0, 3, 1 do
print("========================")
print(i)
local number = i
local result = ~number
print(result)
end

让我们看下输出结果:
BitNotOperation
上面的结果出人意料,0取反为-1,1取反为-2,2取反为-3,3取反为-4,并不是我们想象中的0取反为-2^31

那么为什么会有这样的结果了,让我们结合前面学习的原码,反码,补码知识来破解其中的奥妙。
让我们来一步一步看看,0在取反的过程中是怎么一步一步变成-1的。
正向:
0000(原码) 0
0000(反码) 0
0000(补码) 0
1111(补码取反) -2^31 + 1

因为机器是以补码存储数字的,所以0的存储编码为0000
我们通过对0取反,得到1111(补码取反)
得到了取反后的补码,我们如何知道这个补码表达的是什么数值了?这个时候需要结合补码的计算方式逆向推导出原码值。

补码逆向:
1111 - 1 = 1110(反码) -2^31 + 1
1110(反码) = 原码符号位不变 + 原码其他位取反 = 1001(原码) = -1

可以看到通过正向和逆向反推,我们成功得出了0取反后的值为-1而不是我们想象中的-2^31

移位和异或

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
print("========================")
local num1 = 1
print(num1 << 1)
local num2 = -1
print(num2 << 1)

print("========================")
-- 01101(原码)
-- 01101(补码)
local num3 = 13
-- 11011(原码)
-- 10100(反码)
-- 10101(补码)
local num4 = -11
-- 01101(补码) & 10011(补码)
-- 00101(异后补码)
-- 00101(异后原码)
print(num3 & num4)
-- 01101(补码) | 10101(补码)
-- 11101(或后补码)
-- 11100(或后反码)
-- 10011(或后原码)
print(num3 | num4)
-- 01101(补码) ~ 10101(补码)
-- 11000(异或后)
-- 10111(异或后反码)
-- 11000(异或后原码)
print(num3 ~ num4)
print("========================")
-- 11001(原码)
-- 10111(补码)
local num5 = -9
-- 00011(原码)
-- 00011(补码)
local num6 = 3
-- 10111(补码) ~ 00011(补码)
-- 10100(异或后)
-- 10011(异或后反码)
-- 11100(异或后原码)
print(num5 ~ num6)

BitwiseOperation
可以看到num5和num6异或因为补码的影响得到-12的结果。
Note:
1. 有负数的情况,异和或操作都会受补码影响,整数补码等于原码,所以整数异和或操作等价于原码直接异或
2. 异或操作对符号位会有影响,需要考虑补码影响
3. 位移操作左移会替换符号位,右移会丢弃低位

Lua里的OOP

OOP(Object Oriented Programming)
首先Lua里面编写更重要的是Think in Lua,所以这里我不是强调OOP的重要性,而是单纯因为OOP被更多的C#程序员所熟悉。

Class抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
-- File Name:    Class.lua
-- Description: 模拟Lua里OOP特性(不支持多重继承)
-- Author: TangHuan
-- Create Date: 2018/11/09

-- 使用方法:
-- 1. 定义类
-- local ClassName = Class("ClassName", nil)
-- ***(自定义类数据方法等)
-- 参数说明:
-- clsname类名,super父类
-- 返回结果:
-- ClassName(含Class基本信息结构的table)

-- 2. 实例化类对象
-- 参数说明:
-- BaseClass含类定义的Class Table
-- ...构造类实例对象的构造函数ctor()的参数
-- 返回结果:
-- 基于BaseClass的实例对象
-- new(BaseClass, ...)

---克隆对象(建议用于克隆Class对象)
---@param any 对象
---@return any 克隆对象
function Clone(object)
local lookup_table = {}
local function _copy(object)
if type(object) ~= "table" then
return object
elseif lookup_table[object] then
return lookup_table[object]
end
local new_table = {}
lookup_table[object] = new_table
for key, value in pairs(object) do
new_table[_copy(key)] = _copy(value)
end
return setmetatable(new_table, getmetatable(object))
end
return _copy(object)
end

---判定是否是指定类或者继承至该类
---@param self Class@类实例对象或者类
---@param cname string | Class@类名或者类定义table
local function IsClass(self, cname)
if type(self) ~= "table" then
return false;
end

if type(cname) == "table" then
if self.class == cname then
return true;
elseif self.super then
return self.super.IsClass(self.super, cname);
end
elseif type(cname) == "string" then
if self.class.className == cname then
return true;
elseif self.super then
return self.super.IsClass(self.super, cname);
end
end
return false;
end

--- 提供Lua的OOP实现,快速定义一个Class(不支持多重继承)
--- 模拟Class封装,继承,多态,类型信息,构造函数等
--- 模拟一个基础的Class需要的信息
---@param clsname string@类名
---@param super super@父类
---@return Class@含Class所需基本信息的Class table
function Class(clsname, super)
local classtable = {}
-- ctor模拟构造函数
classtable.Ctor = false
-- className模拟类型信息,负责存储类名
classtable.className = clsname
-- super模拟父类
-- Note: 外部逻辑层不允许直接._super访问父类
classtable._super = super
-- 自身class类table
classtable.class = classtable;
-- 指定索引元方法__index为自身,模拟类访问
-- 后面实例化对象时会将classtable作为元表,从而实现访问类封装的数据
classtable.__index = classtable
-- 是否是指定类或者继承至某类的方法接口
classtable.IsClass = IsClass;
-- 如果指定了父类,通过设置Class的元表为父类模拟继承
if super then
setmetatable(classtable, super)
else
--print("如果定义的不是最基类,请确认是否require了父类!")
end
return classtable
end

--- 提供实例化对象的方法接口
--- 模拟构造函数的递归调用,从最上层父类构造开始调用
---@param cls cls@类定义
---@param ... ...@构造函数变长参数
---@return cls@cls类的实例对象table
function New(cls, ...)
-- 实例对象表
local instance = {}
-- 设置实例对象元表为cls类模拟类的封装访问
setmetatable(instance, cls)
-- create模拟面向对象里构造函数的递归调用(从父类开始构造)
local create
create = function(cls, ...)
if cls._super then
create(cls._super, ...)
end
if cls.Ctor then
cls.Ctor(instance, ...)
end
end
create(cls, ...)
return instance
end

---静态类
function StaticClass(clsname)
return {}
end

上面的代码注释已经很清楚了,就不一一解释了。

理解上面Lua实现OOP的关键在于通过table模拟Class的抽象以及数据封装,通过metatable模拟继承特性。

Class定义

定义一个类的方式如下:

1
2
3
4
5
6
7
8
9
10
11
类名 = Class("类名")

--构造函数
function 类名:Ctor()
--成员变量定义
end

--方法定义
function 类名:方法名()

end

Class继承

继承一个类的方式如下:

1
2
3
4
5
6
7
8
9
10
11
子类名 = Class("子类名", 父类名)

--构造函数
function 子类名:Ctor()
--成员变量定义
end

--方法定义
function 子类名:方法名()

end

Class对象实例化

Lua OOP对象实例化方式如下:

1
2
3
4
local 变量名 = New(类名)
变量名.成员变量
变量名:成员方法()
变量名.静态方法()

Lua只读Table

在游戏开发里,配置表数据往往是固定不允许修改的,无论是C#还是Lua,对于对象修改限制都只能通过上层封装实现此功能。

而这里我要学习了解的就是在Lua里实现ReadOnly table用于配置表,确保开发人员不会出现对配置表的错误操作。

参考:

Sweet Snippet 之 Lua readonly table

基础版只读Table

在Lua官网上其实已经给出了一版ReadOnly的基础实现思路:

Read-Only Tables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function readOnly (t)
local proxy = {}
local mt = { -- create metatable
__index = t,
__newindex = function (t,k,v)
error("attempt to update a read-only table", 2)
end
}
setmetatable(proxy, mt)
return proxy
end

days = readOnly{"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"}

print(days[1]) --> Sunday
days[2] = "Noday" --> stdin:1: attempt to update a read-only table

通过上面的事例可以看出,设计Readonly table的核心思想是通过**__index**(访问不存在Key时触发)和**__newindex**(赋值不存在Key时触发)元方法来接手所有的table访问赋值。

为了确保**__index__newindex的触发,我们需要通过实现一个空table作为代理table来实现所有的Key访问和赋值都能触发__index__newindex**,然后通过实现**__index__newindex**将数据访问赋值指向原始table来实现数据访问和ReadOnly功能。

进阶版只读Table(支持#和pairs)

上面的事例代码虽然实现了基础的ReadOnly功能,但当我们采用#或pairs访问数据长度和数据时会发现,访问不到数据,原因是因为我们的代理table本来就没有数据并且我们也没有自己实现**__len__pairs**元方法指向原始数据访问导致的。所以进阶版ReadOnly实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function ReadOnly (t, name)
local proxy = {}
local mt = { -- create metatable
__index = t,
__newindex = function (t,k,v)
error(string.format("attempt to update a read-only table:%s", name))
end,
__len = function()
return #t
end,
__pairs = function()
return next, t, nil
end
}
setmetatable(proxy, mt)
return proxy
end

local originalTable = {}
table.insert(originalTable, "A")
table.insert(originalTable, "C")
table.insert(originalTable, "B")
local readOnlyTable = ReadOnly(originalTable, "readOnlyTable")
print(#readOnlyTable)
print("==========================")
for key, value in pairs(readOnlyTable) do
print(key)
print(value)
end
print("==========================")
for index, value in ipairs(readOnlyTable) do
print(index)
print(value)
end
print("==========================")
readOnlyTable[3] = "BB"
--readOnlyTable[4] = "D"

ReadOnlyTableOutput

可以看到,通过重写**__len__pairs**我们已经支持了ReadOnly的长度获取和pairs遍历访问。

完善版只读Table(支持递归ReadOnly)

经过上面的努力我们已经支持了#和pairs的遍历操作,但我们的只读Table还只支持了一层,也就是说嵌套的table并没有支持只读设定。

实现思路:

  1. 递归对所有的table调用ReadOnly方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
--- 转换table成只读table
---@param t table @需要变成ReadOnly的table
---@param name string @ReadOnly table的名字(方便报错打印定位)
---@param depth number @只读table深度
---@return table @转换后的只读table
function ReadOnlyTable(t, name, depth)
if type(t) ~= "table" then
error("not support none table to a recusive read-only table!")
return t
end
depth = depth or 1
if depth > 1 then
local nextDepth = depth - 1
for key, value in pairs(t) do
if type(value) == "table" then
t[key] = ReadOnlyTable (t[key], name, nextDepth)
end
end
end
local proxy = {}
local mt = {
__index = t,
__newindex = function (t,k,v)
error(string.format("attempt to update a read-only table:%s", name))
end,
__len = function()
return #t
end,
__pairs = function()
return next, t, nil
end
}
setmetatable(proxy, mt)
return proxy
end

local originalTable = {}
originalTable["A"] = "A"
originalTable["NestTable"] = {
["Nest_A"] = "Nest_A",
["Nest_B"] = "Nest_B",
["NestNestTable"] = {
["Nest_Nest_A"] = "Nest_Nest_A",
["Nest_Nest_B"] = "Nest_Nest_B",
}
}
originalTable["B"] = "B"
local readOnlyTable = ReadOnlyTable(originalTable, "readOnlyTable", 2)
print_table(readOnlyTable)
print(#readOnlyTable)
print("==========================")
for key, value in pairs(readOnlyTable) do
print(key)
print(value)
end
print("==========================")
--readOnlyTable["A"] = "AA" --> ReadOnly Error
--readOnlyTable["NestTable"]["Nest_A"] = "Nest_AA" --> ReadOnly Error
readOnlyTable["NestTable"]["NestNestTable"]["Nest_Nest_A"] = "Nest_Nest_AA"
print_table(readOnlyTable)

RecusiveReadOnlyTableOuput

通过上面的完善,我们成功的实现了支持指定深度的ReadOnly表实现。

优化点

  1. 考虑到原表的性能开销,我们可以在开发期开启ReadOnly表的设计,发包后直接采用原始表访问的方式来优化掉这部分性能开销

Lua回调绑定

在Lua里函数有着第一类值的说法(在Lua中函数和其他值(数值、字符串)一样,函数可以被存放在变量中,也可以存放在表中,可以作为函数的参数,还可以作为函数的返回值。)。

很多时候我们想传递一个类方法作为回调方法,希望回来的时候是自带self,如果我们只按以下写法来使用,我们将丢掉self这个上下文:

1
2
3
4
5
6
7
8
9
10
11
local testHandler = {}

function testHandler:FuncWithoutParams()
print("testHandler:FuncWithoutParams()")
end

function testHandler:TestHandler(cb)
cb()
end

testHandler:TestHandler(testHandler.FuncWithoutParams)

所以为了封装self的,我们需要通过闭包机制来实现一个handler,以下代码实现了带参和不带参的handler.lua版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- File Name:      Handler.lua
-- Description: 带self的回调绑定
-- Author: TonyTang
-- Create Date: 2021/6/5

--- 无参委托绑定
---@param obj table @对象
---@param method func() @方法
---@return func() @委托绑定无参方法
function handler(obj, method)
return function()
method(obj)
end
end

--- 带参委托绑定
---@param obj table @对象
---@param method func() @方法
---@param ... any @参数
---@return func(...) @委托绑定带参方法
function handlerBind(object, method, ...)
local params = table.pack(...)
return function()
method(obj, table.unpack(params))
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local testHandler = {}

function testHandler:FuncWithoutParams()
print("testHandler:FuncWithoutParams()")
end

function testHandler:FuncWithParams(param1, param2, param3)
print(string.format("testHandler:FuncWithParams(%s, %s, %s)", param1, param2, param3))
end

function testHandler:TestHandler()
local handler = _G.handler(self, self.FuncWithoutParams)
handler()
end

function testHandler:TestHandlerBind(param1, param2, param3)
local handlerBind = _G.handlerBind(self, self.FuncWithParams, param1, param2, param3)
handlerBind()
end

testHandler:TestHandler()
testHandler:TestHandlerBind(1, 2, 3)
testHandler:TestHandlerBind(1, nil, 3)
testHandler:TestHandlerBind(nil, 2, 3)

HandlerOutput

但上面这种设计,还不支持自定义传参的基础上保留自定义函数传参:

1
2
3
4
5
6
7
8
9
10
11
function testHandler:FuncWithParams(...)
print("testHandler:FuncWithParams()")
print(...)
end

function testHandler:TestHandlerBind(...)
local handlerBind = _G.handlerBind(self, self.FuncWithParams, "param1", "param2")
handlerBind(...)
end

testHandler:TestHandlerBind(1, 2, 3)

理论上上面这种情况,我们预期的testHandler:FuncWithParams()最终调用传入的参数为2+N个(“param1”+”param2”+…)

实现上面的需求,核心我们需要利用table.pack和table.unpack的机制(但要注意table.unpack(tb)默认效果等价于table.unpack(tb, 1, #tb)即会被nil打断的,这个不是我们希望看到的)。

解决上述问题,我们只需要通过封装参数pack和unpack过程,并明确指定table.unpack(tb, 1, count)的方式来解决被nil打断的情况,最终handler.lua代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
-- File Name:      Handler.lua
-- Description: 带self的回调绑定
-- Author: TonyTang
-- Create Date: 2021/6/5

--- 无参委托绑定
---@param obj table @对象
---@param method func() @方法
---@return func() @委托绑定无参方法
function handler(obj, method)
return function(...)
method(obj, ...)
end
end

--- 带参委托绑定
---@param obj table @对象
---@param method func() @方法
---@param ... any @可变长参数
---@return func(...) @委托绑定带参方法
function handlerBind(object, method, ...)
local params = SafePack(...)
return function(...)
local params2 = SafePack(...)
local concateParams = ConcatePack(params, params2)
method(obj, SaveUnpack(concateParams))
end
end

--- 安全Pack(解决原生pack的nil截断问题, SafePack与SafeUnpack要成对使用)
---@return table @pack后的参数table
function SafePack(...)
local params = {...}
params.n = select("#", ...)
return params
end

--- 安全Unpack(解决原生unpack被nil阶段问题, SafePack与SafeUnpack要成对使用)
---@return ... @unpack后的参数列表
function SaveUnpack(parakParams)
--- 使用table.unpack()显示指定起始值和数量的方式避免被nil打断
return table.unpack(parakParams, 1, parakParams.n)
end

--- 合并参数
---@param packParams1 table @已经pack的参数table1
---@param packParams2 table @已经pack的参数table2
---@return table @合并pack后table
function ConcatePack(packParams1, packParams2)
--- 基于table.pack和table.unpack机制合并参数
local finalParams = {}
local params1Count = packParams1.n
for i = 1, params1Count, 1 do
finalParams[i] = packParams1[i]
end
local params2Count = packParams2.n
for i = 1, params2Count, 1 do
finalParams[params1Count + i] = packParams2[i]
end
finalParams.n = params1Count + params2Count
return finalParams
end

Note:

  1. table.unpack()默认是table.unpack(tb, 1, #tb),所以如果传递了nil的话是会被打断无法返回所有的

Lua小知识

  1. Lua里没有Class只有通过Table模拟的Class
    参考前面的Lua OOP实现

  2. Lua里没有this的概念,self不等价于this,函数调用传的是谁谁就是self。Lua里.定义和:定义相对于self的用法来说相当于静态和成员定义。
    Lua里通过.调用的话是不会默认传递自身作为self的,不主动传的话self为空
    Lua里通过:调用的话默认第一个参数就是调用者,既self

1
2
3
4
5
6
7
8
9
10
11
12
13
tab = {}
function tab.dotFunc(param)
print("self = ", self)
print(param)
end

function tab:colonFunc(param)
print("self = ", self)
print(param)
end

tab.dotFunc(1)
tab:colonFunc(1)
![LuaDotAndColon](/img/Lua/LuaDotAndColon.png)
Note:
:只是起了省略第一个参数self的作用,该self指向调用者本身,并没有其他特殊的地方。
  1. Lua中的函数是带有词法定界(lexical scoping)的第一类值(first-class values)(在Lua中函数和其他值(数值、字符串)一样,函数可以被存放在变量中,也可以存放在表中,可以作为函数的参数,还可以作为函数的返回值)

    1
    2
    3
    4
    5
    6
    7
    8
    class = {}
    function class:Func(param1, func)
    print(param1)
    func()
    end

    myfunc = function() print("tempFunc()") end
    class:Func(1, myfunc)

    LuaFirstClassValues

  2. 指定Lua搜索文件路径

    1
    package.path = *

    package.path是在Lua触发require时会去搜索的路径,默认是LUA_PATH_5_3或者LUA_PATH,详情参考:Lua Manual

  3. Lua文件热重载
    Lua通过require加载进来的文件都会被记录在package.loaded里,所以Lua热重载的核心就是让pacakge.loaded里的对应Lua文件清除后重新加载。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ---热重载指定Lua脚本
    function reloadLua(filename)
    if(package.loaded[filename] == nil) then
    Log.log(string.format("lua脚本 : %s 未被加载", filename))
    else
    Log.log(string.format("lua脚本 : %s 已加载", filename))
    --重置loaded信息
    package.loaded[filename] = nil
    end
    --重新加载Lua脚本
    require(filename)
    end
  4. Lua里pairs和ipairs有区别。pairs是key,value的形式遍历所有值。ipairs是从1开始按顺序递增遍历。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    testtab = {[1] = "Tony", [4] = "Huan", [2] = "Tang", [5] = nil }

    for k, v in pairs(testtab) do
    print(k)
    print(v)
    end

    print("=================")

    for i, v in ipairs(testtab) do
    print(i)
    print(v)
    end

    LuaPairsAndIpairs

    Note:
    pairs不会被nil打断但ipairs都会被nil打断
    ipairs还会被不是按1递增的打断

  5. Lua参数传递
    Lua里通过arg[?]的形式访问传入的参数,默认第一个参数是arg[1],arg[0]是lua脚本自身

  6. Lua里面可以通过#得到table长度
    Note:
    #会被nil打断

  7. Lua里打印数据类型用type

1
2
print(type(1))
print(type("Tony"))

LuaType

Lua常用插件

luarocks

LuaRocks is the package manager for Lua modules.

从官方的介绍可以看出,LuaRocks相对于Lua,就好比Package Ctronl在Sublime Text里的存在,主要目的就是包管理器,方便Lua快速安装各种第三方Lua库。

根据官方的教程,LuaRocks安装设置相当复杂,单纯下载一个exe还有很多环境问题要处理。

这里本人不想使用官方Installation instructions for WindowsNew Page]的一键安装方式,主要是希望能用Lua5.3,他一键安装的方式好像是Lua5.1,所以最后打算把Lua和LuaRocks都按照以下博主的方式从源码编译走一遍流程:

Windows 平台 Luarocks 3.0.2 编译安装

  • 安装MinGW(用于编译源码)

    添加**/MinGW/bin到环境变量,确保gcc编译器能找到.

    WhereMinGW

    关于MinGW和Cygwin的区别这里暂时不深入,详情参考:

    MinGw与Cygwin的区别

    MinGW 的主要方向是让GCC的Windows移植版能使用Win32API来编程。 Cygwin 的目标是能让Unix-like下的程序代码在Windows下直接被编译。

    这里考虑到这里的主要目的是编译Windows版的Lua5.3,用哪一个都行,暂时是用MinGW了。

  • 编译Lua5.3

    下载Lua5.3源码

    MinGW安装好后,编译Lua5.3就很容易了,因为Lua5.3里面自带了MakeFile(指定make如何编译的文件,这里暂时不深入学习),通过命令:

    1
    mingw32-make mingw

    就能触发编译Lua5.3,但为了LuaSocks后续会用到的相关目录准备工作,这里还是采用前面博主提供的build.bat:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    @echo off
    :: ========================
    :: build.bat
    ::
    :: build lua to dist folder
    :: tested with lua-5.3.5
    :: based on:
    :: https://medium.com/@CassiusBenard/lua-basics-windows-7-installation-and-running-lua-files-from-the-command-line-e8196e988d71
    :: ========================
    setlocal
    :: you may change the following variable’s value
    :: to suit the downloaded version
    set work_dir=%~dp0
    :: Removes trailing backslash
    :: to enhance readability in the following steps
    set work_dir=%work_dir:~0,-1%
    set lua_install_dir=%work_dir%\dist
    set compiler_bin_dir=%work_dir%\tdm-gcc\bin
    set lua_build_dir=%work_dir%
    set path=%compiler_bin_dir%;%path%
    cd /D %lua_build_dir%
    mingw32-make PLAT=mingw
    echo.
    echo **** COMPILATION TERMINATED ****
    echo.
    echo **** BUILDING BINARY DISTRIBUTION ****
    echo.
    :: create a clean “binary” installation
    mkdir %lua_install_dir%
    mkdir %lua_install_dir%\doc
    mkdir %lua_install_dir%\bin
    mkdir %lua_install_dir%\include
    mkdir %lua_install_dir%\lib
    copy %lua_build_dir%\doc\*.* %lua_install_dir%\doc\*.*
    copy %lua_build_dir%\src\*.exe %lua_install_dir%\bin\*.*
    copy %lua_build_dir%\src\*.dll %lua_install_dir%\bin\*.*
    copy %lua_build_dir%\src\luaconf.h %lua_install_dir%\include\*.*
    copy %lua_build_dir%\src\lua.h %lua_install_dir%\include\*.*
    copy %lua_build_dir%\src\lualib.h %lua_install_dir%\include\*.*
    copy %lua_build_dir%\src\lauxlib.h %lua_install_dir%\include\*.*
    copy %lua_build_dir%\src\lua.hpp %lua_install_dir%\include\*.*
    copy %lua_build_dir%\src\liblua.a %lua_install_dir%\lib\liblua.a
    echo.
    echo **** BINARY DISTRIBUTION BUILT ****
    echo.
    %lua_install_dir%\bin\lua.exe -e "print [[Hello!]];print[[Simple Lua test successful!!!]]"
    echo.

    :: configure environment variable
    :: https://stackoverflow.com/a/21606502/4394850
    :: http://lua-users.org/wiki/LuaRocksConfig
    :: SETX - Set an environment variable permanently.
    :: /m Set the variable in the system environment HKLM.
    setx LUA "%lua_install_dir%\bin\lua.exe" /m
    setx LUA_BINDIR "%lua_install_dir%\bin" /m
    setx LUA_INCDIR "%lua_install_dir%\include" /m
    setx LUA_LIBDIR "%lua_install_dir%\lib" /m

    pause

    这样一来Lua编译以及Lua环境变量设置就搞定了:

    LuaBuildOutput

    LuaEnvSetting

    测试以下Lua使用:

    MakeSureLuaExits

    Note:

    1. 因为我们使用的是MinGW,所以博客上的make命令是找不到的需要用mingw32-make
    2. 因为涉及到设置环境变量等操作所以启动cmd或者powershell运行bat时需要以管理员身份
    3. 上文的Lua环境变量设置并未设置Lua到Path中,所以还需要单独添加Lua/bin到能确保Lua能被找到使用
  • 编译LuaRocks

    下载LuaRocks源码(下一键带Install.bat的)

    执行Install.bat触发编译流程

    LuaRocksInstallCommand

    详细参数介绍参考:

    Installation instructions for Windows

    最后看到LuaRocks安装成功:

    LuaRocksInstallSuccess

    最后将LuaRocks/systree/bin所在目录添加到环境变量Path里,这样通过LuaRocks安装的第三方库就可以使用了(经测试单独只加到Path里还不行(好像是因为Lua和LuaRocks默认分开安装的),package.path里默认是不会去LuaRocks/systree/bin下找的),需要执行以下命令来查看手动设置LUA_PATH所需的位置信息:

    1
    luarocks path --bin

    LuaRocksPathAndCPath

    原本的LUA_PATH和LUACPATH信息:

    OrignalLuaPathAndCPath

    把上面两个手动设置到环境变量LUA_PATH和LUA_CPATH里:

    LuaEnviromentSetting

接下来测试LuaRocks的使用:

我们尝试安装serpent:

1
luarocks install serpent

InstallSerpent

SerpentInstallatio

后续会讲到serpent的使用学习,到这里Lua5.3和LuaRocks以及serpent的LuaRocks快速安装就全部结束了。

serpent

Lua serializer and pretty printer.

从Git上的介绍可以看出,Serpent是一个支持序列化反序列化以及格式化打印的第三方库。

比如我们希望把内存里的某个Table序列化到本地,支持下一次反序列化加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
print("SerpentStudy!")

require "DIYLog"

local serpent = require "serpent"

local serializeDataTable = {
["Name"] = "TonyTang",
detailInfo = {
["Age"] = 18,
["BirthDate"] = "2000/01/01",
["Adrress"] = "SiChuan"
}
}

print("=================")
local serializeString = serpent.dump(serializeDataTable)
print(serializeString)

print("=================")

local formatSerializeString = serpent.block(serializeDataTable)
print(formatSerializeString)

print("=================")

local result, deserializeDataTable = serpent.load(formatSerializeString)
print_table(deserializeDataTable)

输出:

SerpentOutput

从上面测试用例结果可以看出,通过Serpent我们轻易的做到了Lua序列化反序列化以及格式化输出

Lpeg

LPeg is a new pattern-matching library for Lua, based on Parsing Expression Grammars (PEGs).

从官方的介绍可以看出Lpeg是一个类似正则的但并不是正则的新一代(基于Parsing Expression Grammars)的模式匹配Lua库。

那么什么是Parsing Expression Grammars了?

a parsing expression grammar (PEG), is a type of analytic formal grammar, i.e. it describes a formal language in terms of a set of rules for recognizing strings in the language.

根据Wiki的介绍可以看出Parsing Expression Grammars就是我们编程语言里的解析表达式语法。

为了进一步的深入学习和使用,让我们先通过Luasocks安装Lpeg库。

LpegInstallation

接下来文档Lpeg和下面这篇文章通过 LPeg 介绍解析表达式语法(Parsing Expression Grammars)来深入学习Lpeg的设计和使用:

Pattern

Pattern差不多可以理解成正则里的匹配表达式。

为了方便理解,这里先截图下Lpeg Pattern相关定义的基础介绍:

LpegPatternsDoc

Match

Match也和正则里的概念差不多,就是触发指定字符串使用指定Pattern进行匹配的一个过程,同时返回匹配相关结果(比如返回匹配到的字符位置的下一个字符位置。或者通过Capture函数将匹配结果转换成指定值)

了解了Lpeg里面的一些基础Pattern定义后,让我们看看Lpeg里匹配函数match的介绍:

lpeg.match (pattern, subject [, init])

The matching function. It attempts to match the given pattern against the subject string. If the match succeeds, returns the index in the subject of the first character after the match, or the captured values(if the pattern captured any value).

Unlike typical pattern-matching functions, match works only in anchored mode; that is, it tries to match the pattern with a prefix of the given subject string (at position init), not with an arbitrary substring of the subject. So, if we want to find a pattern anywhere in a string, we must either write a loop in Lua or write a pattern that matches anywhere.

从上面的介绍可以看出,match匹配成功会返回匹配位置的下一个位置或者是捕获到的值(后续会讲到捕获函数相关)。同时match不想正则可以把所有匹配都找出来而是需要通过自行通过match的init方式来loop所有捕获后的子字符串来找出所有匹配的结果。

Note:

  1. 默认情况下,成功匹配时,LPeg 将返回字符消耗数(译者注: 也就是成功匹配子串之后的下一个字符的位置). 如果你只是想看看是否匹配这就足够好了,但如果你试图解析出字符串的结构来,你必须用一些 LPeg 的捕获(capturing)函数.

Capture

A capture is a pattern that produces values (the so called semantic information) according to what it matches. LPeg offers several kinds of captures, which produces values based on matches and combine these values to produce new values. Each capture may produce zero or more values.

从介绍大概理解Capture也是一种Pattern,但它是一种基于Pattern匹配结果转换出结果值的Pattern而不是常规的匹配Pattern(个人理解就好比Pattern+转换函数)。

为了方便理解,这里先截图下Capture相关定义的基础介绍:

LpegCaptureDoc

Note:

  1. A capture pattern produces its values only when it succeeds.(Capture Pattern只会在成功时计算值)

实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
print("LpegStudy")

require "DIYLog"

--- 最终目标:
--- 1. {***}占位替换
--- 2. {num string1 | string2 | string3 }根据num选择对应字符串

local lpeg = require("lpeg")
local match = lpeg.match
local P = lpeg.P
local R = lpeg.R

--- 普通匹配
print("普通匹配:")
--- 不匹配,返回nil
print(match(P("hello"), "world"))
--- 一个匹配,返回6
print(match(P("hello"), "hello"))
--- 一个匹配,返回6
print(match(P("hello"), "helloworld"))

--- 模式组合
print("模式组合:")
local helloPattern = P("hello")
local worldPattern = P("world")
--- *表示匹配pattern1后面跟随pattern2
local helloAndworldPattern = helloPattern * worldPattern
--- +表示匹配pattern1或者pattern2
local helloOrworldPattern = helloPattern + worldPattern
--- 一个匹配,返回11
print(match(helloAndworldPattern, "helloworld"))
--- 不匹配,返回nil
print(match(helloAndworldPattern, "worldhello"))
--- 1个匹配,返回6
print(match(helloOrworldPattern, "helloworld"))
--- 1个匹配,返回6
print(match(helloOrworldPattern, "worldhello"))

--- 解析数字
print("解析数字:")
local integerPattern = R("09")^1
--- 解析数字带捕获函数
local integerCapturePattern = R("09")^1 / tonumber
--- 匹配,返回5
print(match(integerPattern, "2923 2924"))
--- 匹配,返回2923
print(match(integerCapturePattern, "2923 2924"))

--- 解析数字或者字符
print("解析数字或者字符:")
local charPattern = P(1)
--- 匹配,返回2
print(match(charPattern, "hello 123"))
local integerOrchaPattern = integerCapturePattern-- + charPattern
--- 利用Capture.Ct函数存储所有匹配结果到表中
local integerOrcharCapture = lpeg.Ct(integerOrchaPattern^0)
--- 未匹配输出 {}
print_table(match(integerOrcharCapture, "hello!"))
--- 匹配,输出:{123}
print_table(match(integerOrcharCapture, "hello 123"))
--- 匹配,输出:{5,5,5,7,7,7}
print_table(match(integerOrcharCapture, "5 5 5 yeah 7 7 7"))
--- 匹配,输出:{5,5,5,7,7,7}
print_table(match(integerOrcharCapture, "5 5 5 a b c d 7 7 7"))

--- 计算器语法解析器
print("计算器语法解析器:")
local white = lpeg.S(" \t\r\n") ^ 0

local integer = white * lpeg.R("09") ^ 1 / tonumber
local muldiv = white * lpeg.C(lpeg.S("/*"))
local addsub = white * lpeg.C(lpeg.S("+-"))

local factor = integer * muldiv * integer + integer

--- 匹配,输出:{5}
print_table(match(lpeg.Ct(factor), "5"))
--- 匹配,输出:{2,"*",1}
print_table(match(lpeg.Ct(factor), "2*1"))

--- 字符串匹配替换
--- 实现类似gsub的方法的效果
print("字符串匹配替换:")
--- lpeg.Ct -- 仅仅捕获到table
function find(s, patt)
patt = P(patt) / tostring
local p = lpeg.Ct((patt + 1)^0)
return match(p, s)
end

--- lpeg.Cs -- 捕获并替换
function gsub(s, patt, repl)
patt = P(patt)
local p = lpeg.Cs((patt / repl + 1)^0)
return match(p, s)
end

--- 匹配,输出:{"dog","dog"}
print_table(find("hello dog, dog!", "dog"))
--- 匹配,输出:{"hello cat, cat!"}
print_table(gsub("hello dog, dog!", "dog", "cat"))

--- 最终目标1: {***}占位替换
--- 指定字符串,采用table里的key替换{key}成key对应的value
function tableGsub(s, tb)
local result = s
local replaceKey = nil
local replaceValue = nil
for key, value in pairs(tb) do
replaceKey = string.format("{%s}", key)
replaceValue = tostring(value)
print(string.format("字符串:%s 替换:%s为%s", result, replaceKey, replaceValue))
result = gsub(result, replaceKey, replaceValue)
print(string.format("替换后结果:%s", result))
end
return result
end

local content = "测试table替换,名字:{name} 年龄:{age} 性别:{sex} 名字2:{name} 名字3:{name}"
local replaceTable = {
name = "TonyTang",
sex = "Man",
age = 18,
luckyNumber = 7,
}
local timeCounter = New(TimeCounter, "tableGsub")
timeCounter:Start()
content = tableGsub(content, replaceTable)
timeCounter:Stop()
print("替换后结果:replaceValue")
print(content)
print(string.format("替换耗时:%s", timeCounter.EllapseTime))

Lua IDE

首先这里决定选一个相对轻量级的Lua IDE,VS因为太重量级了,这里不考虑。
其次考虑到跨平台和前端常用的(平时主要会用到C#和Lua),最初决定选择VSCode作为Lua代码编写的IDE,VSCode丰富的插件库也是选择VSCode很重要的一个原因之一。经过调研发现VSCode第三方插件Luaide在VSCode 1.33.1以上版本有严重的内存暴涨问题,所以最后笔者转向了VSCode + EmmyLua 插件的方式。

VSCode

Visual Studio Code(简称VS Code)是一个由微软开发的,同时支持Windows、Linux、和macOS系统且开放源代码的代码编辑器[4],它支持测试,并内置了Git 版本控制功能,同时也具有开发环境功能,例如代码补全(类似于 IntelliSense)、代码片段、和代码重构等,该编辑器支持用户个性化配置,例如改变主题颜色、键盘快捷方式等各种属性和参数,还在编辑器中内置了扩展程序管理的功能。

设置同步

VSCode支持高度自由的自定义插件和自定义设置,我们如何在不同的工作环境快速同步IDE信息(插件,自定义设置等),避免每一次在不同工作环境下都重复下载插件设置等操作是一个需要解决的问题。

借助于第三方插件:
SettingsSync(使用了Github Gist)可以同步保存VSCode配置和扩展。

详细配置流程参考:
Settings Sync

上传快捷键:
Shift + Alt + U
下载同步快捷键:
Shift + Alt + D

快捷键

当拿到新的IDE时,为了工作效率,快捷键不熟是一个很影响开发效率的问题。
VSCode丰富的插件库和高度的可自定义可以轻松解决这个问题:

  1. Visual Studio Keymap(第三方插件 – 快速导入VS的快捷键)
  2. Custom Shortcup Keymap(自定义设置快捷键File -> Preference -> Keyboard Shortcuts – 用于满足自定义需求)

Snippets

Snippets又名代码片段,主要目的是为了通过自定义关键词和模板,快速触发代码片段生成。
这里主要针对自定义代码片段的功能使用来学习。
File -> Preference -> User Snippets
e.g.个人文件署名的代码片段:
lua.json

1
2
3
4
5
6
7
8
9
10
11
12
{
"Lua File Title Sneppits": {
"prefix": "LuaFileTitle",
"body": [
"-- File Name: $TM_FILENAME",
"-- Description: This file is used to ${1:Staff}",
"-- Author: ${2:TangHuan}",
"-- Create Date: ${3:Year}/${4:Month}/${5:Day}",
],
"description": "Lua File Title Sneppits"
}
}

这样一来当我们输入LuaFileTitle关键词时,VSCode就会自动触发提示,直接table键选择Snippets就能触发代码生成。
效果如下:

1
2
3
4
-- File Name:    Base.lua
-- Description: This file is used to Staff
-- Author: TangHuan
-- Create Date: Year/Month/Day

详细使用Snippets参考:
Creating your own snippets

Lua调试

Lua作为解析执行的语言,调试相比其他高级语言比较麻烦一点,这里选择借助第三方(Luaide)插件作为辅助工具。

经过调研发现VSCode第三方插件Luaide在VSCode 1.33.1以上版本有严重的内存暴涨问题,所以最后笔者转向了VSCode + EmmyLua 插件的方式。
这里就没有详细了解VSCode + LuaIde的调试方式了。

EmmyLua

EmmyLua
VSCode安装EmmyLua很简单,直接插件里搜索安装即可,这里就不多做说明。

接下来主要结合XLua+VSCode+EmmyLua实战使用EmmyLua提示和调试两大重要功能。

VSCode配置

安装EmmyLua插件后,VSCode基本就已经完成配置了,但要使用VSCode开始编写Lua代码,我们还需要把Lua代码导入到VSCode里来。

VSCode导入Lua代码是以目录的形式,我们导入的我们加载Lua文件所在的最上层目录即可:
LuaFolderStructure

XLua

XLua官网
XLua的集成很简单,从Git下下来,把关键的几个目录放到项目里即可。
主要是如下几个目录:

  1. Tools(打包时XLua代码注入需要的工具在这里 Note: 放Assets同一层不要放到Assets里了)
  2. Assets/XLua/Src(XLua需要的CS源代码)
  3. Assets/XLua/Editor/ExampleConfig.cs(XLua默认给的一套纯Lua开发基于反射生成需要标记CSharpCallLua || LuaCallCSharp || BlackList || Hotfix的一套模板)
  4. Assets/Plugins(XLua编译多平台后的库文件)

Unity加载Lua文件默认不认.lua后缀的文件,我们需要自定义CustomLoader去加载到我们想要加载的.lua文件。
LuaManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using XLua;

/// <summary>
/// Lua单例管理者
/// </summary>
public class LuaManager : SingletonMonoBehaviourTemplate<LuaManager> {

/// <summary>
/// Lua环境(全局唯一)
/// </summary>
public LuaEnv LuaEnv
{
get;
private set;
}

/// <summary>
/// Lua脚本默认创建目录路径
/// </summary>
private string LuaScriptFolderPath;

/// <summary>
/// Lua脚本文件后缀
/// </summary>
private const string LuaFilePostFix = ".lua";

/// <summary>
/// Lua脚本文件路径映射Map
/// Key为文件名(不含.lua后缀),Value为该文件的全路径(含.lua后缀)
/// </summary>
private Dictionary<string, string> mLuaScriptFilePathMap;

/// <summary>
/// Lua初始化
/// </summary>
public override void init()
{
Debug.Log("LuaManager:init()");
if (LuaEnv == null)
{
LuaEnv = new LuaEnv();
}
else
{
Debug.Log("Lua环境已经初始化!");
return;
}

LuaScriptFolderPath = Application.dataPath + "/Scripts/XLua/Lua/";
mLuaScriptFilePathMap = new Dictionary<string, string>();

//自定义CustomLoader
LuaEnv.AddLoader(LuaCustomLoader);

//读取所有Lua文件
readAllLuaFiles();

//加载LuaMain.lua
LuaEnv.DoString("require 'LuaMain'");
}

private void readAllLuaFiles()
{
var allluafiles = Directory.GetFiles(LuaScriptFolderPath, "*.lua", SearchOption.AllDirectories);
foreach(var luafile in allluafiles)
{
var luafilename = Path.GetFileNameWithoutExtension(luafile);
if(!mLuaScriptFilePathMap.ContainsKey(luafilename))
{
mLuaScriptFilePathMap.Add(luafilename, luafile);
}
else
{
Debug.LogError(string.Format("有重名的Lua文件,文件路径:{0}文件路径:{1}",luafile, mLuaScriptFilePathMap[luafilename]));
}
}
}

/// <summary>
/// Lua自定义Loader
/// </summary>
/// <param name="filename"></param>
/// <returns></returns>
private byte[] LuaCustomLoader(ref string filename)
{
Debug.Log(string.Format("加载Lua文件 : {0}", filename));
var scriptfullpath = string.Empty;
#if UNITY_EDITOR
//Editor走File加载
//Unity不认Lua后缀的情况
var luascriptfolderpath = string.Empty;
byte[] luafile = null;
if (mLuaScriptFilePathMap.TryGetValue(filename, out scriptfullpath))
{
luafile = File.ReadAllBytes(scriptfullpath);
}
#else
//TODO
//真机走其他方式加载
return null;
#endif
if (luafile != null)
{
return luafile;
}
else
{
Debug.LogError(string.Format("找不到Lua文件 : {0}", filename));
return null;
}
}
}

可以看到为了成功加载.lua后缀的Lua文件我做了以下几件事:

  1. 通过Directory.GetFiles()获取到所有*.lua文件的路径映射,用于加载时消除目录的概念
  2. 自定义CustomLoader,支持加载(通过File.ReadAllBytes).lua后缀文件
代码提示

Test.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- File Name:    Test.lua
-- Description: This file is used to Test EmmyLua
-- Author: TangHuan
-- Create Date: 2019/04/29

--- 自定义table类型,测试Emmylua注释提示
---@class diytable
---@field field1 number 字段1
---@field field2 string 字段2
local diytable = {
field1 = 321,
field2 = "field2"
}

---@param para1 number 参数1
---@return diytable 返回diytable
function Test(para1)
print("Test(" .. para1 .. ")")
return diytable
end

local table = Test(123)
print(table.field1)
print(table.field2)

输入table.时会看到成员变量提示:
EmmyLuaNotation

EmmyLua提示详细使用参考:
@class类声明注解

查找方法引用

EmmyLua查找方法引用很简单,直接在方法上右键查找所有引用即可:
LuaMethodReference

调试

EmmyLua插件装好后,VSCode导入了Lua加载最上层目录后,调试Lua就很简单了:
F5 -> EmmyLua Attach Debug -> 选择对应Unity进程
然后通过F9下断点就可以像VS一样给Lua下断点了。
VSCodeEmmyLuaDebug

ToLua

待续……

Refrence

通过 LPeg 介绍解析表达式语法(Parsing Expression Grammars)

lpeg

原码, 反码, 补码 详解

谈谈二进制(四)——原码、补码、反码、移码

以前学习笔记

[http://blog.sina.com.cn/s/blog_8c7d49f20102uzsx.html](Game Scripting Mastery)
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0rk.html](Lua – some important concept and knowledge)
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0s0.html](Lua - Coroutines Study )
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0s5.html](Lua - Metatables and Metamethods)
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0s9.html](Lua - Object Oriented Programming)
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0zp.html](Lua - C API)
[http://blog.sina.com.cn/s/blog_8c7d49f20102v0zs.html](Lua - C call Lua & Lua call C )
[http://blog.sina.com.cn/s/blog_8c7d49f20102v1qg.html](Lua - Userdata )
[http://blog.sina.com.cn/s/blog_8c7d49f20102v383.html](Lua - Managing Resources & Threads and States)

官方网站

XLua
EmmyLua

参考书籍

《Programming in Lua third edition》 – Roberto Ierusalimschy
《Lua用户手册》
《Game Scripting Mastery》 – Alex Varanese

Android Sign Study

这一章主要是学习Android签包相关的概念和方法。
通过采用原始签包工具(zipalign & apksigner & keytool),使用命令行的形式对于已经经过Unity打包签名的包进行重签来学习理解相关概念。

Introduction

When

Android requires that all APKs be digitally signed with a certificate before they can be installed.
Android要求所有的APK都必须通过证书签名否则不能被安装到设备上。

What

When you sign an APK, the signing tool attaches the public-key certificate to the APK. The public-key certificate serves as as a “fingerprint” that uniquely associates the APK to you and your corresponding private key.
签名可以唯一标示我们自己的APK。每个签名的APK都会有对应的公钥证书,而这个公钥证书会与我们的private key唯一对应,我们只需保证private key不泄露,就能保证我们签名的程序的唯一性。(公钥证书和私钥是由有公信力的机构分发的确保了公钥的正确性,那么与之唯一对应的私钥就保证了APK的唯一性。详情见后面)

Why

那么我们为什么需要签名了?
以下引用至Android APK签名原理及方法:

  1. 应用程序升级
    如果想无缝升级一个应用,Android系统要求应用程序的新版本与老版本具有相同的签名与包名。若包名相同而签名不同,系统会拒绝安装新版应用。

  2. 应用程序模块化
    Android系统可以允许同一个证书签名的多个应用程序在一个进程里运行,系统实际把他们作为一个单个的应用程序。此时就可以把我们的应用程序以模块的方式进行部署,而用户可以独立的升级其中的一个模块。

  3. 代码或数据共享
    Android提供了基于签名的权限机制,一个应用程序可以为另一个以相同证书签名的应用程序公开自己的功能与数据,同时其它具有不同签名的应用程序不可访问相应的功能与数据。

  4. 应用程序的可认定性
    签名信息中包含有开发者信息,在一定程度上可以防止应用被伪造。例如网易云加密对Android APK加壳保护中使用的“校验签名(防二次打包)”功能就是利用了这一点。

Conceptions

数据摘要(Message Digest)

以下内容引用至Android签名机制之—签名过程详解
这个知识点很好理解,百度百科即可,其实他也是一种算法,就是对一个数据源进行一个算法之后得到一个摘要,也叫作数据指纹,不同的数据源,数据指纹肯定不一样,就和人一样。

消息摘要算法(Message Digest Algorithm)是一种能产生特殊输出格式的算法,其原理是根据一定的运算规则对原始数据进行某种形式的信息提取,被提取出的信息就被称作原始数据的消息摘要。
著名的摘要算法有RSA公司的MD5算法和SHA-1算法及其大量的变体。
消息摘要的主要特点有:
1)无论输入的消息有多长,计算出来的消息摘要的长度总是固定的。例如应用MD5算法摘要的消息有128个比特位,用SHA-1算法摘要的消息最终有160比特位的输出。
2)一般来说(不考虑碰撞的情况下),只要输入的原始数据不同,对其进行摘要以后产生的消息摘要也必不相同,即使原始数据稍有改变,输出的消息摘要便完全不同。但是,相同的输入必会产生相同的输出。
3)具有不可逆性,即只能进行正向的信息摘要,而无法从摘要中恢复出任何的原始消息。

可以看出数据摘要确保了数据的完整性。

数字签名(Signature)

以下内容引用至APK签名原理解析:
数字签名是非对称密钥加密技术与数字摘要技术的应用。将信息摘要用发送者的私钥加密,与原文一起传送给接收者。接收者只有用发送者的公钥才能解密被加密的信息摘要,然后接收者用相同的Hash函数对收到的原文产生一个信息摘要,与解密的信息摘要做比对。如果相同,则说明收到的信息是完整的,在传输过程中没有被修改;不同则说明信息被修改过,因此数字签名能保证信息的完整性。并且由于只有发送者才有加密摘要的私钥,所以我们可以确定信息一定是发送者发送的(发送者的身份认证)。

可以看出数字签名确保了数据的正确性(数据来源可靠性),但需要确保公钥的安全传输问题。

CA机构(Certification Authority)

以下内容引用至APK签名原理解析:
受信任的第三方,承担公钥体系中公钥的合法性检验的责任

根证书

以下内容引用至APK签名原理解析:
是未被签名的公钥证书或自签名的证书;是CA认证中心给自己颁发的证书,是信任链的起始点。安装根证书意味着对这个CA认证中心的信任。用户在使用自己的数字证书之前必须先下载根证书。

数字证书(Certification)

以下内容引用至Android签名机制之—签名过程详解:
所谓数字证书,一般包含以下一些内容:

  1. 证书的发布机构(Issuer)
  2. 证书的有效期(Validity)
  3. 消息发送方的公钥
  4. 证书所有者(Subject)
  5. 数字签名所使用的算法
  6. 数字签名
    可以看出,数字证书其实也用到了数字签名技术。只不过要签名的内容是消息发送方的公钥,以及一些其它信息。但与普通数字签名不同的是,数字证书中签名者不是随随便便一个普通的机构,而是要有一定公信力的机构。这就好像你的大学毕业证书上签名的一般都是德高望重的校长一样。一般来说,这些有公信力机构的根证书已经在设备出厂前预先安装到了你的设备上了。所以,数字证书可以保证数字证书里的公钥确实是这个证书的所有者的,或者证书可以用来确认对方的身份。数字证书主要是用来解决公钥的安全发放问题。
    CertificationVerificationFlowChat

可以看出通过获得权威的机构获取到唯一的数字证书,这样一来解决了前面数字签名提到的公钥安全传输问题(数字证书能够确保公钥的正确性)

keystores

因为没有正确签名的应用,Android系统不会安装或者运行。所以我们通过有公信力机构得到公钥证书以及与之唯一对应的私钥,我们就能验证特定应用是否是特定私钥签名的应用()
接下来我们了解下Android签名过程中一个重要概念:
keystore存储着公信力机构分发的私钥和公钥证书。
通过使用keystore签名APK,我们能够确保APK的唯一性。

How

接下来通过使用相关签名工具来重签一个已经签过名的APK来实战学习理解签名过程。

apktool

apktool是一个帮助我们编码和反编码APK的一个工具。
这里我有一个已经签过名的APK_Signed_Aligned.apk为例进行重签:
第一步:
准备好APK和apktool:
apktoolprepare
第二步:
我们需要反编码APK:
apktooldecode
第三步:
替换APK_Signed_Aligned里的部分内容(比如替换Log之类的)然后进入刚才反编码的文件夹进行APK重编码(这时我们会得到一个重编码后APK_Unsigned_Unaligned.apk):
apktoolbuild
Note:
In order to run a rebuilt application. You must resign the application. Android documentation can help with this.
此时的APK_Unsigned_Unaligned.apk是处于未签名也未进行压缩数据对齐处理(后续会讲到)。

zipalign

zipalign is an archive alignment tool that provides important optimization to Android application (APK) files. The purpose is to ensure that all uncompressed data starts with a particular alignment relative to the start of the file.
zipalign是一个对于APK压缩数据进行对齐处理的工具。
第四步:
对APK_Unsigned_Unaligned.apk进行压缩数据对齐处理得到压缩数据对齐的APK_Unsigned_Aligned.apk:
zipalignalignment

Note:
zipalign位于Android SDK的build tools目录下。

keytool

Manages a keystore (database) of cryptographic keys, X.509 certificate chains, and trusted certificates.
keytool这里可以理解成有公信力的机构(工具)帮助我们生成唯一的公共证书和与之对应的私钥。

第五步:
在对我们进行压缩数据对齐后的APK_Unsigned_Aligned.apk进行重签之前,我们需要生成自己的keystore用于签名认证我们的APK。
使用keytool生成我们需要的公共证书和与之对应的私钥:
keytoolgenerakeystore
上面要填的信息就是之前在数字证书里提到的生成公共秘钥和私钥需要的信息。
最后得到我们的TonyTangKeyStore.jks(签名需要的keystore,公共秘钥和私钥都在里面)
keystore
接下来我们通过keytool查看我们刚生成的keystore信息(因为不是在同一台电脑上测试学习KeyStore,所以路径并不一致):
KeyStoreInfo
可以看到我们的Keystore是由Sun公司提供的,采用SHA1(前面提到的数字摘要算法之一e.g. MD5 or SHA1)算法计算私钥的数字摘要。

Note:
keytool位于JDK的bin目录下。
上面输入的Keystore密码后续签包时会用到。
上面生成keystore默认只有90天有效期,在开发过程中我们要保证keystore有效期远大于软件生命周期,不然期限到了就无法在继续使用同一个keystore,也就不能保证APK的唯一性了。
keytool貌似有个参数-validity是决定有效期的,这里就没有尝试了,详情参考keytool官网。

apksigner

第六步:
得到了keystore,那么我们就可以进行签名工作了。
使用apksigner进行签包:
apksignersigne
通过apksigner验证特定APK是否签名:
apksignerverify
最终我们得到了我们重新重签后的APK_Signed_Aligned.apk(与我们使用的keystore里的私钥唯一对应)

Note:
The apksigner tool, available in revision 24.0.3 and higher of the Android SDK Build Tools
apksigner工具在AndroidSDK 24.0.3的Build Tool才可用。
其他签名工具还有jarsigner和signapk。

Deep

深入学习理解签包里的相关文件:
这里主要参考别人的学习理解Android签名机制之—签名过程详解
通过上文我们可以看出签名后验证APK的相关文件主要有以下三个文件:

  1. MANIFEST.MF
  2. CERT.SF
  3. CERT.RSA

打开MANIFEST.MF(以下只复制了很少一部分做验证使用):

1
2
3
4
5
6
7
8
Manifest-Version: 1.0
Created-By: 1.0 (Android)

Name: assets/bin/Data/Managed/System.Core.dll
SHA1-Digest: cBOiG0b7uqe/+kFj8qkmBvP2/zw=

Name: AndroidManifest.xml
SHA1-Digest: JYiwTP2AVjbfgZVkMuuhe/bPrf0=

可以看出MANIFEST.MF文件记录了APK里文件内容的SHA1-Digest(数字摘要),这个数字摘要通过上面博主找到的源码来看是通过对文件内容做一次SHA1算法后然后通过Base64 Encode得到的。

下面我们直接通过HashTable和Base64网站验证一下(这里以AndroidManifest.xml为例):
AndroidManifestHashValue
因为我们使用的是SHA1数字摘要算法,所以这里用AndroidManifest.xml的SHA1值ECFB805F9645FB3CDB7B29DB4529B6FD7545261D进行反推。
通过Base64 Encode后:
SHA1AfterBase64
把Base64 Encode后的值和我们在MANIFEST.MF里AndroidManifest.xml的SHA1-Digest进行对比,就会发现是一致的。

这里不再对三个文件做深入研究,上面那位博主已经做了很清晰的解释,详情参见Android签名机制之—签名过程详解
这里只记录下三个文件之间的关系以及在APK简明里起的作用做个总结:

  1. MANIFEST.MF(对APK里的部分文件通过SHA1和Base64 Encode后记录下对应的数字摘要,用于验证APK文件的完整性)
  2. CERT.SF(对MANIFEST.MF文件进行一次SHA1和Basee64 Encode进行记录,并针对MANIFEST.MF里每一块进行SHA1和Base64 Encode并记录,用于验证MANIFEST.MF的完整性)
  3. CERT.RSA(这里会包含我们通过keystore生成的私钥计算出的签名以及公钥信息等数字证书信息,用于验证APK的唯一性也就是我们一直强调的签名来源)

那么上面三个文件就能确保APK的唯一性和安全性了吗?
一下来自Android签名机制之—签名过程详解的总结:
首先,如果你改变了apk包中的任何文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是验证失败,程序就不能成功安装。
其次,如果你对更改的过的文件相应的算出新的摘要值,然后更改MANIFEST.MF文件里面对应的属性值,那么必定与CERT.SF文件中算出的摘要值不一样,照样验证失败。
最后,如果你还不死心,继续计算MANIFEST.MF的摘要值,相应的更改CERT.SF里面的值,那么数字签名值必定与CERT.RSA文件中记录的不一样,还是失败。
那么能不能继续伪造数字签名呢?不可能,因为没有数字证书对应的私钥。
所以,如果要重新打包后的应用程序能再Android设备上安装,必须对其进行重签名。

从上面的分析可以得出,只要修改了Apk中的任何内容,就必须重新签名,不然会提示安装失败,当然这里不会分析,后面一篇文章会注重分析为何会提示安装失败。

从上面的总结可以看出,Keystore的Private Key是作为我们APK签名安全的最后一道防线,确保数字签名不会被伪造。

Android Stuido and Gradle

在开始实战APK重签之前,这里需要先学习了解Android Studio和Gradle,这一步可以帮助我们把java代码打包成jar来使用。
详情参考Android Studio

APK重签实战

通过上面的学习,我们知道了APK的重签需要经过一下步骤:

  1. 反解APK
  2. 修改内容
  3. 重新打包APK
  4. APK数据对齐
  5. 重新签名APK

接下来我们以实现以下几个功能作为实战重签APK的学习目标:

  1. 修改AndroidManifest.xml内容(通过修改versioncode打印检验)
  2. 替换APK里的资源(替换streamingasset路径下的一个文本文件内容并打印检验)
  3. 替换签名重签APK(通过apksigner验证apk重签后的签名)

我将会结合前面提到的工具利用Python完成这一次自动化重签功能学习:
直接上学习代码:
resignAPK.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
#!/usr/bin/python
# -*- coding: utf-8 -*-
# 指定文件包含非anscii字符

import os
import sys
import string
import shutil
import re
import argparse
import time

## Decompile APK(反编译APK)
def DecompileAPK():
print("DecompileAPK()")
apttooldecompilecmd = "{}/apktool.bat -f d {}.apk".format(apktoolDir, apkName)
print("------excute cmd :{}".format(apttooldecompilecmd))
os.system(apttooldecompilecmd)

## Modify AndroidManifest File(修改AndroidMainifest.xml)
def ModifyAndroidManifest():
print("ModifyAndroidManifest()")
lines = []
androidManifestFile = open(decompileAndroidManifestPath, mode='r')
for line in androidManifestFile:
line = ReplaceVersionCode(line, newVersionCode)
lines.append(line)
androidManifestFile.close()

androidManifestFile = open(decompileAndroidManifestPath, mode='w')
for line in lines:
androidManifestFile.write(line)
androidManifestFile.close()

## Template Replace(模板替换AndroidManifest.xml里的值)
def CheckTemplateReplace(line, qct, npn):
# Python 2.X string.find
#result = string.find(line, qct)
# Python 3.X str.find
result = line.find(qct)
if result != -1:
line = line.replace(qct, npn)
return line

## Modify Version Code Value(修改AndroidManifest.xml里的version code值)
## 实际使用过程发现apktool反解APK后AndroidManifest.xmnl里没有对应versioncode值
## 但以下代码如果存在versioncode="*"的话是能够替换成功的
def ReplaceVersionCode(line, newverioncode):
# Python 2.X string.find
#result = string.find(line, versionCodeTemplate)
# Python 3.X str.find
result = line.find(versionCodeTemplate)
if result != -1:
line = re.sub('(versionCode="[0-9]*")', "versioncode=\"{}\"".format(newverioncode), line, 1)
return line

## Copy new resource to APK(复制新资源到包内)
def CopyNewResourceToAPK():
print("CopyNewResourceToAPK()")
shutil.copy(newResourceName, "{}".format(replaceResourceDestinationPath))

## Recompile APK(重新打包APK)
def RecompileAPK():
print("RecompileAPK()")

print("------os.chdir(./{})".format(apkName))

os.chdir("./{}".format(apkName))

retval = os.getcwd()

print("------Current working directory {}".format(retval))

apttoolrecompilecmd = "{}/apktool.bat b -o ../{}.apk".format(apktoolDir, apkUnsignedUnaglinedName)

print("------excute cmd :{}".format(apttoolrecompilecmd))

os.system(apttoolrecompilecmd)

print("------os.chdir(./..)")

os.chdir("./..")

retval = os.getcwd()

print("Current working directory {}".format(retval))

## Aligned APK(APK 4K对齐)
def AlignedAPK():
print("AlignedAPK()")

zalignedcmd = "{}\\build-tools\\24.0.3\\zipalign.exe -f 4 {}.apk {}.apk".format(androidSDKDir, apkUnsignedUnaglinedName, apkUnsignedAglinedName)

print("------zalignedcmd :{}".format(zalignedcmd))

os.system(zalignedcmd)

## Resigned APK(重签APK)
def ResignedAPK():
print("ResignedAPK()")

resignedcmd = "{}\\build-tools\\24.0.3\\apksigner.bat sign --ks {} --ks-pass pass:{} --out {}.apk {}.apk".format(androidSDKDir, keystoreFilename, keystorePassword, apkResignedName, apkUnsignedAglinedName)

print("------resignedcmd :{}".format(resignedcmd))

os.system(resignedcmd)

## Repackage process
# argparse解析参数输入,需要先定义一个ArgumentParser对象
# description - -h的时候显示在最前面的信息
parser = argparse.ArgumentParser(description='Study argparse module.')

# add_argument - 定义如何解析一个参数
# 第一个参数代表参数名
# type - 指定参数数据类型
# help - -h时解释该参数的用意
# dest - 表示该参数最终在parser.parse_args()返回对象里的名字
# default - 表示该参数的默认值
# required - 表示该参数是不是必须填的参数
parser.add_argument('--APKName', type=str,
help='APK名字', dest='APKName',
required=True)

parser.add_argument('--VersionCode', type=int,
help='新的VersionCode', dest='NewVersionCode',
required=True)

parser.add_argument('--ResourceName', type=str,
help='新资源名字', dest='NewResourceName',
default='default.aba')

parser.add_argument('--ResourcePath', type=str,
help='新资源包内目录', dest='NewResourcePath',
default='./assets/')

# 解析输入的参数
args = parser.parse_args()

# 打印参数的详细信息,通过访问parser.parse_args()的对象去访问
print(args)
apkName = args.APKName
print("apkName : {0}".format(apkName))
newVersionCode = args.NewVersionCode
print("newVersionCode : {0}".format(newVersionCode))
newResourceName = args.NewResourceName
print("newResourceName : {0}".format(newResourceName))
newResourcePath = args.NewResourcePath
print("newResourcePath : {0}".format(newResourcePath))

currentFolder = os.getcwd()
print("currentFolder : {0}".format(currentFolder))

apktoolDir = currentFolder
print("apktoolDir : {0}".format(apktoolDir))

androidSDKDir = os.getenv("AndroidSDK")
print("androidSDKDir : {}".format(androidSDKDir))
if androidSDKDir == None:
print("androidSDKDir == None,请配置ANdroidSDK环境变量路径")
time.sleep(150)
exit()
else:
print("androidSDKDir != None")

# version code替换原模板
versionCodeTemplate = "versionCode="
print("versionCodeTemplate = {}".format(versionCodeTemplate))

# 需要替换的资源目录
replaceResourcePath = "./{}/{}".format(apkName, newResourcePath)

print("replaceResourcePath = {}".format(replaceResourcePath))

keystoreFilename = "TonyTangNewKeyStore.jks"

print("keystoreFilename = {}".format(keystoreFilename))

keystorePassword = "th13568582998"

print("keystorePassword = {}".format(keystorePassword))

decompileFolderPath = "{}".format(apkName)

print("decompileFolderPath = {}".format(decompileFolderPath))

decompileAndroidManifestPath = "./{}/AndroidManifest.xml".format(decompileFolderPath)

print("decompileAndroidManifestPath = {}".format(decompileAndroidManifestPath))

replaceResourceDestinationPath = "{}{}".format(replaceResourcePath, newResourceName)

print("replaceResourceDestinationPath = {}".format(replaceResourceDestinationPath))

apkUnsignedUnaglinedName = "{}_Unsinged_Unaligned".format(apkName)

print("apkUnsignedUnaglinedName = {}".format(apkUnsignedUnaglinedName))

apkUnsignedAglinedName = "{}_Unsigned_Aligned".format(apkName)

print("apkUnsignedAglinedName = {}".format(apkUnsignedAglinedName))

apkResignedName = "{}_Signed_Aligned".format(apkName)

print("apkResignedName = {}".format(apkResignedName))

print("{}.apk resigned start".format(apkName))

# 反解APK
DecompileAPK()

# 修改AndroidMainifest.xml里面的内容
ModifyAndroidManifest()

# 复制资源到指定目录
CopyNewResourceToAPK()

# 重新打包APK
RecompileAPK()

# APK数据对齐
AlignedAPK()

# 重新签名APK
ResignedAPK()

# 暂停不关闭命令行窗口(仅限Windows)
os.system('pause')

上面的注释都比较详细了,就不再细说每一步了。
这里直接来看下运行这个resignAPK.py的环境要求以及目录结构:
AndroidResignAPKFolderStructure
以下是使用argparse作为参数解析库的帮助查看:
AndroidResignAPKArgParse
以下是正式使用重签resignAPK.py用法:
AndroidResignAPKCommandsUsing
最后输出了一个叫apkName_Signed_Aligned的APK使我们最终重签后的APK:
AndroidResignFinalAPK
ABTestFile.text文件是位于StreamingAsset目录下的一个文件,原文是test,我覆盖后内容是test2。
配合测试访问代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
* Description: ResourceManager.cs
* Author: TONYTANG
* Create Date: 2018/08/12
*/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ResourceManager.cs
/// 资源加载管理单例类
/// </summary>
public class ResourceManager : SingletonTemplate<ResourceManager> {

/// <summary>
/// AB资源路径
/// </summary>
public string ABPath
{
get;
private set;
}
private string mABPath = Application.streamingAssetsPath;

/// <summary>
/// 测试资源文件名
/// </summary>
private string mTestResourceFileName = "/ABTestFile.text";

/// <summary>
/// 加载AB测试资源
/// </summary>
public void loadABTestResource()
{
Debug.Log("loadABTestResource()");
CoroutineManager.Singleton.startCoroutine(loadTestResourceCoroutine());
}

/// <summary>
/// 加载临时资源携程
/// </summary>
/// <returns></returns>
IEnumerator loadTestResourceCoroutine()
{
Debug.Log("loadTestResourceCoroutine()");
var testresourcefullpath = mABPath + mTestResourceFileName;
#if UNITY_ANDROID && !UNITY_EDITOR
UnityEngine.Networking.UnityWebRequest www = UnityEngine.Networking.UnityWebRequest.Get(testresourcefullpath);
yield return www.SendWebRequest();
Debug.Log(string.Format("ABTestFile Content : {0}", www.downloadHandler.text));
#else
var testresource = System.IO.File.ReadAllText(testresourcefullpath);
Debug.Log(string.Format("ABTestFile Content : {0}", testresource));
yield return null;
#endif
}
}

AndroidResignAndReplaceResourceDynamically
至此我们成功完成了通过python编写了自动化重签APK,动态替换APK部分资源,动态修改AndroidManifest.xml内容的功能。

Unity实战

待续……

Reference

Conception Part

keytool
zipalign
apksigner
Apktool
Android Studio
Gradle
Gradle Wiki
如何通俗地理解 Gradle?
Build Script Basics
Authoring Tasks

Knowledge Part

Sign Your App
Android APK签名原理及方法
Android签名机制之—签名过程详解
APK签名原理解析
JNI和NDK交叉编译进阶学习
NDK&JNI&Java&Android初步学习总结归纳
Android DSL Android Plugin DSL Reference
Gradle - Building Android Apps

Other Part

HashTable
Base64Website

Python Study

Introduction

When

Python is a widely used high-level programming language for general-purpose programming, created by Guido van Rossum and first released in 1991.
Python第一版发布于1991年,由Guido van Rossum编写。

What

An interpreted language, Python has a design philosophy which emphasizes code readability (notably using whitespace indentation to delimit code blocks rather than curly brackets or keywords), and a syntax which allows programmers to express concepts in fewer lines of code than possible in languages such as C++ or Java.[22][23] The language provides constructs intended to enable writing clear programs on both a small and large scale.
首先我们要知道Python跟Lua一样,是解释型语言(不需要编译,通过解析执行,有自己的解析器)。Python设计理念强调可读性(通过缩进而非{}来表示代码块)。

Python features a dynamic type system and automatic memory management and supports multiple programming paradigms, including object-oriented, imperative, functional programming, and procedural styles.
Python是动态类型语言,同时有自己的内存管理机制,支持多种编程范式(待深入学习了解)。

Why

了解了Python When & What。那么我们Why什么要选择学习使用Python了?
Python is powerful… and fast; plays well with others; runs everywhere; is friendly & easy to learn; is Open.
supports multiple programming paradigms. It has a large and comprehensive standard library.[25]
把上面可以归纳如下:

  1. Run everywhere(跨平台)
  2. Friendly & easy to learn(学习更友好更容易(对于无编程经验的人更加友好,语法更nature))
  3. Supports multiple programming paradigms(支持多种编程范式)
  4. Large and comprehensive standard library(丰富的标准库和Package)

Note:
这里提一句Python里很有名的一句话:
“Life is short, you need python.”

IDE & Editor

学习任何一门编程语言,好的IDE或者Editor都能帮助我们更加快速的学习。
因为初学Python且Python是解析执行,这里暂时不考虑过于强大的IDE。
IDE详情选择可以参考Python IDE
这里通过使用Sublime Text 3以及其Python相关的扩展插件作为学习Python的Editor。
Sublime Text 3 Python相关插件设置详情

Python Version Choosen

Python有一个比较重要版本分割线2.x & 3.x。
详情参见Python2orPython3
这里就不深入去了解2.x和3.x之间的区别了。
简单的理解就是3.x提供了更多更全的功能支持,同时有更多的丰富的库支持。如果作为初学者,并不需要维护老版本(2.x)的工具代码时,完全可以从3.x学起。

Useful Package

Pip

Python有丰富的标准库。而其中最重要也是新手第一时间就应该安装的就是Pip。
Pip对于Python就好比Package Control好比Sublime Text 3。
Pip(Python Package Index)是一个Python Package集中管理安装的扩展包。
Pip安装详情参考官方文档
Pip安装成功后我们就可以通过Pip去查看或安装丰富的Python Package了。
Package Install:
PipPackageInstallation
Show Intalled Packages:
PipListInstalledPackages
安装之后的Package可以在安装目录查看到:
PythonPackageLocation
更多关于Pip使用查看官方文档

argparse

详细的介绍会在后面Tip的Command Line Args篇章

Tutorial(基础篇)

Introduction

Keywords and Identifier

Identifier(标识.e.g funcname, variable ……)定义规则:

  1. Keywords不能用做变量声明(这应该是任何语言都通用的规范了吧)
  2. 变量名不能以数字开头
  3. 变量名不能用特殊符号(e.g. !, @, #, $, %)
    简单了解下Python的关键词:
    PythonKeywords

Note:
Python is a case-sensitive language. This means, Variable and variable are not the same. Always name identifiers that make sense.
Python是大小写敏感的,大小写不同的变量名是不同变量。

Statements & Comments

Instructions that a Python interpreter can execute are called statements.
Python可解析执行的语句叫做Statements。
多行Statements可以通过\或者[]或者{}或者()链接或封装:

1
2
3
4
5
a = 1 + 2 + \
3 + 4 + \
5 + 6

b = (1 + 2 + 3 + 4)

Python设计理念强调可读性(通过缩进而非{}来表示代码块):
同时Python注释是通过# or ‘’’ or “””:

1
2
3
4
5
6
7
8
for i in range(5):
if i >= 5:
print("{0} >= 5".format(i))
break
else:
# No break happends in for loop
# Then execute this block
print("No number is equal or greater than 5!")

Docstring(Docstring is short for documentation string):
可以看出Docstring是一段文档描述(好比在C#里通过///编写描述信息一样)。Python在书写格式上要求比较严,虽然不会导致报错,但会算作不是规范的写法,这也就是我们为什么会去安装Sublime Text 3 flake8这样的linter插件了。

1
2
3
4
5
6
def addition(par1, par2):
"""This funtion is used to get result of (a + b)."""
return par1 + par2

print(addition(1, 2))
print(addition.__doc__)

DocString
可以看到我们定义了一个加法函数,并编写了docstring。
通过func_name.__doc__我们可以得到那段描述

Python I/O and Import

Python里的输入输出交互(好比C++里的cin,cout)是通过input和print。
因为需要交互所以需要在可执行Python代码的terminal里执行python脚本:
PythonInputAndOutput

因为Python的import是指import Module,我们需要知道Python里Module的定义,
Modules refer to a file containing Python statements and definitions.
Modules在Python里是指一个文件所包含的所有定义和声明(以文件为单位划分,文件名即为import的名字)。

在import Module还有一个重要的点就是,我们需要知道Python查找所有*.py的搜索路径(跟环境变量有点像):

1
print("sys.path:{0}".format(sys.path))

ImoprtSearchPath

我们可以指定只导入特定定义(e.g. from math import pi)

Note:
Python的Module和C#的namespace概念不一样,前者是以文件为单位,后者是以命名空间划分(支持在多个文件里定义在同一命名空间下)

Python Operators

运算符跟大多数编程差不多,这里只讲几个比较特殊的:
Logical Operators:

1
2
3
4
x = True
y = False
print(x and y)
print(x or y)

PythonLogicalOperator
或许跟Python提倡简洁明了有关,可以直接采用and or not之类的逻辑运算符

Identity operators:

1
2
3
4
5
6
7
8
9
x1 = 1
y1 = 1
print("x1 is y1 = ", x1 is y1)
y1 = 2
print("After y1 = 2. x1 is y1 = ", x1 is y1)

list1 = [1, 2, 3]
list2 = [1, 2, 3]
print("list1 is list2 = ", list1 is list2)

PythonIdentityOperator

Note:
is and is not are used to check if two values (or variables) are located on the same part of the memory.
List可以理解成分配了不同的内存去存储实际成员值所以是不同identity,上面x1 = 1和y1 = 1是located on the same part of the memory就需要理解后续会讲到的[Namespaces概念](http://tonytang1990.github.io/2017/06/12/Python-Study/#Object & Class)

Membership operators:

1
2
3
list3 = [1, 'T', 'H']
print('T' in list3)
print(1 not in list3)

PythonMemberOperator

Note:
in & not in are used to test whether a value or variable is found in a sequence (string, list, tuple, set and dictionary).
in & not in 主要是用于判断特定变量或者值是否存在于特定sequence里。

Flow Control

if,elif,else,while,for这里主要记录一些Python相对于C++,C#,Java这些语言里的一些区别。

Python里是以:结束条件语句,以缩进表示层级。
while和for有一点比较特殊,支持else语句(没有break语句执行时)。

1
2
3
4
5
6
7
8
for i in range(5):
if i >= 5:
print("{0} >= 5".format(i))
break
else:
# No break happends in for loop
# Then execute this block
print("No number is equal or greater than 5!")

FlowControlForUsing

Function

Python的Function定义关键词是def,格式如下:

1
2
3
def func_name(paras):
"""Docstring"""
Statements

Python支持显示传递参数,也支持默认参数和不限数量参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# User-defined functions
def my_python_function(para1, dp1="dp1", dp2="dp2"):
"""My Python Function Docstring."""
# Build-in functions
print("my_python_function(para1:{0} dp1:{1} dp2:{2})".format(para1, dp1, dp2))

my_python_function("Tony")

# Keyword para call
my_python_function(dp1="ndp1", para1="Tang")


# Parameters without length limited
def arbitrary_para_function(*paras):
""" Python function without length of parameter limitation."""
for para in paras:
print(para)

arbitrary_para_function("para1", "para2", "para3")

PythonFunctionDefinition

Python也支持匿名函数(Lambda Function),只是不像C++,C#使用(x)=>表示而是lambda x:

1
2
sqrfunc = lambda x: x * 2
print(sqrfunc(2))

PythonAnonymousFunction

Modules

C,C++里有#include。C#有using。Java有import。Python里有Modules。
但Python里的Modules更像C,C++是针对文件为单位。
使用的时候通过import filename即可(也支持指定别名 as newname)

1
2
3
def my_first_python_func():
"""MyFirstPython.py's function."""
print("my_first_python_func() called")
1
2
3
import MyFirstPython as MFP

MFP.my_first_python_func()

PythonModuleImportAndUsing

知道了Python是以文件为单位导入的,那么我们也需要知道Python导入文件时的搜索路径(通过打印sys.path即可)

Note:
Python也支持只导入指定文件里的指定对象
e.g.

1
from math import pi

Package

C++,C#里有namespace。Java有package。Python里有Package(相当于Java里package和C#里namespace概念,负责抽象对应Directory到指定Package里)
Steps:

  1. 创建文件夹(文件夹名作为Package Name)
  2. 文件夹目录下创建一个__init__.py文件(默认可以不写任何内容)
  3. 通过import PackageName.Module引入特定文件
    e.g.
1
import MyPackage.MyFirstPython

PythonPackageUsing

Note:
Package是针对Directory而言,Module是针对file而言

Datatypes

Variable变量赋值在Python里简单明了:

1
2
3
p1 = 1
p2, p3, p4 = 2, 3, "Tony"
print(p1, p2, p3, p4)

判断数据类型Python是通过type()这个类似C#GetType()。
判断是否是某个类型实例Python是通过isinstance(),类似于Python里的is关键词。

1
2
3
4
5
6
7
8
9
10
11
class MyClass:
"""My first class."""

name

myclassinstance = MyClass()
myclassinstance.name = "Tony"
mystring = "Tony"
print(myclassinstance, " is typeof ", type(myclassinstance))
print("myclassinstance is typeof MyClass:", isinstance(myclassinstance, MyClass))
print(mystring, " is typeof ", type(mystring))

DataTypeCheck
可以看出在Python是弱语言类型,声明变量是无需显示指定变量类型。
同时通过type和isinstance可以判断实例对象类型。

Note:
这里的Data Type是指实例对象的类型,Python是弱类型语言。

Basic Data Types

Python支持大部分基本数据类型,e.g. int, float等。
有一点比较特殊的是Python支持complex numbers(复数):x + yj(x为实部,yj为虚部)

1
2
cn = 2 + 3j
print("cn = {0}".format(cn))

ComplexNumber
同时表示不同进制的Number只需添加不同前缀即可:
NumberSystem

List

List is an ordered sequence of items. It is one of the most used datatype in Python and is very flexible. All the items in a list do not need to be of the same type.
Python的List好比数组,但是更灵活,可存储不同类型的对象。

1
2
3
pythonList = [1, "a", 3.0, True]
for member in pythonList:
print(member, " is typof ", type(member))

PythonList

更多的List处理参见List.Method和其他Build-in Functions(比如len(),sorted()等)

Note:
List是通过[]定义。

Tuple

Tuple is an ordered sequence of items same as list.The only difference is that tuples are immutable. Tuples once created cannot be modified.
Tuple和List的唯一区别就是创建后不可再被改变,但如果内嵌可变的(e.g. List)那么List的数据是可以被改变的。Tuple是通过()定义内容。

1
2
3
4
5
6
my_tuple = (1, "string", [1, "th"])
my_tuple[2][0] = 2
for tuplemember in my_tuple:
print(tuplemember)
del my_tuple
# my_tuple -- Error: name 'my_tuple' is not defined

TupleUsing
从上面可以看出del关键词可以删除变量的引用。

Note:
Tuple是通过()定义。
Tuple相比List更适合用于存储固定的不同数据类型的数据。
Everything is object in Python.(这一点跟Java,C#一样)

Strings
1
2
3
4
mypythonstring = "我的名字是TonyTang"
print(mypythonstring)
print(mypythonstring[0])
mypythonstring[0] = '你'

PythonStrings

Note:
String is sequence of Unicode characters. Strings are immutable
Python里是采用Unicode编码存储字符串。Strings可以通过[]访问,但是不可被改变的。
We cannot delete or remove characters from a string. But deleting the string entirely is possible using the keyword del.因为string是immutable的,所以我们不能通过del删除string里面的个别character,但是我们可以删除对string的整个引用。

Set

Set is an unordered collection of unique items. Set is defined by values separated by comma inside braces { }. Items in a set are not ordered.
Set也是一系列数据的集合类型,但是是无序的。

1
2
3
mySet1 = {1, "a", 2, "b", 1}
for member in mySet1:
print("member in mySet1:{0}".format(member))

PythonSet
可以看出因为Set是无序的,连循环遍历出来的结果也是不确定的。所以针对Set,[]访问就是无意义也是不被允许的。

Every element is unique (no duplicates) and must be immutable (which cannot be changed).
Set还有一点特性就是成员是唯一且不可变的同时支持类似于集合与或等操作:

1
2
3
4
5
6
mySet2 = {"a", 2, "b"}
for member in (mySet1 & mySet2):
print("member in (mySet1 & mySet2):{0}".format(member))

for member in (mySet1 - mySet2):
print("member in (mySet1 - mySet2):{0}".format(member))

SetStudy

Note:
Set是通过{}定义。
更多操作参考Set.API

Dictionary

Dictionary is an unordered collection of key-value pairs.
Python里的Dictionary跟C#里的Dictionary差不多,代表无序的Key-Value主键值对。

1
2
3
myDictionary = {1: "Tony", "Tang": 2}
print(myDictionary[1])
print(myDictionary["Tang"])

PythonDictionary
可以看出Python里Dictionary的键值对定义时通过:分割,然后通过[]去方案对应Key得到Value值。

Note:
Dictionary是通过{}定义。
更多操作参考Dictionary.API

Conversion between data types

基础数据类型转换通过int,float,complex显示转换即可:

1
2
3
4
5
myint = int(2.5)
print(myint)
mylist = list('hello')
for member in mylist:
print(member)

PythonDataConversion

Files

File Operation

首先看看最新官方文档给出的API说明:
open(file, mode=’r’, buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)

1
2
3
4
5
6
7
8
9
10
11
12
13
# try: finally: Make sure f close successfully
# specify encoding='utf-8' is used to avoid platform dependent problems
try:
f = open("./FilesFolder/TonyTangFile.txt", mode='r', encoding='utf-8')
for line in f:
print(line)
finally:
f.close()

# Another way to make sure file close successfully
with open("./FilesFolder/TonyTangFile.txt", mode='w', encoding='utf-8') as f:
# Do file operation here
f.write("Flow my heart!\nI have a dream!")

PythonFileOperation
从上面可以看到,open的几个关键参数,mode决定了可读还是可写等模式,encoding决定编码格式是为了确保不同平台脚本的一致性。

Directory

Python提供了os module支持很多文件夹目录操作的API

1
2
3
4
5
6
7
8
9
10
11
12
13
import os

print(os.getcwd())
print(os.listdir())
os.chdir("./FilesFolder")
print(os.getcwd())
if os.path.exists("MyNewFilesFolder") == False:
os.mkdir("MyNewFilesFolder")
print(os.listdir())
if os.path.exists("MyRenameFilesFolder") == True:
os.removedirs("MyRenameFilesFolder")
os.rename('MyNewFilesFolder', 'MyRenameFilesFolder')
print(os.listdir())

PythonDirectory
从上面可以看出Python的API都很简洁明了,跟bat,shell很像,具体使用过程中主要还是按需求差文档写自己的自动化即可。

Exception

Python Exception
Exception Handling
User-defined Exception

Object & Class

Name

Name (also called identifier) is simply a name given to objects.

1
2
3
4
5
6
7
8
9
10
11
a = 2
print('id(2) = ', id(2))
print('id(a) = ', id(a))

a = a + 1
print('id(a) = ', id(a))
print('id(3) = ', id(3))

b = 2
print('id(2) = ', id(2))
print('id(b) = ', id(b))

PythonNamespaceOutput
id()适用于打印object地址,从上面的输出可以看到只要Name指向Object的值是一致的,那么他们所指向的地址就是一致的。详情参考下图:
PythonName
This is efficient as Python doesn’t have to create a new duplicate object. This dynamic nature of name binding makes Python powerful; a name could refer to any type of object.

Note:
Everything in Python is an object.

1
import this

上面这段代码很有意思,会在控制台打印出Python的编程哲学:
TheZenOfPython

Namepsace & Scope

namespace is a collection of names.(前面讲解了什么是Name,接下来要理解的Namespace就好比一系列Name的集合)
我们平时用到的Build-in Functions就属于Build-in Namespace里。
让我们结合下图看看Python里Namespace的层级:
PythonNamespaceHierarchical

讲完Namespace,Python里还有一点跟Namespace密切相关的Scope:
Scope is the portion of the program from where a namespace can be accessed directly without any prefix.(Namespace在Python就好比C++,C#里的namespace。Scope就好比C++,C#里变量的有效区域。)

现阶段主要分为3中Scope:

  1. Scope of the current function which has local names(当前function scope)
  2. Scope of the module which has global names(全局scope)
  3. Outermost scope which has build-in names(最外层Build-in scope)

接下来透过实例学习理解Python里的Scope:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def outer_function():
"""Outer Function."""
a = 20

def inner_function():
"""Inner Function."""
global a
a = 30
print("a = ", a)

inner_function()
print("a = ", a)

a = 10
outer_function()
print("a = ", a)

PythonScope
可以看出在Function内的定义因为在不同的Scope不会覆盖Global Scope的a定义。当我们显示指定Function Scope里的a为global的a时,我们可以在Function Scope内引用到Global的a。

Class

接触过面向对象编程的人应该对Class都不陌生,他是我们抽象对象定义的关键类型。
在面向对象编程语言的Python里Class也起着同样的作用,只不过定义的方式有所区别,详情如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MyFirstClass(object):
"""docstring for MyClass"""

def __init__(self, arg):
"""Class Constructor."""
super(MyFirstClass, self).__init__()
self.name = arg

def sayhi(self):
"""Class Member Function."""
print("Hello World!")

def printname(self):
"""Print name member."""
print("self.name = ", self.name)

# 这里因为我是定义在MyFirstPython.py文件里的,所以导入的是MyFirstPython
import MyFirstPython
mfp = MyFirstPython.MyFirstClass("Tony")
mfp.sayhi()
mfp.printname()
print(mfp.name)
mfp.age = 10
print(mfp.age)
del mfp.name
# mfp.printname() AttributeError: 'MyFirstClass' object has no attribute 'name'
del mfp
# mfp.sayhi() NameError: name 'mfp' is not defined

PythonClassDefinition
从上面可以看出Python里Class的定义格式如下:

1
2
3
4
5
6
7
8
9
def classname(父类):
"""Docstring Class description."""
def __init__(self,arg):
"""Docstring Constructor description."""
# constructor's body

def functionname(arg):
"""DocString Member Function description."""
# body

__init__就好比C++里的构造函数。
Python里的继承写法:classname()括号里面跟的是父类,在子类可以通过super去访问父类。
值得一提的是Docstring,每一个类或者方法都有Docstring,我们可以通过classname.__doc__的方式去访问。
同时我们看到Python里还可以在运行时动态定义成员变量(e.g. mfp.age)
Python里不是通过new关键词去实例化类对象,但del关键词就好比C++里和new配对的delete,但这里的delete不仅是针对new出来的实例化对象进行回收,还能针对特定实例化成员进行删除。(当我们del mfp实例对象时,我们只是把mfp的对于Object的引用从corresponding namespace里移除而非真的销毁内存里Object。真正的销毁是发生在没有人在绑定到该Object时。)

This automatic destruction of unreferenced objects in Python is also called garbage collection.(真正的销毁就好比C++里的GC对内存进行释放)

Note:
Python is an object oriented programming language.

Inheritance

Like C++, a class can be derived from more than one base classes in Python. This is called multiple inheritance.

关于继承这里就不多讲了,大家参考文档介绍。

Operator Overloading

Operator Overloading翻译过来应该是运算符重载。

在了解Operator Overloading之前,让我们来了解下Python Class里的一些特殊方法。

这里的特殊方法学过lua的同学应该会感到很亲切:

  1. new – New一个Class的实例对象时会被调用
  2. init – Class的构造函数(父类构造函数需要手动调用,默认不递归调用)
  3. del – Class实例对象销毁时调用(类似C#析构函数)
  4. class – 访问类的静态变量的时候(实例对象可以直接通过self访问)
  5. str – str(),format()以及print()函数触发Class获取字符串表达方法
  6. lt – <运算符操作时调用
  7. le – ==运算法操作时调用
  8. hash – hash()方法计算Class实例对象Hash值时
  9. getattr – 访问Class成员不能存在时调用(类似Lua的__index元方法)
  10. setattr – 设置Class成员值时调用(类似Lua的__newindex元方法)
  11. ……

更多特殊方法参考:Special method names

从上面可以看出Python和Lua有很多相似的地方。

接下来让我们看看实战效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# MyFirstPython.py
# File Name: MyFirstPython.py
# Description: This file is used to study python
# Author: TangHuan
# Create Date: 2017/6/5

import shutil

class MyFirstClass(object):
"""docstring for MyClass"""
# class Static Member
age = 10

# 构造函数
def __init__(self, name):
"""Class Constructor."""
super().__init__()
print("MyFirstClass:__init__({})".format(name))
self.name = name

# str(),format()以及print()函数触发
def __str__(self):
return "MyFirstClass name:{} age:{}".format(self.name, self.age)

# 获取不存在成员时触发
def __getattr__(self, name):
print("MyFirstClass:__getattr__({}) not exist!".format(name))

# 设置成员值时触发
def __setattr__(self, name, value):
super().__setattr__(name, value)
print("MyFirstClass:__setattr__({}, {})!".format(name, value))

# 加号运算符重载
def __add__(self, other):
newName = self.name + other.name
return MyFirstClass(newName)

def sayhi(self):
"""Class Member Function."""
print("MyFirstClass:sayhi() Hello World!")

def printname(self):
"""Print name member."""
print("MyFirstClass:printname() self.name = ", self.name)


def my_first_python_func():
"""MyFirstPython.py's function."""
print("my_first_python_func() called")

1
2
3
4
5
6
7
8
9
10
11
12
13
# Class e.g.
mfp = MyFirstPython.MyFirstClass("Tony")
print(mfp.__class__.age)
mfp.sayhi()
mfp.printname()
print(mfp.name)
print(mfp.noneexitname)
mfp.age = 10
print(mfp.age)
print(mfp)
mfp2 = MyFirstPython.MyFirstClass("Tang")
tempMfp = mfp + mfp2
print(tempMfp)

ClassSpecialFunctionsOutput

通过上面的学习用例,可以看到我们通过自定义__init__,str,getattr,setattr,__add__等特殊方法实现了类构造,类成员自定义访问设置,类+运算符重载,类print()等方法自定义输出等效果。看到这里,不得不说Python特殊方法和Lua的元方法概念很像。

Iterator

Iterator in Python is simply an object that can be iterated upon. An object which will return data, one element at a time.

这里的Iterator和C#里的迭代器应该是一个概念。实现迭代器需要实现两个特殊方法(iter__和__next)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File Name: 		IteratorStudy.py
# Description: This file is used to study python iterator
# Author: TangHuan
# Create Date: 2021/5/25

# 迭代器类
class IteratorClass(object):
"""Class to implement an iterator"""

# 构造函数
def __init__(self, max):
super().__init__()
self.max = max

# 返回迭代器
def __iter__(self):
print("IteratorClass:__iter__()")
self.n = 0
return self

# 迭代器MoveNext
def __next__(self):
print("IteratorClass:__next__()")
if self.n <= self.max:
value = self.n
self.n += 1
return value
else:
raise StopIteration

iteratorClassInstance = IteratorClass(5)
for iter in iteratorClassInstance:
print(iter)

IteratorOuput

Generator

Python generators are a simple way of creating iterators. Simply speaking, a generator is a function that returns an object (iterator) which we can iterate over (one value at a time).

It is fairly simple to create a generator in Python. It is as easy as defining a normal function, but with a yield statement instead of a return statement.

看介绍Generators的概念和C#的携程差不多。

Generator和普通方法的区别:

  1. Generator function contains one or more yield statements.
  2. When called, it returns an object (iterator) but does not start execution immediately.
  3. Methods like __iter__() and __next__() are implemented automatically. So we can iterate through the items using next().
  4. Once the function yields, the function is paused and the control is transferred to the caller.
  5. Local variables and their states are remembered between successive calls.
  6. Finally, when the function terminates, StopIteration is raised automatically on further calls.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Generator
GeneratorVariable = 0
def GeneratorFunction():
global GeneratorVariable
print("GeneratorFunction() {}".format(GeneratorVariable))
yield GeneratorVariable
GeneratorVariable += 1
print("GeneratorFunction() {}".format(GeneratorVariable))
yield GeneratorVariable
GeneratorVariable += 1
print("GeneratorFunction() {}".format(GeneratorVariable))
yield GeneratorVariable

GeneratorFunc = GeneratorFunction()
print("GeneratorFunction Start")
for generator in GeneratorFunc:
print(generator)

GeneratorOuput

可以看到Generator在Python里还能直接通过for循环的形式便利这一点和C#的携程还不太一样。

通过Generator我们可以简化自定义Iterator的写法,无需自定义__iter__和__next__方法,通过自定义的Generator方法即可实现迭代效果。Generator更多好处参考:Generator

Closure

Closure指类似传统语言里的闭包,但在Python里有几个较大的区别:

  1. 闭包访问外围变量时,默认是只读的,需要通过nonlocal关键字来标识闭包内部可写。
  2. 闭包必须是嵌套函数定义
  3. 嵌套函数必须访问外部成员
  4. 外围函数必须返回嵌套函数自身

Python里用闭包的好处:

Closures can avoid the use of global values and provides some form of data hiding. It can also provide an object oriented solution to the problem.

Decorators

A decorator takes in a function, adds some functionality and returns it.

看介绍Decorator像是设计模式里的修饰器概念,能够做到给现有功能添加额外功能的作用。

看介绍利用万物皆object的概念(类似Lua里第一类值)的概念,实现封装函数调用实现扩展函数功能。

还有更多更复杂的使用方式,详情参考:Decorators

Note:

  1. everything in Python (Yes! Even classes), are [objects].

Property

Property类似于C#里的属性概念。Python通过关键字@property实现属性的快速定义。

PropertyKeywordDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File Name: 		PropertyStudy.py
# Description: This file is used to study python property
# Author: TangHuan
# Create Date: 2021/5/25

# 属性类
class PropertyClass(object):
"""Class to implement an property"""

# 构造函数
def __init__(self, propertyvalue):
super().__init__()
print("PropertyClass.__init__()")
self.Property = propertyvalue

@property
def Property(self):
print("Getting Property Value : {}".format(self._Property))
return self._Property

@Property.setter
def Property(self, value):
print("Setting Property Value : {}".format(value))
self._Property = value

propertyClassInstance = PropertyClass(5)
print(propertyClassInstance.Property)
propertyClassInstance.Property = 10
print(propertyClassInstance.Property)

PropertyOutput

更多学习参考:[Property][https://www.programiz.com/python-programming/property]

RegEx

Date and Time

Process

multiprocessing is a package that supports spawning processes using an API similar to the threading module.

threading module constructs higher-level threading interfaces on top of the lower level _thread module.

The subprocess module allows you to spawn new processes, connect to their input/output/error pipes, and obtain their return codes. This module intends to replace several older modules and functions:os.system os.spawn*

从介绍可以看出,multiprocessing包是用于支持多进程类似多线程功能的模块。

threading是多线程的一个上层抽象模块。

multiprocessing是多进程的一个上层抽象模块(相比threading,不受限于**Global Interpreter Lock(多线程锁,限定同时只有一个Thread执行)**可以真正利用多核来跑多进程功能。

subprocess模块是封装的更好的一个创建使用子进程的模块,同时提供了和子进程处理交互相关的接口。

这里我们重点学习process相关即multiprocessing和subprocess相关概念和使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
File Name: MultiProcessStudy.py
Description: This file is used to study python subprocess
Author: TangHuan
Create Date: 2021/8
"""

import os
import subprocess
import time
import random
from multiprocessing import Pool

def process_task(subProcessName):
print("subProcessName:{0} Process pid:{1}".format(subProcessName, os.getpid()))
start = time.time()
time.sleep(random.random())
end = time.time()
print("SubProcess:{0} Process pid:{1} runs:{2} seconds!".format(subProcessName, os.getpid(), end - start))

if __name__=='__main__':
print("MultiProcessStudy")
# 打印主进程id
print("Current Process pid:{0}".format(os.getpid()))

# 创建4个多进程(子进程)的进程池
p = Pool(4)
for i in range(5):
p.apply_async(process_task, args=("SubProcess{0}".format(i),))
print("Waiting for all subprocesses done.")
# 停止提交更多的任务到p里
p.close()
# 等待p里的所有子线程任务完成
p.join()
print("All subprocesses done.")

# SubProcess的方式开启子进程1
subProcess1 = subprocess.Popen(['python', 'SubProcess1.py'])
print("start SubProcess1")
# SubProcess的方式开启子进程2
subProcess2 = subprocess.Popen(['python', 'SubProcess2.py'])
print("start SubProcess2")
# 等待子进程1完成
subProcess1.wait()
print("SubProcess1 complete")
# 等待子进程2完成
subProcess2.wait()
print("SubProcess2 complete")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# File Name: 		SubProcess1.py
# Description: This file is used to study python subprocess
# Author: TangHuan
# Create Date: 2021/8/7

#!/usr/bin/python
# -*- coding: utf-8 -*-

import os
import time

print("SubProcess1-1")

print("SubProcess1 parent pid:{0}".format(os.getppid()))
print("SubProcess1 pid:{0}".format(os.getpid()))
time.sleep(1)
print("SubProcess1-2")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# File Name: 		SubProcess2.py
# Description: This file is used to study python subprocess
# Author: TangHuan
# Create Date: 2021/8/7

#!/usr/bin/python
# -*- coding: utf-8 -*-

import os
import time

print("SubProcess2-1")

print("SubProcess2 parent pid:{0}".format(os.getppid()))
print("SubProcess2 pid:{0}".format(os.getpid()))
time.sleep(1)
print("SubProcess2-2")

MultiProcessingOutput

从上面的测试代码可以看出,我们通过mutilprocessing模块提供的Pool(用于控制一组子进程的创建使用)成功创建了4个多进程池开启5个多进程任务(其中有一个任务需要等待一个可用的进程才能执行)。

通过SubProcess模块,我们实现了开启多个进程执行不同Python脚本的功能,从而实现多进程执行多Python脚本任务的需求。

更多更细节的学习参考:

多进程

Note:

  1. multiprocessing和subprocess是提供了多进程而非多线程
  2. Unix/Linux操作系统多进程是采用fork()实现真正的进程复制,而Windows不支持fork()函数,Windows上多进程是通过在子进程里创建新的Python解析器来实现的(来源:On Windows, there is no such system call. Python needs to perform the quite heavy task of creating a fresh Python interpreter session in the child, and re-create (step by step) the state of the parent.),所以我们执行多进程时要通过命令行而非Sumblime等软件。

待续……

Tips

encode and decode

字符编码问题,无论是shell,python都是需要考虑的,不然会出现乱码的情况。

encode(编码)和decode(解码)是需要一一对应的,不然同一个二进制数据,通过不同的编码和解码会得出不一样的结果。

首先我们来看看Python官网对于Encode和Decode的介绍:
Strings are stored internally as sequences of code points in range 0x0–0x10FFFF. (See PEP 393 for more details about the implementation.) Once a string object is used outside of CPU and memory, endianness and how these arrays are stored as bytes become an issue. As with other codecs, serialising a string into a sequence of bytes is known as encoding, and recreating the string from the sequence of bytes is known as decoding.

从上面可以看出,我们的string最终都是以字节码形式存储起来的。我们指定如何编码字符串到字节码的方法叫做编码。我们制定如何解析字节码到字符串的方法叫做解码。

关于字符编码的详细学习,这里参考以前转载的一片文章来加深理解:
关于字符编码,你所需要知道的(ASCII,Unicode,Utf-8,GB2312…)

不难看出,编码和解码方式要是不一致,最终字节码数据解析出来就会出现不正确(比如乱码)的情况。

这里需要提一下Python 3.X里有提供codecs的Package专门负责处理encode,decode问题。
codecs深入了解链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/python
# -*- coding: utf-8 -*-
# 指定文件包含非anscii字符

import sys
import codecs

# 打印系统默认编码
print(sys.getdefaultencoding())

# 如果默认编码不是utf-8,强制设置成utf8去支持文本内非anscii字符
# 下面这段代码针对Python 2.X
# Phtyon 3.X默认就是utf-8编码不需要自己设定utf-8编码
#reload(sys)
#sys.setdefaultencoding('utf-8')

s="English-中文"
print(s)

s2=codecs.encode(s,"gb2312")
print(codecs.decode(s2,"gb2312"))
#解码格式与编码格式gb2312不一致,报错,无法解析
#print(codecs.decode(s2,"utf-8"))

# 需要指定和文件一样的编码格式才能正确解析文件里的内容
# 读取解码格式与inputfile.txt保存的编码格式utf-8不一致报错
#f = open('inputfile.txt','r',encoding='gb2312')
f = open('inputfile.txt','r',encoding='utf-8')
content = f.read()
print(content)

EncodeAndDecode

可以看到当我们把通过gb2312编码的字符串通过对应的gb2312解析时能正确显示,但用utf-8去解析时就出现了问题。同理文件读取指定的解码格式与文件自身的编码格式不一致也会出现无法读取的情况。

结论:
编码和解码格式要一一对应才能正确解析显示。
String is sequence of Unicode characters. Strings are immutable. We cannot delete or remove characters from a string. But deleting the string entirely is possible using the keyword del.
Python里是采用Unicode编码存储字符串。Strings可以通过[]访问,但是不可被改变的。
因为string是immutable的,所以我们不能通过del删除string里面的个别character,但是我们可以删除对string的整个引用。

main

'__main__' is the name of the scope in which top-level code executes. A module’s name is set equal to '__main__' when read from standard input, a script, or from an interactive prompt. A module can discover whether or not it is running in the main scope by checking its own __name__, which allows a common idiom for conditionally executing code in a module when it is run as a script or with python -m but not when it is imported:

从上面的介绍可以看出__main__表示是否是从最上层执行(比如直接执行脚本,命令行执行脚本)代码的名字。

如何判定是否是从最上层执行当前python脚本了?

从上面的介绍可以看出,__name__变量标识了当前python脚本是通过什么方式执行的。

1
2
3
4
5
6
7
# File Name: 		MainName.py
# Description: This file is used to study __name__ and __main__ using
# Author: TangHuan
# Create Date: 2021/5/22

print("MainName.py")
print(__name__)

比如上面这段代码,在通过脚本直接执行时,我们会看到打印输出:

DirectExecuteNameOuput

如果我们通过以下代码导入MainName.py会看到打印输出:

1
import MyPackage.MainName

ImportNameOutput

在命令行执行脚本和直接执行脚本同理。

可以看出当我们通过非import方式导入脚本时,__name__的值为__main__这个特殊的值,我们可以通过判定这个值来判定当前python脚本的执行方式,因此我们经常会看到以下代码:

1
2
if __name__ == "__main__":
print("DoSomething()")

这样就能做到只有通过非import方式执行当前python脚本时才执行某些东西。

Command Line Args

os.argv

很多时候作为脚本语言Python,我们写的Python脚本更多是用于自动化流程(比如打包签包等),而调用此Python脚本的方式大多为命令行调用。这里就引出了如何通过命令行将我们所需的参数传递进我们Python脚本的问题。

Python自带了一个os.argv的参数用于获取命令行传入参数数据。

1
2
for arg in sys.argv:
print(arg)

如果我们通过命令执行以下脚本传入参数:

1
***.py param1 param2 param3

我们会看到以下输出:

PythonArgsOutput

可以看到第0个参数表示当前python脚本执行的文件路径。

但上面这种方式,虽然能获得命令行传入的参数,但对于参数的格式以及参数的要求对于用的人来说都还是不透明且未做任何安全检查也未给与任何提示信息的,这样会导致Python脚本内部还需要自行做这些参数检查。这里就要引出接下来要提到的模块功能:argparse

Note:

  1. 如果以-c的参数触发python,os.argv的第一个参数是-c,详情参考:-c

argparse

The argparse module makes it easy to write user-friendly command-line interfaces. The program defines what arguments it requires, and argparse will figure out how to parse those out of sys.argv. The argparse module also automatically generates help and usage messages and issues errors when users give the program invalid arguments.

argparse是Python里帮助我们引导输入参数和快速解析参数的模块。

接下来我们直接上学习Demo,简单学习了解下如何使用argparse帮助我们快速引导输入参数和快速解析参数:
argparsestudy.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/python
# -*- coding: utf-8 -*-
# 指定文件包含非anscii字符

import os
import argparse
import sys

# argparse解析参数输入,需要先定义一个ArgumentParser对象
# description - -h的时候显示在最前面的信息
parser = argparse.ArgumentParser(description='Study argparse module.')

# add_argument - 定义如何解析一个参数
# 第一个参数代表参数名
# type - 指定参数数据类型
# help - -h时解释该参数的用意
# dest - 表示该参数最终在parser.parse_args()返回对象里的名字
# default - 表示该参数的默认值
# required - 表示该参数是不是必须填的参数
parser.add_argument('--APKName', type=str,
help='APK名字', dest='APKName',
required=True)

parser.add_argument('--ClientChId', type=str,
help='新的ClientChId值', dest='New_Clientchid',
default='com.default.com')

parser.add_argument('--LEBIAN_VERCODE', type=int,
help='新的LEBIAN_VERCODE值', dest='New_Lebian_Vercode',
default='0')

parser.add_argument('--TemplateValue', type=str,
help='替换AndroidManifest.xml里quicksdk_packName字段为新的*', dest='NewTemplateValue',
default='com.defaulttemplatevalue.com')

parser.add_argument('--ResourcePath', type=str,
help='放入新资源到指定包内目录', dest='NewResourcePath',
default='./default.aba')

# 解析输入的参数
args = parser.parse_args()

# 打印参数的详细信息,通过访问parser.parse_args()的对象去访问
print(args)
print("APKName : {0}".format(args.APKName))
print("New_Clientchid : {0}".format(args.New_Clientchid))
print("New_Lebian_Vercode : {0}".format(args.New_Lebian_Vercode))
print("NewTemplateValue : {0}".format(args.NewTemplateValue))
print("NewResourcePath : {0}".format(args.NewResourcePath))

命令行输入:
Python .\argparsestudy.py -h
会看到每个参数的详细信息
ArgparseHelpInfo

真正使用时因为–APKName是必传参数,所以按下面方式调用即可:
Python .\argparsestudy.py –APKName com.tonytang.com
ArgparseUsing
可以看到我们通过argparse模块轻松的完成参数输入引导以及参数解析的任务。

更多详细学习参考:
argparse — Parser for command-line options, arguments and sub-commands

Path

关于Path模块,这里需要先了解一下跟语言无关的一个基础知识。

路径中斜杠/和反斜杠\的区别

从博客介绍中可以看出,斜杠和反斜杠主要是Windows路径和Unix路径里区分,大部分情况(无论是浏览器还是html url都是反斜杠)。

这里就引出了在自动化流程里涉及路径时,我们需要兼容Windows,Mac,Android或者IOS时候路径问题需要处理。而Path模块正是能够处理这一问题的现有模块功能。

os.path

这里只是简单示范几个常规API用法,详情参考:os.path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os
import sys

FilePath = sys.argv[0]
print("FilePath = {}".format(FilePath))

# 获取当前工作目录
WorkingFolder = os.getcwd()
print("WorkingFolder = {}".format(WorkingFolder))

# 文件所在文件夹名
FileFolderName = os.path.dirname(FilePath)
print("FileFolderName = {}".format(FileFolderName))

if os.path.isdir(WorkingFolder):
print("{} is directory!".format(WorkingFolder))
else:
print("{} is not directory!".format(WorkingFolder))

OSPathAPIOutput

pathlib

This module offers classes representing filesystem paths with semantics appropriate for different operating systems. Path classes are divided between pure paths, which provide purely computational operations without I/O, and concrete paths, which inherit from pure paths but also provide I/O operations.

上面提到了两种概念PurePath和Concrete Paths,前者只是提供路径概念(比如区分Windows还是Unix文件系统以及路径相关操作)不和文件系统挂钩。后者就是Pathlib提供的OOP文件路径系统抽象,提供了一系列的文件操作接口(Pathlib相当于进阶版的Path抽象模块)。

UMLPathDesgin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import os
import sys
from pathlib import Path

FilePath = sys.argv[0]
print("FilePath = {}".format(FilePath))

# 获取当前工作目录
WorkingFolder = os.getcwd()
print("WorkingFolder = {}".format(WorkingFolder))

# 文件所在文件夹名
FileFolderName = os.path.dirname(FilePath)
print("FileFolderName = {}".format(FileFolderName))

if os.path.isdir(WorkingFolder):
print("{} is directory!".format(WorkingFolder))
else:
print("{} is not directory!".format(WorkingFolder))

# 这一步可以自动转化成对应平台的路径格式(最后需要str的时候需要转化成str)
PathWorkingFolder = Path(WorkingFolder)
print("PathWorkingFolder = {}".format(PathWorkingFolder))

WorkingFolderJoinPath = PathWorkingFolder.joinpath("joinpath")
print("WorkingFolderJoinPath = {}".format(WorkingFolderJoinPath))

if PathWorkingFolder.exists():
print("{} exists!".format(PathWorkingFolder))

PathLibAPIOutput

这里用Pathlib除了他提供了更多的文件路径操作外,还有就是它继承了PurePath跨平台文件路径处理的特性,让我们可以在处理文件路径时无需关心平台。

Bat and Shell

Bat和Shell在Unity自动化&IOS自动化签包文章中已经了解过了,这里主要是介绍如何在python里和bat,shell交互(比如我们想获取环境变量里的数据,执行shell命令等)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os

androidhome = os.getenv("ANDROID_HOME")
if androidhome == None:
print("未配置ANDROID_HOME环境变量路径,请先配置ANDROID_HOME环境变量")
else:
print("ANDROID_HOME = {}".format(androidhome))

WorkingFolder = os.getcwd()
print("WorkingFolder = {}".format(WorkingFolder))

CmdCommand = "echo Hello World"
print("CmdCommand = {}".format(CmdCommand))
# 执行CDM命令
os.system(CmdCommand)

# 确保不自动关闭
input("请按任意键继续...")

SystemInfoOutput

注释和用法都很详细了,这里就不多介绍了。

Python GUI

GUI(Graphic User Interface)可视化用户界面。
Python实现GUI有很多选择,这里参考一位博主的分享:
Python 的图形界面(GUI)编程?

学习需要从需求出发,这里本人主要是想用Python写点简单的GUI来满足可视化即可,所以对GUI要求不高,本人不是以Python为主,只是用Python写点周边小工具,所以这里本人考虑的是学习了解Python自带的TKinter。

如果对GUI要求比较高,有比较常用Python,根据上面那位博主的建议来看,PyQT是个不错的选择。

TKinter

官方介绍:
Tkinter is Python’s de-facto standard GUI (Graphical User Interface) package. It is a thin object-oriented layer on top of Tcl/Tk.

接下来将根据Tkinter 8.5 reference: a GUI for Python去学习了解TKinter的概念以及使用。

待续……

Reference

Conception Part

Python
Guido van Rossum

Tkinter

Encode and Decode in Python

Tutorial Part

Python Tutorial
Python Package Index

Tkinter 8.5 reference: a GUI for Python

codecs

argparse

Python教程

Other Part

Python2orPython3
Python 的图形界面(GUI)编程?

Unity自动化打包

本章节主要是为了学习Unity自动化打包相关知识以及运用。
本文大部分属于本人学习理解的东西,如有不对欢迎指出,学习交流,共同进步。

本章节的流程如下:

  1. 了解什么是打包以及打包过程中的一些相关概念
  2. 了解什么是自动化以及帮助我们快速完成一键打包的工具
  3. 自动化打包工具或编程语言的选取
  4. Unity自动化打包过程中的相关工具
  5. 实战集成运用到Unity构建一键打包
  6. 更多学习程序发布以及相关概念

在了解相关自动化工具之前,我们需要先简单了解下什么是版本控制。在我们的日常开发工作中必不可少。

版本控制

版本控制相关概念学习

打包

平时做游戏开发,在完成整个游戏功能开发后,需要通过打包上真机测试。
把所有游戏资源代码打包成安装包的过程称为打包。

多平台

在多平台的处理上,Unity已经帮我们做了很大一部分工作了。让我们大部分时间只需关心游戏核心开发(Unity部分API在不同平台有差异),在打包这一块只需打选择对应平台即可。

但Unity自带的打包,终究只包含最基本的打包流程,当我们有特殊需求时,我们需要通过Unity以及相关工具完成支持我们自定义的打包流程。

自动化

在了解什么是自动化之前,让我们看看打包的基本流程。
在打包过程中,打包往往包含4个步骤。

  1. Preprocess(打包准备步骤)
    这一步主要是为正式打包编译做准备,比如做一些平台相关设置,资源打包处理,文件复制等
  2. Build(打包编译步骤)
    这一步是程序代码和资源正式打包成安装包的步骤,主要是通过指定打包参数执行打包。
    Unity在打包到Android和IOS平台时有差异。Android是直接就打包出APK,而IOS要先打包成XCode工程,然后在通过编译XCode工程打包出Ipa安装包。
  3. Postprocess(打包结束后期处理步骤)
    这一步是打包完成后负责后续的自动化操作以及善后工作,比如在打包IOS时打包出XCode工程后,可以在这一步进行XCode工程的自动化处理部分(比如动态添加info.plist文件信息,动态插入代码等)
  4. Deploy(安装步骤)
    这一步主要是打包好的安装包安装到真机的步骤

如果没有自动化,我们需要一步一步人工手动去完成上面的步骤。
而自动化就是只需人工设置打包最基本的参数信息,然后通过一步就自动化完成上述步骤的过程。

Jenkins

Introduction
Jenkins是一个用Java编写的开源的持续集成工具。Jenkins提供了软件开发的持续集成服务。它运行在Servlet容器中(例如Apache Tomcat)。它支持软件配置管理(SCM)工具(包括AccuRev SCM、CVS、Subversion、Git、Perforce、Clearcase和RTC),可以执行基于Apache Ant和Apache Maven的项目,以及任意的Shell脚本和Windows批处理命令。Jenkins的主要开发者是川口耕介。

本人使用目的:
做Unity游戏打包机,持续集成自动化打包流程,做到一键打包。

Deployment

  1. Download Jeakins
  2. Open up a terminal in the download directory and run java -jar jenkins.war
  3. Browse to http://localhost:8080 and follow the instructions to complete the installation.(安装插件)

Jenkins实战

自由风格构建系统

主要是采用特定的构建系统去构建自动化。(比如java通过ant||Maven,windows通过bat||python等)
OpenAutomation

如果不想采用Pipeline编写自动化流程,也可以通过直接编写调用Windows Bat或者Python等命令触发自动化流程。
OpenAutomationWithCommands

Unity3d Plugin不支持Pipeline,所以为了使用Unity3d Plugins,这里采用自由风格构建系统来搭建Unity3D的自动化打包。

  1. Create Free Style(创建自由风格项目)
    新建 -> 构建一个自由风格的软件项目
    JenkinsFreeStyleProject
  2. 设置参数构造(用于自定义参数添加)
    FreeStyleWithParametersSetting
  3. 设置代码来源
    FreeStyleWithSourceSetting
  4. 设置构建命令(bat || python || unity3d……)
    FreeStyleWithCommandsSetting
  5. 设置构建完毕后的一些行为(比如邮件通知)
    FreeStyleWithPostProcessing

Note:
想要改变默认的workspace路径的话。
设置->使用自定义的工作空间(只针对当前设置的自由风格项目有效)
FreeStyleWithOwnWorkspace

Pipeline
What

Jenkins Pipeline is a suite of plugins which supports implementing and integrating continuous delivery pipelines into Jenkins. Pipeline provides an extensible set of tools for modeling simple-to-complex delivery pipelines “as code”.(这里的Pipeline是一套插件,用于支持持续自动化集成编写。好比脚本文件定义一系列的自动化流程,但并不等价于脚本。)

那么Pipeline是用什么语言编写了?
Jenkins是基于Java的,Pipeline的编写是基于Groovy。
Groovy是Java平台上设计的面向对象编程语言。这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用。

Why

为什么需要Pipeline?

  1. Code – 可管理可编辑可视化的自动化流程脚本
  2. Durable – 可持续化开发
  3. Pausable – 可以自动也可以停止等待输入
  4. Versatile – 支持复杂的持续交付需求
  5. Extensible – “The Pipeline plugin supports custom extensions to its DSL [5: Domain-Specific
    Language] and multiple options for integration with other plugins.”
How

接下来让我们看看如何创建一个Pipeline。

  1. 下载Pipeline插件
    DownloadPipelinePlugin
  2. New Item – 新建Pipeline(选择Pipeline类型)
    CreatePipeline
  3. Configure Pipeline
    • 配置Pipeline的名字
      ConfigurePipeline
    • 设置Jenkins Pipeline Code的来源
      • 网页版编写
        CreatePipelineSetPipelineSource
      • SVN下含Jenksfile文件
        CreatePipelineSetPipelineSourceSVN
        这里不深入讨论Jenkins的各种设置,详情查看官方文档Jenkins User Handbook

Note:
Pipeline supports two syntaxes, Declarative and Scripted Pipeline

Jenkinsfile

除了直接在网页上编写Pipeline自动化流程。我们也可以指定Jenkinsfile文件作为编写Pipeline的地方。

为什么需要Jenkinsfile?
主要还是为了放入版本管理,方便所有人可视化看到自动化流程代码。

e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#!groovy
pipeline {
//agent指定jenkins从哪里以及如何执行pipeline
//any表示从any available agent处执行
agent any

//看定义在那一层,就只针对那一层起作用(pipeline一层针对pipeline。stage一层针对特定stage)
//也可以修改已定义的环境变量值
environment {
UNITY_PROJECT_PATH = 'E:\\TonyTang\\Work\\Projects\\TGame_code'
}

//自定义参数
//可以通过pipeline代码编写,也可以直接在web UI界面上添加
parameters{
string(name: 'Platform', defaultValue: 'Android', description: 'Which plaftform is using?')
booleanParam(name: 'DEBUG_BUILD', defaultValue: true, description: 'Is debug version?')
}

stages {
stage('postprocess'){
//when表示执行条件判断
//决定stage是否应该执行
when{
expression
{
return params.DEBUG_BUILD
}
}
steps{
//params是访问pipeline里定义的所有parameters入口
echo "Current platform is ${params.Platform}"
echo 'postprocess'
echo UNITY_PROJECT_PATH
//env是jenkins定义的全局的环境变量
//http://localhost:8080/job/TonyTangPipeline/pipeline-syntax/globals
echo env.WORKSPACE
}
}
stage('build') {
steps {
//Windows调用bat
//Mac Linux采用sh
bat 'TonyTangPipeline'
}
}
stage('deploy'){
steps{
echo 'deploy'
//支持调用python
bat 'python FirstPython.py'
}
}
}

//post表示Pipeline执行完之后执行,做一些clean up操作
post {
//根据pipeline的结果做特定操作
always{
echo 'Always!'
}
success {
echo 'Success!'
}
failure{
echo 'Failed!'
}
}
}

// Script Pipeline(Optional)
//在pipeline执行完成之后执行
//支持大部分Groovy
//Script Pipeline的参数定义需要在properties里
properties([parameters([string(defaultValue: 'Android', description: 'Which platform is using?', name: 'Platform')])])

node {
//定义字符串
def Date = '2017//4//26'
stage('postprocess') {
//要使用${}访问string,需要用""
echo "Date = ${Date}"
echo "Current platform is ${params.Platform}"
echo 'postprocess....'
}
stage('build') {
echo 'build....'
}
stage('deploy') {
echo 'deploy....'
}
}

Note:

  1. 指定使用版本管理后,Jenkinsfile必须放到版本管理里才能找到。
  2. Jenkinsfile必需放到前面设置的本机相对路径下才能找到。
Problems
  1. 构建参数一旦通过代码指定添加后,代码删除了依然存在
  2. No valid crumb was included in the request

Solutions:

  1. 通过Pipeline -> Setting的UI界面手动删除参数
  2. 在系统管理 –> Configure Global Security
    取消“防止跨站点请求伪造(Prevent Cross Site Request Forgery exploits)”的勾选。
Tools
Pipeline Tools

Snippet Generator
PipelineSnippetGenerator
这是一个很有用帮助快速编写Pipeline的工具,他可以帮助我们把我们熟悉的自动化脚本转换成Pipeline格式的语句,也可以快速生成一些特定功能的脚本(比如邮件功能等)。(Jenkins提供)

Pipiline全局环境变量

Email Plugin

利用现有的SMTP(腾讯,网易等)服务进行搭建邮件提醒,详情参见如下:
Jenkins——应用篇——插件使用——Mailer Plugin
Jenkins自带Email配置如下:
Manage Jenkins -> Configure System -> E-mail Notification
EmailNotificationSetting
Jenkins自带的Mailer提供的功能很有限,为了提供更多更广的功能,这里我们需要用到Jenkins Email Extension Plugin
Jenkins进阶系列之——01使用email-ext替换Jenkins的默认邮件通知
Email Extension Plugin配置详情参见如下:
Manage Jenkins -> Configure System -> Extended E-mail Notification
ExtendedEmailNotificationPart1
针对workspace自定义邮件内容配置如下:
WorkspaceExtendedEmailSetting
最终收到Email提醒:
EmailResult

Unity3D Plugin

管理插件->Unity3d Plugin
Unity本身是支持从命令行启动U3D,执行Editor特定方法的。
但Unity3D Plugin是一个针对Jenkins集成Unity命令行的插件,可以帮助我们直接在Jenkins上编写U3D命令,对于Jenkins更加友好。
好处如下:

  1. Log file redirection(Log重定位到Jenkins界面)
  2. Distributed builds(分布式编译)

Unity3D Plugin实战配置

  1. Configure Unity Installation Path
    System Setting -> Global Tool Configuration
    JenkinsUnityPathConfiguration
  2. 编写Unity下可通过命令行调用的代码
1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;
using System.Collections;
using UnityEditor;

public class UnityBuild {

[MenuItem("Custom/UnityBuild/OneKeyBuild")]
static void OneKeyBuild()
{
Debug.Log("OneKeyBuild() Called!");
}
}
  1. 配置Unity命令行运行启动以及参数
    JenkinsUnityMethodCall
    JenkinsUnityMethodCallOutput
    从上面可以看出我们成功的配置了Jenkins以及对于Unity打包方法的调用(在这一步我们可以做很多我们自己对于Unity打包的流程设置,资源打包或者其他的预处理等)
  2. 编写完整的打包设置以及相关预处理代码
    待续…….
    接下来是为了在IOS进行Jenkins自动化打包而设置的步骤
  3. 在IOS搭配SmartSVN拉VisualSVN上的文件(Windows上用TortoiseSVN作为SVN客户端即可)
  4. 在IOS上配置Jenkins(和Windows上类似,需要下载Java支持Jenkins运行)
    这里讲几个配置搭建Mac OSX的Jenkins时遇到的坑:
    1. 配置Unity插件Unity安装路径问题
      Windows上是到Editor上一层即可。IOS是到Unity.app这一层(Unity.app(e.g. /Applications/Unity/Unity.app/)在命令行里可以往里访问)
    2. 指定IOS Subversion URL(e.g. https://192.168.1.3/svn/***)时显示无法访问或者不正确的credential(这里是因为默认的Mac SVN版本过低需要更新Mac OSX自带的SVN)
    3. 更新Mac OSX自带SVN时在添加新装的SVN到环境变量里后依然显示是老版的SVN
      在~/目录下touch .bash_profile(创建.bash_profile)
      然后添加环境变量路径export PATH=”/opt/subversion/bin;$PATH”
      使其环境变量路径设置生效source .bash_profile
      查看当前SVN版本svn –version
      如果上一步依然显示是旧版SVN且当前系统是10.11
      那么我们就需要删除Mac OSX上原本的SVN以实现更新SVN的目的
      查看旧版SVN路径which svn
      删除旧版SVN(sudo -s rm -rf /svnpath/svn*)(此时如果出现No Permission to remove,那么恭喜,你是用的10.11系统。10.11推出了SIP(System Integrity Protection)机制去防止root用户对于部分文件的操作权限限制用于防止病毒破坏或修改重要文件)
      为了能够删除旧版SVN,这里我们需要进到Recover Mode去临时关闭SIP(重启电脑一直按住cmd+R,然后在terminal里执行csrutil disable,关闭成功后就可以重启电脑去删除旧版svn了)
      然后将新版SVN链接到旧版SVN执行路径(sudo -s ln -s /opt/subversion/bin/svn* /svnpath)
      最后到Recover Mode里恢复SIP(csrutil enable)
    4. 打包IOS时报错:_RegisterApplication(), FAILED TO establish the default connection to the WindowServer, _CGSDefaultConnection() is NULL.
      这是因为Jenkins默认安装时是以Jenkins作为用户和组。开机自启动时也是以Jenkins用户和组来运行的。而Unity我是默认安装在我自己的用户apple和admin组下。所以在Jenkins需要启动Unity时应该是没有权限调用 Unity Editor 的命令行。所以我们要做的就是确保Unity和Jenkins运行在同一个User和Group下。
      以下采用把Jenkins改到apple和wheel下运行以支持Unity的命令行运行。
      详情参见macOS 安装配置 Jenkins 持续集成 Unity 项目指南
    5. 指定Custom Workspace(自定义到桌面特定目录后每次都报Failed to mkdir,这个跟第四个问题是同一个问题,默认Jenkins用户权限问题)

Note:
很多文件是Unity自动生成的(比如Library目录),我们无需加入版本管理(SVN只要不加入版本管理即可)

Note:
Unity3d Plugin不支持在Pipeline里添加。
同时Unity3d Plugin只在3.4.2 to 5.0.1的版本下测试过。(经本人测试在Unity5.4.0f3的版本也能正常使用)

Jenkins从零搭建

这里的从零搭建,在借助Jenkins插件的基础上,尽量采用Pipeline编写为主,插件UI为辅的方式来实现一整套完整的Jenkins自动化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#!groovy
pipeline {
//agent指定jenkins从哪里以及如何执行pipeline
//any表示从any available agent处执行
agent any

//看定义在那一层,就只针对那一层起作用(pipeline一层针对pipeline。stage一层针对特定stage)
//也可以修改已定义的环境变量值
environment {
UNITY_PROJECT_PATH = 'E:\\TonyTang\\Work\\Projects\\MyGitHubProjects\\GitStudy2'
GIT_URL = 'git@github.com:TonyTang1990/GitStudy.git'
GIT_AUTH = '7d947f92-1e9c-44ba-9a80-9744f92f2ff6'
}

//自定义参数
//可以通过pipeline代码编写,也可以直接在web UI界面上添加
parameters{
string(name: 'Platform', defaultValue: 'Android', description: 'Which plaftform is using?')
booleanParam(name: 'DEBUG_BUILD', defaultValue: true, description: 'Is debug version?')
}

stages {
stage('postprocess'){
//when表示执行条件判断
//决定stage是否应该执行
when{
expression
{
return params.DEBUG_BUILD
}
}
steps{
//params是访问pipeline里定义的所有parameters入口
echo "Current platform is ${params.Platform}"
echo 'postprocess'
echo "${env.UNITY_PROJECT_PATH}"
echo "${env.GIT_URL}"
echo "${env.GIT_AUTH}"
//env是jenkins定义的全局的环境变量
//http://localhost:8080/job/TonyTangPipeline/pipeline-syntax/globals
echo env.WORKSPACE
}
}
stage('Git') {
steps {
echo 'Git operation'
checkout([$class: 'GitSCM',
userRemoteConfigs: [[url: "${env.GIT_URL}", credentialsId: "${env.GIT_AUTH}"]],
extensions: [[$class: 'CleanBeforeCheckout', $class: 'GitLFSPull']]
])
}
}
stage('build') {
steps {
//Windows调用bat
//Mac Linux采用sh
bat 'BatAutomation'
}
}
stage('deploy'){
steps{
echo 'deploy'
//支持调用python
bat 'python PythonAutomation.py'
}
}
}

//post表示Pipeline执行完之后执行,做一些clean up操作
post {
//根据pipeline的结果做特定操作
always{
echo 'Always!'
}
success {
echo 'Success!'
}
failure{
echo 'Failed!'
}
}
}

// Script Pipeline(Optional)
//在pipeline执行完成之后执行
//支持大部分Groovy
//Script Pipeline的参数定义需要在properties里
properties([parameters([string(defaultValue: 'Android', description: 'Which platform is using?', name: 'Platform')])])

node {
//定义字符串
def Date = '2017//4//26'
stage('postprocess') {
//要使用${}访问string,需要用""
echo "Date = ${Date}"
echo "Current platform is ${params.Platform}"
echo 'postprocess....'
}
stage('build') {
echo 'build....'
}
stage('deploy') {
echo 'deploy....'
}
}

脚本语言

为了快速编写一些自动化任务,我们往往需要用到脚本语言。

bat

批处理文件(英语:Batch file),又称批次档,在DOS、OS/2、微软视窗系统中,是一种用来当成脚本语言运作程序的文件。它本身是文本文件,其中包含了一系列让具备命令行界面的解释器读取并运行的指令。它相当于是类Unix系统下的Shell script。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
:: 关闭命令输出打印
@echo off
:: UTF8
chcp 65001
:: 清除命令行界面内容
CLS
:: 输出打印信息
echo Tony Tang bat study
:: 设置打印传入参数
set par1=%1
set par2=%2
echo %par1%
echo %par2%
:: 输出打印当前目录路径
echo %cd%
:: 切换到上一层路径
cd ..
:: 输出打印当前目录路径
echo %cd%
:: 切换到BatStudy目录
cd BatStudy
:: 输出打印当前目录路径
echo %cd%
:: 打印帮助信息
::HELP
:: 自定义变量(定义变量时=号两边不能有空格)
set batvariable=tonytang
:: 打印自定义变量
echo %batvariable%
:: 自定义数字变量
set number=3
:: 打印自定义数字变量
echo number = %number%
:: 条件语句
if %number%==2 (echo %number%==2) else (echo %number%!=2)
:: 检查文件是否存在
set filename=batcontent.txt
echo filename=%filename%
if EXIST ./%filename% (echo %filename% exits) else (echo %filename% not exist)
:: 普通循环语句
for %%i in (1 2 3) do echo %%i
:: 当前目录文件循环遍历语句
for %%f in (*) do @echo %%f
:: 启用变量执行时解析
SetLocal EnableDelayedExpansion
for %%i in (1 2 3) do (
set numberVariable=%%i
echo !numberVariable!
set numberVariable=!numberVariable!+!numberVariable!
echo !numberVariable!
)
:: 暂停等待输入
pause
:: 退出命令界面 0代表正常退出
EXIT 0

BatStudyOutput

这里只列举一些常用基础的,详细Windows Batch学习参考:
Batch file – Programming tutorial
Batch Script Tutorial

Shell

Windows上的bat,power shell
Linux上的shell
好处:
简单快速的编写一些系统相关的任务(比如文件复制等)
缺点:
有平台差异

在正式学习Shell之前,让我们依然从What,How,Why,When这四个问题着手:
What?
Unix shell,一种壳层与命令行界面,是UNIX操作系统下传统的用户和计算机的交互界面。第一个用户直接输入命令来执行各种各样的任务。

第一个Unix shell是由肯·汤普逊,仿效Multic上的shell所实现出来,称为sh。

后续出了其他的Shell:

  1. Bourne-Again shell - bash
  2. Debian Almquist shell - dash
  3. Korn shell - ksh
  4. Z shell - zsh

上面的几个Shell都可以看作是后续发展功能越来越强大的Shell解析器,至于具体分别包含些什么功能就不在本文讨论的范围内了。这里我们主要以bash和shell作为解析器来学习编写shell脚本。

How?
Shell脚本(英语:Shell script),又称Shell命令稿、程序化脚本,是一种计算机程序与文本文件,内容由一连串的shell命令组成,经由Unix Shell直译其内容后运作。被当成是一种脚本语言来设计,其运作方式与解释型语言相当,由Unix shell扮演命令行解释器的角色,在读取shell脚本之后,依序运行其中的shell命令,之后输出结果。利用shell脚本可以进行系统管理,文件操作等。

可以看出Shell脚本是被当做脚本语言来解释执行,通过调用系统内核的一些工具来实现特定功能任务的。

Why?
Shell并不适合编写复杂的程序任务,因为他只提供了一些比较基础的工具,并且不具备面向对象编程等高级语言的特性。
既然如此那我们为何还要坚持使用Shell了?为何不直接选择Python,Ruby,Perl这些相对高级一点的解释性语言了?
理论上是可以的,但Shell属于原生就具备提供的工具,可以很快捷的编写出一些看似简单但却方便的工具脚本。即使Python可以实现,但针对特定的功能可能没有Shell来的那么快捷方便,根据需求选择合适的脚本语言也是很重要的。后续我会结合Python夸平台的特性,使用Python结合Shell编写一些自动化相关的工具。

Note:
Shell跟操作系统紧密相关,原本并非跨系统平台的,但通过移植很多shell具备了跨系统平台的能力(Windows上通过Cygwin与MinGW可以模拟执行Unix Shell)。

When?
那么什么时候适合用Shell了?从前面三个问题不难看出,Shell只适合用于一些不复杂的功能任务里,无论是语言特性还是跨平台都没有Python那些高级语言支持的好。如果需要实现复杂的且跨平台,需要高级语言特性(面向对象等)时,不应该考虑Shell而是Python,Perl,Ruby这些。

Note:
这里所说的shell和Window的batch并不是同一个东西。

bash
  • 指定解析器
1
2
#!/bin/bash
# Proper header for a Bash script

**#!**是two-byte的magic number
表示这个文本是executable shell script,后面紧跟的/bin/bash表示shell解析器是用的bash

  • 输出打印
    学程序,最初开始的地方肯定是Hello World。
    在bash里打印输出是通过echo指令。
1
2
3
#!/bin/bash
# Proper header for a Bash script
echo "Hello World"

Bash echo

  • 特殊符号
  1. 井号(#) – 注释符号
1
# Proper header for a Bash script(这一行#号开头表示注释)
  1. ; – 命令分隔符(允许同一行多个指令)
1
2
#! /bin.bash
echo "Hello"; echo "World"
![CommandSeparator](/img/Bash/CommandSeparator.png)
  1. ;; – 跳出case语句
1
2
3
4
5
6
#! /bin/bash
variable="TonyTang"
case "$variable" in
TonyTang) echo "TonyTang1";;
TonyTang) echo "TonyTang2";;
esac
![TerminateCommand](/img/Bash/TerminateCommand.png)
  1. .
    • 表示文件是隐藏的
1
2
3
4
#! /bin/bash
touch .hidden-file
ls -l
ls -al
![HidenProfileCommand](/img/Bash/HidenProfileCommand.png)
- 表示当前目录
1
2
3
pwd
cd .
pwd
![CurrentDirectoryCommand](/img/Bash/CurrentDirectoryCommand.png)
- 正则里面表示匹配单个字母
  1. “” - 字符串(保留大部分特殊符号)
  2. ‘’ - 字符串(保留所有特殊符号)
  3. ,
  4. \ - 反斜杠,忽略特殊符号
1
2
#! /bin/bash
echo "\"Hello World"\"
![Backslash](/img/Bash/Backslash.png)
  1. / - 文件路径分隔符
  2. : - 相当于内置true
1
2
3
4
5
6
while :
do
echo "Number 1"
echo "Number 2"
echo "Number 3"
done
![ColonOperator](/img/Bash/ColonOperator.png)
  1. ! - 取反
  2. 星号(*) - 通配符或者乘法
  3. ? - 三目运算符或者表示测试条件
  4. $
    • $ 变量取值符号
    • $? 退出状态码
    • $$ 进程号
1
2
3
4
5
#! /bin/bash
var1=100
echo "var1 = $var1"
echo $?
echo $$
![VariableSubstitution](/img/Bash/VariableSubstitution.png)
  1. &> >& >> < <>

    • &> 重定向输出到指定文件

    • <> 文件读取

1
2
3
4
5
#! /bin/bash
ls -l
echo "Hello World" >HelloWorldFile.txt
ls -l
cat HelloWorldFile.txt
![RedirectionCommand](/img/Bash/RedirectionCommand.png)
  1. ~ ~+ - home目录和当前目录
1
2
3
4
echo ~
echo $HOME
echo ~+
echo $PWD
![FolderCommands](/img/Bash/FolderCommands.png)
  1. ` - 命令执行符号(可以用于快速执行命令并将结果复制)
1
2
Date=`date +%Y%m%d%H%M%S`
echo $Date
![CommandsSubstitution](/img/Bash/CommandsSubstitution.png)
  • Shell小知识
  1. $0 - $9
    $0表示Shell文件名
    $1-$9表示Shell的第一个到第九个参数

  2. ls -l
    查看文件详细信息(比如读写权限等)
    LSCommand
    具体如何理解最前面的权限信息,参考第六章、Linux 的文件权限与目录配置

  3. chmod
    修改文件权限

  4. cd
    切换目录

  5. pwd or ~+
    当前所在目录

  6. echo
    输出信息

  7. exit
    退出shell

  8. $?
    前一段shell的返回输出值

  9. $ or ${}
    去变量值


  10. 多行shell支持

  11. $SHELL
    从当前Shell执行结束的地方打开一个新的terminal窗口(能保持之前所有的输出信息都在)

待续……

Python(严格意义上不算脚本语言)

好处:

  1. 跨平台
  2. 解释性语言,开发方便快速
  3. 库丰富
    待续……

IOS自动化打包

IOS打包准备
  1. Apple Account(发布到IOS设备上需要)
    任何账号都可以发布到自己的设备测试,但是如果要使用GC or In-App Purchases或者发布到App Store的话需要注册Apple Developer Program。

  2. Xcode(up-to-date version)
    因为Xcode是Mac上的软件,所以这里需要Mac电脑或者是黑苹果。(开发所需的SDK,IDE套装都在这)

  3. Adding your Apple ID to Xcode
    Open Xcode -> Xcode -> Preferences -> Account ->

  4. 在开发之前,Mac需要获得IOS Certificartes(用于对Mac开发授权,程序签名等)
    登陆Apple Developer Program -> Certificates ,identifiers & Profiles -> Certificate Signing Request
    Create CSR(Certificate Signing Request):

    1. Folder -> Utilities Folder -> Keychain Access
    2. Keychain Access -> Certificate Assistant -> Request a Certificate from a Certificate Authority -> 填完内容存储到本地
      CSR里包含了public key和private key。
      当CSR创建以后,需要使用CSR去请求一个Certificate:
    3. 上传CSR
    4. 下载Certificate
    5. 运行安装Certificate
      安装Certificate后public和private key pair就生成了(在Keychain Access下可以查看)而我们的应用程序签名是使用私钥来签名用公钥来进行验证, 而苹果生成的Certificate 只包含了公钥,当你用自己的私钥签名后,苹果会用公钥来进行验证,确保是你自己对程序签名而不是别人冒充的
      通过导出共享公钥和私钥,可以实现共同开发(Mac开发授权,程序签名等)。
  5. 设置App ID(相当于Android包名,用于识别App)
    App ID包含以下组成部分:

    1. Prefix – 作为Team ID
    2. Suffix – 作为Build ID的搜索字符串(可指定通配符)
    3. AppServices – 指定包含的Service(比如Game Center, IAP, iCloud等服务),只有指定了这些,使用包含了此APP ID的Provision Profile才能使用特定服务
  6. 添加设备列表(只有被添加了的设备列表才能用于安装和测试程序)
    需要通过Itunes查看UDID,然后添加到设备列表。

  7. 用上述我们创建的Certificate, Apple ID, Devices List等生成我们的Provision Profile(Unity Cloud Build的时候需要指定)
    不同的Provision Profile有不同的用处,比如iPhone Developer证书用于测试设备上运行,iPhone Distribution证书用于提交应用到App Store。
    让我们来理解一些相关概念,下文引用至IOS开发 证书总结
    Identifier:
    顾名思义App ID(application id, not apple id), 对于你的一个或者一组app他们的唯一标识, 这个App ID跟你Xcode中的Targets —–> General—–> Identity中的Bundle Identifier是匹配的,(其余的那些推送服务啊什么的都是配置在APP ID下面的) 如下图:

    Provisioning Profile
    一个Provisioning Profile包含了上述所有内容 Certificate && App ID && Device, 这个Provisioning Profile文件会在打包时嵌入到.ipa的包里,如下图:

    所以一台设备上运行应用程序的过程如下(以Developer Provisioning Profile为例):
    1 检查app 的 bunld ID 是否 matches Provisioning Profile 的 App ID
    2 检查 app 的 entitements 是否 matches Provisioning Profile 的 entitements
    3 用Certificate来验证签名签名
    4 检查此设备的UDID是否存在于 Provisioning Profiles中 (仅在 非发布证书中)

    如何创建?
    在 Provisioning Profiles 中点加号,然后依次选择App ID, Certificate, Devices(development),再指定名称,最后下载, 双击则安装到Xcode中

    Xcode中的配置
    Project && Target 的 build settings 中搜索Code sign…
    然后分别选好对应的证书,如果选择列表中没有刚才创建的证书可以双击直接复制名字上去

    关于推送服务
    基于上面的操作,如果需要推送服务我们还需要申请一个推送证书
    依次进入 Certificates —>Production —>Apple Push Notification service SSL (Production)
    然后选择需要推送服务的App ID
    再选择前面创建的.cerSigningRequest文件
    最后点击generated生成推送证书
    IOS开发流程大致如下:
    IOSAppDistributionWorkflows

XUPorter or Unity API(PBXFroject)

PBXFroject是Unity 5.X版本加进来的。因为5.X以前没有关于XCode自动化修改的相关接口,所以以前的版本用的都是XUPoter这个第三方插件工具来实现自动化修改info.plist文件,自动添加library之类的功能。

本人只使用过XUPoter还没详细使用PBXProject,但XUPoter的作者在Unity 5.X开始已经不在更新支持了,因为有了PBXProject,所以可以这样说,Unity 5.X版本开始可以直接使用Unity 5.X不需要再使用XUPoter了。

XUPoter

这里直接给出作者工具链接,就不详细讲解如何集成使用了,具体参考官方作者网站。
onevcat/XUPorter

PBXProject

对IOS的打包后的XCode工程做后处理,我们需要在代码里实现IPostProcessBuild接口的OnPostProcessBuild方法。

XcodePostProcess.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using UnityEngine;
using System;
using System.Collections.Generic;
UnityEditor.iOS.Xcode

public static class XCodePostProcess : IPostprocessBuild

public void OnPostprocessBuild(BuildTarget target, string path)
{
Debug.Log("XCodePostProcess.OnPostprocessBuild for target " + target + " at path " + path);

//通过PBXProject访问并修改XCode工程相关的设置
string projpath = path + "/Unity-iPhone.xcodeproj/project.pbxproj";
PBXProject proj = new PBXProject();

//读取XCode项目的数据
proj.ReadFromString(File.ReadAllText(projPath));

//获取XCode项目的Unity Target名字
string nativetargetname = PBXProject.GetUnityTargetName();

//获取XCode项目的Unity Target的GUID
string nativeTarget = proj.TargetGuidByName(nativetargetname);

//获取XCode项目的Test Target名字
string testTarget = proj.TargetGuidByName(PBXProject.GetUnityTestTargetName());

//设置需要设定的target列表
string[] buildTargets = new string[] { nativeTarget, testTarget };

//BitCode是用于提交AppStore后优化数据用的,IOS上是optional,但watchOS和tvOS是必须的。动态设置BitCode值
proj.SetBuildProperty(buildTargets, "ENABLE_BITCODE", "NO");

//动态修改UnityAppController.mm文件的arc编译参数
ArcOnFileByProjectPath(proj, nativetargetname, "Classes/UnityAppController.mm", false);

//自动化删除ReleaseForProfiling和ReleaseForRunning两个Configuration
proj.RemoveBuildConfig("ReleaseForProfiling");
proj.RemoveBuildConfig("ReleaseForRunning");

//整个XCode项目数据写回去
File.WriteAllText(projPath, proj.WriteToString());
}

/// <summary>
/// 开启或关闭指定target的指定文件的arc(Automatic Reference Counting)
/// </summary>
/// <param name="proj"></param>
/// <param name="targetname"></param>
/// <param name="filepath"></param>
/// <param name="isenablearc"></param>
private void ArcOnFileByProjectPath(PBXProject proj, string targetname, string filepath, bool isenablearc)
{
string targetguid = proj.TargetGuidByName(targetname);
var fileguid = proj.FindFileGuidByProjectPath(filepath);
Debug.Log("targetguid = " + targetguid);
Debug.Log("fileguid = " + fileguid);
var compileflaglist = new List<string>();
if(isenablearc)
{
compileflaglist.Add("-fobjc-arc");
}
else
{
compileflaglist.Add("-fno-objc-arc");
}
proj.SetCompileFlagsForFile(targetguid, fileguid, compileflaglist);
}
}

这里就不一一截Mac对应的图了,示例代码对XCode的各种参数设置都进行了修改,注释也很清楚了,具体详细的使用,请参考官网API解释。

IOS自动化签包

Xcodebuild是XCode上编译Xcode工程的工具。

所以接下来我们主要是要学习的是使用Xcodebuild去编写自动化签包的工具。

学习XCode Project项目里的一些相关概念。
一下概念来源iOS系统提供开发环境下命令行编译工具:xcodebuild:

  1. Workspace:简单来说,Workspace就是一个容器,在该容器中可以存放多个你创建的Xcode Project, 以及其他的项目中需要使用到的文件。
    使用Workspace的好处有:
    • 扩展项目的可视域,即可以在多个项目之间跳转,重构,一个项目可以使用另一个项目的输出。Workspace会负责各个Project之间提供各种相互依赖的关系;
    • 多个项目之间共享Build目录。
  2. Project:指一个项目,该项目会负责管理生成一个或者多个软件产品的全部文件和配置,一个Project可以包含多个Target。
  3. Target:一个Target是指在一个Project中构建的一个产品,它包含了构建该产品的所有文件,以及如何构建该产品的配置。
  4. Scheme:一个定义好构建过程的Target成为一个Scheme。可在Scheme中定义的Target的构建过程有:Build/Run/Test/Profile/Analyze/Archive
  5. BuildSetting:配置产品的Build设置,比方说,使用哪个Architectures?使用哪个版本的SDK?。在Xcode Project中,有Project级别的Build Setting,也有Target级别的Build Setting。Build一个产品时一定是针对某个Target的,因此,XCode中总是优先选择Target的Build Setting,如果Target没有配置,则会使用Project的Build Setting。

了解了XCode Project中的一些概念,让我们看看Xcodebuild到底是什么样的工具了?
xcodebuild is a command-line tool that allows you to perform build, query, analyze, test, and archive operations on your Xcode projects and workspaces from the command line. It operates on one or more targets contained in your project, or a scheme contained in your project or workspace.

如果想查看xcodebuild的详细说明,我们可以通过命令行输入:man xcodebuild查看即可:
XcodebuildManualPage

首先以XcodeProject为例,我们来看看上面的概念在实际操作过程中对应的东西:
XcodeProjectDemo
从上面可以看出,我们的Demo Xcode Project是没有workspace的,只有一个叫做Unity-iPhone.xcodeproj的Xcode工程文件,而这个文件对应的就是我们前面提到的Project的概念。

如果我们想查看Xcode Project里面的相关schemes或者target信息,我们可以通过命令行切换到Xcode Project目录下然后输入xcodebuild -list -project *.xcodeproj命令来查看:
XcodeProjectListInfo
从上面的信息我们可以看到,我们的Unity-iPhone.xcodeproj项目里有两个Targets分别为:Unity-iPhone和Unity-iPhone Tests以及一个Schemes:Unity-iPhone。这些都分别对应了我们前面最初所了解的相关Xcode Project的概念。

如果想要查看Xcode Project的详细Build Setting设置信息,我们可以通过输入xcodebuild -showBuildSettings来查看:
XcodeProjectBuildSettingsInfo
-showBuildSettings有不少东西,很多概念需要对比着Xcode工程里的设置来看,这里暂时不深究每一个代表什么意思。

了解了Workspace,Project,Target,Scheme,BuildSetting等等概念以及如何通过xcodebuild工具查看Xcode工程相关信息后,接下来我们看一下xcodebuild是如何编译打包导出IPA的。

这里参考网上,我发现Xcode9以后有两种方式来实现自动化打包。

  1. Automatic
  2. Manual

这里我先以Manual的形式来学习打包,后续再学习了解Automatic的形式打包。

首先我们来看看官方对xcodebuild工具的介绍:
xcodebuild is a command-line tool that allows you to perform build, query, analyze, test, and archive operations on your Xcode projects and workspaces from the command line.
可以看出xcodebuild正式苹果官方给出的通过命令行触发打包编译导出一系列操作的工具。

具体想知道xcodebuild的详细使用介绍,可以在shell里输入man xcodebuild查看即可:
ManXcodebuild

Manual:
手动打包之前,我们要用到下面几个比较重要的xcodebuild命令参数:

  1. clean – Remove build products and intermediate files from the build root(SYMROOT)(清楚通过build触发编译的所有中间文件)
  2. build – Build the target in the build root(SYMROOT). This is the default action, and is used if no action is given.(build指定target)
  3. archive – Archive a scheme from the build root(SYMROOT). This requires specifying a scheme.(打包指定scheme)
  4. exportArchive – Specifies that an archive should be exported. Requires -archivePath, -exportPath, and -exportOptionsPlist. Cannot be passed along with an action.(指定Archive文件导出IPA)

下面以工作中完整的命令行编译打包导出IPA为例来讲解学习(敏感信息中文代替):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#! /bin/bash
# 检查并创建文件夹
CheckAndCreateFolder()
{
echo "CheckOrCreateFolder parameter1 : $1"
if [ -d $1 ]
then
echo "$1 exits!"
else
echo "$1 not exits!"
mkdir -p $1
fi
}

currentfolder=~+
homefolderpath=~
xcodeexportoptionfolderpath="${homefolderpath}/Desktop/XcodeAutoSign/"
currentdatetime=`date +%Y%m%d%H%M%S`

#签包相关信息(默认大陆Appstore)
# 包名
bundleid="对应包名"
# 大陆
areas="dalu"
#证书类型
provisionprofiletype="AppStore"
# 证书名字
codesignidentity="对应证书名字"
# 描述文件名字
provisioningprofile="对应描述文件名字"
# Deployment最低版本指定
deploymenttarget="对应最低IOS支持版本"
# 打包导出配置文件
# 最终签名相关信息都是以配置文件为准
exportoptionsplist="对应签名配置文件"

echo "--------------------"
echo "1. 大陆Appstore"
echo "2. 大陆AdHoc"
echo "3. 台湾Appstore"
echo "4. 台湾AdHoc"
echo "--------------------"
echo "输入打包类型数字id(默认不输入是大陆AppStore):"
read countrynumber
# 默认大陆
echo "countrynumber = ${countrynumber:=1}"

echo "输入工程文件夹全路径:"
read xcodeprojectfolderpath
xcodeprojectfullpath="${xcodeprojectfolderpath}/Unity-iPhone.xcodeproj"
echo "xcodeprojectfullpath : ${xcodeprojectfullpath}"

# 检查工程Xcode项目文件是否存在
if [ -d ${xcodeprojectfullpath} ]
then
echo "Xcode项目文件存在!"
else
echo "Xcode项目:${xcodeprojectfullpath}文件不存在!签包IPA失败!" > ErrorLog.txt
exit 0
fi

if [ $countrynumber -eq 1 ]
then
#大陆Appstore
echo "大陆AppStore"
areas="dalu"
provisionprofiletype="AppStore"
bundleid="对应包名"
codesignidentity="对应证书名字"
provisioningprofile="对应描述文件名"
deploymenttarget="对应最低IOS支持版本"
exportoptionsplist="对应签名配置文件"
elif [ $countrynumber -eq 2 ]
then
#大陆Ad Hoc
echo "大陆AdHoc"
areas="dalu"
provisionprofiletype="AdHoc"
bundleid="对应包名"
codesignidentity="对应证书名字"
provisioningprofile="对应描述文件名"
deploymenttarget="对应最低IOS支持版本"
exportoptionsplist="对应签名配置文件"
elif [ $countrynumber -eq 3 ]
then
#台湾Appstore
echo "台湾Appstore"
areas="taiwan"
provisionprofiletype="AppStore"
bundleid="对应包名"
codesignidentity="对应证书名字"
provisioningprofile="对应描述文件名"
deploymenttarget="对应最低IOS支持版本"
exportoptionsplist="对应签名配置文件"
elif [ $countrynumber -eq 4 ]
then
#台湾AdHoc
echo "台湾AdHoc"
areas="taiwan"
provisionprofiletype="AdHoc"
bundleid="对应包名"
codesignidentity="对应证书名字"
provisioningprofile="对应描述文件名"
deploymenttarget="对应最低IOS支持版本"
exportoptionsplist="对应签名配置文件"
else
echo "请输入1或者2或者3或者4!签包失败!" > ErrorLog.txt
exit 0
fi

# IPA文件名
ipadname="${areas}_${provisionprofiletype}_$currentdatetime"
# IPA输出目录全路径
ipaexportpath="/Users/Shared/build-ios/${areas}/${provisionprofiletype}/${currentdatetime}"
# Archive文件名
archivefilename="${xcodeprojectfolderpath}/${areas}_archive.xcarchive"

# 切换到对应工程目录下
cd $xcodeprojectfolderpath
echo "切换到对应工程目录后当前目录:"
echo ~+

# 检查输出目录是否存在,不存在则创建一个
CheckAndCreateFolder $ipaexportpath

#检查导出配置文件是否路径正确且存在
echo "导出配置文件路径:"
echo "${xcodeexportoptionfolderpath}/${exportoptionsplist}"
if [ -f ${xcodeexportoptionfolderpath}/${exportoptionsplist} ]
then
echo "导出配置文件存在!"
else
echo "导出配置文件不存在!签包IPA失败!" > ErrorLog.txt
exit 0
fi

# 判定符合条件的Archive文件是否存在,
# 存在则直接Export,
# 不存在则完全重新签包
if [ ! -d ${archivefilename} ]
then
echo "${archivefilename}文件不存在!"
#clean下工程
echo "开始清理工程"
xcodebuild clean \
-project Unity-iPhone.xcodeproj \
-configuration Release -alltargets \
PRODUCT_BUNDLE_IDENTIFIER="$bundleid"

#开始编译
echo "开始编译打包"
xcodebuild -project Unity-iPhone.xcodeproj \
-scheme Unity-iPhone \
-configuration Release \
PRODUCT_BUNDLE_IDENTIFIER="$bundleid" \
CODE_SIGN_STYLE="Manual" \
CODE_SIGN_IDENTITY="$codesignidentity" \
PROVISIONING_PROFILE="$provisioningprofile" \
IPHONEOS_DEPLOYMENT_TARGET="$deploymenttarget" \
-archivePath "${archivefilename}" \
archive
else
echo "${archivefilename}文件存在!"
fi

#开始打包Archive
echo "开始导出IPA"
xcodebuild -exportArchive \
-archivePath "${archivefilename}" \
-exportOptionsPlist "${xcodeexportoptionfolderpath}/${exportoptionsplist}" \
-exportPath "${ipaexportpath}"

#修改最终输出的ipa文件名
mv -f "${ipaexportpath}/Unity-iPhone.ipa" "${ipaexportpath}/${ipadname}.ipa"

echo "xcodeprojectfolderpath : $xcodeprojectfolderpath"
echo "homefolderpath : $homefolderpath"
echo "ipaexportpath : $ipaexportpath"
echo "bundleid : $bundleid"
echo "codesignidentity : $codesignidentity"
echo "provisioningprofile : $provisioningprofile"
echo "deploymenttarget : $deploymenttarget"
echo "exportoptionsplist : $exportoptionsplist"
echo "xcodeexportoptionfolderpath : $xcodeexportoptionfolderpath"
echo "currentdatetime : $currentdatetime"
echo "ipadname : $ipadname"
echo "archivefilename : $archivefilename"

echo "签包结束."
$SHELL

这里针对上面的自动化签包脚本,我们需要理解几个关键概念:

  1. 包名 – 我们平时说的包名,Unity里平台设置那里设置的包名
    Xcode里显示参考下图:
    XcodeBundleId
  2. 证书名字 – IOS开发打包需要的证书文件(比如:Development,Distribution证书)
    因为Apple Developer Account最近刚到期,无法截后台账号的图,这里只放一张Keychain里面显示已经安装在本地的Certificate图:
    KeyChainCertificates
    证书名字通过右键对应Certificate后选择Get Info可查看:
    CertificateDetail
  3. 描述文件 – 是指后台指定了Certificate,Apple Id, Device List等信息的描述文件
    ProvisioningProfile
    如果想详细查看里面的信息,可以通过下面这个命令:
1
security cmd -D -i *.mobileprovision

这里面就包含了很多信息,这里我们暂时需要通过这个找到描述文件的名字:
ProvisionProfileName

  1. IOS最低版本支持 – 这个是对应Xcode里设置Deployment Target的那个参数,表示打包支持的最低IOS版本
    DeploymentTarget
  2. 签名配置文件 – 关于导出IPA时的签名信息以及相关参数等的配置文件
    ExportOptionFile
    让我们看下里面的详细信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>provisioningProfiles</key>
<dict>
<key>对应包名</key>
<string>对应描述文件名</string>
</dict>
<key>signingCertificate</key>
<string>***</string>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>***</string>
<key>uploadSymbols</key>
<false/>
</dict>
</plist>

可以看到签名配置文件里面指定了导出类型(app-store or development等),证书名以及包名,描述文件信息等信息。
那么这个配置文件哪里来的了?
手动Archive后,我们会在*.ipa的同层文件目录下得到一个叫ExportOption.plist的文件,这个就是那个配置文件。

Note:
如果我们的程序开启了苹果相关的服务,比如Apple Wallet等,那这些服务会以参数的信息显示在上面的配置文件里,如果后台证书配置了使用特定服务,但该配置文件里没有,就会提示特定字段信息对不上或者遗漏而报错。

可以看出通过上面的自动化,我们可以命令行指定以下内容:

  1. 包名
  2. 证书
  3. 描述文件
  4. IOS最低支持版本号
  5. 签名证书

接下来我们看看xcodebuild编译里是如何指定这些信息并成功导出IPA的:

  1. 清理工程
1
2
3
4
xcodebuild  clean \                              # 指定了清理工程
-project Unity-iPhone.xcodeproj \ # 指定清理哪一个project
-configuration Release -alltargets \ # 指定清理Release下的所有targets
PRODUCT_BUNDLE_IDENTIFIER="$bundleid"# 指定了包名(这一个可能不需要)
  1. 编译打包
1
2
3
4
5
6
7
8
9
10
xcodebuild -project Unity-iPhone.xcodeproj \                # 指定打包哪个project
-scheme Unity-iPhone \ # 指定打包哪个scheme
-configuration Release \ # 指定编译Release
PRODUCT_BUNDLE_IDENTIFIER="$bundleid" \ # 指定包名
CODE_SIGN_STYLE="Manual" \ # 指定手动签名
CODE_SIGN_IDENTITY="$codesignidentity" \ # 指定证书
PROVISIONING_PROFILE="$provisioningprofile" \ # 指定描述文件
IPHONEOS_DEPLOYMENT_TARGET="$deploymenttarget" \# 指定IOS最低版本
-archivePath "${archivefilename}" \ # 指定生成的Archive文件路径
archive # 指定编译打包
  1. 导出IPA
1
2
3
4
xcodebuild -exportArchive \                                         # 指定导出IPA
-archivePath "${archivefilename}" \ # 指定Archive文件路径
-exportOptionsPlist "${xcodeexportoptionfolderpath}/${exportoptionsplist}" \ # 指定签名配置文件路径
-exportPath "${ipaexportpath}" # 指定IPA输出路径

上面的注释已经很详细了,这里就不在详细讲解了。
通过archive命令,我们得到了一个*.xcarchive的文件。
ArchiveFile
通过这个*.xcarchive文件,我们可以通过指定签名配置文件导出我们想要的IPA文件。

Automatic:
待续……

References

http://www.liaoxuefeng.com/wiki/0013739516305929606dd18361248578c67b8067c8c017b000/001374027586935cf69c53637d8458c9aec27dd546a6cd6000)

Jenkins Part

Groovy
Uniy3d Plugin
Jenkins——应用篇——插件使用——Mailer Plugin
Jenkins进阶系列之——01使用email-ext替换Jenkins的默认邮件通知
macOS 安装配置 Jenkins 持续集成 Unity 项目指南
MAC EI Capitan上更新系统自带SVN版本(关闭SIP方能sudo rm)
About System Integrity Protection on your Mac
Email-ext plugin

Shell Part

Unix shell
Shell脚本
Advanced Bash-Scripting Guide

Xcodebuild Part

Xcode 9 最新 最准确 的自动化打包
iOS系统提供开发环境下命令行编译工具:xcodebuild
Building from the Command Line with Xcode FAQ
Build settings reference

Other Part




Editor

Editor Comparison

先如今有很多优秀的编辑器(有些近乎IDE的水准):

  1. Sublime Text 3
  2. Vim
  3. Emacs
  4. VS Code
  5. Notepade++
    ……

每一种都各有各的优势,作为一个小白用户,连Sublime Text 3和VS Code都算不上使用熟悉,所以这里并不能做详细的比较。

Editor Chosen

How to choose editor?
在选择自己的IDE或则编辑器的时候,我很赞同一句话“没有最厉害最优秀的只有最适合。”
所以哪一种最好最适合取决于我们用它做什么。

What do I need?
作为一个前端程序员,大部分时间是在做手游或者PC端的开发,需要和Android,IOS,PC打交道。
在Windows上主要使用VS,Android Studio作为IDE。
在Mac上使用XCode作为IDE。
主要使用C++,C#,Java,Python,bat,Lua等编程语言。
但VS,Android Studio,XCode都属于比较大型的IDE,这里主要是需要一个轻量级的编辑器。
主要希望有下列功能:

  1. 跨平台(跨Mac和Windows开发)
  2. 语法高亮
  3. 代码补充
  4. 调试(option)
  5. 快速切换,查询,跳转等方便快捷的快捷修改
  6. 快速启动
  7. 方便自定义(比如背景,快捷键设置等)
  8. 扩展性强(方便添加支持更多功能)
    ……

Why choose Sublime Text 3?
这里就不一一说明Sublime Text 3的好处了,几乎上面我需要的都可以很方便的扩展支持,这也就是为什么选择Sublime Text 3的原因。

Sublime Text 3

Sublime Text 3官网

Install

Download Sublime Text 3

Useful Package

  1. Package Control
    Sublime Text 3扩展性强就在于通过安装扩展包,可以得到很多有用的功能支持。
    而帮助快速安装和查找有用的Package,就不得不提Package Control,有了它我们可以快速的搜索和安装想要的Package。
    如何安装Package Control:
    Package Control Installation
    如果不知道有哪些Package,可以在下面官网查询:
    Package Control Package
  2. Terminal
    偶尔会需要打开命令行窗口,这个插件可以帮助我们直接在Sublime里通过快捷键快速打开系统自带命令行
    Terminal
    Note:
    虽然Sublime Text 3也有Git相关的插件,但是考虑已经添加了快速打开命令行的插件,并且Git使用不会太频繁,所以不单独添加Git扩展。
  3. Markdown
    Markdown是我们经常会去编写文档的一类格式,语法高亮,快速浏览效果是效率的关键。
    1. Markdown Edit
      Better syntax understanding and good color schemes.

    2. Markdown Preview
      ctrl+shift+p,然后输入Markdown Preview: Preview in Browser
      我们去Preference -> Key Binding里去改快速生成Markdown HTML的快捷键

MarkPreviewShortCut
最后得到我们Markdown的网页版:
MarkDownPreviewOnBrowser

  1. SublimeCodeIntel
    SublimeCodeIntel 作为一个代码提示和补全插件,支持 JavaScript、Mason、XBL、XUL、RHTML、SCSS、Python、HTML、Ruby、Python3、XML、Sass、XSLT、Django、HTML5、Perl、CSS、Twig、Less、Smarty、Node.js、Tcl、TemplateToolkit 和 PHP 等所有语言,是 Sublime Text 自带代码提示功能基础上一个更好的扩展,自带代码提示功能只可提示系统代码,而SublimeCodeIntel则可以提示用户自定义代码。
    这里本人主要是为了配置Python
    因为官网提到Default Settings在更新时会被覆盖,如果要修改设置应该写在User里。
    Preferences -> Package Settings -> SublimeCodeIntel -> Setting User
    配置Python如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 /*
SublimeCodeIntel user settings
*/
{
/*
Defines a configuration for each language.
*/
"codeintel_language_settings": {
"Python": {
// For Windows Python 2.7
"python": "H:/StudyTools/Phyton2_7/python.exe",
"pythonExtraPaths":[
"H:/StudyTools/Phyton2_7",
"H:/StudyTools/Phyton2_7/DLLs",
"H:/StudyTools/Phyton2_7/Lib",
"H:/StudyTools/Phyton2_7/Lib/lib-tk",
"H:/StudyTools/Phyton2_7/Lib/site-packages",
],
},

"Python3": {
"python": "H:/StudyTools/Python3_6/python.exe",
"pythonExtraPaths": [
"H:/StudyTools/Python3_6",
"H:/StudyTools/Python3_6/DLLs",
"H:/StudyTools/Python3_6/Lib",
"H:/StudyTools/Python3_6/Lib/tkinter",
"H:/StudyTools/Python3_6/Lib/site-packages",
],
},
}
}
其中是Python和Python3分别代表Python2.x和Python3.x的安装路径
如果需要配置其他语言,也写在"codeintel_language_settings"里即可。
这个由于还没正式开始学习使用Python,但测试无论配没配置上面,系统提示和自定义方法都会有自动提示和补全功能和函数跳转(Alt+click)(不确定是Sublime Text 3自带的还是SublimeCodeIntel的功能,暂时先按上面的方式设置)
  1. SublimeLinter
    SublimeLinter是一个 framework for linters(静态代码检查的框架)。要支持不同的语言需要额外装对应的扩展包。
    主要是为了装Python的静态代码和代码规范检查。
    现在SublimeLinter上Python的Linter有很多,比如pep8,pylint,flake8,bandit等
    作为Python初学者这里暂时不纠结哪一个更好,暂时使用flake作为尝试。
    因为SublimeLinterFlake8会用到flake,所以需要安装flake先:
    打开terminal,输入pip install flake
    python3自带pip
    低于python2.7.9的好像要自己装pip
    pip可以理解成类似于Package Control帮助快速安装Python工具包的工具。
    确认flake8.exe在环境变量中:
    WhereFlake8
    然后通过PackageControl安装SublimeLinterFlake8即可。
    这样一来就有静态代码和代码规范检查了:
    SublimeLinterFlake8
  2. Git
    Git是程序员经常打交道的东西。为了在Sublime Text 3里快速的使用Git提高效率。安装Git插件可以支持我们在Ctrl+Shift+P里直接调用Git命令。
    安装步骤还是通过Package Control这里就不再重述。
    直接看看安装好后的效果:
    GitPlugin
  3. GitGutter
    GitGutter主要是提供了Git提交时在Sublime Text 3里支持高亮显示增删改查等变化。
    老规矩安装(Packgage Control)
    直接看看使用GitGutter提交时的高亮显示变化效果:
    GitGutter

Custom Sublime Text 3

  1. Key Binding
    自定义快捷键。打开Preference -> Key Binding进行快捷键修改(这里以打开修改方法跳转,文件搜索等快捷键跟VS番茄插件一致为例)
    KeyBinding
    Note:
    改快捷键绑定只是让新的快捷键覆盖相同快捷键但并不关闭原始快捷键设置,要想默认快捷键不起作用还得设置相同的快捷键覆盖。

  2. Sublime Text 3 Setting
    字体大小,行号显示,拼写检查等设置。
    修改默认设置跟自定义快捷键差不多。
    比如我们想默认字体大小改大一点:
    Preference -> Settings
    SublimeText3Setting
    我们希望修改只针对MarkDown文件的语法高亮规则:
    打开*.md文件
    Preference -> Settings - Syntax Specific

  3. Theme

  4. Custom Snippets
    自定义快速填充片段。帮助我们在快速编写重复的代码或者注释。
    Tools -> Developer -> New Snippets
    放在:C:\Users\mshz\AppData\Roaming\Sublime Text 3\Packages\User下
    貌似每一个Snippets文件只能编写一个对应的Snippet。
    所以要想管理不同编程语言之间的Snippets,我们可以通过建立不同的文件夹进行管理分类。
    接下来看看Snippet事例和使用效果:

1
2
3
4
5
6
7
8
9
10
11
12
<snippet>
<content><![CDATA[
# File Name: $TM_FILENAME
# Description: This file is used to ${1:Staff}
# Author: ${2:TangHuan}
# Create Date: ${3:Year}/${4:Month}/${5:Day}
]]></content>
<!-- Optional: Set a tabTrigger to define how to trigger the snippet -->
<tabTrigger>PyFileHeader</tabTrigger>
<!-- Optional: Set a scope to limit where the snippet will trigger -->
<scope>source.python</scope>
</snippet>


这里的内容是触发当前Snippet的输入内容
这里的内容是当前Snippet支持的文件类型
看看使用效果:
SublimeText3SnippetsTrigger
SublimeText3SnippetsUsing

Useful Shortcut

Package

  1. Open Package Control - Ctrl + Shift + P
    PackageControl
  2. Open Console - Ctrl + `
    Console
  3. Open CMD Console - Ctrl + Shift + T
    ShowCommands
    Windows上默认是打开的power shell,如果希望打开普通的cmd窗口,可以在Preferences > Package Settings > Terminal > Settings – Default 里指定terminal路径参数等
    TerminalSetting

Editing

  1. Go Anything - Ctrl + P
    平时打开一个工程或则大的文件目录,我们想要快速定位特定文件里的特定内容。
    这时Go Anything能满足需求。
    比如查找FPSDisplay.cs文件里Init()符号:
    FPSDisplay.cs @Init
    查找FPSDisplay.cs文件里的特定文字:
    FPSDisplay.cs #Hello
    跳转特定文件特定行:
    FPSDisplay.cs :1(行号)
  2. Multiple Selections
    当我们需要在文本中找到特定文本并进行修改时,我们可以通过Multiple Selections进行选中我们想要选中的文本然后进行一次修改多行改变,而不用修改多次。
    Ctrl+D:选中当前光标所在位置的单词。连续使用时,进行多光标选择,选中下一个同名单词。
    Ctrl+K:配合Ctrl+D可以跳过下一个同名单词。
    下面是选择替换FPSDisplay.cs中的mTestSublimeGoAnything为例(只替换第一个和第三个匹配的):
    MutilpleSelection

Usefule Commands

  1. sublime.log_commands(True) – 在console窗口显示当前操作的命令(可以针对特定命令修改成我们自己想要的快捷键)
    ShowCommands

Usefule Setting

  1. Split Editing
    View -> Layout -> **
    很多时候我们需要同时查看多个文件,方便对照查看并修改。
    分割出来的窗口在窗口操作上是独立的,但同样的文件在两个窗口是同一份。
    SplitEdit

User Setting Backup

备份Sublime Text 3所有的Package和个性化设置。
我们只需要保存一下目录即可:
C:\Users\user\AppData\Roaming\Sublime Text 3\Packages
当需要同步的时候,把上面的内容覆盖到新的电脑上Sublime Text 3对应的目录即可。

Reference

Sublime Text 3
一个前端程序猿的Sublime Text3的自我修养
Sublime Text 3 Documentation
开发者最常用的 8 款 Sublime text 3 插件
Sublime Text 使用小结

参考书籍《Unity Shader 入门精要》 – 冯乐乐(一位在图形渲染方面很厉害的女生)

使用的Unity版本:
Unity 5.4.0f3

Rendering

关于图形渲染相关知识以及OpenGL相关学习参见:
Computer_Graphic_Study
OpenGL_Study

Unity Shader

首先让我们通过官网的一张图来看看Unity Shader里面占据重要地位的3D Model,Material,Shader之间的关系:
UnityShaderFundamention
可以看出Material会决定使用哪些Texture和Shader去渲染Model。
Shader是针对Texture和Model去做运算的地方。

Unity Shader最主要的有两种(还有Image Effect Shader,Computer Shader等后续会学习):

  1. Surface shader
    Time to use – “Whenever the material you want to simulate needs to be affected by lights in a realistic way, chances are you’ll need a surface shader. Surface shaders hide the calculations of how light is reflected and allows to specify “intuitive” properties such as the albedo, the normals, the reflectivity and so on in a function called surf.”
    可以看出Unity的Surface Shader帮我们把光照对物体的影响的计算抽象了出来。我们只需设定一些控制系数,然后通过Surface Shader的surf方法就能触发光照作用对表面的运算。
    下面简单看下官网给出的Surface Shader Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CGPROGRAM
// Uses the Lambertian lighting model
#pragma surface surf Lambert

sampler2D _MainTex; // The input texture

struct Input {
float2 uv_MainTex;
};

void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
Note:
必须使用#pragma surface去表示是surface shader
Surface Shader的Code是包含在SubShader里的并且必须写在CGPROGRAM和ENDCG标识之间。
  1. Fragment and Vertex Shaders
    Fragment and Vertex Shaders就跟OpenGL里差不多,model的vertex会经历完整的Shader Pipeline,最终通过计算得出最终的颜色。
    让我们来看看官网给出的Fragment and Vertex Shaders Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Pass {
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

struct vertInput {
float4 pos : POSITION;
};

struct vertOutput {
float4 pos : SV_POSITION;
};

vertOutput vert(vertInput input) {
vertOutput o;
o.pos = mul(UNITY_MATRIX_MVP, input.pos);
return o;
}

half4 frag(vertOutput output) : COLOR {
return half4(1.0, 0.0, 0.0, 1.0);
}
ENDCG
}

可以看出vert方法好比OpenGL里vertex shader的main函数入口。
frag方法好比OpenGL里fragment shader的main函数入口。
这里frag方法return的half4相当于OpenGL FS里的FragColor。
可以看出我们还是需要在VS里先将vertex变换到透视投影坐标系,然后最后传递给FS,FS计算出最终的颜色。
Note:
这里需要注意的一点是,在VS和FS Shader里,code是包含在Pass {…..}里的。

让我们来看看官网给出的Shader的大体结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Shader "MyShader"
{
Properties
{
// The properties of your shaders
// - textures
// - colours
// - parameters
// ...
}

SubShader
{
// The code of your shaders
// - surface shader
// OR
// - vertex and fragment shader
// OR
// - fixed function shader
}
}

Properties:
“The properties of your shader are somehow equivalent to the public fields in a C# script; they’ll appear in the inspector of your material, giving you the chance to tweak them.”
从这里看出Properties在Unity里相当于OPENGL Shader里定义的Uniform,Sampler,全局变量等,用于作为可控的输入。
让我们来看看官网给出的例子

1
2
3
4
5
6
7
8
9
10
11
12
Properties
{
_MyTexture ("My texture", 2D) = "white" {}
_MyNormalMap ("My normal map", 2D) = "bump" {} // Grey

_MyInt ("My integer", Int) = 2
_MyFloat ("My float", Float) = 1.5
_MyRange ("My range", Range(0.0, 1.0)) = 0.5

_MyColor ("My colour", Color) = (1, 0, 0, 1) // (R, G, B, A)
_MyVector ("My Vector4", Vector) = (0, 0, 0, 0) // (x, y, z, w)
}

让我们先看看对应显示在Unity面板是什么模样:
UnityShaderProperty
2D代表_MyTexture,_MyNormalMap是texture类型.Color代表颜色。Range代表范围值……
可以看到Properties里定义的变量都显示在了Unity面板里用于控制。但是真正通过Shader去访问,我们还需要在SubShader里重新定义对应的变量。
SubShader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SubShader
{
// Code of the shader
// ...
sampler2D _MyTexture;
sampler2D _MyNormalMap;

int _MyInt;
float _MyFloat;
float _MyRange;
half4 _MyColor;
float4 _MyVector;

// Code of the shader
// ...
}

可以看出Texture在Unity Shader里面的定义和在OpenGL Shader里差不多,也是通过sampler2D,只是不再需要加uniform修饰了。
这里值得注意的是Color对应的不是Vector4而是half4。
还有就是在Properties里定义的变量名在SubShader要一一对应。

接下来让我们看看官网给出的SubShader里Shader具体是怎样的格式:

1
2
3
4
5
6
7
8
9
10
11
12
SubShader
{
Tags
{
"Queue" = "Geometry"
"RenderType" = "Opaque"
}
CGPROGRAM
// Cg / HLSL code of the shader
// ...
ENDCG
}

真正的Shader Code是在CGPROGRAM和ENGCG里。
这里需要先提一下Tags的作用,Tags是为了告诉Unity我们Shader的特定属性(比如RenderType-渲染类型,Queue–渲染顺序)。

Surface Shaders

让我们看看Surface Shader的大体流程:
下图来源
SurfaceShaderFlowChart
从上图可以看出,Surace Shader通过Surface function生成最终参与光照计算所需的数据(rendering properties – e.g. texture……)。

让我们来看看Surface Shader里的关键组成部分:

  1. Surface function
    Surface function把model data作为输入,把rendering properties作为输出,这些rendering properties最后会参与光照的计算得到最终颜色。
  2. Input
    Input structure一般包含了texture相关的信息(比如 UV坐标信息),也可以包含一些额外信息(参见)
    Note:
    定义texture coordinate的时候必须以uv或者uv2开头加texture名字。
  3. SurfaceOutput
    SurfaceOutput描述了surface的属性,这些属性最终会参与光照计算。
1
2
3
4
5
6
7
8
9
struct SurfaceOutput
{
fixed3 Albedo; // diffuse color
fixed3 Normal; // tangent space normal, if written
fixed3 Emission;
half Specular; // specular power in 0..1 range
fixed Gloss; // specular intensity
fixed Alpha; // alpha for transparencies
};
我们可以通过Shader去计算上面参与光照计算的属性去影响Surface的最终效果,从而达到Surface Shader的真正目的。
  1. Light Model
    Light Model是真正通过SurfaceOutput属性去计算光照的地方,通过使用或编写不同的Light Model我们可以实现不同的光照影响效果。
    那么如何定义光照运算函数了?
    1. half4 Lighting (SurfaceOutput s, half3 lightDir, half atten);
      因为没有viewDir所以这里是用于不需要知道观察点的Ambient光照计算
    2. half4 Lighting (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten);
      因为有了viewDir,知道了观察点信息,我们可以计算出需要知道观察点的Diffuse和Specular光照。
    3. half4 Lighting_PrePass (SurfaceOutput s, half4 light);
      这个用于Deferer Shading(但这里我比较好奇的是如果要计算Diffuse和Specular光照还是需要知道lightDir和viewDir等信息才对。)
      Note:
      自定义光照模型的时候,我们不需要定义所有的光照函数,只需定义我们需要的即可。
  2. Vertex Function
    Provide the ability to change vertices before sending them to surf.
    定义方式如下:
1
void vert(inout appdata_full v){......}
appdata_full包含了所有vertex相关的信息
  1. Finalcolor Function
    用于对经过Surface Shader处理后的pixel进行最后的处理。
    格式如下:
1
2
3
4
5
#pragma surface surf Lambert finalcolor:mycolor
void mycolor (Input IN, SurfaceOutput o, inout fixed4 color)
{
color *= _ColorTint;
}

接下来让我们通过创建自己的Surface Shader来感受下Surface Shader实际运用效果(这里我拿了SurviveShooter里的模型做输入):
以下学习参考至Surface shaders in Unity3D
我们删掉默认创建的Shader Code,只简单输出Albedo(Diffuse Color)看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Shader "Custom/TonyShader" {
SubShader {
// 定义渲染顺序 - RenderType
Tags { "RenderType"="Opaque" }

CGPROGRAM
// #pragma surface指明是surface shader
// surf Lambert指明用的光照模式是Lambert(会决定光照是如何计算的)
#pragma surface surf Lambert

struct Input {
float2 color : COLOR;
};

void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = 1;
}
ENDCG
}
FallBack "Diffuse"
}

SurfaceShaderWithAlbedoSetting
接下来让我们给Hellephant加上纹理图案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Shader "Custom/TonyShader" {
Properties{
// 为了在编辑器里提供控制纹理输入,Properties必须和struct Input里的uv_MainTex对应
_MainTex("Texture", 2D) = "white" {}
}

SubShader{
Tags{ "RenderType" = "Opaque"}
CGPROGRAM
#pragma surface surf Lambert
// 提供纹理输入
struct Input{
float2 uv_MainTex
};
sampler2D _MainTex;

void surf (Input IN, inout SurfaceOutputStandard o) {
o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
}
Fallback "Diffuse"
}

SurfaceShaderWithTexture
让我们看看现在对应的Unity Material Editor:
SurfaceShaderMaterialEditorWithTexture
图像看起来还不真实,没有深度感,接下来我们添加Normal Mapping 参见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Shader "Custom/TonyShader" {
Properties{
.....
_BumpMap("Bumpmap", 2D) = "bump" {}
}

SubShader{
......

struct Input{
......
float2 uv_BumpMap;
};
......
sampler2D _BumpMap;

void surf (Input IN, inout SurfaceOutputStandard o) {
......
o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_Bumpmap));
}
ENDCG
}
Fallback "Diffuse"
}

UnpackNormal接受一个fixed4的输入,并将其转换为所对应的法线值(fixed3)
比较有无Normal map的效果,见如下:
SurfaceShaderWithNormalMap
SurfaceShaderWithTexture
接下来让我们通过计算Normal和Viedir的情况去高亮物体:
这里需要用到build-in variable viewDir(代表观察者看顶点的方向)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Shader "Custom/TonyShader" {
Properties{
......
_RimColor("Rim Color", Color) = (1.0, 0.0, 0.0, 0.0)
_RimPower("Rim Power", Range(0.5, 8.0)) = 3.0
}
SubShader {
......

struct Input {
.....
float3 viewDir;
};
......
float4 _RimColor;
float _RimPower;

void surf (Input IN, inout SurfaceOutput o) {
......
half rim = 1.0 - saturate(dot(normalize(IN.viewDir), o.Normal));
o.Emission = _RimColor.rgb * pow(rim, _RimPower);
}
ENDCG
}
FallBack "Diffuse"
}

saturate的作用是把观察者方向和顶点法线夹角Cos值限定在[0,1],再用1减去这个值,表明观察者方向和顶点法线夹角越小rim值越小,当夹角大于等于90度的时候达到rim最大值。从而实现高亮边缘的效果(周边和观察者角度往往比较大)。
SurfaceShaderWithEmssion
接下来我们看看通过vertex function去动态计算新vertex值的效果(这里我们根据顶点法线方向去收缩顶点postion):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Shader "Custom/TonyShader" {
Properties{
......
_Amount("Extrusion Amount", Range(-0.5,0.5)) = 0.0
}
SubShader {
......

CGPROGRAM
// vertex:vert定义了vert方法名
#pragma surface surf Lambert vertex:vert

float _Amount;
// vert方法定义,根据顶点法线去计算新的顶点位置
void vert(inout appdata_full v){
v.vertex.xyz += v.normal * _Amount;
}

void surf (Input IN, inout SurfaceOutput o) {
......
}
ENDCG
}
FallBack "Diffuse"
}

SurfaceShaderWithNegativeExtrusion
SurfaceShaderWithPositiveExtrusion
在vert function里我们也可以自定义一些成员在surface shader里传递,但这样的话vert必须有两个参数,一个inout appdata_full v,一个out Input o,这里的out Input o回座位surf function的Input IN参数作为输入。Custom Input Member详细用法参见
接下来让我们看看Light Model对于最终颜色计算的影响:
待学习了解
最后我们来看看Final Color Function是如何影响最终颜色计算的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Shader "Custom/TonyShader" {
Properties{
......
_ColorTint("Tint", Color) = (0.0, 1.0, 1.0, 1.0)
}
SubShader {
Tags { "RenderType"="Opaque" }

CGPROGRAM
// finalcolor:mycolor定义final function name
#pragma surface surf Lambert vertex:vert finalcolor:mycolor

......

fixed4 _ColorTint;
// final color function格式, color会影响所有lightmaps,light probes等的颜色信息
void mycolor(Input IN, SurfaceOutput o, inout fixed4 color)
{
color *= _ColorTint;
}

void surf (Input IN, inout SurfaceOutput o) {
......
}
ENDCG
}
FallBack "Diffuse"
}

SurfaceShaderWithFinalColorFunction

Vertex and Fragment Shaders

见后续Shader学习

Unity Shader入门精要

渲染管线

参考以前的学习:
Computer_Graphic_Study
OpenGL_Study

Unity Shader基础

材质和Unity Shader

结合前面的学习,我们知道了在Unity里Material会决定使用那些Texture和Shader去渲染Model。

这里再简单梳理下他们之间的关系:

  1. Model(模型) – 最终我们需要通过渲染去实现特定效果的模型。(提供模型数据 e.g. 顶点,法线等)
  2. Texture(贴图数据) – 这里没有直接说成纹理是因为Texture不仅能提供纹理数据,还能提供作为其他计算的数据(e.g. 光照方面的数据等)
  3. Material(材质) – 会决定使用哪些Texture作为数据输入(e.g. 纹理数据,光照计算相关数据等)。通过指定Shader去处理Model和Texture等传入的数据进行处理。而材质面板是与Shader沟通的桥梁,允许我们通过面板暴露Shader暴露出的参数进行渲染控制。
  4. Shader – 处理前面传入的Model,Texture等数据加以处理计算实现各式各样的渲染效果。

Unity Shader分类:

  1. Standard Surface Shader – 前面我们提到的提供了一个包含标准光照模型计算的表面着色器。(最终还是会被编译成对应图形API接口的Vertex and Fragment Shader)
  2. Unlit Shader – 不包含光照的Vertex and Fragment 。Shader,需要自己编写完整的渲染管线流程代码。
  3. Image Effect Shader – 主要用于实现各种屏幕后处理效果。
  4. Compute Shader – 利用GPU的并行性进行一些与常规渲染流水线无关的计算。

这里我们主要学习Unlit Shader。

Shader对于Unity而言也是一种资源文件,所以依然有导入设置:
UnityShaderSettingPanel
Note:
Unity Shader最终还是会被编译成对应图形编程接口(e.g. DX,OpenGL……)的Shader代码实现真正的渲染。

ShaderLab

“ShaderLab是Unity提供的编写Unity Shader的一种说明性语言。ShaderLab类似于CgFX和DX Effects语言。
“(引用自《Unity Shader入门精要》)

ShaderLab帮助我们快速定义Shader编写过程中需要资源和设置等。Unity会最终把ShaderLab编写的Shader编译成对应平台的真正代码和Shader文件,实现跨平台。

Unity Shader结构

这里我们创建一个基本的UnlitShader,结合里面的代码来学习理解(详情参考代码里的注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 材质索引Shader时的路径
Shader "Custom/UnlitShader"
{
// Shader暴露给材质面板的属性用于动态修改控制
// Properties在Unity里相当于OPENGL Shader里定义的Uniform,Sampler,全局变量等,用于作为可控的输入

// 2D代表_MyTexture,_MyNormalMap是texture类型.Color代表颜色。Range代表范围值,更多内容查官网
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}

// 每一个Unity Shader至少要包含一个SubShader(Unity会使用第一个可以在目标平台运行的SubShader用于渲染,如果都不支持则会使用Fallback语义指定的Unity Shader)
// SubShader定义了真正的Shader相关代码(渲染流程,渲染状态设置等)
SubShader
{
// Tags是为了告诉Unity我们Shader的特定属性(比如RenderType - 渲染类型,Queue—渲染顺序)。
// Note:
// Tag也可以在Pass里声明,但SubShader里的Tags相当于针对所有Pass的Tag设置,优先于Tag本身的Tag设置
Tags { "RenderType"="Opaque" }
LOD 100

// Pass代表一次完成的渲染流程
// 这个好比OpenGL里实现Shadow Map效果时需要先通过First Pass生成光源点角度的Depth Texture,
// 然后Second Pass通过比较摄像机角度的Depth Texture得出哪些点是Shadow的结论里的Pass一样
Pass
{
// Pass支持定义名字
// Name "PassName"
// 我们可以通过这个名字去指定使用特定Pass
// UsePass "ShaderName/PassName"
// GrabPass负责抓取屏幕并存储在一张纹理中用于后续Pass处理

// Unity Shader采用的是类似CG的Shader语言编写
// Shader代码被包含在CGPROGRAM和ENDCG之间
// Note:
// 如果想要使用GLSL来编写需要包含在GLSLPROGRAM和ENDGLSL之间
CGPROGRAM
// 真正的Shader代码从这里开始
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog

#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};

// 映射Properties里暴露给Material材质面板的对象
sampler2D _MainTex;
float4 _MainTex_ST;

// Vertex Shader入口
v2f vert (appdata v)
{
v2f o;
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}

// Fragment Shader入口
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}

// 在所有Pass都不被支持时,我们可以指定最终使用的Pass
// Fallback "PassName"
}

数学相关知识

参考书籍:
《3D数学基础:图形与游戏开发》
《Fundamentals of Computer Graphics (3rd Edition)》 — Peter Shirley, Steve Marschnner
《Real-Time Rendering, Third Edition》 — Tomas Akenine-Moller, Eric Haines, Naty Hoffman

这里只说几点DX和OpenGL还有Unity之间需要注意的特殊点:

  1. 坐标系
    DX和Unity使用的是左手坐标系。
    OpenGL使用的是右手坐标系。
  2. 行列向量
    DX使用的是行向量(矩阵乘法是右乘)
    OpenGL和Unity使用的是列向量(矩阵乘法是左乘)
  3. 正交矩阵
    因为正交矩阵M(T)=M(-1)
    在提前得知是正交矩阵的前提下可以直接使用M(T)(转置矩阵)快速求的M(-1)(逆矩阵)(e.g. 旋转是正交的)
    逆矩阵可以用于快速回复之前的矩阵变化
  4. 44矩阵
    | M(3
    3) t(31)|
    | 0(1
    3) 1 |
    M(33)代表线性变换(e.g. 旋转,缩放,错切,径向,正交投影…..)
    t(3
    1)代表平移变换
    0(1*3)代表零矩阵
    1代表标量1
  5. 纹理坐标
    OpenGL中(0,0)是左下角
    DX中(0,0)是左上角

关于渲染流程里的坐标系转换相关知识参考之前的学习:
Coordinate Transformations & Perspective Projection

Unity Shader的内置变量参考:
UnityShaderVariables.cginc

Unity Shader学习之旅

本人使用的Unity版本为5.4.0f3 Personal

一个最简单的顶点/片元着色器

依然采用代码实战,跟之前Unity Shader结构讲的类似,但更细节一些,相关解释都写在注释里了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Shader "Custom/SimpleShader"
{
// 定义Material材质与Shader的交流面板
Properties{
// 声明一个Color类型的属性
_Color("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0)
}

SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

// 定义一个与Material面板交流控制的对应属性
fixed4 _Color;

// 自定义结构体定义vertex shader输入
struct a2v {
// POSITION告诉Unity,用模型空间的顶点坐标填充vertex变量
float4 vertex : POSITION;
// NORMAL告诉Unity,用模型空间的法线方向填充normal变量
float3 normal : NORMAL;
// TEXTURE0告诉Unity,用模型的第一套纹理坐标填充texcoord变量
float4 texcoord : TEXCOORD0;
};

// 自定义结构体用于vertex shader和fragment shader之间数据传入
struct v2f {
// SV_POSITION告诉Unity,pos是裁剪空间中的顶点坐标位置信息
float4 pos : SV_POSITION;
// color存储颜色信息
fixed3 color : COLOR0;
};

v2f vert (a2v v)
{
// 声明输出结构体实例
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 将v.normal的顶点法线信息从[-1.0,1.0]到[0.0, 1.0]并存储在颜色信息中
o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5);
return o;
}

// SV_Target告诉渲染器把用户的输出颜色存储到一个渲染目标中,这里默认是帧缓存中
// 因为vertex shader是针对顶点运算,fragment shader是针对fragment
// 所以fragment的输入是顶点数据进行插值后得到的
// 顶点数据只有三角形的三个顶点,中间的fragment信息通过插值得到
// i是vertex shader输出后通过插值传递过来的
fixed4 frag (v2f i) : SV_Target
{
// v.normal映射后的颜色信息
fixed3 c = i.color;
// 使用Material面板控制的_Color参与最终的fragment颜色运算
c *= _Color.rgb;
return fixed4(c, 1.0);
}
ENDCG
}
}
}

Unity内置文件和变量

[Unity Shader内置文件官网下载地址](http://unity3d.com/cn/get-unity/download/
archive)

内置文件包含文件:

1
#include "**.cginc"

内置Shader目录结构:
UnityBuildInShaderFolderStructure

  1. CGIncludes – 包含所有的内置包含文件
  2. DefaultResources – 包含了一些内置组件或者功能需要的Unity Shader
  3. DefaultResourcesExtra – 包含了所有的Unity中内置的Unity Shader
  4. Editor – 只有一个脚本,定义了Unity 5引入的Standard Shader所有的材质面板

常用包含文件介绍:

  1. UnityCG.cginc – 包含了常用的帮助函数,宏和结构体等
  2. UnityShaderVariables.cginc – 包含了许多内置的全局变量,e.g. UNITY_MATRIX_MVP
  3. Lighting.cginc – 包含了各种内置的光照模型
  4. HLSLSupport.cginc – 声明了很多用于跨平台编译的宏和定义

Note:
Unity Shader内置文件如果安装时下载安装了,也可以在Editor\Data\CGIncludes目录下找到所有内置文件

CG/HLSL语义

“语义实际上就是让Shader知道从哪里读取数据,并把数据输出到哪里。”

“SV代表的含义是系统数据(system-value)。DX10以后引入的。大部分时候SV_POSITION和POSITION是等价的。但为了更好的跨平台最好尽量采用SV开头的语义来修饰。”

Shader semantics官网查询

Shader Debugger

欲善其事,必先利其器。
在学习Unity Shader之前我们需要知道如何去调试Unity Shader。

Color Info

在渲染的时候把数据信息以颜色的方式渲染到物体上,用于了解特定数据信息。

Unity Frame Debugger

Unity提供的的帧调试器,是基于Unity API层面。(Window -> Frame Debug)

VS Graphics Debugger

VS提供的基于渲染API层面的调试器。
Unity Shader里要写#pragma enable_d3d11_debug_symbols开启DX11渲染

具体调试方法参考:
Implementing Fixed Function TexGen in Shaders Debugging DirectX 11 shaders with Visual Studio

Note:
Frames can only be captured if Unity is running under DirectX 11(只能基于DX11)

NVIDIA NSight

NVIDIA公司提供的基于渲染API层面的调试器。
之前了解过,好像只支持N卡。具体没有使用过。

Unity中的基础光照

光照知识回顾

关于光照的基本学习参考之前OpenGL中的学习:
Light and Shadow

从上面可以看出Light影响着每一个物体最终颜色的成像计算。

  1. 环境光(Ambient) – 环境光与光照方向无关,只需考虑方向光的颜色和方向光所占比重,决定着物体的基本颜色。
  2. 漫反射(Diffuse) – 与光照的方向和顶点normal有关,决定着不同角度(顶点法线)的顶点反射能力。
  3. 镜面反射(Specular) – 与光照的方向和eye观察还有顶点normal有关,决定了不同观察位置的反射高光系数。

标准光照模型

“光照模型 – 根据材质属性(如漫反射属性等),光源信息(如光源方向,辐照度),使用一个等式去计算沿某个方向的出射度的过程。”

BRDF(Bidirectional Reflectance Distribution Function)光照模型
“当给定射入光线的方向和辐射度后,BRDF可以给出在某个出射方向上的光照能量分布。”

那么什么是标准光照模型了?
“标准光照模型只关心直接光照,也就是那些直接从光源发射出来照射到物体表面后,经过物体表面的一次反射直接进入摄像机的光线。”

标准光照模型有四部分组成:

  1. 自发光(emissive)
  2. 高光反射(specular)
  3. 漫反射(diffuse)
  4. 环境光(ambient)

从这里可以看出之前学习OpenGL的光照计算可以算是基于标准光照模型来计算的,只是没有考虑自发光的光照运算。不难看出所谓的光照模型其实就是不同的光照计算公式。

在之前OpenGL的学习里,都是通过顶点法线插值后在Fragment Shader里计算Specular,Diffuse,Ambient来进行光照运算的,这种技术被称为Phong着色(Phong Shading)。(针对每一个fragment进行光照运算)

我们也可以针对每一个顶点进行光照运算,e.g. 高洛德着色(Gouraud shading)。针对每个顶点计算光照后,通过线性插值后最终输出显示。(因此不适合非线性的光照计算显示)

但上述标准光照模型(这里称为Blinn-Phong模型)无法模拟真实的物理现象的效果。
“Blinn-Phong模型是各项同性(isotropic)的,也就是说,当我们固定视角和光源方向旋转这个表面时,反射不会发生任何改变。各向异性(anisotropic)反射性质的效果需要机遇物理的光照模型来运算。”

漫反射光照模型

兰伯特光照模型

c(light) – 光源颜色信息
m(diffuse) – 漫反射系数
n – 顶点法线
l – 光源方向

兰伯特光照模型:
c(diffuse) = (c(light) * m(diffuse)) * max(0, dot(n,l))

基于顶点的兰伯特光照模型Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'

// 逐顶点的漫反射光照计算Shader
// 逐顶点是在vertex shader里针对每一个vert计算Diffuse光照然后通过顶点颜色插值显示出来
Shader "Custom/DiffuseVertexLevelShader"
{
// Diffuse Light Model
// c(light) -- 光源颜色信息
// m(diffuse) -- 漫反射系数
// n -- 顶点法线
// l -- 光源方向_WorldToObject
// c(diffuse) = (c(light) * m(diffuse)) * max(0, dot(n, l))

Properties
{
// 给Material面板暴露漫反射系数c(diffuse)用于动态控制漫反射系数
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags { "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

// 因为是计算基于Vertex Level的漫反射光照模型计算
// 所以在vertex shader里已经完成了对光照color的运算
// 这里只需将结果通过插值传递给fragment shader即可
struct v2f
{
float4 pos : SV_POSITION;
float3 color : COLOR;
};

// 对应材质面板的漫反射系数
fixed4 _Diffuse;

v2f vert (a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 计算漫反射光
// 为了确保法线从物体坐标系转换到世界坐标系正确,这里需要乘以object2world矩阵的逆矩阵的转置矩阵
// _World2Object代表object2world的逆矩阵
// 因为unity使用的是列向量,所以通过mul()_World2Object作为第二个参数相当于左乘_World2Object的转置矩阵
// 之所以只乘以3x3的矩阵是因为平移变换不会影响法线方向
fixed3 worldnormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
// 假设只有一个全局direction light
fixed3 worldlightdir = normalize(_WorldSpaceLightPos0.xyz);
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldnormal, worldlightdir));
o.color = ambient + diffuse;
return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 将vertex shader计算出的环境光加漫反射颜色显示出来
return fixed4(i.color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}

基于片元计算的兰伯特光照模型Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'

// 逐片元的漫反射光照计算Shader
// 逐片元是在fragment shader里针对每一个fragment计算Diffuse光照
Shader "Custom/DiffuseFragLevelShader"
{
// Diffuse Light Model
// c(light) -- 光源颜色信息
// m(diffuse) -- 漫反射系数
// n -- 顶点法线
// l -- 光源方向
// c(diffuse) = (c(light) * m(diffuse)) * max(0, dot(n, l))

Properties
{
// 给Material面板暴露漫反射系数c(diffuse)用于动态控制漫反射系数
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

// 因为是计算基于Fragment Level的漫反射光照模型计算
// 所以在vertex shader需要把world space的normal传给fragment shader进行diffuse光照运算
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : NORMAL;
};

// 对应材质面板的漫反射系数
fixed4 _Diffuse;

v2f vert(a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 计算漫反射光
// 为了确保法线从物体坐标系转换到世界坐标系正确,这里需要乘以object2world矩阵的逆矩阵的转置矩阵
// _World2Object代表object2world的逆矩阵
// 因为unity使用的是列向量,所以通过mul()_World2Object作为第二个参数相当于左乘_World2Object的转置矩阵
// 之所以只乘以3x3的矩阵是因为平移变换不会影响法线方向
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 假设只有一个全局direction light
fixed3 worldlightdir = normalize(_WorldSpaceLightPos0.xyz);
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal, worldlightdir));
fixed3 color = ambient + diffuse;
// 将计算出的环境光加漫反射颜色显示出来
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}

问题:
背光区域没有明暗变化看起来像一个平面。

半兰伯特(Half Lambert)光照模型

c(light) – 光源颜色信息
m(diffuse) – 漫反射系数
n – 顶点法线
l – 光源方向
a – a倍漫反射辐射度
b – 漫反射辐射度偏移量

半兰伯特光照模型:
c(diffuse) = c(light) * m(diffuse) * (a * dot(n,l) + b)

基于片元的半拉伯特特光照模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'

// 逐片元的半兰伯特光照模型没有对漫反射辐射度负值做max处理,
// 而是通过a倍缩放加上b偏移量得出最终的漫反射辐射度,
// 这样一来背面漫反射辐射度也会被映射到不同的值而非0,从而拥有不同的漫反射效果。
Shader "Custom/HalfLambertShader"
{
// Half Labert Model
// c(light) -- 光源颜色信息
// m(diffuse) -- 漫反射系数
// n -- 顶点法线
// l -- 光源方向
// a -- a倍漫反射辐射度
// b -- 漫反射辐射度偏移量
// 半兰伯特光照模型 :
// c(diffuse) = c(light) * m(diffuse) * (a * dot(n, l) + b)

Properties
{
// 给Material面板暴露漫反射系数c(diffuse)用于动态控制漫反射系数
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

// 因为是计算基于Fragment Level的漫反射光照模型计算
// 所以在vertex shader需要把world space的normal传给fragment shader进行diffuse光照运算
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : NORMAL;
};

// 对应材质面板的漫反射系数
fixed4 _Diffuse;

v2f vert(a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 计算漫反射光
// 为了确保法线从物体坐标系转换到世界坐标系正确,这里需要乘以object2world矩阵的逆矩阵的转置矩阵
// _World2Object代表object2world的逆矩阵
// 因为unity使用的是列向量,所以通过mul()_World2Object作为第二个参数相当于左乘_World2Object的转置矩阵
// 之所以只乘以3x3的矩阵是因为平移变换不会影响法线方向
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 假设只有一个全局direction light
fixed3 worldlightdir = normalize(_WorldSpaceLightPos0.xyz);
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// (dot(i.worldNormal, worldlight) * 0.5 + 0.5)将漫反射辐射度映射到[0,1],背面不再全是0
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * (dot(i.worldNormal, worldlightdir) * 0.5 + 0.5);
fixed3 color = ambient + diffuse;
// 将计算出的环境光加漫反射颜色显示出来
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}

Note:
半兰伯特光照模型没有对漫反射辐射度负值做max处理,而是通过a倍缩放加上b偏移量得出最终的漫反射辐射度,这样一来背面漫反射辐射度也会被映射到不同的值,从而拥有不同的漫反射效果。

高光反射光照模型

c(light) – 光源颜色信息
m(specular) – 材质高光反射系数
v – 视角方向
r – 反射方向
n – 顶点法线
l – 光源方向
m(gloss) – 发光系数

r可以通过n和l计算出来:
r = 2 * dot(-n,l) * n + l
跟OpenGL一样,Unity提供了直接计算反射光线方向的API:reflect(i,n)

c(specular) = (c(light) * m(specular)) * pow(max(0,dot(v,r)), m(gloss))
上面这个公式和之前在OpenGL之前学习的高光反射并没有太多出入,详情参考

这里主要要区分Phong光照模型和Blinn-Phong光照模型:

  1. Phong光照模型
    c(specular) = (c(light) * m(specular)) * pow(max(0,dot(v,r)), m(gloss))
    dot(v,r) – 是指观察者所在位置和完美反射光线之间夹角
  2. Blinn-Phong光照模型
    h = (v + l) / |v + l|
    c(specular) = (c(light) * m(specular)) * pow(max(0,dot(n,h)), m(gloss))
    dot(n,h) – 是指将v和l向量归一化后的向量与n之间的夹角

可以看出来两者在计算高光反射的发光基准系数时采用了不同的运算方式。虽然两者都是经验模型,但后者是对于前者的一种改进方式。

跟漫反射一样,高光反射的计算依然有基于顶点和基于片元之分。
基于片元的Phhong高光反射光照模型代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'

// 逐顶点的环境光+漫反射+Phong高光反射光照计算Shader
Shader "Custom/LightModel/PhongSpecularFragLevelShader"
{
// Specular Light Model
// c(light) -- 光源颜色信息
// m(specular) -- 材质高光反射系数
// v -- 视角方向
// r -- 光线反射方向
// n -- 顶点法线
// l -- 光源方向
// m(gloss) -- 发光系数
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(v,r)), m(gloss))

Properties
{
// 给Material面板暴露漫反射系数c(diffuse)用于动态控制漫反射系数
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系的顶点位置
float3 worldNormal : NORMAL; // 世界坐标系的法线
float3 worldPos : TEXCOORD0; // 世界坐标系的顶点位置
};

// 对应材质面板的漫反射系数
fixed4 _Diffuse;
// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;

v2f vert(a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 计算漫反射光
// 为了确保法线从物体坐标系转换到世界坐标系正确,这里需要乘以object2world矩阵的逆矩阵的转置矩阵
// _World2Object代表object2world的逆矩阵
// 因为unity使用的是列向量,所以通过mul()_World2Object作为第二个参数相当于左乘_World2Object的转置矩阵
// 之所以只乘以3x3的矩阵是因为平移变换不会影响法线方向
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 假设只有一个全局direction light
fixed3 worldlightdir = normalize(_WorldSpaceLightPos0.xyz);
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal, worldlightdir));

// 光线反射方向
fixed3 reflectdir = normalize(reflect(-worldlightdir, i.worldNormal));
// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// 计算高光反射的颜色信息
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(v,r)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectdir, viewdir)), _Gloss);
// 将高光反射的颜色信息输出到最终的顶点颜色
fixed3 color = ambient + diffuse + specular;

// 将计算出的环境光+漫反射+Phong高光反射颜色显示出来
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}

基于片元的Blinn-Phhong高光反射光照模型代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'

// 逐顶点的环境光+漫反射+BlinnPhong高光反射光照计算Shader
Shader "Custom/LightModel/BlinnPhongSpecularFragLevelShader"
{
// Specular Light Model
// c(light) -- 光源颜色信息
// m(specular) -- 材质高光反射系数
// v -- 视角方向
// r -- 光线反射方向
// n -- 顶点法线
// l -- 光源方向
// m(gloss) -- 发光系数
// h = (v + l) / |v + l|
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))

Properties
{
// 给Material面板暴露漫反射系数c(diffuse)用于动态控制漫反射系数
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系的顶点位置
float3 worldNormal : NORMAL; // 世界坐标系的法线
float3 worldPos : TEXCOORD0; // 世界坐标系的顶点位置
};

// 对应材质面板的漫反射系数
fixed4 _Diffuse;
// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;

v2f vert(a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
// 计算漫反射光
// 为了确保法线从物体坐标系转换到世界坐标系正确,这里需要乘以object2world矩阵的逆矩阵的转置矩阵
// _World2Object代表object2world的逆矩阵
// 因为unity使用的是列向量,所以通过mul()_World2Object作为第二个参数相当于左乘_World2Object的转置矩阵
// 之所以只乘以3x3的矩阵是因为平移变换不会影响法线方向
o.worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 假设只有一个全局direction light
fixed3 worldlightdir = normalize(_WorldSpaceLightPos0.xyz);
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal, worldlightdir));

// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
// h = (v + l) / |v + l| v和l向量归一化后的向量
fixed3 halfdir = normalize(worldlightdir + viewdir);
// 计算高光反射的颜色信息
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(i.worldNormal, halfdir)), _Gloss);
// 将高光反射的颜色信息输出到最终的顶点颜色
fixed3 color = ambient + diffuse + specular;

// 将计算出的环境光+漫反射+BlinnPhong高光反射颜色显示出来
return fixed4(color, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}

效果图:
LightModelShader

Note:
“如果没有全局光照技术,这些自发光的表面并不会真的着凉周围的物体,而是它本身看起来更亮了而已。”
计算机图形学的第一定律:如果它看起来是对的,那么它就是对的。

上面的运算都是我们通过基础的向量,矩阵运算来实现。在Unity中我们可以使用Unity内置的函数来帮助我们快速的计算得到指定结果。详情查看UnityCG.cginc。

Note:
“如果没有全局光照技术,这些自发光的表面并不会真的着凉周围的物体,而是它本身看起来更亮了而已。”
计算机图形学的第一定律:如果它看起来是对的,那么它就是对的。

Lighting in Unity

Global illumination, or ‘GI’, is a term used to describe a range of techniques and mathematical models which attempt to simulate the complex behaviour of light as it bounces and interacts with the world.(GI代表通过技术或者数学模型模拟光照运算交互。e.g. 半兰伯特(Half Lambert)光照模型

Light在Unity主要分为Realtime(相当于Forward Rendering)和Precomputed(预先生成相关光照贴图数据参与光照计算)

Realtime Lighting

directional, spot and point, are realtime. This means that they contribute direct light to the scene and update every frame.(实时的顾名思义实时计算光照)

有了实时光照计算为什么还需要Precomputed了?
Unfortunately, the light rays from Unity’s realtime lights do not bounce when they are used by themselves. In order to create more realistic scenes using techniques such as global illumination we need to enable Unity’s precomputed lighting solutions.(从官方原文来看,实时光照并没有计算光照被其他物体反弹之后的运算量,要想更加真实的光照所以需要Precomputed Lighting)

Precomputed Lighting

Static Lightmap(Baked GI Lighting):
Lightmap – When ‘baking’ a ‘lightmap’, the effects of light on static objects in the scene are calculated and the results are written to textures which are overlaid on top of scene geometry to create the effect of lighting.(Bake GI Light会生成一张LightMap,这张Lightmap记录了场景里所有静态物体的光照运算结果。)

Note:
静态Lightmap一旦生成就不能再被运行时修改,实现不了场景里光照动态变化的效果

Realtime Lightmap(Baked Realtime GI Lighting):
Realtime GI Lightmap弥补了Static Lightmap的不足,可以实现预先计算所有实时光照(这里的实时光照是指全局光照(含间接光照的影响计算)))需要的数据去参与运行时全局光照计算。

To speed up the precompute further Unity doesn’t directly work on lightmaps texels, but instead creates a low resolution approximation of the static geometry in the world, called ‘clusters’.(Unity为了加快Precompute速度,Lightmap的运算并不是texels级而是clusters级,可以理解成相对粗糙一点的精确度)

具体使用哪种光照策略取决于用户实际情况(显存,内存,GPU,CPU等)。

Baked Light Rules:

  1. Only Static GameObject才会参与Baked Lighting。
  2. Light Bake Mode取决于光照的Bake设置。
  3. Mixed的Bake Mode即会参与Bake也会参与Realtime。
  4. 具体Bake设置见Window -> Lighting(设置Bake Static Lightmap还是Realtime)

Note:
Precomputed Lighting是个典型空间换时间的例子。通过预算生成大量的光照信息Lightmap来避免大量的实时光照运算。

Using both Baked GI and Precomputed Realtime GI together in your scene can be detrimental to performance. A good practise is to ensure that only one system is used at a time, by disabling the other globally. (官方建议,Baked GI和Precomputed Realtime GI两者选其一不建议同时使用,避免性能问题)

Bake Realtime Lighting实战
  1. Realtime Resolution
    Realtime Resolution is the number of realtime lightmap texels (texture pixels) used per world unit.(一个world unit使用多少个纹理贴图的像素)

这个参数会影响最终Bake出来的光照采样计算结果,同时也会影响Precompute的时间消耗。合适的值取决于场景对于光照采样精度的需求与Bake时间之间的平衡。如果场景小(比如室内)且光源很多那么精度高一点Bake时间长一点是有意义的。如果场景大(比如室外)光源变化不大,那么精度高对最终的采样光照计算结果并没有太大影响只会增加Bake时间,那这样是不值得的。

  1. Chart(光照图)
    a Chart is an area of a lightmap texture to which we map the lightmap UVs of a given Scene object. We can think of this as a small tile containing an image of the lighting affecting that object.(Chart表示一个光照贴图,用于映射特定Object的Lightmap UV的信息)

A Chart is made up of two parts: irradiance (lighting) and directionality (encoding the dominant light direction).(Chart由两部分组成:辐射度和光照方向)

LightmapChart

Note:
Unity Unit设置的不同对于Realtime Resolution的选择也会有影响。

Baked GI是生成一张Lightmap Texture用于运行时参与光照计算。Baked Realtime Lighting是将信息存到Lighting Data Asset(包含了运行时生成低分辨率lightmap的相关信息)里用于运行时生成低解析度的光照图。

Chart is a minimum of 4x4 texels(Chart最小是4*4像素)。

Charts are created to encompass the UV lightmap coordinates of a Static Mesh Renderer. The number of Charts that an object requires is therefore largely determined by the number of UV shells (pieces) needed to unwrap the object in question.(光照图(Charts)的目的主要是用来包住静态网格着色器(Static Mesh Renderer)的UV贴图坐标。一个物件所需要的光照图数量主要是看物体有多少片UI Shell需要拆解。)

降低Chart的数量是减少Precompute Time的关键因素之一。
除了选择合适的Realtime Resolution,还有什么方式可以帮助我们降低Chart数量了?

  1. 降低Static物体的UV Shells(相当于减少特定物体所需的Lightmap Chart)
  2. 减少Static物体(用Lighting Probe实现)
  3. 减少Clusters数量(PRGI基础运算单元)

在减少UV Shells数量之前,先了解下背后的原理,以及相关帮助和查看窗口:
官方的UV Shells拆分示例:
MultipleUVShells

SingleUIShell

查看LightmapChart:
Scene -> Draw Mode -> GI -> UV Chart
UVChart

查看特定物件的UV Chart:

  1. 选中要查看的物体
  2. Window -> Lighting -> Obejct
  3. 左上角选择Chart模式

SpecificObjectUVChart
Charting模式的预览视窗会用不同的颜色的格子表示光照图,并用浅蓝色的线表示UV贴图。

更多关于UV Chart生成相关的因素,参考:
Unwrapping and Chart reduction

这里简单提一下:
Mesh Renderer上面的:

  1. Auto UV Max Distance(自動最大UV距離)
  2. Auto UV Max Angle(自動最大UV角度)
  3. Preserve UVs(保留UV)
  4. Ignore Normals(忽略法線)

开启Auto Lighting,然后调节相关数值通过UV Chart Preview帮助我们快速查看UV Chart实时效果。

如何通过UV Chats判定失真程度?
UVChartDistortionAnalysi
当启用UI Charts绘制模式时,失真的程度能从场景里的棋盘圆拉扯状况来评估。

如何正确使用Probe Lighting减少Static物体参与PRGI?
Probe lighting is a fast technique for approximating lighting in realtime rendering applications such as games.(光照探测技术是一个能在游戏里让即时光照更逼真的快速演算法)

原理:
Probe lighting works by sampling the incoming lighting at a specific point in 3D space and encoding this information across a sphere using mathematical functions known as spherical harmonics. These coefficients have low storage cost and can be then be quickly ‘unpacked’ at gameplay and used by shaders within the Scene to approximate surface lighting.(光照探测的原理是透过放在3D空间里的探针来接受照明信息,然后用像球鞋函数(spherical harmonics)的数学算法将结果编码在一个球体上,这些信息暂用空间很小,但在游戏运行时解包很快,并参与表面光照计算)

Note:
我們的目的是取样场景里的间接或反射光照。为了让每个光照探头效能花在刀口上, 尽量确保他们对一些变化明显的地方进行采样

如何减少Cluster数量?
让我们先通过查看贴图了解下Cluster真实情况:
ClusterPicture

Clusters sample the albedo of the Static geometry to which they are mapped. Then, during the Light Transport stage of the precompute, the relationship between these Clusters is calculated so that light can be propagated throughout the Cluster network.
(Cluster会对静态物体表面的反射率(Albedo)进行采样,在光照传递计算阶段计算Cluster之间的关系好让光在整个Cluster网之间传递)

一旦与计算完成后,你就可以修改Skybox或光线的位置,强度和颜色,不需要重新与计算,光线就会透过这些Cluster网信息,联通场景材质和发光材质一并考虑计算光照反射。

Clustering或Lit Clustering Scene Mode模式来观察Cluster分布

了解了Cluster相关知识概念后,那么如何真正降低Cluster数量了?
Lightmap Parameters控制,用于公用。
Asset > Create > Lightmap Parameters
通过GameObejct -> Mesh Renderer -> Lightmao Parameters指定
LightMapParameter

具体参数详细学习参考:
Unity預計算即時GI - 8.微調光照參數

PRGI实战:

  1. 设置需要Bake的物体为Static(规划好层级可快速批量设置Static)
  2. Window -> Lighting -> Build(勾选Auto,自动触发Precompute)
  3. 减少Lightmap Chart数量(部分物体(比如凸起的小物件)用Lighting Probe技术而非Static)
  4. 管理Lighting Probe(创建GameObject > Light > Light Probe Group)
  5. 放置Lighting Probe(GameObject > Light > Light Probe Group -> Edit Light Probes)

Baked Lighting AssetBundle实战:
目标:

  1. Baked Lighting,通过打包AB的形式加载还原Baked Lighting
  2. 支持Reflection Probe的AB加载还原(这个只是单纯加载了依赖的Cubemap没有做任何的还原操作,暂时表现是对的,具体待学习了解)

实战:

  1. 新建一个场景,添加必要的物件与光照
  2. 设置需要Bake的物体为Static
  3. 减少Lightmap Chart数量(部分物体(比如凸起的小物件)用Lighting Probe技术而非Static)
  4. 设置所有需要参与Bake的光照为Baked模式(只用于Baked Lighting)
  5. Window -> Lighting -> Build(勾选Auto,自动触发Precompute Lightmap)
  6. 删除所有完成静态烘焙的Light
  7. 记录场景里所使用的光照信息(LightmapSettings.lightmaps)以及所有参与了静态光照的静态物体所使用的光照图信息(Renderer.lightmapIndex和Renderer.lightmapScaleOffset)
  8. 运行时加载所有依赖的光照贴图信息,并还原所有静态物体使用的光照贴图信息

烘焙之后的场景光照使用信息:
LightmapBake

烘焙之后记录下场景使用的光照信息运行时还原:

打包场景AB以及光照信息记录代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var sceneasset = AssetDatabase.LoadAssetAtPath<Object>(abfullpath);
AssetImporter assetimporter = AssetImporter.GetAtPath(abfullpath);
var abname = ABPackageHelper.Singleton.getABTypeCorrespondingName(EABType.E_AB_SCENE, sceneasset.name);
assetimporter.assetBundleName = abname;

// 记录Lightmap相关信息,加载场景时用于还原Lightmap
var scenename = Path.GetFileNameWithoutExtension(abfullpath);

//打开需要打包的场景
var scene = EditorSceneManager.OpenScene(abfullpath);

var scenego = GameObject.Find(scenename);
var lmr = scenego.GetComponent<LightMapRestore>();
if (lmr != null)
{
GameObject.DestroyImmediate(lmr);
}
lmr = scenego.AddComponent<LightMapRestore>();

// 记录Lightmap使用信息
Debug.Log(string.Format("LightmapSettings.lightmaps.Length = {0}", LightmapSettings.lightmaps.Length));

// 测试目的,暂时只默认只有一个Lightmap
lmr.LightMapColor = LightmapSettings.lightmaps[0].lightmapColor == null ? string.Empty : LightmapSettings.lightmaps[0].lightmapColor.name;
lmr.LightMapDir = LightmapSettings.lightmaps[0].lightmapDir == null ? string.Empty : LightmapSettings.lightmaps[0].lightmapDir.name;
lmr.LightMapDataList = new List<LightMapData>();

// 打印所有烘焙对象的Lightmap相关信息(测试查看)
var allrootgos = scene.GetRootGameObjects();
for (int m = 0; m < allrootgos.Length; m++)
{
var allmeshcom = allrootgos[m].transform.GetComponentsInChildren<Renderer>();
for (int n = 0; n < allmeshcom.Length; n++)
{
LightMapData lmd = new LightMapData();
lmd.RendererObject = allmeshcom[n];
lmd.LightMapIndex = allmeshcom[n].lightmapIndex;
lmd.LightMapScaleOffset = allmeshcom[n].lightmapScaleOffset;
lmr.LightMapDataList.Add(lmd);
}
}

// 保存场景
EditorSceneManager.SaveScene(scene);

AssetDatabase.Refresh();

还原代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class LightMapData
{
public Renderer RendererObject;

public int LightMapIndex;

public Vector4 LightMapScaleOffset;
}

/// <summary>
/// 静态光照烘焙还原
/// </summary>
public class LightMapRestore : MonoBehaviour {

public string LightMapColor;

public string LightMapDir;

public List<LightMapData> LightMapDataList;

void Start()
{
// 还原各静态烘焙物体的lightmap信息
foreach(var lmd in LightMapDataList)
{
Debug.Log(string.Format("还原{0}Lightmap信息:LightMapIndex:{1} LightMapScaleOffset:{2}", lmd.RendererObject.name, lmd.LightMapIndex, lmd.LightMapScaleOffset));
lmd.RendererObject.lightmapIndex = lmd.LightMapIndex;
lmd.RendererObject.lightmapScaleOffset = lmd.LightMapScaleOffset;
}
}
}

光照信息记录:
LightMapData

加载还原后的效果:
LightMapRestore

那如何Bake多份Lightmap进行动态切换了?
Unity 5.X如果想Bake多份Lightmap,需要复制保留之前Bake出的Texture,然后通过修改LightmapSetting的值即可(因为是同一个场景,静态物体的lightmapIndex和lightmapScaleOffset值都没变,所以直接切换LightmapSetting里的lightmaps即可)。

1
2
3
4
var lightmaptex = TextureResourceLoader.getInstance().loadTexture(newlightmapname);
var newlightmapdata = new LightmapData();
newlightmapdata.lightmapColor = lightmaptex;
LightmapSettings.lightmaps = new LightmapData[] { newlightmapdata };

参考文章:
Introduction to Precomputed Realtime GI
Unity預計算即時GI

Render Path

Forward Rendering
实时光照运算,计算量跟灯光数量成正比

Deffered Rendering
实时光照运算,计算量跟Pixels数量成正比而非光照

Edit -> Project Setting -> Player -> Rendering -> Rendering Path

Color Space

Linear Color Space
A significant advantage of using Linear space is that the colors supplied to shaders within your scene will brighten linearly as light intensities increase.

Gamma Color Space
Gamma’ Color Space, brightness will quickly begin to turn to white as values go up, which is detrimental to image quality.

Edit -> Project Setting -> Player -> Rendering -> Color Space

High Dynamic Range

HDR(High Dynamic Range) defines how extremely bright or dark colors are captured by scene cameras.(HDR决定光照颜色信息精度)

Note:
HDR is unsupported by some mobile hardware. It is also not supported in Forward Rendering when using techniques such as multi-sample anti-aliasing (MSAA).(Forward Rendering使用MSAA时不支持HDR)

待续

基础纹理

纹理类型分类

一下以Unity支持的导入设置纹理格式来分(一下只重点关注几个重要的):
Note:
随着Unity版本的变化,以下格式可能有细微变化(比如5.4里面的Adavanced已经没有了,取而代之的是细分到其他几个类型里)

Delfaut(Advanced)

这个是Unity在高版本默认的纹理格式,也是平时我们最常用的纹理格式之一。

如果要想将2D纹理图片作为纹理渲染到3D物体上,我们需要选择Default进行纹理格式的基本导入设置。

这里只提几个比较重要的设置,后续会讲到相关的概念。

  1. Non Power of 2(用于将不是2的倍数的纹理图片进行优化处理成2的倍数的宽高)
  2. Generate Mip Maps(预生成多个不同大小的纹理图片,用于不同距离是显示,解决很大的纹理在显示很小的时候失真和浪费的问题)
  3. Wrap Mode(用于设置处理在tiled纹理图片时的方式)

Normal Map

在之前的OpenGL学习中已经接触过Normal Map了,这里只是简单说一下什么是和为什么需要Normal Map。

什么是Normal Map?
Normal Mapping也叫做Dot3 Bump Mapping,它也是Bump Mapping的一种,区别在于Normal Mapping技术直接把Normal存到一张NormalMap里面,从NormalMap里面采回来的值就是Normal,不需要像HeightMap那样再经过额外的计算。
值得注意的是,NormalMap存的Normal是基于切线空间的,因此要进行光照计算时,需要把Normal,Light Direction,View direction统一到同一坐标空间中。

至于什么是Bump Mapping,为什么normal map的normal值是存在tangent space,什么是tangent space,详情参考:
Normal Map

为什么需要Normal Map?
如果要在几何体表面表现出凹凸不平的细节,那么在建模的时候就会需要很多的三角面,如果用这样的模型去实时渲染,出来的效果是非常好,只是性能上很有可能无法忍受。Bump Mapping不需要增加额外的几何信息,就可以达到增强被渲染物体的表面细节的效果,可以大大地提高渲染速度,因此得到了广泛的应用。

Sprite

这个是我们制作UI和2D游戏最常用的格式。

这里也将几个Sprite比较重要的设置:

  1. Packing Tag(Unity自带的打包图集工具所使用用于决定哪些图片时打在同一个图集里的标识。采用TexturePacker单独处理图片格式以及图集见后面)
  2. Pixels Per Unit(用于决定图片在屏幕上的映射显示大小,这个以及Camera Orthographic Size会影响我们制作Sprite时的大小标准以及Pixel Perfect显示。详情参考之前的学习Unity Unit & Pixel Per UnitNGUI 2.7屏幕自适应)
  3. Generate Mip Maps(这个概念和前面Default提到的一样,但大部分时候UI和2D游戏都不需要设置这个,因为UI一般不会有太大的大小变化,2D游戏里的图片同理)

Lightmap

待学习

更多的纹理格式还有Editor GUI and Legacy, Cursor, Cookier, Single Channel这里就不一一详解了,详情参见[TextureTypes]](https://docs.unity3d.com/Manual/TextureTypes.html)

纹理大小

Ideally, Texture dimension sizes should be powers of two on each side (that is, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048 pixels (px), and so on). The Textures do not have to be square; that is the width can be different from height.

上面是Unity官方给出的建议,Texture纹理大小宽高最好是2的N次方倍数。

那么为什么会有Power Of 2这个原则了?

  1. 硬件GPU(以前的GPU还不支持Non Power Of 2大小的Texture加载。现在虽然支持了但Power Of 2的加载速度始终要比Non Power Of 2快)
  2. 内存使用(None Power Of 2的Texture在分配内存时始终会分配最近的Power Of 2大小,如果不采用Power Of 2的Texture会浪费部分内存分配)
  3. 纹理压缩格式支持(有些纹理压缩格式只支持特定宽高的纹理图片,比如DXT1只支持mutilple of 4的Texture Size压缩)

Texture的最大Size受限于渲染API(相当于硬件所支持的API)等级有关:
TextureMaxSize

Note:
如果我们想在Unity里使用Non Power Of 2的Texture,我们需要在导入Texture时手动设置成NPO2,不然Unity会自动帮我们转换成Power Of 2的Texture。

参考文章:
OpenGL* Performance Tips: Power of Two Textures Have Better Performance

图片格式

图片是游戏里使用最基础也是最频繁的资源,图片的格式是决定内存占用多少的关键。

所以这里需要了解为什么图片格式会影响最终的内存占用以及如何选择正确的图片格式达到最低的内存占用实现我们所需的效果。以及相关工具帮助我们优化图片内存占用的问题。

图片加载内存计算方式?
图片宽度 * 图片高度 * 每个像素点的位数 = 内存大小

图片格式决定了每个像素点的位数的多少,比如RGBA8888(32-bit图)代表每个像素点存储了8 bit的R,G,B,A(分别代表红绿蓝透明通道),总共32 bit即4 byte大小。

所以1024 * 1024大小的RGBA8888的图片所需内存占用计算如下:
1024 * 1024 * 4byte = 4M

图片格式有哪些?
以下引用至图片格式及内存占用:
(1)RGBA8888(32-bit)
RGBA 4个通道各占8位,如果想获得最好的图片质量,使用这种格式最靠谱,但他占用的内存会比16位的纹理多一倍,加载速度相对较慢。
(2)RGBA4444(16-bit)
RGBA 4个通道各占4位,他对每个通道都有不错的支持,还能保证相对良好的图片质量,以及加载速度和内存占用。
(3)RGB5A1 (16-bit)
RGB 3个通道各占5位,A通道仅占一位,RGB通道表现良好,A通道只有透明和不透明两种,加载速度和内存占用表现良好。
(4)RGB565 (16-bit)
RGB 3个通道分别占5、6、5位,没有A通道,在16为纹理中,图片质量最好。

如何选择正确的图片格式?
了解各个图片格式的详细信息后,图片格式的选取根据具体需求而定。取舍主要在于内存占用和显示质量。待深入度研究。

参考文章:
图片格式及内存占用

纹理压缩方式

While Unity supports many common image formats as source files for importing your Textures (such as JPG, PNG, PSD and TGA), these formats are not used during realtime rendering by 3D graphics hardware such as a graphics card or mobile device. 3D graphics hardware requires Textures to be compressed in specialized formats which are optimised for fast Texture sampling. The various different platforms and devices available each have their own different proprietary formats.

从上述官网描述可以看出,我们游戏最终使用的纹理格式并非最初我们导入的JPG,PNG,PSD or TGA,而是经过特定压缩方式(ETC,PVRTC,ATC,DXT……)压缩后的指定纹理格式的Texture,目的是为了运行使用Texture时更高效,内存占用和显示质量上可以做取舍。

各平台主流通用的纹理压缩方式:
PC – DXT
Android – ETC
IOS – PVRTC

Unity纹理使用

下面是个人总结的部分知识点:
Unity最终使用的不是我们最初导入到项目工程里的JPG,PNG等,而是通过根据指定压缩方式(e.g. ETC)得到的特定纹理格式(e.g. RGBA8888)的纹理图片,目的是为了减少内存开销以及加快GPU的纹理采样速度。

如何选择正确的纹理格式?
了解各个纹理格式的详细信息后,纹理格式的选取根据具体需求而定。取舍主要在于内存占用和显示质量之间。

那么有什么相关工具可以帮助我们优化图片使用过程中的内存占用问题吗?
答案是:SpritePacker(Unity官方自带工具,最新的Unity 2017貌似推出了Sprite Atlas,打包图集更加灵活) 和 TexturePacker(第三方工具,打包图集(优化空白处减少内存开销,指定图集导出格式减少内存占用,减少drawcall等好处))

那么使用哪一个(Sprite Packer or Texture Packer)用于Unity图集打包工具更好了?
Unity通过SpritePacker模糊了图集的概念,通过Sprite Packer设置Tag打包的图集只有在最后打包的时候才会被打进包里。Sprite Packer虽然与Unity开发结合的比较紧密,但灵活度上有所欠缺,Unity 2017推出的Sprite Atlas在灵活度上加强了不少。
TexturePacker作为第三方工具,结合Unity使用灵活度更高,可以自定义图集打包的各项参数等,最终放到Unity里的图集是已经打包好的图集。
就当前5.4版本的Unity而言,在没有Sprite Altas的前提下,个人觉得采用Texture Packer会更灵活一些,通过Texture Packer我们可以预先打出图集,然后直接将图集打包成ab。

纹理的基础使用

纹理最基础的应用是用于模型外观。

我们直接来看看BlinnPhong光照模型结合纹理贴图的使用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

// Blinn-Phong光照模型结合单独的纹理使用Shader
Shader "Custom/Texture/SingleTextureShader"
{
Properties
{
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
// 给Material面板暴露纹理图片
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION; // 物体坐标系顶点位置
float3 normal : NORMAL; // 物体坐标系法线
float4 texcoord : TEXCOORD0; // 纹理
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系顶点位置
float3 worldnormal : TEXCOORD0; // 世界坐标系法线
float3 worldpos : TEXCOORD1; // 世界坐标系顶点位置
float2 uv : TEXCOORD2; // 纹理映射信息
};

// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;
// 对应材质面板的纹理贴图
sampler2D _MainTex;
// 纹理材质的纹理属性(e.g. 纹理缩放值,偏移值)
float4 _MainTex_ST;

v2f vert (a2v v)
{
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldnormal = normalize(UnityObjectToWorldNormal(v.normal));
o.worldpos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 通过纹理基础信息和纹理属性得出纹理映射UV坐标
// TRANSFORM_TEX() == (tex.xy * name##_ST.xy + name##_ST.zw)
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 使用纹理贴图的颜色信息作为漫反射系数
fixed3 albedo = tex2D(_MainTex, i.uv).rgb;

// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

//漫反射
// 顶点光线入射向量
fixed3 worldlightdir = normalize(UnityWorldSpaceLightDir(i.worldpos.xyz));
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(i.worldnormal, worldlightdir));

// 高光反射
// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(UnityWorldSpaceViewDir(i.worldpos.xyz));
// h = (v + l) / |v + l| v和l向量归一化后的向量
fixed3 halfdir = normalize(worldlightdir + viewdir);
// 计算高光反射的颜色信息
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(i.worldnormal, halfdir)), _Gloss);
// 将高光反射的颜色信息输出到最终的顶点颜色
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}

BlinnPhong光照模型的纹理使用

Mipmap

说到纹理的使用,这里不得不提的就是Mipmap的应用:
mipmap主要是由于物体在场景中因为距离的缘故会在屏幕显示的大小有变化,如果我们在物体很远,只需要显示很小一块的时候还依然采用很大的纹理贴图,最终显示在屏幕上的纹理会很不清晰(失真)。为了解决这个问题,mipmap应运而生,通过事先生成或指定多个级别的同一纹理贴图,然后在程序运作的过程中通过计算算出应该使用哪一个等级的纹理贴图来避免大纹理小色块失真的问题。

在Unity为Texture开启Mipmap,我们需要把纹理类型设置成Advanced,然后勾选Generate Mip Map即可:
TextureMipmapImportSetting

需要注意的是,Unity开启Mipmap后会导致资源纹理内存占用增加。关于Advanced纹理的各个详细参数信息参考Texture Import Setting

Note:
注意纹理在DX和OpenGL里的起点不一样,DX是左上角(0,0),OpenGL和Unity是左下角(0,0)。

凹凸映射

这里的凹凸映射指的就是之前OpenGL学习的Normal Mapping和Bump Mapping,这里直接使用之前的总结:高模normal map用于低模模型上,即不增加渲染负担又能增加渲染细节。

还有一个凹凸映射叫做高度纹理(Height Map),一般用于存储地形高度信息。

Height Map

用于存储高度信息,通常用于地形的Height Map。

Normal Mapping

关于Normal Mapping要注意的地方主要是两点:

  1. 存储的法线信息是基于切线空间的,需要转换计算位于世界坐标系的法线信息。
  2. 纹理贴图的信息范围是[0,1],而法线信息时[-1,1]需要通过映射转换。

Normal Mapping详细信息参考

这里直接给出使用Unity编写Normal Mapping的使用代码和效果(以下代码采用的是将切线空间的法线转换到世界坐标系参与光照计算的方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'

Shader "Custom/Texture/NormalMappingShader"
{
Properties
{
// 给Material面板暴露主纹理
_MainTex("Texture", 2D) = "white" {}
// 给Material面板暴露法线贴图
// bump是Unity自带的法线纹理
_NormalMap("Normal Map", 2D) = "bump" {}
// 法线贴图的有效程度,用于控制凹凸程度
_BumpScale("Bump Scale", Float) = 1.0
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION; // 物体坐标系顶点位置
float3 normal : NORMAL; // 物体坐标系法线
float4 tangent : TANGENT; // 切线空间的顶点切线(用于计算切线空间的坐标系)
float4 texcoord : TEXCOORD0; // 纹理位置信息(用于通过采样获取纹理的UV信息)
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系顶点位置
float4 uv : TEXCOORD0; // 主纹理和法线纹理的UV坐标信息(xy为主纹理的UV,zw为法线纹理的UV,用于后续的纹理采样)
float4 ttow0 : TEXCOORD1; // 切线空间y轴转换到世界坐标系后的y轴
float4 ttow1 : TEXCOORD2; // 切线空间x轴转换到世界坐标系后的x轴
float4 ttow2 : TEXCOORD3; // 切线空间z轴转换到到世界坐标系后的z轴
};

// 对应材质面板的纹理贴图
sampler2D _MainTex;
// 纹理材质的纹理属性(e.g. 纹理缩放值,偏移值)
float4 _MainTex_ST;
// 对应材质面板法线贴图
sampler2D _NormalMap;
// 法线贴图材质的纹理属性(e.g. 纹理缩放值,偏移值)
float4 _NormalMap_ST;
// 对应材质面板法线贴图有效程度参数
float _BumpScale;
// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;

v2f vert(a2v v)
{
// 通过tangent和normal值计算出将tangent转换到世界坐标系的矩阵(世界坐标系下的XYZ轴)
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

// **_ST
// x contains X tiling value
// y contains Y tiling value
// z contains X offset value
// w contains Y offset value
// 主纹理的UV信息
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// 法线纹理的UV信息
o.uv.zw = v.texcoord.xy * _NormalMap_ST.xy + _NormalMap_ST.zw;

float3 worldpos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 世界坐标系下的normal(相当于y轴)
fixed3 worldnormal = UnityObjectToWorldNormal(v.normal);
// 世界坐标系下的tangent(相当于x轴)
fixed3 worldtangent = UnityObjectToWorldDir(v.tangent.xyz);
// 世界坐标系下的normal和tangent计算出binormal
// 世界坐标系下的binormal(相当于z轴)
fixed3 worldbinormal = cross(worldnormal, worldtangent) * v.tangent.w;

// 填充从tangent space转换到world space的矩阵
// 注意因为是列向量,所以注意填充方式
// worldpos填充在w后续使用
o.ttow0 = float4(worldtangent.x, worldbinormal.x, worldnormal.x, worldpos.x);
o.ttow1 = float4(worldtangent.y, worldbinormal.y, worldnormal.y, worldpos.y);
o.ttow2 = float4(worldtangent.z, worldbinormal.z, worldnormal.z, worldpos.z);

return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 世界坐标系顶点位置
fixed3 worldpos = float3(i.ttow0.w,i.ttow1.w,i.ttow2.w);

// 计算世界坐标系的法线信息
// bump为切线空间的法线信息
fixed3 bump = UnpackNormal(tex2D(_NormalMap, i.uv.zw));

bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));

// 将切线空间空间的法线信息转换到世界坐标系下
bump = normalize(half3(dot(i.ttow0.xyz, bump), dot(i.ttow1.xyz, bump), dot(i.ttow2.xyz, bump)));

// 利用转换到世界坐标系的法线信息,正式开始光照计算
// 使用纹理贴图的颜色信息作为漫反射系数
fixed3 albedo = tex2D(_MainTex, i.uv).rgb;

// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

// 漫反射
// 顶点光线入射向量
fixed3 worldlightdir = normalize(UnityWorldSpaceLightDir(worldpos));
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(bump, worldlightdir));

// 高光反射
// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(UnityWorldSpaceViewDir(worldpos));
// h = (v + l) / |v + l| v和l向量归一化后的向量
fixed3 halfdir = normalize(worldlightdir + viewdir);
// 计算高光反射的颜色信息
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(bump, halfdir)), _Gloss);
// 将高光反射的颜色信息输出到最终的顶点颜色
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}

BlinnPhong光照模型的Normal Mapping纹理使用

Ramp Texture(渐变纹理)

比较普遍的用法是使用渐变纹理来控制漫反射光照运算。
让我们直接看代码和效果图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
Shader "Custom/Texture/RampTextureShader"
{
Properties
{
// 给Material面板暴露渐变纹理贴图
_RampTex("Ramp Texture", 2D) = "white" {}
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION; // 物体坐标系顶点位置
float3 normal : NORMAL; // 物体坐标系法线
float4 texcoord : TEXCOORD0; // 纹理位置信息(用于通过采样获取纹理的UV信息)
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系顶点位置
float3 worldnormal : TEXCOORD0; // 世界坐标系下的法线
float3 worldpos : TEXCOORD1; // 世界坐标系下的顶点位置
float2 uv : TEXCOORD2; // 渐变纹理的UV坐标信息
};

// 对应材质面板渐变纹理贴图
sampler2D _RampTex;
// 渐变纹理贴图的纹理属性(e.g. 纹理缩放值,偏移值)
float4 _RampTex_ST;
// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;

v2f vert(a2v v)
{
// 通过tangent和normal值计算出将tangent转换到世界坐标系的矩阵(世界坐标系下的XYZ轴)
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldnormal = UnityObjectToWorldNormal(v.normal);
o.worldpos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);
return o;
}

fixed4 frag(v2f i) : SV_Target
{
fixed3 worldnormal = normalize(i.worldnormal);
fixed3 worldlightdir = normalize(UnityWorldSpaceLightDir(i.worldpos));

// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 半兰伯特反漫射光照模型
fixed halflambert = dot(i.worldnormal, worldlightdir) * 0.5 + 0.5;
// 利用半兰伯特得到的[0,1]范围值渐变纹理采样
fixed3 diffusecolor = tex2D(_RampTex, fixed2(halflambert, halflambert)).rgb;

// _LightColor0代表全局唯一的direction light光照的颜色信息
fixed3 diffuse = _LightColor0 * diffusecolor;

// 高光反射
// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(UnityWorldSpaceViewDir(i.worldpos));
// h = (v + l) / |v + l| v和l向量归一化后的向量
fixed3 halfdir = normalize(worldlightdir + viewdir);
// 计算高光反射的颜色信息
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(i.worldnormal, halfdir)), _Gloss);

// 将高光反射的颜色信息输出到最终的顶点颜色
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}

渐变纹理
可以看出利用半兰伯特把法线映射到[0,1]后,作为渐变纹理采样的uv,这样一来面向光照方向的顶点会更靠纹理采样的1,反之完全与光照相反方向的顶点会更靠近纹理采样的0的位置,然后把渐变纹理的采样信息作为漫反射的基础颜色信息参与光照运算。

Mask Texture(遮罩纹理)

“遮罩允许我们可以保护某些区域,使它们免于修改。遮罩纹理已经不止限于保护区域避免修改,而是存储我们希望逐像素控制的表面属性。”
让我们直接看代码和效果图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
Shader "Custom/Texture/MaskTextureShader"
{
Properties
{
// 给Material面板暴露主纹理
_MainTex("Texture", 2D) = "white" {}
// 给Material面板暴露法线贴图
// bump是Unity自带的法线纹理
_NormalMap("Normal Map", 2D) = "bump" {}
// 法线贴图的有效程度,用于控制凹凸程度
_BumpScale("Bump Scale", Float) = 1.0
// 给Material面板暴露高光反射遮罩纹理
_SpecularMask("Specular Mask", 2D) = "white" {}
// 给Material面板暴露高光反射系数
_Specular("Specular", Color) = (1, 1, 1, 1)
// 给Material面板暴露高光范围
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
// 指定Pass用于Forward Rendering中的Light计算
Tags{ "LightMode" = "ForwardBase" }

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct a2v
{
float4 vertex : POSITION; // 物体坐标系顶点位置
float3 normal : NORMAL; // 物体坐标系法线
float4 tangent : TANGENT; // 切线空间的顶点切线(用于计算切线空间的坐标系)
float4 texcoord : TEXCOORD0; // 纹理位置信息(用于通过采样获取纹理的UV信息)
};

struct v2f
{
float4 pos : SV_POSITION; // 投影坐标系顶点位置
float2 uv : TEXCOORD0; // 主纹理和法线纹理和遮罩纹理公用一个UV坐标信息
float4 ttow0 : TEXCOORD1; // 切线空间y轴转换到世界坐标系后的y轴
float4 ttow1 : TEXCOORD2; // 切线空间x轴转换到世界坐标系后的x轴
float4 ttow2 : TEXCOORD3; // 切线空间z轴转换到到世界坐标系后的z轴
};

// 对应材质面板的纹理贴图
sampler2D _MainTex;
// 纹理材质的纹理属性(e.g. 纹理缩放值,偏移值)
float4 _MainTex_ST;
// 对应材质面板法线贴图
sampler2D _NormalMap;
// 对应材质面板法线贴图有效程度参数
float _BumpScale;
// 对应材质面板的高光反射遮罩纹理
sampler2D _SpecularMask;
// 对应材质面板的高光反射系数
fixed4 _Specular;
// 对应材质面板的高光范围
float _Gloss;

v2f vert(a2v v)
{
// 通过tangent和normal值计算出将tangent转换到世界坐标系的矩阵(世界坐标系下的XYZ轴)
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

// 公用的纹理UV信息
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

float3 worldpos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 世界坐标系下的normal(相当于y轴)
fixed3 worldnormal = UnityObjectToWorldNormal(v.normal);
// 世界坐标系下的tangent(相当于x轴)
fixed3 worldtangent = UnityObjectToWorldDir(v.tangent.xyz);
// 世界坐标系下的normal和tangent计算出binormal
// 世界坐标系下的binormal(相当于z轴)
fixed3 worldbinormal = cross(worldnormal, worldtangent) * v.tangent.w;

// 填充从tangent space转换到world space的矩阵
// 注意因为是列向量,所以注意填充方式
// worldpos填充在w后续使用
o.ttow0 = float4(worldtangent.x, worldbinormal.x, worldnormal.x, worldpos.x);
o.ttow1 = float4(worldtangent.y, worldbinormal.y, worldnormal.y, worldpos.y);
o.ttow2 = float4(worldtangent.z, worldbinormal.z, worldnormal.z, worldpos.z);

return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 世界坐标系顶点位置
fixed3 worldpos = float3(i.ttow0.w,i.ttow1.w,i.ttow2.w);

// 计算世界坐标系的法线信息
// bump为切线空间的法线信息
fixed3 bump = UnpackNormal(tex2D(_NormalMap, i.uv));

bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy)));

// 将切线空间空间的法线信息转换到世界坐标系下
bump = normalize(half3(dot(i.ttow0.xyz, bump), dot(i.ttow1.xyz, bump), dot(i.ttow2.xyz, bump)));

// 利用转换到世界坐标系的法线信息,正式开始光照计算
// 使用纹理贴图的颜色信息作为漫反射系数
fixed3 albedo = tex2D(_MainTex, i.uv).rgb;

// 环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

// 漫反射
// 顶点光线入射向量
fixed3 worldlightdir = normalize(UnityWorldSpaceLightDir(worldpos));
// 得到世界坐标系的顶点法线和direction light方向后可以开始计算漫反射
// _LightColor0代表全局唯一的direction light光照的颜色信息
// 点乘世界坐标系的法线和光源方向得到漫反射辐射度
// saturate防止负值
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(bump, worldlightdir));

// 高光反射
// 世界坐标系下的从顶点到观察点的观察方向v
fixed3 viewdir = normalize(UnityWorldSpaceViewDir(worldpos));
// h = (v + l) / |v + l| v和l向量归一化后的向量
fixed3 halfdir = normalize(worldlightdir + viewdir);
// 高光遮罩纹理采样信息(这里只是用了r红色通道信息作为mask)
fixed specularmask = tex2D(_SpecularMask, i.uv).r;
// 计算高光反射的颜色信息(高光遮罩纹理参与过滤)
// c(specular) = (c(light) * m(specular)) * pow(max(0, dot(n,h)), m(gloss))
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(bump, halfdir)), _Gloss) * specularmask;
// 将高光反射的颜色信息输出到最终的顶点颜色
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}

高光遮罩纹理
可以看到我们利用遮罩纹理的R颜色信息作为高光的过滤依据,也就是说遮罩纹理里R信息为0的顶点将不会计算高光反射。

透明效果

Reference Website

Surface shaders in Unity3D
[Unity Shader内置文件官网下载地址](http://unity3d.com/cn/get-unity/download/
archive)
Shader semantics官网查询
Unity Shader入门精要勘误
Unity Shader入门级你要源代码
Gouraud Shading