前言

本篇文章是为了记录学习Unity官方一些好的第三方插件,作为基础的学习了解,为未来遇到问题思考解决方案时拓宽思路。

Sprite Atlas

依然从What,Why,How三个角度了学习了解Sprite Atlas。

What

Sprite Atlas是官方Sprite Packer的升级版本,是为了更好的解决图集打包加载问题而开发的,而图集是为了解决Draw Call问题。

Why

老版Unity的时候,图集打包功能(SpritePacker,打Tag的方式)并不完善,更多的是采用第三方好比TexturePacker打包图集在放到Unity项目里的工作流。

Sprite Atlas主要提供了以下三个功能:
1.创建、编辑图集以及设定图集参数
2.添加图集Variant(变种)
3.运行时访问图集

新版Sprite Atals已经很完善了,考虑到以下几个原因,决定学习官方的打包图集工具跟着官方走:

  1. TexturePacker是付费的,Sprite Atlas免费
  2. Sprite Atlas是官方的,跟官方走一般来说不会错
  3. Spritre Atlas已经成熟可以商用的级别

How

使用Sprite Atlas之前,我们需要打开Sprite Atlas功能开关:
Edit -> Project Setting -> Editor -> Sprite Packer -> Mode(Always Enable)
EnableSpriteAtlas

创建、编辑图集以及设定图集参数

在Sprite Atlas里Sprite Atlas是作为一种资源(Asset)存在。
创建Sprite Atlas:
右键 -> Sprite Atlas

选中创建的Sprite Atlas我们能看到相关设置:
SpriteAtlasSetting
这里的设置基本都和图集打包相关,这里就不一一详解了。
详情参见官网:
Sprite Atlas

点击Sprite Atlas -> Pack Preview即可查看打包后的结果:
PackPreview

这里关于Include in Build这个参数要详细说明一下,这个参数会决定一下几方面:

  1. 影响使用SpriteAtlas图集资源的资源是如何关联SpriteAtlas的(会决定使用该SpriteAtlas资源加载时加载哪一个图集资源–这里指的是有图集Variant的情况)
  2. 影响SpriteAtlas是否需要采用Later Bind(触不触发SpriteAtlasManager.atlasRequested接口,通过这个接口结合图集Variant我们可以做到例如不同平台加载不同图集的适配)。反之勾选Include in Build在依赖使用时会自动加载依赖的Sprite Atlas。
    这里貌似有些Unity版本有个Bug,勾选Include in Build会导致资源打包冗余
    IncludeInBuildCauseRedundancySpriteAtlasTexture

针对第一条详情参考:
Resolving different Sprite Atlas scenarios

Note:

  1. Sprite Atlas支持Objects for Packing设置文件夹
  2. 参与打包的图片必须设置成Sprite(2D and UI)格式
  3. Include in Build会影响SpriteAtlas的选择(比如是否要使用Later Bind等)

添加图集Variant(变种)

什么是图集Variant?
A Variant Sprite Atlas is a type of Sprite Atlas that does not contain its own list of selected Textures as it has no Objects for Packing list in its properties. Instead, it receives a copy of the content from the Sprite Atlas set as its Master Atlas.
图集Variant是指不能指定打包内容的图集,图集Variant内容来源于复制指定的Master图集。

图集Variant的适用场景?
图集Variant的主要目的是为了用于不同分辨率设备适配不同图集来实现,内存和效果的可控。

图集Variant设置适用:
SpriteAtlasVariants
可以看到我们创建一个SpriteAtlas把Type设置成Variant后,然后指定Master Atlas,最后通过调节Scale为0.5我们得到了比playerpreview图集小一倍大小的图集效果。

结论:

图集变体是类似Mipmap的东西,通过多打包多个不同大小的图集到游戏里,Unity自动帮我们选择合适的图集来加载,从而做到高分辨率机型加载高分辨率的,低分辨率机型加载低分辨率的,典型的空间换时间做法

Note:

  1. 要想只打包使用图集Variant,需要把图集Variant的Include in Build打开,把图集Master的Include in Build关闭

运行时访问图集

Sprite Atlas作为一种资源开放给用户,支持在脚本中直接访问,还可以通过名字获取图集中的精灵。

这里我们采用设置不勾选Include in Build的设置打包SpriteAtlas

接下来我们通过SpriteAtlas接口访问打包AB后的Sprite Atlas里的图片资源试试:
首先让我们看看SpriteAtlas打包AB后的的资源数据:
SpriteAtlasAssetBundlePreview
可以看到SpriteAtlas作为资源参与AB打包,里面包含了SpriteAtlas,图集Texture2D以及Sprite数据。

这里我们看看UI预制件引用SpriteAtlas看看依赖打包的情况:
UIPrefabReferenceNotIncludeBuildSpriteAtlas
从上面可以看出UI窗口预制件包含了依赖的Sprite(这里和以前不一样,以前是不会包含依赖的Sprite的,会直接通过依赖关系加载依赖AB实现自动还原),个人猜测这个和后续讲到的SpriteAtlas的Later Bind有关。

加载UI窗口预制件后发现Sprite Missing了:
UIPrefabSpriteMissing
通过查看依赖文件我发现依赖信息是没有对SpriteAtlas的依赖信息的。不知道这算不算是一个Bug还是设计如此,因为没有依赖信息对资源加载管理会造成问题,忘知道的朋友告知。
UIPrefabDepencency

问题:

  1. 加载使用SpriteAtlas的UI Prefab,SpriteAtlas会自动加载进内存且没有依赖信息。但如果此时主动加载了Sprite Atlas并主动强制卸载该Sprite Atlas的AB会导致UI Prefab使用的Sprite Atlas也跟随被清除。从这里看出UI Prefab对Sprite Atlas的依赖信息是有必要的,不然没法正确的管理资源加载。

这里引申出了SpriteAtlas一个很重要的功能,Later Bind

Note:

  1. 图集的被动(依赖)加载管理会有使用该图集的资源所决定(比如UI Prefab使用了SpriteAtlas,两个分别打包AB,这时加载UI Prefab后SpriteAtlas会自动加载进内存,而UI Prefab卸载以后,SpriteAtlas没有其他主动加载该SpriteAtlas的会自动卸载。)
Later Bind

SpritePacker时代“图集”我们是看不到的,因为设计者的初衷是想让开发者完全不用考虑图集的事。但是,这跟游戏开发时的动态加载图集时矛盾的!我们要动态加载就必须要知道atlas名字和sprite名,这样才能在 运行时动态的找到一张sprite!所以U3D程序员必须要清楚的知道你当前界面用的是哪atlas的哪个sprite。但是按照SpritePacker的做法他的初衷应该是想把图集干掉(干掉的意思让开发者不用关心),只需要考虑sprite即可,但是这是行不通的!
**SpriteAtlas其实就是为了解决上面说的问题而发布的功能。通过它我们可以将atlas和UIprefab“解耦”。同时,在Unity编辑器状态下我们仍然可以利用未打成SpriteAtlas的Sprite进行开发。等开发完成我们再发布成SpriteAtlas。**Unity提供了所谓“延迟绑定”(late bind)的技术来让我们实现这个功能。

AtlasManager.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
public class AtlasManager : SingletonTemplate<AtlasManager>
{
public AtlasManager()
{
DIYLog.Log("添加SpriteAtals图集延时绑定回调!");
SpriteAtlasManager.atlasRequested += onAtlasRequested;
}

/// <summary>
/// 响应SpriteAtlas图集加载回调
/// </summary>
/// <param name="atlasname"></param>
/// <param name="callback"></param>
private void onAtlasRequested(string atlasname, Action<SpriteAtlas> callback)
{
DIYLog.Log($"加载SpriteAtlas:{atlasname}");
// Later Bind -- 依赖使用SpriteAtlas的加载都会触发这里
ResourceModuleManager.Singleton.requstResource(
atlasname,
(abi) =>
{
DIYLog.Log($"Later Bind加载SpriteAtlas:{atlasname}");
var sa = abi.loadAsset<SpriteAtlas>(atlasname);
callback(sa);
});
}

******
}

在加载使用SpriteAtlas(仅当设置成不勾选Include in Build)的相关资源时(比如UI Prefab)上诉回调会被触发完成对SpriteAtlas的加载,确保引用的Sprite能正确显示。同时这一机制就允许我们做到不同平台的不同图集资源加载适配这样的功能。

疑问:

  1. Later Bind只是加载SpriteAtlas提供的一个回调接口(无论是主动还是被动(依赖)加载),回调接口内只知道加载了指定的SpriteAtlas却不知道具体的详细用法,这样一来基于对象绑定和索引计数的资源管理系统就无法正确的加载管理了(因为被动(依赖)加载得不到SpriteAtlas的依赖信息)。

个人方案:

  1. 当前这套基于对象绑定和索引计数的方案暂时没想到好的方案,可能需要对SpriteAtals的资源管理单独做一套管理策略来支持Later Bind的用法。有更好方案的朋友,望告知。
注意事项
  1. 开启Sprite Packer的设置Sprite Packer -> Mode(Always Enable)只决定图集合成时机(比如是运行时还是打包时还是编辑器时)。
  2. Sprite Atlas支持Objects for Packing设置文件夹
  3. 参与打包的图片必须设置成Sprite(2D and UI)格式
  4. Include in Build会影响SpriteAtlas图集资源的资源是如何关联SpriteAtlas(比如影响是否需要使用Later Bind,不勾选需要使用Later Bind否则会报警告)
相关概念
  1. Sprite Atlas把图集打包的概念延迟到特定时刻(比如打包或者打包AB)而编辑模式依然可以使用原始Sprite工作,总的来说就是延时自动化打包图集时机+允许使用原始Sprite的概念。
  2. Include in Build设置主要是看有没有动态适配加载图集需求,有的话就建议不勾选,然后结合Later Bind来做动态图集加载适配。反之勾选Include in Build在依赖使用时会自动加载依赖使用的Sprite Atals。
  3. Include in Build其次还会影响Sprite Atlas变体使用的选择。
  4. SpriteAtlas到了Unity 2018 LTS版本感觉才稳定些,建议使用稳定版本的SpriteAtlas
  5. Later Bind不会区分主动加载还是被动(依赖)加载SpriteAtlas,都会统一回调,要使用Later Bind机制的需要取消勾选Include in Build并解决SpriteAtlas的资源加载管理问题(在沿用原来的对象绑定和索引计数的资源管理方案基础上本人暂时没有好的方案)
  6. Sprite Atlas无论够不够选Include in Build打包AB都得不到Sprite Atlas的依赖信息,感觉这是一个bug
Bug记录
  1. 勾选Include in Build会导致资源打包冗余(部分Unity版本比如Unity2018.4.1f1),建议升级到LTS版本
结论

SpriteAtlas虽然解决了Atlas和UIPrefab的解耦,同时提供了图集变体的概念来解决适配图集适配方案,但也带来了一些新的问题要解决(e.g. 要解决没有依赖信息的SpriteAtals基础上做到正确的资源加载管理)。

——————————–2021/20/12更新开始————————————–

正确使用姿势参考:

【Unity游戏开发】SpriteAtlas与AssetBundle最佳食用方案

简单来说:

  1. SpriteAtlas 勾选Include in build
  2. SpriteAtlas不设置AB打包
  3. 直接当做单Sprite一样加载

——————————–2021/20/12更新结束————————————–

Timeline

Timeline to create cinematic content, game-play sequences, audio sequences, and complex particle effects.

官方介绍Timeline可以用于制作类似电影过场表现的工具。

Timeline分为两个部分:

  1. Timeline Asset(数据)
  2. Timeline instance(数据关联实体对象)

Timeline Asset

介绍

Timeline Asset: Stores the tracks, clips, and recorded animations without links to the specific GameObjects being animated.

通过介绍可以看出Timeline Asset是类似于Animation Clip一样的资源文件可以用于录制动画但不需要关联到特定的物体上

Timeline Asset记录的动画数据是以子节点资源的形式挂载在Timeline Asset下的:

TimelineAssetHierachy

Note:
前面说的不需要关联到特定的物体上是指不需要像AnimationClip那样动画是针对指定物体制作的(挂给其他物体用不了该动画),Timeline可以通过PlayableDirector动态关联动画对象。

分类

Timeline Asset是以Track为最小单位。

主要支持以下几种Track:

  1. Track Group(仅分层管理用)
  2. Activation Track(激活物体Track)
  3. Animation Track(动画Track)
  4. Audio Track(音频Track)
  5. Control Track(时间相关的控制)
  6. Playable Track(自定义Track)

TimelineComponent

这里我们着重学习Playable Track,自定义的一些表现行为都是通过Playable Track来做的。其他几个就不一一讲解了。

Playable Track

自定义Playable Track需要知道两个东西:

  1. PlayableAsset(用于支持Timeline创建和显示自定义PlayableAsset数据定义)
  2. PlayableBehaviour(用于Playable实际的生命周期等逻辑实现)

示例:

1
2
3
4
5
// 枚举事件定义
public enum EEventId
{
DefaultEvent = 1, // 默认事件
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 事件分发器Asset
/// </summary>
[System.Serializable]
public class EventDispatcherAsset : PlayableAsset
{
/// <summary>
/// 事件
/// </summary>
public EEventId EventId = EEventId.DefaultEvent;

// Factory method that generates a playable based on this asset
public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
{
var playable = ScriptPlayable<EventDispatcherPlayable>.Create(graph);
playable.GetBehaviour().EventId = EventId;
return playable;
}
}
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
/// <summary>
/// 事件分发器Playable
/// </summary>
public class EventDispatcherPlayable : PlayableBehaviour
{
/// <summary>
/// 事件
/// </summary>
[Header("事件")]
public EEventId EventId = EEventId.DefaultEvent;

// Called when the owning graph starts playing
// Playable开始执行时
public override void OnGraphStart(Playable playable)
{

}

// Called when the owning graph stops playing
// Playable停止执行时
public override void OnGraphStop(Playable playable)
{

}

// Called when the state of the playable is set to Play
// 触发Playable执行时
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
Debug.Log(string.Format("OnBehaviourPlay() 分发事件 : {0}", EventId));
}

// Called when the state of the playable is set to Paused
// Playable暂停执行时
public override void OnBehaviourPause(Playable playable, FrameData info)
{

}

// Called each frame while the state is set to Play
// Playable更新执行时
public override void PrepareFrame(Playable playable, FrameData info)
{

}
}

TimelineCustomPlayable

代码不复杂就不详细介绍了。通过上面的代码,我们支持了一个创建自定义分发事件的Playable Track,而这个自定义的事件分发就可以用作我们Timeline和游戏逻辑之间的一个桥梁,帮助我们在Timeline播放的过程中自定义响应做一些表现。

Timeline instance

介绍

Timeline instance: Stores links to the specific GameObjects being animated or affected by the Timeline Asset. These links, referred to as bindings, are saved to the Scene.

要想关联场景物体和Timeline Asset让我们制作的电影过场动起来,我们需要创建Timeline instance(这里指的Playable Director组件)。

TimelinePlayableDirectorInspector

可以看到我们通过Timeline Asset录制的动画是通过Playable Director的Bindings动态关联到了场景物体上。

Note:
因为Timeline Asset是纯数据,Timeline instance决定了数据关联的对象,所以我们可以重用Timeline Asset对不同的物体做动画表现。

Timeline类设计

Playable

The Playables API provides a way to create tools, effects or other gameplay mechanisms by organizing and evaluating data sources in a tree-like structure known as the PlayableGraph. The PlayableGraph allows you to mix, blend, and modify multiple data sources, and play them through a single output.

从介绍个人的理解,Playable是一套可自定义数据处理流程成树状结构的工具。而自定义出来的树状结构数据就叫做PlayableGraph(后续会讲到)。而Timeline的核心正是Playable。

使用Playables API的好处:

  • The Playables API allows for dynamic animation blending. This means that objects in the scenes
    could provide their own animations. For example, animations for weapons, chests, and traps could be dynamically added to the PlayableGraph and used for a certain duration.
  • The Playables API allows you to easily play a single animation without the overhead involved in creating and managing an AnimatorController asset.
  • The Playables API allows users to dynamically create blending graphs and control the blending weights directly frame by frame.
  • A PlayableGraph can be created at runtime, adding playable node as needed, based on conditions. Instead of having a huge “one-size-fit-all” graph where nodes are enabled and disabled, the PlayableGraph can be tailored to fit the requirements of the current situation.

官网上介绍了Playable的不少好处,从介绍可以看出最大的好处就是运行时自定义组装数据处理流程,避免离线构建一套过于臃肿的数据定义(比如AnimatorController)

Note:

  1. Playable是Struct而非Class,为了避免内存分配导致GC
  2. Playable和PlayableOutput没有提供太多方法,而是通过PlayableExtension和PlayableOutputExtension用扩展方法的方式来提供的方法

PlayableGraph

The PlayableGraph defines a set of playable outputs that are bound to a GameObject or component. The PlayableGraph also defines a set of playables and their relationships. Figure 1 provides an example.

The PlayableGraph is responsible for the life cycle of its playables and their outputs. Use the PlayableGraph to create, connect, and destroy playables.

从上面的介绍,个人理解,PlayableGraph是一个自定义数据处理流程的完整单位,负责自定义数据处理流程里的节点定义,关联,以及生命周期。PlayableGraph是由Plyable(e.g.Playable,MixPlayable)+PlayableOuput组成。

PlayableAsset

A base class for assets that can be used to instantiate a Playable at runtime.

PlayableAsset是自定义Playable时定义数据Asset的基类。

PlayableBehaviour

PlayableBehaviour is the base class from which every custom playable script derives.

PlayableBehaviour是自定义Playable时定义行为实现的脚本基类。

PlayableOutput

个人理解是定义Playable里面的输出节点。所有的输入节点最后连接成树状结构后连到输出节点上,执行出自定义行为的输出表现。

参考下图:

PlayableOutputPreview

PlayableBinding

Struct that holds information regarding an output of a PlayableAsset. PlayableAssets specify the type of outputs it supports using PlayableBindings.

看介绍PlayableBinding是用于定义并持有PlayableAsset的数据输出信息。

ExposedReference

Creates a type whos value is resolvable at runtime. ExposedReference is a generic type that can be used to create references to Scene objects and resolve their actual values at runtime and by using a context object. This can be used by assets, such as a ScriptableObject or PlayableAsset to create references to Scene objects.

从介绍可以看出,ExposedReference是用于创建一个类型(其值在运行时处理绑定的)。用于解决PlayableAsset引用场景对象组件数据等问题。

辅助工具(GraphVisualizer)

The PlayableGraph Visualizer is a tool that displays the PlayableGraphs in the scene. It can be used in both Play and Edit mode and will always reflect the current state of the graph.

因为Playable的设计很庞大,里面有很多类和概念,为了方便理解Playable设计和使用可视化的查看构建出来的PlayableGraph显得额外有用,而PlayableGraph Visualizer正是这么一款工具。

接下来我们结合GraphVisualizer动态通过代码创建模拟用Playable API实现动画Animator Controller里的动画状态机类似的效果,从而深入学习理解Playable里各个类的设计和使用。

单AnimationClip单AniamtionOuput的动画播放
1
2
3
4
5
6
7
8
9
10
// 创建动画播放PlayableGraph
mCustomGraph = PlayableGraph.Create("DIYAnimationPlayableGraph");
// 创建动画播放PlyableGraph的PlayableOutput
var animoutput = AnimationPlayableOutput.Create(mCustomGraph, "AnimationOutput", CustomAnimator1);
// 创建动画播放PlyableGraph的动画Playable
var animationclipplayable = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip1);
// 设置动画播放PlayableOutput的关联Playable
animoutput.SetSourcePlayable(animationclipplayable);
// 播放创建的PlayableGraph
mCustomGraph.Play();

通过上面的代码我们动态创建了1个PlayableGraph,1个动画Plyable,1个动画PlayableOutput作为动画播放输出,并设置他们之间的关联,运行代码打开PlayableGraphVisualizer就可以查看到生成的单动画Clip播放PlayableGraph图了。

PlayableGraphVisualizerPreview

多AnimationClip混合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 创建动画播放PlayableGraph
mCustomGraph = PlayableGraph.Create("DIYAnimationPlayableGraph");
// 创建动画播放PlyableGraph的PlayableOutput
var animationOutput = AnimationPlayableOutput.Create(mCustomGraph, "AnimationOutput", CustomAnimator1);
// 创建动画播放混合Playable(设置两个输入Playable)
mAnimationMixerPlayable = AnimationMixerPlayable.Create(mCustomGraph, 2);
// 关联动画混合Playable到PlaybleOutput作为唯一输入
animationOutput.SetSourcePlayable(mAnimationMixerPlayable);
// 创建动画播放PlyableGraph的动画播放Playable1和Playable2
var animationClipPlayable1 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip1);
var animationClipPlayable2 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip2);
// 关联动画播放Playable1和Playable2作为动画混合Playable的输入连接(分别连接输入0和输入1接口)
mCustomGraph.Connect(animationClipPlayable1, 0, mAnimationMixerPlayable, 0);
mCustomGraph.Connect(animationClipPlayable2, 0, mAnimationMixerPlayable, 1);
// 播放创建的PlayableGraph
mCustomGraph.Play();

通过上面的代码我们动态创建了一个PlayableGraph,2个动画Plyable作为基础输入,1个动画混合Playable作为混合节点,1个动画PlayableOutput作为动画播放输出,并设置他们之间的关联,运行代码打开PlayableGraphVisualizer就可以查看到生成的动画混合播放PlayableGraph图了。

AnimationClipMixPreview

多AnimationClip多AnimationOuput

一个PlayableGraph是可以拥有多个PlayableOutput输出的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建动画播放PlayableGraph
mCustomGraph = PlayableGraph.Create("DIYAnimationPlayableGraph");
// 创建动画播放PlyableGraph的PlayableOutput1和PlayableOutput2
var animationOutput1 = AnimationPlayableOutput.Create(mCustomGraph, "AnimationOutput1", CustomAnimator1);
var animationOutput2 = AnimationPlayableOutput.Create(mCustomGraph, "AnimationOutput2", CustomAnimator2);
// 创建动画播放PlyableGraph的动画播放Playable1和Playable2
var animationClipPlayable1 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip1);
var animationClipPlayable2 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip2);
// 关联动画播放Playable1到动画播放PlaybleOutput1
// 关联动画播放Playable2到动画播放PlaybleOutput2
animationOutput1.SetSourcePlayable(animationClipPlayable1);
animationOutput2.SetSourcePlayable(animationClipPlayable2);
// 播放创建的PlayableGraph
mCustomGraph.Play();

通过上面的代码我们动态创建了一个PlayableGraph,2个动画Plyable对应2个动画PlayableOutput动画播放输出的输入,并设置他们之间的关联,运行代码打开PlayableGraphVisualizer就可以查看到生成的动画混合播放PlayableGraph图了。

MultipleAnimationInputAndOutputPreview

多AnimationClip混合并控制权重加播放状态

不同的Playable是可以设置混合权重加播放状态的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建动画播放PlayableGraph
mCustomGraph = PlayableGraph.Create("DIYAnimationPlayableGraph");
// 创建动画播放PlyableGraph的PlayableOutput
var animationOutput = AnimationPlayableOutput.Create(mCustomGraph, "AnimationOutput1", CustomAnimator1);
// 创建动画混合Playable
mAnimationMixerPlayable = AnimationMixerPlayable.Create(mCustomGraph, 2);
// 设置动画混合Playalbe作为动画播放输出Playable
animationOutput.SetSourcePlayable(mAnimationMixerPlayable);
// 创建动画播放PlyableGraph的动画播放Playable1和Playable2
var animationClipPlayable1 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip1);
var animationClipPlayable2 = AnimationClipPlayable.Create(mCustomGraph, CustomAnimationClip2);
// 设置动画混合输入和权重
mCustomGraph.Connect(animationClipPlayable1, 0, mAnimationMixerPlayable, 0);
mCustomGraph.Connect(animationClipPlayable2, 0, mAnimationMixerPlayable, 1);
mAnimationMixerPlayable.SetInputWeight(0, 1.0f);
mAnimationMixerPlayable.SetInputWeight(1, 1.0f);
// 暂停动画播放Playable1
animationClipPlayable1.Pause();
// 播放创建的PlayableGraph
mCustomGraph.Play();

通过上面的代码我们动态创建了一个PlayableGraph,2个动画Plyable作为动画混合Playable的输入,1个动画混合Playable作为动画播放输出的输入,1个PlayableOutput作为动画播放输出,并设置他们之间的关联和权重,并把其中一个动画播放Playable设置成暂停状态,运行代码打开PlayableGraphVisualizer就可以查看到生成的动画混合播放PlayableGraph图了。

MultipleAnimationBlendAndSetPlayableState

Note:

  1. 父Playable的播放状态设置会影响所有子节点Playable播放状态

Timeline实战

通过上面有了基础的知识后,接下来我们以实战的方式深入学习Timeline。

  • 目标
  1. 人物模型移动(Timeline自带)+人物模型动画播放(Timeline自带)+镜头移动(Cinemachine+Timeline)+2D对话+3D气泡对话的剧情表现+特效播放(Timeline自带)+音乐播放控制(扩展自定义Track,方便走自己的音效播放接口)
  2. 支持Timeline Editor拖拽预览+运行时播放预览
  • 功能开发
  1. 音乐播放控制(扩展自定义Track,方便走自己的音效播放接口)
  2. Timeline模型移动控制Track(TimeLine支持)
  3. 学习Cinemachine扩展Timeline的Track使用控制镜头移动表现
  4. 实现2D气泡对话+3D对话功能
  5. 扩展Timneline实现2D气泡对话Track+3D对话Track
  6. 动态创建实体对象的自定义Track绑定控制(如何实现?)
  • 准备工作
  1. Package Manager里安装Cinemachine模块
  2. 下载导入模型特效音效等相关资源

安装好Cinemachine后,打开Timeline里面就会看到多了Cinemachine的Track,从而就可以在Timeline里使用Cinemachine相关的扩展功能了:

TimelineCinemachineTrack

首先我们先放一下我们自定义制作的Timeline的预制件结构:

CustomTimelinePrefabHierachyPreview


音乐播放控制Track

CustomAduioTrack.cs(自定义音乐播放Track)

1
2
3
4
5
6
7
8
9
/// <summary>
/// CustomAudioTrack.cs
/// 自定义音效Track
/// </summary>
[TrackClipType(typeof(CustomAudioAsset))]
public class CustomAudioTrack : PlayableTrack
{

}

CustomAudioAsset.cs(自定义音乐播放Asset数据结构)

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
/// <summary>
/// CustomAudioAsset.cs
/// 自定义音效播放Asset
/// </summary>
public class CustomAudioAsset : BaseGameAsset, ITimelineClipAsset
{
/// <summary>
/// 音效类型
/// </summary>
public enum AudioType
{
Bgm = 1, // 背景音乐
Sound = 2, // 音效
}

/// <summary>
/// 音效播放组件
/// </summary>
public ExposedReference<AudioSource> SourceAudio;

/// <summary>
/// 播放的音效类型
/// </summary>
public AudioType PlayedAudioType = AudioType.Bgm;

/// <summary>
/// 音效资源路径
/// </summary>
public string AudioPath;

/// <summary>
/// 不支持重叠
/// </summary>
public ClipCaps clipCaps
{
get { return ClipCaps.None; }
}

public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
var playable = ScriptPlayable<CustomAudioBehaviour>.Create(graph);
var audiobehaviour = playable.GetBehaviour();
audiobehaviour.Name = Name;
audiobehaviour.SourceAudio = SourceAudio.Resolve(graph.GetResolver());
audiobehaviour.PlayedAudioType = PlayedAudioType;
audiobehaviour.AudioPath = AudioPath;
return playable;
}
}

CustomAudioBehaviour.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
/// <summary>
/// CustomAudioBehaviour.cs
/// 自定义音效播放Behaviour
/// </summary>
public class CustomAudioBehaviour : BaseGameBehaviour
{
/// <summary>
/// 音效播放组件
/// </summary>
public AudioSource SourceAudio;

/// <summary>
/// 播放的音效类型
/// </summary>
public CustomAudioAsset.AudioType PlayedAudioType;

/// <summary>
/// 音效资源路径
/// </summary>
public string AudioPath;

/// <summary>
/// 是否正在播放音乐
/// </summary>
private bool mIsPlayingAudio;

public override void OnBehaviourPlay(Playable playable, FrameData info)
{
Debug.Log($"AudioBehaviour:OnBehaviourPlay() 执行指令:{Name}");
}

// Called each frame while the state is set to Play
// Playable更新执行时
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
TryPlayAudio();
}

// Called when the state of the playable is set to Paused
// Playable暂停执行时
public override void OnBehaviourPause(Playable playable, FrameData info)
{
Debug.Log($"AudioBehaviour:OnBehaviourPause() 执行指令:{Name}");
if(SourceAudio != null && SourceAudio.clip != null)
{
SourceAudio.clip = null;
}
mIsPlayingAudio = false;
}

/// <summary>
/// 尝试执行音乐播放
/// </summary>
private void TryPlayAudio()
{
if(mIsPlayingAudio == false && SourceAudio != null)
{
if (PlayedAudioType == CustomAudioAsset.AudioType.Bgm)
{
// TODO: 走背景音乐播放接口
SourceAudio.clip = Resources.Load<AudioClip>(AudioPath);
SourceAudio.Play();
}
else if (PlayedAudioType == CustomAudioAsset.AudioType.Sound)
{
// TODO: 走音效播放接口
SourceAudio.clip = Resources.Load<AudioClip>(AudioPath);
SourceAudio.Play();
}
mIsPlayingAudio = true;
}
}
}

接下来看下我们自定义的音效播放Track和音乐Asset的使用面板效果:

CustomAudioTrackPreview

CustomAudioAssetBGMInspector

CustomAudioAssetSoundInspector

从上面可以看到,我们通过ExposedReference的方式,挂载绑定了场景里的AudioSource作为背景音乐和音效播放组件。

接下来为了避免每一个CustomAudioAsset都自己去指定AudioSource绑定组件,我们可以通过在CustomAudioTrack来支持在Track统一指定AudioSource。

CustomAudioTrack.cs

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// CustomAudioTrack.cs
/// 自定义音效Track
/// </summary>
[TrackClipType(typeof(CustomAudioAsset))]
[TrackBindingType(typeof(AudioSource))]
public class CustomAudioTrack : PlayableTrack
{

}

CustomAudioBehaviour.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
/// <summary>
/// CustomAudioBehaviour.cs
/// 自定义音效播放Behaviour
/// </summary>
public class CustomAudioBehaviour : BaseGameBehaviour
{
******

/// <summary>
/// 音效播放组件
/// </summary>
private AudioSource mSourceAudio;

// Called each frame while the state is set to Play
// Playable更新执行时
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
if(mSourceAudio == null)
{
mSourceAudio = playerData as AudioSource;
}
TryPlayAudio();
}

******
}

CustomAudioTrack通过声明TrackBindingType(typeof(AudioSource))指定Track可以绑定AudioSource组件。

CustomAudioBehaviour通过在运行时ProcessFrame里动态使用Track上指定的AudioSource组件作为音乐播放组件来实现CustomAudioBehaviour采用Track统一绑定组件的的效果。

CustomAudioTrackWithAudioSourceBindingPreview

进阶

Unity使用Cinemachine和Timeline入门
Cinemachine and Timeline

Cinemachine

Timeline可以看做是自定义展示效果的工具,而Cinemachine可以看做是多摄像机的一套完整控制表现的工具。

更多的Cinemachine学习见后面

Cinemachine

Powering cameras for films and games

根据官网的简单介绍可以看出,Cinemachine是官方出的一款摄像机强大的控制系统,通过该系统,我们可以轻松的做到电影或者游戏级别的摄像机功能以及表现。

博主这里考虑学习使用Cinemachine的一个重大原因是,做赛车游戏,希望有一个稳定(不抖动)跟随的摄像机。(自己写的效果有抖动问题)

Cinemachine里有两个重要的组件和概念:

  1. Cinemachine Brain
  2. Virtual Cameras

Cinemachine Brain

The Cinemachine Brain is a component in the Unity Camera itself. Cinemachine Brain monitors all active Virtual Cameras in the Scene. To specify the next live Virtual Camera, you activate or deactivate the desired Virtual Camera’s game object.

从上面的介绍可以看出,Cinemachine Brain组件式挂载在摄像机对象上的一个组件,负责管理所有Virual Camera,同一时间只有一个Virtual Camera起作用用于主摄像机,优先选择Priority高的已激活Virutal Camera作为当前起作用的摄像机,优先级相同的情况下看哪个Virtual Camera后激活优先。

CinemachineBrain

关于Cinemachine Brain的详细参数介绍,这里就不深入探讨了,感兴趣的朋友参考文档去自行了解。

Note:

  1. Timeline重写了Cinemachine Brain的Priority系统可以更好的控制摄像机达到更精准的摄像机控制。

Virtual Cameras

Cinemachine does not create new cameras. Instead, it directs a single Unity camera for multiple shots. You compose these shots with Virtual Cameras. Virtual Cameras move and rotate the Unity camera and control its settings.

从上面的介绍可以知道,Cinemachine里的Virtual Cameras并非真正的Camera组件,而是一个抽象概念,而这个抽象的Virtuel Cameras会去负责控制真正的摄像机达到预定的效果。

每一个Virtual Cameras都会对应一个GameObject和一个CinemachineVirtualCamera组件脚本:

CinemachineVirtualCamera

Virtual Camera States

Virtual Camera有三种状态:

  1. Live:The virtual camera is actively controlling the Unity Camera. The virtual camera is tracking its targets and being updated every frame.
  2. Standby: The virtual camera is tracking its targets and being updated every frame, but no Unity Camera is actively being controlled by it. This is the state of a virtual camera that is enabled in the scene but perhaps at a lower priority than the Live virtual camera.
  3. Disabled: The virtual camera is present but disabled in the scene. It is not actively tracking its targets and so consumes no processing power. However, the virtual camera can be made live from the Timeline.

从介绍可以看到Live是指当前Virtual Camera优先级最高起作用的那个。而Standby是优先级低于当前起作用的Virtual Camera但没有Disable的Virtual Camera,这些摄像机逻辑上依然会更新跟随自己设定的对象,但不会影响真实摄像机运作。最后Disabled是即低于当前最高优先级Virtual Camera也没有激活的Virtual Camera,这类摄像机不会有任何运行开销,逻辑层面也不会在继续跟随自己的目标对象。

Virtual Camera Params

关于Virtual Camera的细节参数学习,接下来学习几个重要的参数,更多的细节参考:

Cinemachine overview

Priority正如前面提到的,决定了Virtual Camera在Cinemachine Brain里的选择优先级。

FollowLook At两个参数分别决定了摄像机的跟随对象和对焦对象,这两个参数是常规游戏类型里都需要的概念,好比博主当前要做的赛车游戏,Follow和Look At对象都是赛车就能做到跟随和聚焦赛车的效果。

Body属性提供了Virtual Camera对于摄像机移动的算法选择,从而达到适配各种游戏类型的摄像机镜头移动要求。

CinemachineBodyProperties

这里博主的赛车游戏默认选择Transposer来达到平滑跟随效果。

Aim属性提供了Virtual Camera对于佘香即旋转的算法选择,从而达到适配各种游戏类型的摄像机镜头选择要求。

CinemachineAimProperties

BodyAim通过细节的参数调整能实现各种角度以及各种游戏类型需求的摄像机跟随聚焦效果,这里博主暂时就不深入学习讨论了,有兴趣的可以参考文档去了解细节。

那么Virtual Camera的聚焦效果是如何实现的了?

了解聚焦效果之前有几个重钙的概念需要了解:

  1. Dead zone: The area of the frame that Cinemachine keeps the target in.

  2. Soft zone: If the target enters this region of the frame, the camera will re-orient to put it back in the dead zone. It will do this slowly or quickly, according to the time specified in the Damping settings.

  3. Screen: The screen position of the center of the dead zone. 0.5 is the center of the screen.

  4. Damping: Simulates the lag that a real camera operator introduces while operating a heavy physical camera. Damping specifies quickly or slowly the camera reacts when the target enters the soft zone while the camera tracks the target. Use small numbers to simulate a more responsive camera, rapidly moving or aiming the camera to keep the target in the dead zone. Larger numbers simulate heavier cameras, The larger the value, the more Cinemachine allows the target to enter the soft zone.

CinemachineZone

Dead Zone, Soft Zone, Screen参数在Body和Aim的属性面板上有体现,他们决定了摄像机是如何应对Look At对象走出指定区域时响应变化以及如何确保Look At对象在指定区域内的。

Cinemachine Camera Types

Unity Cinemachine自带了很多种摄像机用于不同的游戏种类:

CinemachineCameraTypes

不同的摄像机种类适用于不同的游戏类型。

Virtual Camera

Virtual Camera在前面的介绍已经提过了,这里就不再重复了,后续的摄像机类型大部分也是基于Virtual Camera的,只是在Virtual Camera的基础上实现了一套各自摄像机管理方式从而达到不同的摄像机类型效果。

FreeLook Camera

此类摄像机没有基于Virtual Camera而是单独挂载了一个Cinemachine Free Look的脚本实现的。

A Cinemachine Camera geared towards a 3rd person camera experience. The camera orbits around its subject with three separate camera rigs defining rings around the target. Each rig has its own radius, height offset, composer, and lens settings. Depending on the camera’s position along the spline connecting these three rigs, these settings are interpolated to give the final camera position and state.

从介绍可以看出,FreeLook Camera是用于第三人称围绕目标对象旋转控制的一套摄像机类型(比如第三人称RPG等游戏,有围绕主角相关的摄像机旋转控制等)。

State-Driven Camera

这里的State指的是Animator里的State,通过State-Drive Camera我们可以轻易的实现Animator State和Virtual Camera选择的绑定。这样就能轻易实现Walk状态用什么Virtual Camera,Idle状态使用什么Virtual Camera的效果。

更多的摄像机类型参考:

About Cinemachine

这里还值得一提的是摄像机限定移动范围和摄像机被障碍物主档导致目标对象不可见时Cinemachine提供的一种解决方案:

Collision Avoidance and Shot Evaluation

CinemachineCollider

An add-on module for Cinemachine Virtual Camera that post-processes the final position of the virtual camera. Based on the supplied settings, the Collider will attempt to preserve the line of sight with the LookAt target of the virtual camera by moving away from objects that will obstruct the view.

简单来说CinemachineCollider是通过相关设置,通过射线检测评估出当前摄像机最优的位置,从而避免被障碍物遮挡视线等问题。

Note:

  1. The collider uses Physics Raycasts to do these things, hence obstacles in the scene must have collider volumes in order to be visible to the CinemachineCollider.

CinemachineConfiner

An add-on module for Cinemachine Virtual Camera that post-processes the final position of the virtual camera. It will confine the virtual camera’s position to the volume specified in the Bounding Volume field.

从介绍可以看出CinemachineConfiner相比CinemachineCollider,这是一个单纯限制摄像机运动范围的组件,更加轻量,没有过多多射线检测评估等过程,用于简单的限制摄像机运动范围,此组件更佳。

Note:

  1. This is less resource-intensive than CinemachineCollider, but it does not perform shot evaluation.

实战

实战前,先让我们明确下个人的摄像机需求,需求明确了才能更加正确的使用Cinemachine达到效果。

摄像机需求:

  1. 支持相机单独控制操作
  2. 支持相机限定移动范围操作
  3. 支持相机锁定目标操作
  4. 支持相机锁定目标和自由操作自由切换操作
  5. 支持相机锁定目标操作回调上层逻辑(用于配合逻辑层做事情)
  6. 场景摄像机支持多个摄像机同时控制(比如:MainCamera和3D UI Camera)

待续……

TextMeshPro

What

TextMesh Pro is the ultimate text solution for Unity. It’s the perfect replacement for Unity’s UI Text and the legacy Text Mesh.

TextMeshPro原本是第三方个人编写的一款更优的Unity文本替代方案,后被Unity官方收购。

Why

使用TextMeshPro替代原生文本的原因有很多:

  1. TMP使用了SDF(Signed Distance Field)技术达到不失真的放大缩小渲染
  2. TMP自带实现了图文混排(通过富文本打Tag的形式)以及很多文本所需的功能(e.g. 字间距,自定义字体大小,段落格式,对齐方式等)
  3. TMP使用Shader去实现更好的文本表现效果(类似PS的一些效果)满足文本需求

缺点:

  1. 不支持动态字体(看网上说是TMP1.4支持动态字体(Generating Settings),但我在Unity2018.4.1.f1上导入的TMP没有找到动态字体设置的入口,望知道的朋友告知一声在哪里设置),需要生成对应的字体纹理库(对于文字多的比如中文来说有很大的内存压力)

SDF(Signed Distance Field)介绍:
一个空间区域的SDF函数定义为,从空间中任意给定的一点到该区域边界的距离,SDF函数值的符号取决于该点在区域的内部还是外部,外部为正,内部为负。

这里可以简单的理解成TMP通过函数式的定义方式(类似矢量图的定义方式)来解决像素图放大模糊的问题来渲染出可动态放大缩小不模糊的图像。深入理解参考下面的链接:函数式编程与 Signed Distance Field

How

TMP需要通过TMP提供的字体库制作工具制作所需的对应字体文件:
FontAssetCreatePanel
如果想要使用图文混排还需要自己制作SpriteAsset:
SpriteAssetCreatePanel
制作好后创建TextMeshPro组件后指定需要使用的Font Asset和Sprite Asset即可:
TextMeshProAssignFontAssetAndSpriteAsset

最终效果:
TextMeshProUsing
TextMeshPro同一个Font Asset需要制作不同的渲染效果需要通过制作不同的Material来实现:
CreateFontAssetMaterial

然后在针对新创建的Material Asset调整Shader和具体效果即可,创建完成后就可以在Font Asset的材质下拉列表里选择需要使用的效果:
FontAssetMaterialChosen

关于TextMeshPro的默认相关设置(e.g. 默认创建的Font Asset,默认Fallback到的Font Asset等)是通过TMP Settings文件里的设置来完成的:
TMPSettings

Note:

  1. TMP的Fallback的使用是在原始设定的Font Asset里找不到对应文字时的一种回退安全机制,官方的建议是真正使用的Font Asset采用高分率图集,Fallback的Font Asset采用低分辨率图集。

引用

Sprite Atlas

Sprite Atlas
Master and Variant Sprite Atlases
Variant Sprite Atlas
Sprite Atlas workflow
Preparing Sprite Atlases for distribution
Sprite Packer Modes
Unity2017新功能之Sprite Atlas详解
UGUI的图集处理方式-SpriteAtlas的前世今生

Timeline

Unity–Timeline自定义PlayableTrack
Unity2017中Timeline的简单使用方法
Unity使用Cinemachine和Timeline入门

graph-visualizer

Timeline

xtending Timeline: A Practical Guide

Creative Scripting for Timeline

Playable API:定制你的动画系统

Playables API

The PlayableGraph

ScriptPlayable and PlayableBehaviour

Playables Examples

TextMesh Pro

TextMesh Pro
函数式编程与 Signed Distance Field
Unity游戏开发——TextMeshPro的使用
在Unity 2018中充分使用TextMesh Pro
Making the most of TextMesh Pro in Unity 2018

Cinemachine

Cinemachine overview

Cinemachine Learn

数据库

数据存储对于游戏开发来说是必不可少的,在常规的游戏开发中,后端服务器会采用MySQL(关系型数据库),**MongoDB(非关系型数据库)**等作为数据库存储。

关于关系型和菲关系型数据库的学习理解参考:

网络通信和服务器学习

本章节是在没有后端但又有玩家数据存储需求的前提下诞生的,通过调研**SQLite(关系型数据库)**正是定位本地高效数据库而非CS结构的数据库,所以本章节通过深入学习SQLite,弄懂如何正确高效的把本地玩家数据存储到本地数据库。

SQLite

What

首先来看看SQLite官网的一个介绍:
SQLite is not directly comparable to client/server SQL database engines such as MySQL, Oracle, PostgreSQL, or SQL Server since SQLite is trying to solve a different problem.
Client/server SQL database engines strive to implement a shared repository of enterprise data. They emphasize scalability, concurrency, centralization, and control. SQLite strives to provide local data storage for individual applications and devices. SQLite emphasizes economy, efficiency, reliability, independence, and simplicity.
SQLite does not compete with client/server databases. SQLite competes with fopen().

从上面的介绍可以看出,SQLite(C语言编写的库)虽然也是关系型数据库,但他的定位和MySQL不一样,定位的是本地高效数据库而非CS结构的数据库。

Why

那么什么时候适用于SQLite?什么时候适用于MySQL(CS)了?
SQLite官网也给出了明确的介绍,详情参见官网SQLite
这里只列举下官网介绍的什么时候适用于MYSQL:

  1. Client/Server Applications(CS结构的程序–需要大量客户端服务器通信存储)
  2. High-volume Websites(高吞吐量的网站)
  3. Very large datasets(数据存储量大–数据大)
  4. High Concurrency(高并发–很多人同时写入)

How

这里本人明确了暂时是打算用于单机版游戏的本地数据库,所以倾向于用SQLite来做。
接下来会接触以下几个知识点:

  1. Unity(游戏开发引擎)
  2. SQLite4Unity3d(第三方给Unity封装好的SQLite库–支持Window,Android(ARM64貌似也支持了)和IOS)
  3. Navicat(可视化数据库工具)
  4. SQL(数据库查询语言)

目标:

  1. 编写一个数据库操作UI界面,支持数据库的增删改查操作(方便学习使用SQLite4Unity3d提供的接口访问SQLite数据库)
  2. 利用Navicat查看Window本地存储的数据库数据(学会Navicat的基本使用和数据库SQL相关操作)
  3. 打包编译Window和Android版本到真机上查看效果(确保多平台的兼容性)

SQLite4Unity3d实战

环境准备:
Unity版本:2018.4.8f1
SQLite4Unity3d: SQLite4Unity3d
Navivat: Navicat Premium
SQL:SQL学习1 or SQL学习2

Note:

  1. SQLite4Unity3d uses only the synchronous part of sqlite-net, so all the calls to the database are synchronous.(SQLite4Unity3d有这么一句官方提示,可以看出所有的数据库操作都是同步的。这里考虑到都是单机本地存储同时数据量也不会很大,所以同步异步不是很重要,所以并不影响个人使用。)

PC实战

接下来结合SQLite4Unity3d来实战学习数据库的增删改查等操作。
第1步
创建空的Unity工程,制作简单的交互UI
SQLiteUserInterface

第2步
拷贝SQLite相关库文件:
SQLitePlugin
还要拷贝SQLite.cs(SQLite4Unity3d作者封装的访问SQLite的相关接口)
通过查看SQLite.cs的源代码可以看出,作者封装的这一层通过CS层多抽象了一层数据库访问(增删改查)和表格数据结构,然后结合反射(反射查找对应类的数据库)和Linq(实现快速的数据库查询)来实现通用的数据访问。

第3步
定义数据库表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 玩家信息
/// </summary>
public class Player
{
[PrimaryKey]
public int UID { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }

public override string ToString()
{
return $"[Player: UId={UID}, FirstName={FirstName}, LastName={LastName}, Age={Age}]";
}
}

第4步
建立SQLite数据库连接(不存在的话会根据Flag决定是否自动创建):

1
2
3
DatabaseFolderPath = $"{Application.persistentDataPath}/";
// 建立数据库连接
mSQLiteConnect = new SQLiteConnection($"{DatabaseFolderPath}/{DatabaseName}", SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);

可以看到本来StreamingAssets目录下没有数据库文件的这一下就创建成功了。
CreateDatabase

第5步
数据库里创建玩家表:

1
mSQLiteConnect.CreateTable<Player>();

CreatePlayerTable
因为还没有数据所以看不到数据打印,通过Navicat打开数据库,我们可以看到已经成功创建了Player表:
PlayerTableView

第6步
玩家表插入玩家数据:

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
mSQLiteConnect.Insert(new Player
{
UID = 1,
FirstName = "Huan",
LastName = "Tang",
Age = 29,
});
mSQLiteConnect.Insert(new Player
{
UID = 3,
FirstName = "XiaoYun",
LastName = "Zhou",
Age = 28,
});
mSQLiteConnect.Insert(new Player
{
UID = 2,
FirstName = "Jiang",
LastName = "Fan",
Age = 28,
});
mSQLiteConnect.Insert(new Player
{
UID = 5,
FirstName = "ZhenLiang",
LastName = "Li",
Age = 29,
});
mSQLiteConnect.Insert(new Player
{
UID = 4,
FirstName = "XiaoLin",
LastName = "Kuang",
Age = 28,
});

InsertPlayerTableData

第7步
玩家表删除指定玩家ID数据:

1
mSQLiteConnect.Delete<Player>(deleteuid);

DeletePlayerTableData

第7步
玩家表修改指定玩家Age数据:

1
2
valideplayer.Age = newage;
mSQLiteConnect.Update(valideplayer);

ModifyPlayerTableAgeData

第8步
删除玩家表所有数据:

1
var rownumber = mSQLiteConnect.DeleteAll<Player>();

DeleteAllPlayerTableData
可以看到我们成功删除了玩家表里的所有数据,接下来用Navicat验证下数据库里的情况:
DatabaseAfterDeleteAllPlayerTableData

第9步
删除数据库里的玩家表:

1
var rownumberaffected = mSQLiteConnect.DropTable<Player>();

DeletePlayerTable
删除玩家表后通过Navicat查看数据库:
DatabaseViewAfterDeletePlayerTable
通过查看源码,我们可以看到,DraopTable底层实际是通过调用SQL的Drop语句来实现删除表的:

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// Executes a "drop table" on the database. This is non-recoverable.
/// </summary>
public int DropTable<T>()
{
var map = GetMapping (typeof (T));

var query = string.Format("drop table if exists \"{0}\"", map.TableName);

return Execute (query);
}

关于SQL更多学习参考:
SQL 教程
SQL Tutorial
可以看到通过使用SQLite4Unity3d的SQLite.cs相关的接口,成功的实现了对于数据库的创建,以及数据库表的创建,增删改查等操作。

第10步
关闭数据库连接:

1
mSQLiteConnect.Close();

Android实战

为了验证SQLite4Unity3d的跨平台能力,接下来打包验证真机:
Android真机数据库操作:
AndroidSQLiteUsing

Android真机数据库文件查看(本人使用的ES文件浏览器):
![DatabaseViewAfterOperation]/img/Database/DatabaseViewAfterOperation.png)

Android真机数据库详细信息PC查看:
AndroidDatabaseView

可以看到Android真机上数据库的一些基础操作都成功了的。IOS这里就暂时不验证了,根据Github上的介绍理论上是支持的。

Note:

  1. 真机可读取目录修改为Application.persistentDataPath,因为Application.streamingAssetsPath在真机上是只读目录。
  2. 真机使用ES文件浏览器来查看程序目录下的数据库文件

SQLite4Unity3d进阶

学习了SQLite4Unity3d的基础使用,接下来要考虑的是实战使用时,如何封装一层数据库管理,方便游戏里的所有数据库数据都成功的加载读取修改保存。

这里主要是对于SQLite.cs的简单封装,用于支持多数据库创建,以及数据库创建路径管理,以及自定义数据库表的封装访问。

上代码之前,先简单看下封装的代码设计:

  • DatabaseManager.cs(对多数据库创建访问以及路径规划进行封装支持)
  • BaseDatabase.cs(数据库基类抽象–实现对数据库操作进行封装)
  • BaseTableData.cs(数据库表数据基类抽象–实现对数据库表操作进行封装)
  • BaseIntTableData.cs(int做主键的表数据抽象–实现对int主键表的操作封装)
  • BaseStringTableData.cs(string做主键的表数据抽象–实现对string主键表的操作封装)

DatabaseManager.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
namespace TH.Modules.Data
{
/// <summary>
/// 数据库管理类
/// 主要功能如下:
/// 1. 数据库路径处理(数据库存储管理)
/// 2. 多数据库支持
/// 3. 数据库连接,关闭,CUID操作等操作依然使用SQLite的封装
/// </summary>
public class DatabaseManager : SingletonTemplate<DatabaseManager>
{
/// <summary>
/// 数据库文件夹地址
/// </summary>
private readonly static string DatabaseFolderPath = Application.persistentDataPath + "/Database/";

/// <summary>
/// 已连接的数据库映射Map
/// Key为数据库名,Value为对应的数据库连接
/// </summary>
private Dictionary<string, SQLiteConnection> ConnectedDatabaseMap;

public DatabaseManager()
{
//检查数据库文件目录是否存在
if(!Directory.Exists(DatabaseFolderPath))
{
Directory.CreateDirectory(DatabaseFolderPath);
}
ConnectedDatabaseMap = new Dictionary<string, SQLiteConnection>();
}

/// <summary>
/// 打开数据库连接
/// </summary>
/// <param name="databasename"></param>
/// <param name="openflags"></param>
/// <param name="storeDateTimeAsTicks"></param>
public void openDatabase(string databasename, SQLiteOpenFlags openflags = SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create, bool storeDateTimeAsTicks = false)
{
if(!isDatabaseConnected(databasename))
{
var databasepath = DatabaseFolderPath + databasename;
var connection = new SQLiteConnection(databasepath, openflags, storeDateTimeAsTicks);
ConnectedDatabaseMap.Add(databasename, connection);
Debug.Log($"连接数据库:{databasename}");
}
else
{
Debug.Log($"数据库:{databasename}已连接,请勿重复连接!");
}
}

/// <summary>
/// 获取指定数据库连接
/// </summary>
/// <param name="databasename"></param>
/// <returns></returns>
public SQLiteConnection getDatabaseConnection(string databasename)
{
SQLiteConnection connection;
if (ConnectedDatabaseMap.TryGetValue(databasename, out connection))
{
return connection;
}
else
{
return null;
}
}

/// <summary>
/// 关闭数据库连接
/// </summary>
/// <param name="databasename"></param>
public void closeDatabase(string databasename)
{
SQLiteConnection connection;
if(ConnectedDatabaseMap.TryGetValue(databasename, out connection))
{
connection.Close();
ConnectedDatabaseMap.Remove(databasename);
Debug.Log($"关闭数据库:{databasename}");
}
else
{
Debug.LogError($"未连接数据库:{databasename},关闭失败!");
}
}

/// <summary>
/// 关闭所有已连接的数据库
/// </summary>
public void closeAllDatabase()
{
foreach (var databasename in ConnectedDatabaseMap.Keys)
{
closeDatabase(databasename);
}
}

/// <summary>
/// 清除
/// </summary>
public void clear()
{
closeAllDatabase();
}

/// <summary>
/// 指定数据库是否已连接
/// </summary>
/// <param name="databasename"></param>
/// <returns></returns>
private bool isDatabaseConnected(string databasename)
{
return ConnectedDatabaseMap.ContainsKey(databasename);
}

#region 辅助方法
/// <summary>
/// 获取指定数据库表的所有数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="databasename">数据库名</param>
/// <returns></returns>
public string getTableAllDatasInOneString<T>(string databasename) where T : new()
{
SQLiteConnection sqliteconnection = getDatabaseConnection(databasename);
if (sqliteconnection != null)
{
var querytable = sqliteconnection.Table<T>();
if (querytable != null)
{
var result = string.Empty;
foreach (var data in querytable)
{
result += data.ToString();
result += "\n";
}
return result;
}
else
{
return string.Empty;
}
}
else
{
return string.Empty;
}
}
#endregion
}
}

TODO:

  1. 现阶段是默认存在包外的persistentDataPath,对于数据库如何做好加密管理避免被轻易修改,这个问题还有待学习思考。
  2. 数据库表是否存在还没找到方式判定(暂时是从流程上确保表存在的)

BaseDatabase.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
namespace TH.Modules.Data
{
/// <summary>
/// BaseDatabase.cs
/// 数据库基类抽象
/// Note:
/// 子类自行实现单例模式方便访问
/// </summary>
public abstract class BaseDatabase
{
/// <summary>
/// 数据库连接
/// </summary>
protected SQLiteConnection mSQLiteConnect;

/// <summary>
/// 数据库名
/// </summary>
public virtual string DatabaseName
{
get
{
return $"{GetType().Name}.db";
}
}

protected BaseDatabase()
{

}

/// <summary>
/// 加载数据库
/// </summary>
public void LoadDatabase()
{
mSQLiteConnect = DatabaseManager.Singleton.openDatabase(DatabaseName);
}

/// <summary>
/// 数据库是否已连接
/// </summary>
/// <returns></returns>
public bool IsConnected()
{
return mSQLiteConnect != null;
}

///// <summary>
///// 指定表是否存在
///// </summary>
///// <typeparam name="T"></typeparam>
///// <returns></returns>
//public bool IsTableExist<T>() where T : BaseTableData, new()
//{
// if (!IsConnected())
// {
// Debug.LogError($"数据库:{DatabaseName}未连接,访问类名:{typeof(T).Name}表是否存在失败!");
// return false;
// }
// // TODO: 找到方法判定是否存在
// return false;
//}

/// <summary>
/// 创建指定数据库表
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool CreateTable<T>() where T : BaseTableData, new()
{
if(!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,创建类名:{typeof(T).Name}表失败!");
return false;
}
//if(!IsTableExist<T>())
//{
// Debug.LogError($"数据库:{DatabaseName},类名:{typeof(T).Name}表已存在,请勿重复创建!");
// return false;
//}
mSQLiteConnect.CreateTable<T>();
return true;
}

/// <summary>
/// 插入指定int主键的表数据(如果表不存在则创建表)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool InsertDataI<T>(T data) where T : BaseIntTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
mSQLiteConnect.Insert(data);
return true;
}

/// <summary>
/// 插入指定int主键的表数据(如果表不存在则创建表,如果主键存在则覆盖更新)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool InsertOrReplaceDataI<T>(T data) where T : BaseIntTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
mSQLiteConnect.InsertOrReplace(data);
return true;
}

/// <summary>
/// 插入指定int主键的表数据(如果表不存在则创建表)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool InsertDataS<T>(T data) where T : BaseStringTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
mSQLiteConnect.Insert(data);
return true;
}

/// <summary>
/// 插入指定string主键的表数据(如果表不存在则创建表,如果主键存在则覆盖更新)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool InsertOrReplaceDataS<T>(T data) where T : BaseStringTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
mSQLiteConnect.InsertOrReplace(data);
return true;
}

/// <summary>
/// 删除指定UID的表数据(整形主键)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool DeleteDataByUIDI<T>(int uid) where T : BaseIntTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,删除类名:{typeof(T).Name}表UID:{uid}数据失败!");
return false;
}
var deletedNumber = mSQLiteConnect.Delete<T>(uid);
Debug.Log($"数据库:{DatabaseName},删除类名:{typeof(T).Name}表UID:{uid}数量:{deletedNumber}");
return deletedNumber > 0;
}

/// <summary>
/// 删除指定UID的表数据(字符串主键)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool DeleteDataByUIDS<T>(string uid) where T : BaseStringTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,删除类名:{typeof(T).Name}表数据失败!");
return false;
}
var deletedNumber = mSQLiteConnect.Delete<T>(uid);
Debug.Log($"数据库:{DatabaseName},删除类名:{typeof(T).Name}表UID:{uid}数量:{deletedNumber}");
return deletedNumber > 0;
}

/// <summary>
/// 更新指定int主键的表数据(如果表不存在则创建表)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool UpdateDataI<T>(T data) where T : BaseIntTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
var updatedNumber = mSQLiteConnect.Update(data);
Debug.Log($"数据库:{DatabaseName},更新类名:{typeof(T).Name}表UID:{data.UID}数量:{updatedNumber}");
return updatedNumber > 0;
}

/// <summary>
/// 更新指定string主键的表数据(如果表不存在则创建表)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public bool UpdateDataS<T>(T data) where T : BaseStringTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,插入类名:{typeof(T).Name}表数据失败!");
return false;
}
//if (!IsTableExist<T>())
//{
// CreateTable<T>();
//}
var updatedNumber = mSQLiteConnect.Update(data);
Debug.Log($"数据库:{DatabaseName},更新类名:{typeof(T).Name}表UID:{data.UID}数量:{updatedNumber}");
return updatedNumber > 0;
}

/// <summary>
/// 获取指定UID的表数据(整形主键)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public T GetDataByUIDI<T>(int uid) where T : BaseIntTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,获取类名:{typeof(T).Name}表数据失败!");
return null;
}
var data = mSQLiteConnect.Find<T>((databaseTable) => databaseTable.UID == uid);
if (data == null)
{
Debug.LogError($"数据库:{DatabaseName},获取类名:{typeof(T).Name}的UID:{uid}表数据不存在!");
return null;
}
return data;
}

/// <summary>
/// 获取指定UID的表数据(字符串主键)
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public T GetDataByUIDS<T>(string uid) where T : BaseStringTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,获取类名:{typeof(T).Name}表数据失败!");
return null;
}
var data = mSQLiteConnect.Find<T>((databaseTable) => databaseTable.UID == uid);
if (data == null)
{
Debug.LogError($"数据库:{DatabaseName},获取类名:{typeof(T).Name}的UID:{uid}表数据不存在!");
return null;
}
return data;
}


/// <summary>
/// 获取指定表所有数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public TableQuery<T> GetAllData<T>() where T : BaseTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,获取类名:{typeof(T).Name}表数据失败!");
return null;
}
var tbData = mSQLiteConnect.Table<T>();
if (tbData == null)
{
Debug.LogError($"数据库:{DatabaseName},获取类名:{typeof(T).Name}表所有数据不存在!");
return null;
}
return tbData;
}

/// <summary>
/// 删除指定表所有数据
/// </summary>
/// <returns></returns>
public int DeleteTableAll<T>() where T : BaseTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,删除类名:{typeof(T).Name}表所有数据失败!");
return 0;
}
//if (!IsTableExist<T>())
//{
// Debug.LogError($"数据库:{DatabaseName},删除类名:{typeof(T).Name}表不存在,删除所有数据失败!");
// return 0;
//}
var deletedNumber = mSQLiteConnect.DeleteAll<T>();
Debug.Log($"数据库:{DatabaseName},删除类名:{typeof(T).Name}表所有数据数量:{deletedNumber}");
return deletedNumber;
}

/// <summary>
/// 删除指定表
/// </summary>
/// <returns></returns>
public int DeleteTable<T>() where T : BaseTableData, new()
{
if (!IsConnected())
{
Debug.LogError($"数据库:{DatabaseName}未连接,删除类名:{typeof(T).Name}表失败!");
return 0;
}
var deletedNumber = mSQLiteConnect.DropTable<T>();
Debug.Log($"数据库:{DatabaseName},删除类名:{typeof(T).Name}表数量:{deletedNumber}");
return deletedNumber;
}

/// <summary>
/// 关闭数据库
/// </summary>
public void CloseDatabase()
{
DatabaseManager.Singleton.closeDatabase(DatabaseName);
mSQLiteConnect = null;
}
}
}

BaseTableData.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
namespace TH.Modules.Data
{
/// <summary>
/// BaseTableData.cs
/// 数据库表数据基类抽象
/// </summary>
public abstract class BaseTableData
{
/// <summary>
/// 表名
/// </summary>
public string TableName
{
get
{
return GetType().Name;
}
}

public BaseTableData()
{

}

/// <summary>
/// 打印数据
/// </summary>
public override string ToString()
{
return $"[TableName:{TableName}]";
}
}
}

BaseIntTableData.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
namespace TH.Modules.Data
{
/// <summary>
/// BaseIntTableData.cs
/// Int做主键的表数据抽象
/// </summary>
public abstract class BaseIntTableData : BaseTableData
{
/// <summary>
/// 主键UID
/// </summary>
[PrimaryKey]
public int UID
{
get;
set;
}

public BaseIntTableData() : base()
{

}

/// <summary>
/// 构造函数
/// </summary>
/// <param name="uid"></param>
public BaseIntTableData(int uid) : base()
{
UID = uid;
}

/// <summary>
/// 打印所有表数据
/// </summary>
public override string ToString()
{
return $"[TableName:{TableName} UID:{UID}]";
}
}
}

BaseStringTableData.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
namespace TH.Modules.Data
{
/// <summary>
/// BaseStringTableData.cs
/// string做主键的表数据抽象
/// </summary>
public abstract class BaseStringTableData : BaseTableData
{
[PrimaryKey]
public string UID
{
get;
set;
}

public BaseStringTableData() : base()
{

}

/// <summary>
/// 构造函数
/// </summary>
/// <param name="uid"></param>
public BaseStringTableData(string uid) : base()
{
UID = uid;
}

/// <summary>
/// 打印数据
/// </summary>
public override string ToString()
{
return $"[TableName:{TableName} UID:{UID}]";
}
}
}

GameDatabase.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
/// <summary>
/// GameDatabase.cs
/// 游戏数据库
/// </summary>
public class GameDatabase : BaseDatabase
{
/// <summary>
/// 单例对象
/// </summary>
public static GameDatabase Singleton
{
get
{
if(mSingleton != null)
{
return mSingleton;
}
mSingleton = new GameDatabase();
return mSingleton;
}
}
private static GameDatabase mSingleton;

public GameDatabase() : base()
{

}
}

Player.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
/// <summary>
/// 玩家信息表成员结构
/// </summary>
public class Player : BaseIntTableData
{
/// <summary>
///
/// </summary>
public string FirstName
{
get;
set;
}

/// <summary>
///
/// </summary>
public string LastName
{
get;
set;
}

/// <summary>
/// 年龄
/// </summary>
[Indexed]
public int Age
{
get;
set;
}

public Player() : base()
{

}

/// <summary>
/// 构造函数
/// </summary>
/// <param name="uid"></param>
/// <param name="firstName"></param>
/// <param name="lastName"></param>
/// <param name="age"></param>
public Player(int uid, string firstName, string lastName, int age) : base(uid)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}

/// <summary>
/// 打印数据
/// </summary>
/// <returns></returns>
public override string ToString()
{
return $"[Player: UID={UID}, FirstName={FirstName}, LastName={LastName}, Age={Age}]";
}
}

进阶版的实战代码见Github源码:

SQLiteStudy

结论

  1. SQLite适用于非CS结构,存储数据量不大,不会多人并发操作数据库的情况(好比单机游戏的数据库)。
  2. 数据量大或者多人操作频繁或者需要CS架构支持,更适合MySQL+Redis
  3. SQLite4Unity3d通过反射和Linq对SQLite的封装实现了对于SQL无感和ORM(Object Relational Mapping)数据库访问方式。
  4. SQLite4Unity3d支持多平台,适合单机游戏数据存储

Reference

SQLite4Unity3d

Github地址

SQLiteStudy

前言

本章节是为了记录学习游戏开发过程中的服务器搭建以及前后端网络通信相关的知识点,游戏开发中联网是必不可少的,无论是从游戏热更新(服务器通知版本以及热更相关信息),还是游戏社交(微信分享,联机组队),还是防破解(服务器校验)都离不开网络通信和服务器。

本人是前端程序,对后端服务器开发几乎一窍不通,本篇文章也算是为了开阔自己的眼界,同时也是为了未来自己的游戏有网络这一块的支持需要对服务器搭建以及网络通信要有所了解。本文着重点不在于高效和优美的框架设计,着眼于基础的网络通信和服务器搭建,而这正是此篇博客的初衷。

  • 目标
  1. 理解游戏服务器工作原理
  2. 理解不同服务器框架的优劣,选择合适的服务器框架
  3. 学会搭建基础的游戏服务器,具备基础的数据存储功能(本文重点)
  4. 掌握网络通信知识,搭建前后端通信框架,支持前后端通信(本文重点)

服务器

那么什么是服务器了?

个人简单的理解,可以把服务器理解成运行在远程电脑上,通过网络对客户端提供服务(数据处理,数据存储读取,消息转发等)的机器或者软件。

了解服务器架构:

游戏服务器架构的演进简史

从上面可以看出服务器有以下几个基础功能:

  1. 对于游戏数据和玩家数据的存储(数据存储)
  2. 对玩家数据进行数据广播和同步(消息通信)
  3. 把一部分游戏逻辑在服务器上运算,做好验证,防止外挂(服务器验证)
  4. 服务器架构设计(容灾,性能,扩容等)

第三点涉及到前后端框架设计(帧同步 or 状态同步等),第四点更与服务器端密切相关,本文着眼点是前两点,第三点和第四点作为未来学习的一个知识点,。

数据存储

关系型数据库

关系数据库(英语:Relational database),是创建在关系模型基础上的数据库,借助于集合代数等数学概念和方法来处理数据库中的数据。现实世界中的各种实体以及实体之间的各种联系均用关系模型来表示。

个人简单的理解,关系数据库大概就是以前学SQL的时候,数据库里通过表来抽象实体数据,表之间通过ID映射关联起来的概念(e.g. 比如数据库里有两张表,一张员工表,一张员工数据表。员工表用于抽象员工ID,员工名字,员工薪资等信息。而员工数据用于存储员工ID,员工入职日期等信息。然后两张表示通过员工ID来映射关联起来。)。

典型的关系型数据库:
MySQL

典型的关系型数据查询语言:
SQL

这里还值得一提的一个本地数据库:
SQLite

详细的MYSQL和SQLite对比参考:
SQLite和MySQL数据库的区别与应用

SQLite

SQLite学习详情参考

非关系型数据库

NoSQL是对不同于传统的关系数据库的数据库管理系统的统称。两者存在许多显著的不同点,其中最重要的是NoSQL不使用SQL作为查询语言。其数据存储可以不需要固定的表格模式,也经常会避免使用SQL的JOIN操作,一般有水平可扩展性的特征。

看了上面Wiki的介绍,本人其实还是一知半解,等待后续深入学习理解非关系型数据库。

有了关系型数据库为什么还需要非关系型数据库了?
当代典型的关系数据库在一些数据敏感的应用中表现了糟糕的性能,例如为巨量文档创建索引、高流量网站的网页服务,以及发送流式媒体。关系型数据库的典型实现主要被调整用于执行规模小而读写频繁,或者大批量极少写访问的事务。NoSQL的结构通常提供弱一致性的保证,如最终一致性,或交易仅限于单个的数据项。不过,有些系统,提供完整的ACID保证在某些情况​​下,增加了补充中间件层(例如:CloudTPS)

典型的菲关系型数据库:
MongoDB,Redis

数据库选择

关系数据库还是NoSQL数据库
数据库选择历程
游戏服务器存储系统设计
本人对数据库的经验还停留在学校学习SQL的时候,也可以理解成使用过一点关系型数据库。
这里是想深入理解关系和非关系之间的区别,同时为选择游戏开发时使用的数据库类型做知识储备。以下理解的不对的地方,忘指出。

结合上面两篇文章的学习理解,个人理解关系型数据库(e.g. MySQL)适用于读写不频繁(面向表级别的锁定),需要强大的查询(SQL)功能。
而非关系型数据库(e.g. Redis)适用于频繁读写(性能要求高 – Redis是Ke-Vlaue结构,且面向内存的存储模型(使用RDB和AOF做持久化)),需要高扩展性。

结合网上的学习理解,使用Redis(做内存Cache)+MySQL(做稳定的数据库存储查询)利用各自优势使用在不同模块来实现高性能的服务器存储设计是比较高效合理的。

这里本人出于学习目的,暂定以关系型数据库(MySQL)作为服务器数据库存储介质,暂时不考虑Redis。

出于单机游戏数据库存储学习,暂时以关系型数据库(SQLite)作为本地数据库存储介质。

后期Redis进阶学习:
Redis 教程

消息通信

消息通信是本篇文章的重点,首先让我们理一理实现消息通信前我们需要做些什么?

  1. 联网方式(选择通信协议,比如: TCP || UPD)
  2. 数据格式定义(会影响数据编码和解析,比如: Protobuf)
  3. 数据收发(涉及到网络通信监听,比如: Socket编程)
  4. 数据解析(涉及到二进制数据写入和读取,一般来说都不会明文传输)

一般来说消息通信的流程如下:

  1. 服务器启动了网络监听(比如通过Socket监听固定端口等待客户端连接)
  2. 客户端通过Socket连接服务器端口
  3. 指定前后端消息协议(Protobuf),基于协议封装消息数据
  4. 客户端通过封装二进制消息数据通过Socket传递给服务器
  5. 服务器接受到二进制数据通过对应方式解析消息后返回客户端消息
  6. 客户端做出对应表现

联网方式

了解联网方式之前,我们需要知道一些相关的网络知识。

以前学计算机网络课程时,印象最深的就是网络的OSI七层分类:

OSI模型

这里我们主要关注的是以下两层:

  1. 会话层

    负责在数据传输中设置和维护计算机网络中两台计算机之间的通信连接。这一层是负责网络连接的,比如我们通过Socket启动端口监听

  2. 传输层

    把传输表头(TH)加至数据以形成数据包。传输表头包含了所使用的协议等发送信息。例如:传输控制协议(TCP)等。这一层负责添加网络协议相关信息,比如根据不同网络协议(TCP or UDP)用不同的方式处理丢包问题

更详细的网络相关知识介绍参考:

TCP和UDP的区别

这里我们只要知道TCP(Transmission Control Protocol,传输控制协议)协议是面向连接的可靠传输协议,有三次握手过程。而UDP(User Data Protocol,用户数据报协议)协议是无连接不可靠协议

传输协议选择?
TCP
理由:

  1. 游戏对网络数据准确性要求较高

数据格式定义

这里的数据格式指的是网络消息数据格式定义。

在完成了客户端和服务器基础网络数据传输能力(TPC)后,我们必须定义一个统一的消息定义(即数据格式)和消息处理的方式,这样客户端和服务器端才能正常的解析网络数据。

数据格式需求:

  1. 知道数据大小
  2. 知道数据类型
  3. 知道如何序列化和反序列化数据

基础的数据格式定义如下:

  1. 数据长度(short - 2字节) – 用于存储消息数据长度
  2. 消息ID(short - 2字节) – 用于区分消息类型
  3. 二进制数据(Protobuf) – 用于序列化和反序列化数据

数据收发

有了TCP网络数据传输的能力后,我们还需要网络通信的能力。
网路通信方式选择?
Socket

数据解析

数据的解析就是前面提到的数据序列化和反序列化的能力。

数据解析协议选择?
Protobuf
理由:

  1. 强大的生态圈,多语言支持,数据前后兼容性,强大序列化反序列化能力等

服务器验证

为什么需要验证?

单机游戏不联网很容易有外挂的一个原因就是本地数据(本地存储或者只存在内存中)很容易被串改且无法验证正确性,比如通过修改内存数据(e.g. 数值挂),修改游戏运行速度(e.g. 加速挂)

解决这一问题的关键正式服务器验证,把所有的数据都存储在服务器上,所有的数据修改都必须通过服务器校验才能通过,这样一来客户端伪造数据就能被识别出来从而防止部分外挂。

服务器验证的应用场景?

服务器验证有一点很重要的应用就是服务器运行战斗逻辑,确保所有客户端结果一致性。

这一点涉及到前后端同步框架相关的知识,详情参考:

两种同步模式:状态同步和帧同步

这里直接借用博客里的一些定义来加深基础理解:

什么是同步?

所谓同步,就是要多个客户端表现效果是一致,数据是一致。

什么是状态同步?

状态同步下,客户端更像是一个服务端数据的表现层。以服务器通知为准。

什么是帧同步?

帧同步下,通信就比较简单了,服务端只转发操作,不做任何逻辑处理。

FrameSync

状态同步和帧同步的区别?

最大的区别就是战斗核心逻辑写在哪,状态同步的战斗逻辑在服务端,帧同步的战斗逻辑在客户端。

这里只是简单的引用上面博主的部分说明来加深理解,更深入学习请参考前面给出的博客链接。

服务器从零开发

前面是对于服务器相关理论知识的一些基础知识的学习实战。接下来为了真正做到开发一个可用的服务器,接下来打算从零开发搭建一个可用的服务器用于个人独立游戏(最重要的目的是从中学习到服务器相关的知识以及用到自己的游戏中来)。

服务器语言选择

作为一个服务器知识几乎为零的前端小白(用到过的语言为C#,C++,Lua,Python,Java–其中C#和Lua是现在用的最多的),打算入门服务器开发(主要用于个人游戏开发),所以在语言选择上主要是希望易上手,性能也还行。在高并发方面要求不高。

对于相对熟悉C#和Lua的博主来说能使用**C#**开发服务器的是比较合适的。

未来的语言学习要针对开发的游戏类型对服务器的性能要求等来分析再做具体选型。

结合网上的资料,了解到:

11种服务器编程语言对比(附游戏服务器框架) 2020.06

服务器开源框架

选择一个好的开源框架作为服务器开发入门来说是必不可少的,这里了解基于C#的服务器框架比较少,有一个ET的服务器框架比较出名,但ET看起来过于庞大,入门起来可能比较难,所以这里暂时没有考虑。

未来的学习方向可能不限语言,不一定是ET也可能是别的语言的服务器框架。

现阶段打算先从0开始打通服务器和客户端的流程,所以打断用C#自己写一套简易版的服务器来熟悉整个服务器开发。

网络通信

网络通信是指通过网络进行数据传输处理的能力。为了实现网络通信,我们需要借助于TCP( or UDP or **)协议进行数据传输,借助Socket进行端口监听,从而实现网络通信的能力。

联网方式选择?
TCP协议 + Socket
理由:

  1. 游戏开发对网络稳定性要求较高,所以选择以TCP作为网络传输协议。

数据格式定义选择?
Protobuf
理由:

  1. 强大的生态圈,多语言支持,数据前后兼容性,强大序列化反序列化能力等

前后端通信模式选择?
帧同步
理由:

  1. 现阶段只是为了实现前后端的通信能力,只是希望后端提供消息处理和数据存储的功能,暂时不考虑反外挂,服务器验证等问题,所以选择更适合以后端转发通知的CS模式的帧同步。

联网通信实现

二进制抽象类

联网通信免不了对二进制数据的写入,所以在实现TPC和Socket网络连接传输消息之前,我们需要实现一个对二进制数据进行读取和写入工具类实现。

以下代码参考至:
Unity3D —— Socket通信(C#)

ByteBufferWriter.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
/*
* Description: ByteBufferWriter.cs
* Author: TONYTANG
* Create Date: 2019/07/14
*/

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

namespace Net
{
/// <summary>
/// ByteBufferWriter.cs
/// 二进制写入抽象类
/// </summary>
public class ByteBufferWriter
{
// Note:
// 注意大小端问题
// BinaryWriter和BinaryReader都是小端读写

/// <summary>
/// 内存写入类
/// </summary>
private MemoryStream mMemoryStream;

/// <summary>
/// 二进制流写入类
/// </summary>
private BinaryWriter mBinaryWriter;

public ByteBufferWriter()
{
mMemoryStream = new MemoryStream();
mBinaryWriter = new BinaryWriter(mMemoryStream);
}

public ByteBufferWriter(byte[] data)
{
if(data != null)
{
mMemoryStream = new MemoryStream(data);
mBinaryWriter = new BinaryWriter(mMemoryStream);
}
else
{
mMemoryStream = new MemoryStream();
mBinaryWriter = new BinaryWriter(mMemoryStream);
}
}

/// <summary>
/// 关闭二进制读取写入
/// </summary>
public void Close()
{
mBinaryWriter.Close();
mMemoryStream.Close();
mBinaryWriter = null;
mMemoryStream = null;
}

/// <summary>
/// 写入一个字节数据
/// </summary>
/// <param name="v"></param>
public void WriteByte(byte v)
{
mBinaryWriter.Write(v);
}

******

/// <summary>
/// 获取字节数据
/// </summary>
/// <returns></returns>
public byte[] ToBytes()
{
mBinaryWriter.Flush();
return mMemoryStream.ToArray();
}

/// <summary>
/// 写入数据
/// </summary>
public void Flush()
{
mBinaryWriter.Flush();
}
}
}

ByteBufferReader.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: ByteBufferReader.cs
* Author: TONYTANG
* Create Date: 2019/07/14
*/

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

namespace Net
{
/// <summary>
/// ByteBufferReader.cs
/// 二进制读取抽象类
/// </summary>
public class ByteBufferReader
{
// Note:
// 注意大小端问题
// BinaryWriter和BinaryReader都是小端读写

/// <summary>
/// 内存写入类
/// </summary>
private MemoryStream mMemoryStream;

/// <summary>
/// 二进制流读取类
/// </summary>
private BinaryReader mBinaryReader;

public ByteBufferReader()
{
mMemoryStream = new MemoryStream();
mBinaryReader = new BinaryReader(mMemoryStream);
}

public ByteBufferReader(byte[] data)
{
if(data != null)
{
mMemoryStream = new MemoryStream(data);
mBinaryReader = new BinaryReader(mMemoryStream);
}
else
{
mMemoryStream = new MemoryStream();
mBinaryReader = new BinaryReader(mMemoryStream);
}
}

/// <summary>
/// 关闭二进制读取写入
/// </summary>
public void Close()
{
mBinaryReader.Close();
mMemoryStream.Close();
mBinaryReader = null;
mMemoryStream = null;
}

/// <summary>
/// 读取一个字节数据
/// </summary>
public byte ReadByte()
{
return mBinaryReader.ReadByte();
}

******
}
}

测试用例:
NetMessage.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
using System;
using System.Collections.Generic;

namespace Net
{
/// <summary>
/// 网络消息抽象类
/// </summary>
public class NetMessage
{
/// <summary>
/// 消息ID
/// </summary>
public ushort MessageID
{
get;
private set;
}

/// <summary>
/// 消息二进制数据
/// </summary>
public byte[] MessageData
{
get;
private set;
}

/// <summary>
/// 消息长度
/// </summary>
public ushort MessageLength
{
get;
private set;
}

private NetMessage()
{

}

public NetMessage(ushort messageid, byte[] messagedata)
{
MessageID = messageid;
MessageData = messagedata;
MessageLength = (ushort)messagedata.Length;
}
}
}

NetMessage msg = new NetMessage(1, Encoding.UTF8.GetBytes("我是消息内容"));
ByteBufferWriter bbw = new ByteBufferWriter();
bbw.WriteShort(msg.MessageLength);
bbw.WriteShort(msg.MessageID);
bbw.WriteBytes(msg.MessageData);
var bytesdata = bbw.ToBytes();
bbw.Close();
ByteBufferReader bbb = new ByteBufferReader(bytesdata);
var msglength = bbb.ReadShort();
var msgid = bbb.ReadShort();
var msgcontent = bbb.ReadBytes(msglength);
Debug.Log("msglength = " + msglength);
Debug.Log("msgid = " + msgid);
Debug.Log("MessageContent = " + Encoding.UTF8.GetString(msgcontent));

输出:
ByteBufferOutput

注意问题:

  1. 大小端问题(前后端写入和读取字节的大小端方式必须一致)

大小端知识:
理解字节序
浅谈字节序(Byte Order)及其相关操作

网络通信

TCP + Socket
流程:

  1. 启动服务器端套接字监听等待客户端连接
  2. 服务器端启动线程监听客户端消息
  3. 启动客户端套接字连接服务器套接字端口
  4. 客户端启动消息发送线程等待向服务器发送客户端消息
  5. 服务器端接受并返回客户端消息
  6. 客户端处理服务器端消息

NetConnector.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
/// <summary>
/// 网络连接抽象类
/// </summary>
public class NetConnector
{
/// <summary>
/// IP地址
/// </summary>
public string IpAddress
{
get;
private set;
}

/// <summary>
/// 端口号
/// </summary>
public int Port
{
get;
private set;
}

/// <summary>
/// 网络套接字
/// </summary>
public Socket NetSocket
{
get;
private set;
}

/// <summary>
/// 是否连接成功
/// </summary>
public bool IsConnected
{
get;
private set;
}

/// <summary>
/// IP地址
/// </summary>
public IPAddress IP
{
get;
private set;
}

/// <summary>
/// IP以及端口地址
/// </summary>
public IPEndPoint IPEndPoint
{
get;
private set;
}

public NetConnector()
{
NetSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IsConnected = false;
}

public NetConnector(ProtocolType protocoltype = ProtocolType.Tcp)
{
NetSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, protocoltype);
IsConnected = false;
}

/// <summary>
/// 监听指定ip和端口地址
/// </summary>
/// <param name="ipaddress"></param>
/// <param name="port"></param>
public void ListenTo(string ipaddress, int port)
{
IpAddress = ipaddress;
Port = port;
IP = IPAddress.Parse(IpAddress);
IPEndPoint = new IPEndPoint(IP, Port);
NetSocket.Bind(IPEndPoint);
NetSocket.Listen((int)SocketOptionName.MaxConnections);
Console.WriteLine("启动监听{0}成功", NetSocket.LocalEndPoint.ToString());
}

/// <summary>
/// 连接指定地址
/// </summary>
/// <param name="ipaddress"></param>
/// <param name="port"></param>
public bool ConnectTo(string ipaddress, int port)
{
IpAddress = ipaddress;
Port = port;
IP = IPAddress.Parse(IpAddress);
IPEndPoint = new IPEndPoint(IP, Port);

try
{
// 连接指定地址
NetSocket.Connect(IPEndPoint);
IsConnected = true;
}
catch
{
IsConnected = false;
}
return IsConnected;
}

/// <summary>
/// 断开连接
/// </summary>
public void Close()
{
IsConnected = false;
NetSocket.Shutdown(SocketShutdown.Both);
NetSocket.Close();
}
}

NetServer.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
/// <summary>
/// 网络服务器抽象类
/// </summary>
public class NetServer : SingletonTemplate<NetServer>
{
/// <summary>
/// 服务器是否启动
/// </summary>
public bool IsServerStart
{
get;
private set;
}

/// <summary>
/// 网络连接器
/// </summary>
private static NetConnector mNetConnector;

/// <summary>
/// 限定长度的客户端消息接受二进制数组
/// </summary>
private static byte[] ClientMsg = new byte[1024 * 1024];

public NetServer()
{
mNetConnector = new NetConnector();
IsServerStart = false;
}

public NetServer(ProtocolType protocoltype = ProtocolType.Tcp)
{
mNetConnector = new NetConnector(protocoltype);
IsServerStart = false;
}

/// <summary>
/// 启动服务器端口监听
/// </summary>
public void StartServer()
{
if(IsServerStart == false)
{
// 启动服务器端口监听以及等待客户端连接线程
IsServerStart = true;
mNetConnector.ListenTo("192.168.1.3", 8888);
Thread clientconnectthread = new Thread(ClientConnectListener);
clientconnectthread.Start();
}
else
{
Console.WriteLine("服务器端已启动!");
}
}

/// <summary>
/// 发送消息到客户端
/// </summary>
/// <param name="msg"></param>
public void SendMessage(NetMessage msg)
{
//clientsocket.Send(WriteMessage(msg));
}

/// <summary>
/// 客户端连接请求监听线程
/// </summary>
private void ClientConnectListener()
{
while(true)
{
// 检查是否有客户端连接(阻塞的)
Socket clientsocket = mNetConnector.NetSocket.Accept();
Console.WriteLine("客户端{0}成功连接", clientsocket.RemoteEndPoint.ToString());
// 想客户端发送连接成功数据(这里的二进制数据可以换成Protobuf序列化的)
NetMessage netmsg = new NetMessage(1, Encoding.UTF8.GetBytes("连接成功!"));
clientsocket.Send(WriteMessage(netmsg));
// 为连接的客户端创建接受消息的线程
Thread resmsgthread = new Thread(ReceiveMessage);
resmsgthread.Start(clientsocket);
}
}

/// <summary>
/// 写入网络传输所需数据
/// 1. 数据长度(short - 2字节) -- 用于存储消息数据长度
/// 2. 消息ID(short - 2字节) -- 用于区分消息类型
/// 3. 二进制数据(Protobuf) -- 用于序列化和反序列化数据
/// </summary>
/// <param name="netmsg"></param>
private byte[] WriteMessage(NetMessage netmsg)
{
ByteBufferWriter bbw = new ByteBufferWriter();
bbw.WriteShort(netmsg.MessageLength);
bbw.WriteShort(netmsg.MessageID);
bbw.WriteBytes(netmsg.MessageData);
bbw.Flush();
var bytesdata = bbw.ToBytes();
bbw.Close();
return bytesdata;
}

/// <summary>
/// 接受客户端消息
/// </summary>
/// <param name="clientsocket"></param>
private void ReceiveMessage(object clientsocket)
{
Socket cltsocket = (Socket)clientsocket;
while(true)
{
try
{
int receivenumber = cltsocket.Receive(ClientMsg);
if(receivenumber == 0)
{
Console.WriteLine("客户端 : {0}断开了连接!", cltsocket.RemoteEndPoint.ToString());
cltsocket.Shutdown(SocketShutdown.Both);
cltsocket.Close();
break;
}
else
{
Console.WriteLine(string.Format("接收客户端{0}消息, 长度为{1}", cltsocket.RemoteEndPoint.ToString(), receivenumber));
ByteBufferReader bbr = new ByteBufferReader(ClientMsg);
// 消息数据长度
int msglen = bbr.ReadShort();
// 消息id
int msgid = bbr.ReadShort();
// 数据二进制内容
byte[] msgdata = bbr.ReadBytes(msglen);
var msgcontent = Encoding.UTF8.GetString(msgdata);
Console.WriteLine(string.Format("消息数据长度 : {0} 消息ID : {1} 消息数据内容:{2}", msglen, msgid, msgcontent));
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
cltsocket.Shutdown(SocketShutdown.Both);
cltsocket.Close();
break;
}
}
}
}

NetClient.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
/// <summary>
/// NetClient.cs
/// 网络客户端抽象
/// </summary>
public class NetClient : SingletonTemplate<NetClient>
{
/// <summary>
/// 服务器端的二进制数据缓存区
/// </summary>
private static byte[] ServerMsg = new byte[1024 * 1024];

/// <summary>
/// 客户端网络连接器
/// </summary>
private NetConnector mNetConnector;

/// <summary>
/// 是否已连接服务器
/// </summary>
public bool IsServerConnected
{
get
{
return mNetConnector.IsConnected;
}
}

/// <summary>
/// 消息接受线程
/// </summary>
private Thread mMsgReceiveThread;

public NetClient()
{
mNetConnector = new NetConnector();
mMsgReceiveThread = null;
}

public NetClient(ProtocolType protocoltype = ProtocolType.Tcp)
{
mNetConnector = new NetConnector(protocoltype);
mMsgReceiveThread = null;
}

/// <summary>
/// 连接服务器
/// </summary>
/// <param name="ip">ip地址</param>
/// <param name="port">端口号</param>
public void ConnectToServer(string ipaddress, int port)
{
if(IsServerConnected)
{
mNetConnector.Close();
}
try
{
// 连接服务器
mNetConnector.ConnectTo(ipaddress, port);
Debug.Log(string.Format("连接服务器:{0}成功!", mNetConnector.IPEndPoint.ToString()));
}
catch
{
Debug.Log(string.Format("连接服务器:{0}失败!", mNetConnector.IPEndPoint.ToString()));
}

if (IsServerConnected)
{
// 启动客户端线程去监听接受服务器消息
mMsgReceiveThread = new Thread(ReceiveMessage);
mMsgReceiveThread.Start(mNetConnector.NetSocket);
}
}

/// <summary>
/// 断开连接
/// </summary>
public void Close()
{
if (IsServerConnected)
{
Debug.Log(string.Format("断开服务器地址 : {0}连接!", mNetConnector.NetSocket.RemoteEndPoint.ToString()));
mNetConnector.Close();
}
}

/// <summary>
/// 发送消息到服务器
/// </summary>
/// <param name="msg"></param>
public void SendMessage(NetMessage msg)
{
if (IsServerConnected == false)
{
Debug.Log("服务器未连接成功,无法发送数据!");
return;
}
else
{
try
{
mNetConnector.NetSocket.Send(WriteMessage(msg));
}
catch
{
mNetConnector.Close();
}
}
}


/// <summary>
/// 写入网络传输所需数据
/// 1. 数据长度(short - 2字节) -- 用于存储消息数据长度
/// 2. 消息ID(short - 2字节) -- 用于区分消息类型
/// 3. 二进制数据(Protobuf) -- 用于序列化和反序列化数据
/// </summary>
/// <param name="netmsg"></param>
private byte[] WriteMessage(NetMessage netmsg)
{
ByteBufferWriter bbw = new ByteBufferWriter();
bbw.WriteShort(netmsg.MessageLength);
bbw.WriteShort(netmsg.MessageID);
bbw.WriteBytes(netmsg.MessageData);
bbw.Flush();
var bytesdata = bbw.ToBytes();
bbw.Close();
return bytesdata;
}

/// <summary>
/// 服务器连接消息接受监听线程
/// </summary>
private void ReceiveMessage(object clientsocket)
{
Socket cltsocket = (Socket)clientsocket;
while (true)
{
try
{
if(mNetConnector.IsConnected)
{
if(mNetConnector.NetSocket.Available > 0)
{
int receivenumber = cltsocket.Receive(ServerMsg);
if (receivenumber == 0)
{
Debug.Log(string.Format("服务器端 : {0}断开了连接!", cltsocket.RemoteEndPoint.ToString()));
break;
}
else
{
ByteBufferReader bbr = new ByteBufferReader(ServerMsg);
var msglength = bbr.ReadShort();
var msgid = bbr.ReadShort();
var msgdata = bbr.ReadBytes(msglength);
string msgcontent = Encoding.UTF8.GetString(msgdata);
Debug.Log(string.Format("服务器返回数据: 消息ID : {0} 消息长度 : {1} 消息内容 : {2}", msgid, msglength, msgcontent));
}
}
}
else
{
break;
}
}
catch (Exception ex)
{
Debug.Log(ex.Message);
mNetConnector.NetSocket.Close();
break;
}
}
}
}

NetMessage.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
/// <summary>
/// 网络消息抽象类
/// </summary>
public class NetMessage
{
/// <summary>
/// 消息ID
/// </summary>
public ushort MessageID
{
get;
private set;
}

/// <summary>
/// 消息二进制数据
/// </summary>
public byte[] MessageData
{
get;
private set;
}

/// <summary>
/// 消息长度
/// </summary>
public ushort MessageLength
{
get;
private set;
}

private NetMessage()
{

}

public NetMessage(ushort messageid, byte[] messagedata)
{
MessageID = messageid;
MessageData = messagedata;
MessageLength = (ushort)messagedata.Length;
}
}

测试用例:

  1. 启动服务器
1
2
NetServer.Singleton.StartServer();
Console.ReadLine();
  1. 启动客户端
1
2
NetClient.Singleton.ConnectToServer("192.168.1.3", 8888);
NetClient.Singleton.SendMessage(new NetMessage(2, Encoding.UTF8.GetBytes("测试客户端发送给服务器!")));

服务器端输出:
ServerOutput

客户端输出:
ClientOutput

可以看到通过Socket编程,我们使用TPC连接成功将消息从客户端发送到服务器以及服务器发送到客户端进行处理。NetMessage的简单封装是为了上层逻辑能区分收到的是什么消息以及消息数据有多少,基于byte[]的存储让我们可以快速的切换数据序列化和反序列化的方式(为后面使用Protobuf打下基础)。

待续……

数据格式解析

服务器

数据库Redis

Reference

进阶

对于网络框架的设计和理论知识进阶学习:
教你从头写游戏服务器框架

基础知识

关系数据库
NoSQL
关系数据库还是NoSQL数据库
数据库选择历程
Redis
SQLite
SQLite和MySQL数据库的区别与应用
两种同步模式:状态同步和帧同步
游戏服务器架构的演进简史
TCP和UDP的区别
OSI模型

相关资料

SQL 教程
SQL Tutorial

实战讲解

我们来用Unity做一个局域网游戏(上)
Unity3D —— Socket通信(C#)
Unity网络服务器搭建【中高级】
教你从头写游戏服务器框架

unity3d联网游戏:客户端与服务器端的交互

IOShootGameDemo

其他

11种服务器编程语言对比(附游戏服务器框架) 2020.06

NoahGameFrame

前言

本篇文章是为了记录学习使用Unity第三方插件Odin,通过深入学习Odin,加快平时我们编写编辑器工具的效率。

Odin

Odin是一款可以帮助我们快速制作友好编辑器界面的工具。

功能特性

Serialize Anything

那么什么是序列化?
熟悉二进制导表工具和Proto协议数据发送解析的话,对序列化不会陌生。
从平时我们编写代码的角度来说序列化就是把数据对象存储成二进制(序列化)的过程,然后再通过读取二进制数据能构造出原始对象(反序列化)。

接下来看看Unity官方对序列化的定义:
Serialization is the automatic process of transforming data structures or object states into a format that Unity can store and reconstruct later.(序列化就是将数据结构或对象状态转换成可供Unity保存和随后重建的自动化处理过程。)

官方的序列化远比我们平常说的数据对象序列化更宏大:

  1. 文件的保存/读取,包括Scene、Asset、AssetBundle,以及自定义的ScriptableObject等。
  2. Inspector窗口
  3. 编辑器重加载脚本脚本
  4. Prefab
  5. Instantiation
    所有这些都是通过Unity序列化和反序列化来完成的。

Unity序列化有一套规则,这里就不详细介绍了,详情查看官网:
Script Serialization

众所周知,Unity官方序列化并非所有类型都支持,比如:Dictionary,OOP多态。
这些限制让我们在编写代码时受到诸多限制,而我们一般是通过特定的处理方式去支持这些缺失的特性(e.g. 通过两个List去模拟Dictionary的序列化或者通过自定义序列化去支持OOP多态)
详情查看:
Unity插件开发基础—浅谈序列化系统

所以这里再引出Odin的一大特性:
Serialize Anything(所有序列化都支持(e.g. Dictionary,OOP多态问题等))
这一大特性就已经是相当的强大了,这也是我们采用Odin能够快速编写更加友好的编辑器界面工具的一大原因。

Note:
Odin’s serialization is currently supported on all platforms, save Universal Windows Platform without IL2CPP..

实战

While Odin by default injects itself into the inspector for every type that doesn’t already have a custom editor defined, the serialization system is more conservative than that. You always have to explicitly decide to make use of Odin’s serialization system.

  1. 开启Odin代码Inspector
1
2
3
4
5
6
7
8
9
10
11
12
public class SerializeStudy : MonoBehaviour
{
/// <summary>
/// Unity自身不支持序列化的Dictionary
/// </summary>
public Dictionary<int, string> SerializeDictionary = new Dictionary<int, string>();

/// <summary>
/// Unity自身支持序列化的List
/// </summary>
public List<string> SerializeNormalList = new List<string>();
}

UnityListSerializationUI
可以看到默认的Dictionary是不支持序列化的,同时默认的List面板是长上面那样的。

接下来我们通过打开Odin序列化再来对比下区别:
Tools -> Odin Inspector -> Preference -> Editor Types -> User Types
指定我们自定义的SerializeStudy类使用Odin的序列化:
TurnOnCustomClassOdinSerialization
再次对比查看序列化面板:
TurnOnOdinSerializationListUI
可以看到List默认的面板显示已经变成了Odin自定义的了,但是Dictionary依然没有被序列化显示出来,因为我们只是指定了SerializeStudy类使用Odin的Inspector,但还未指定SerializeStudy使用Odin的序列化。

  1. 指定使用Odin序列化
    要支持Dictionary使用Odin的序列化,我们需要显示指定(继承至SerializedMonoBehaviour or SerializedScriptableObject)使用Odin的序列化:
1
2
3
4
public class SerializeStudy : SerializedMonoBehaviour
{
******
}

OdinSerializationUI
可以看到我们成功的使用Odin序列化了Dictionary且采用Odin自定义的Inspector显示。

这里再展现一个Odin二维数组类型的序列化使用,从这个可以看出Odin的强大之处:

1
2
3
4
5
6
7
public class SerializeStudy : SerializedMonoBehaviour
{
/// <summary>
/// Unity的自身不支持的二维数组
/// </summary>
public bool[,] TwoDimensionArray = new bool[5, 5];
}

OdinSerializationTwoDimensionArrayUI
可以看到一句简单的定义在Odin序列化和Odin自定义Inspector的帮助下,帮助我们快速构建和支持了Dictionary和二维数组的序列化,这对于我们构建自己的编辑器可以说是如有神助,而这正式Odin的强大之处。

Note:
关于OOP多态序列化问题,本人测试和Unity插件开发基础—浅谈序列化系统文章里的测试结果不一致(Unity自带序列化支持多态),本人使用的是2017.4.3f1,所以这里就不实战Odin的OOP多态功能了。

New Attributes

这里的Attribute指的是C#语言里的那个标签Attribute。
A core feature of Odin is how easy it makes it to change the appearance of your scripts in the editor - essentially letting you create powerful, complex custom editors at a whim merely by applying a few attributes to your members.

在不使用Odin前,我们一般是类通过自定义CustomEditor的形式去自定义Editor UI或者是直接通过继承EditorWindow去编写Editor UI工具,主要是通过EditorGUILayout,GUILayout一系列的类来手动完成布局和排版显示等功能。
使用Odin后,我们可以通过Odin提供的Attribute可以快速影响我们自定义脚本的Editor表现,帮助我们快速创建友好的可视化界面。

实战

  1. PropertyOrder(快速指定面板显示顺序)
    Unity默认排序是跟Public可序列化成员变量定义的顺序挂钩的:
1
2
3
4
5
6
public class AttributeStudy : MonoBehaviour
{
public int Second;

public int First;
}

UnitySerializationOrderUI
通过Odin的PropertyOrder属性我们可以快速的指定排版顺序

1
2
3
4
5
6
7
8
public class AttributeStudy : MonoBehaviour
{
[PropertyOrder(2)]
public int Second;

[PropertyOrder(1)]
public int First;
}

OdinPropertyOrderUI
2. ValueDropdown属性(显示指定成员变量下拉可选值)

1
2
3
4
5
6
7
8
9
10
11
public class AttributeStudy : MonoBehaviour
{
[ValueDropdown("DropDownListString")]
public string DropDownListStringName;

private List<string> DropDownListString = new List<string>(){
"Tony",
"Tang",
"Huan",
};
}

OdinValueDropDownAttributeUI
3. TabGroup属性(指定序列化显示分组)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class AttributeStudy : MonoBehaviour
{
[TabGroup("Normal List Tab")]
[PropertyOrder(2)]
public List<string> NormalListString = new List<string>(){
"Tony2",
"Tang2",
"Huan2",
};

[TabGroup("Drop Down Tab")]
[PropertyOrder(1)]
[ValueDropdown("DropDownListString")]
public string DropDownListStringName;

private List<string> DropDownListString = new List<string>(){
"Tony1",
"Tang1",
"Huan1",
};
}

OdinTabGroupAttributeUI
4. MetaAttributes属性(e.g. ValidateInput, Required 验证序列化数据输入)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AttributeStudy : MonoBehaviour
{
[ValidateInput("IsGreaterThanZero")]
public int GreaterThanZero;

private bool IsGreaterThanZero(int value)
{
return value > 0;
}

[Required]
public GameObject RequiredValue;
}

OdinValideInputAndRequiredAttributeUI
这里就不所有都一一举例了,从上面可以看出,借助于Odin的Attribute和序列化以及Ordin自定义的Inspector,可以快速帮助我们管理UI排版以及编写高效准确的UI。
更多Attribute详情:
Odin Attributes
Odin官方给的Demo(Assets\Plugins\Sirenix\Demos\Attributes Overview.unitypackage)

Effortless Integration

容易集成这一特性从前面的Serialize Anything和Attribute的简单易用其实已经不言而喻了。

接下来主要学习Odin是如何帮助我们快速创建自定义窗口UI显示的。

  1. Odin普通编辑器窗口
    一般来说我们要自定义窗口UI,我们会继承EditorWindow然后结合EditorGUILayout和GUILayout之类的类来定义窗口UI的显示排版以及逻辑处理。
    基础的Odin窗口需要继承至OdinEditorWindow:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CustomClassInfoDisplayOdinEditorWindow : OdinEditorWindow
{
public class CustomClass
{
public int Index;
public string Name;
public GameObject Go;
}

[MenuItem("/TonyTang/Tools/OdinEditorWindow/MyFirstOdinEditorWindow")]
private static void OpenWindow()
{
var window = GetWindow<CustomClassInfoDisplayOdinEditorWindow>();
}

public List<CustomClass> CustomClassListData = new List<CustomClass>();
}

OdinEditorWindowCustomClassInfoDisplayUI
​ 可以看到借助于继承OdinEditorWindow,我们不再需要重写OnGUI之后去自己定义GUI排版显示以及数据处理,直接编写public可序列化变量就能达到快速显示UI操作交互界面。

  1. Odin带菜单栏的自定义编辑器窗口
    1. 带菜单栏的Odin窗口需要继承至OdinMenuEditorWindow
    2. OdinMenuTree用于创建窗口菜单管理
    3. OdinMenuItem用于创建自定义菜单节点(重写OnDrawMenuItem可自定义菜单节点显示)
    4. 自定义类结合Odin Attribute做自定义显示界面
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
using Sirenix.OdinInspector;
using Sirenix.OdinInspector.Editor;
using Sirenix.Utilities;
using System;
using System.Collections;
using UnityEditor;
using UnityEngine;

/// <summary>
/// 自定义含菜单的Odin窗口
/// </summary>
public class CustomOdinMenuEditorWindow : OdinMenuEditorWindow
{
/// <summary>
/// 自定义的OdinMenuItem类
/// </summary>
public class CustomMenuItem : OdinMenuItem
{
/// <summary>
/// 自定义OdinMenuItem类的数据抽象类
/// </summary>
public class CustomMenuItemContentClass
{
public int Index;
public string Name;
public GameObject Go;
public bool CheckOn;

public CustomMenuItemContentClass(int index, string name, GameObject go, bool checkon)
{
Index = index;
Name = name;
Go = go;
CheckOn = checkon;
}
}

public CustomMenuItem(OdinMenuTree tree, string name, IList value) : base(tree, name, value)
{
}

public CustomMenuItem(OdinMenuTree tree, string name, object value) : base(tree, name, value)
{
}
}

/// <summary>
/// 自定义显示界面的OdinMenuItem类
/// </summary>
public class CustomDrawMenuItem : OdinMenuItem
{
/// <summary>
/// 自定义显示界面OdinMenuItem类的数据抽象类
/// </summary>
public class CustomDrawMenuItemContentClass
{
//利用Odin Attribute排版显示UI
[FoldoutGroup("自定义折叠组")]
public int Index;
[FoldoutGroup("自定义折叠组")]
public string Name;
[FoldoutGroup("自定义折叠组")]
public GameObject Go;
[FoldoutGroup("自定义折叠组")]
public bool CheckOn;

public CustomDrawMenuItemContentClass(int index, string name, GameObject go, bool checkon)
{
Index = index;
Name = name;
Go = go;
CheckOn = checkon;
}
}

/// <summary>
/// 自行保存需要显示的数据
/// </summary>
private CustomDrawMenuItemContentClass mCustomDrawMenuData;

public CustomDrawMenuItem(OdinMenuTree tree, string name, CustomDrawMenuItemContentClass data) : base(tree, name, data)
{
mCustomDrawMenuData = data;
}

public CustomDrawMenuItem(OdinMenuTree tree, string name, IList value) : base(tree, name, value)
{

}

public CustomDrawMenuItem(OdinMenuTree tree, string name, object value) : base(tree, name, value)
{

}

/// <summary>
/// 自定义MenuItem显示
/// </summary>
/// <param name="rect"></param>
/// <param name="labelRect"></param>
protected override void OnDrawMenuItem(Rect rect, Rect labelRect)
{
labelRect.x -= 16;
this.mCustomDrawMenuData.CheckOn = GUI.Toggle(labelRect.AlignMiddle(18).AlignLeft(16), this.mCustomDrawMenuData.CheckOn, GUIContent.none);
}
}

[MenuItem("TonyTang/Tools/OdinMenuEditorWindow/CustomOdinMenuEditorWindow")]
private static void OpenWindow()
{
var window = GetWindow<CustomOdinMenuEditorWindow>();
}

protected override OdinMenuTree BuildMenuTree()
{
var menutree = new OdinMenuTree(true);

var custommenustyle = new OdinMenuStyle
{
BorderPadding = 0f,
AlignTriangleLeft = true,
TriangleSize = 16f,
TrianglePadding = 0f,
Offset = 20f,
Height = 23,
IconPadding = 0f,
BorderAlpha = 0.323f
};

menutree.DefaultMenuStyle = custommenustyle;
//添加菜单风格控制显示菜单
menutree.AddObjectAtPath("菜单风格", custommenustyle);

//添加MenuItem节点
var custommenuitemdata = new CustomMenuItem.CustomMenuItemContentClass(1, "CustomMenuItemContentClass", null, false);
var custommenuitemobj = new CustomMenuItem(menutree, "默认菜单子子节点名", custommenuitemdata);
menutree.AddMenuItemAtPath("菜单子节点1", custommenuitemobj);

var customdrawmenuitemdata = new CustomDrawMenuItem.CustomDrawMenuItemContentClass(1, "CustomDrawMenuItemContentClass", null, false);
var customdrawmenuitemobj = new CustomDrawMenuItem(menutree, "自定义菜单子子节点名", customdrawmenuitemdata);
menutree.AddMenuItemAtPath("菜单子节点2", customdrawmenuitemobj);


menutree.EnumerateTree().AddThumbnailIcons().SortMenuItemsByName();

return menutree;
}
}

OdinCustomMenuStyleUI
可以看到借助于Odin我们很轻松的创建定义了复杂的编辑器窗口UI显示,不再需要手动通过EditorGUILayout去编写排版,开发效率大大提升,编写出来的UI也更加美观。

这里就不再一一学习使用Odin相关特性,详情查询:
Code Reference
Odin官方给的Demo(Assets\Plugins\Sirenix\Demos\Editor Windows.unitypackage)

Extendable

在了解Odin的高度扩展性之前,我们需要了解Odin的Drawer机制。

The single most important part of understanding how Odin’s drawing system works, is understanding the drawing chain. In Odin, any single property is not drawn by a single drawer; instead each property has many drawers assigned to it.

从上面可以看到,理解Odin drawing system工作机制,我们需要了解Odin的drawing chain(绘制链)。在Odin里每一个显示在面板的Property(这里的Property不等价于C#里的Property)都不是单一一个drawer绘制出来的而是许多个drawers的连续绘制的结果。

首先Odin提供我们了一个叫ShowDrawerChain的Attibute属性,可视化的显示二楼每一个Property的绘制Drawer组合(每一个Drawer有一个优先级,优先级高的在前面)。

OdinShowDrawerChainAttribute

从上图可以看出,我们定义一个int的可序列化字段,通过Odin绘制出来需要三个Drawer:

  1. PropertyContextMenuDrawer (并不做任何的绘制,只做了右键Content Menu显示的检查)
  2. PrimitiveValueConflictDrawer (检查值冲突(比如多选时有不一样的值类型))
  3. Int32Drawer(最终负责绘制int值面板显示的Drawer)

理解了Odin绘制Property是通过连续的多个Drawer组合的形式后,让我们来学习了解Odin里不同种类的Drawer是如何组成Odin的Drawer绘制机制的。

  1. Value Drawers(继承至OdinValueDrawer,优先级(0, 0, 1),类型默认的Drawer)
  2. Attribute Drawers(继承至OdinAttributeDrawer,优先级(0, 0, 1000),Attibute绘制)
  3. Attribute Value Drawers(Value Drawers和Atrribute Drawers两者的结合)
  4. Group Drawers(继承至PropertyGroupAttribute,不同于前面三个Drawers,Group Drawers是负责一组Property的指定绘制方式)
  5. Gemeric Drawers(泛型 – 解决显示指定Drawers类型信息的问题)

这里不过多的去纠结Odin底层源代码设计和实现,只是通过了解Odin的绘制设计方式来加深对Odin使用的理解,便于应用到实际工程里去解决具体问题。

更多学习请参考Odin里的Demo和官方文档。

Note:

Note that all drawers in Odin are always strongly typed, and do not allow type polymorphism.(Odin里的Drawer是强类型,不支持多态)

官方使用建议

Getting Started

实战

目标

  1. 编写一个基于Odin的游戏模块的文档说明编辑器工具(Odin)
  2. 自动根据编译信息反射生成最新类型和字段信息(反射判定)
  3. 提供字段可编写自定义介绍的内容并存储共享(ScriptableObject存储共享)
  4. 提供类型,枚举搜索查看功能

先上最终效果图(这里只挑个别展示,主要是学习编写过程中Odin的小知识和小技巧):

  1. 模块文档整体界面
    ModuleDocIntroductionUI
  2. 基础类型说明
    ModuleDocBasicTypeIntroductionUI
  3. Effect详情
    ModuleDocEffectTypeDetailUI

实现

接下来主要从三个方面来学习讲解上面文档工具通过Odin编写所需要注意的技巧和实现。

  1. Odin自定义编辑器菜单实现
  2. Odin自定义类型+Odin自定义编辑器布局显示实现
  3. 自定义编辑器数据序列化存储

自定义编辑器菜单

自定义菜单主要是通过继承Odin的OdinMenuEditorWindow类然后结合自定义OdinMenuTree + 自定义OdinMenuStyle来实现自定义的菜单布局加风格显示。

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
/*
* Description: GameModuleDocumentEditorWindow.cs
* Author: TANGHUAN
* Create Date: 2019/06/04
*/

using Sirenix.OdinInspector;
using Sirenix.OdinInspector.Editor;
using Sirenix.Utilities.Editor;
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

/// <summary>
/// 游戏模块文档编辑器窗口
/// </summary>
public class GameModuleDocumentEditorWindow : OdinMenuEditorWindow
{
public static OdinMenuStyle MenuStyle = new OdinMenuStyle
{
BorderPadding = 0f,
AlignTriangleLeft = true,
TriangleSize = 16f,
TrianglePadding = 0f,
Offset = 20f,
Height = 30,
IconPadding = 0f,
BorderAlpha = 0.323f
};

*******

protected override OdinMenuTree BuildMenuTree()
{
DocDataUtility.CheckAndCreateFolder(DocDataUtility.DocumentDataSaveFolderPath);
LoadAllData();

var menutree = new OdinMenuTree(true);
menutree.DefaultMenuStyle = MenuStyle;

menutree.AddObjectAtPath("游戏模块文档", IntroductionEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单", null);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/基础类型说明", DataTypeIntroEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块", EffectModuleIntroEditorWindowData);
//menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/Effect基础配置介绍", EffectModuleBasicConfigEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/Effect", EffectIntroEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/Effect/Effect预览", EffectPreviewEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/Effect/Effect查询", EffectQueryEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/Effect/Effect详情", EffectDetailEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/EffectView", EffectViewIntroEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/EffectView/EffectView预览", EffectViewPreviewEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/EffectView/EffectView查询", EffectViewQueryEditorWindowData);
menutree.AddObjectAtPath("游戏模块文档/模块菜单/Effect模块/EffectView/EffectView详情", EffectViewDetailEditorWindowData);

return menutree;
}

[MenuItem("TonyTang/Tools/GameModuleDocument/GameModuleDocumentMenuEditorWindow")]
private static void OpenWindow()
{
var window = GetWindow<GameModuleDocumentEditorWindow>();
}

******
}

代码已经很清晰了,这里就不详细说明了。
通过继承OdinMenuEditorWindow重写BuildMenuTree()方法,使用OdinMenuTree自定义菜单栏,配合自定义OdinMenuStyle决定惨淡显示风格。

自定义编辑器布局

自定义编辑器布局主要是通过使用Odin的OdinEditorWindow + Odin自定义序列化类型显示 + Odin序列化标签来实现的。

Effect详细信息自定义窗口部分代码(工作原因,不方便放出全部代码,这里只用作学习记录分享Odin的使用):

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
/*
* Description: EffectDetailEditorWindow.cs
* Author: TONYTANG
* Create Date: 2019/06/04
*/

using Sirenix.OdinInspector;
using Sirenix.OdinInspector.Editor;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;

/// <summary>
/// EffectDetailEditorWindow.cs
/// Effect详情窗口
/// </summary>
[TypeInfoBox("子类不会显示父类字段信息!要查询父类字段信息直接查询父类类型!")]
public class EffectDetailEditorWindow : OdinEditorWindow
{
public class EffectDetailData
{
/// <summary>
/// Effect参数说明数据
/// </summary>
public class EffectDetailParamDesData
{
/// <summary>
/// 参数类型信息
/// </summary>
[LabelText("")]
[VerticalGroup("参数类型")]
[TableColumnWidth(150, Resizable = false)]
[ReadOnly]
public string ParamType;

/// <summary>
/// 参数名
/// </summary>
[LabelText("")]
[VerticalGroup("参数名")]
[TableColumnWidth(150, Resizable = false)]
[ReadOnly]
public string ParamName = "参数名";

/// <summary>
/// 参数介绍
/// </summary>
[LabelText("")]
[VerticalGroup("参数介绍")]
[TextArea]
public string ParamIntro;

/// <summary>
/// 参数额外信息
/// </summary>
[LabelText("")]
[VerticalGroup("参数额外信息")]
[TextArea(3, 6)]
public string ParamExtraInfo;
}

/// <summary>
/// Effect名
/// </summary>
[LabelText("Effect名")]
[VerticalGroup("基础信息")]
[TableColumnWidth(220, Resizable = false)]
[ReadOnly]
public string EffectName;

/// <summary>
/// Effect父类名字
/// </summary>
[LabelText("父Effect名")]
[VerticalGroup("基础信息")]
[TableColumnWidth(220, Resizable = false)]
[ReadOnly]
public string ParentTypeName;

/// <summary>
/// Effect参数说明数据
/// </summary>
[LabelText("")]
[VerticalGroup("参数详细信息")]
[TableList(DrawScrollView = false, IsReadOnly = true)]
public List<EffectDetailParamDesData> EffectParamDesDetailList = new List<EffectDetailParamDesData>();
}

/// <summary>
/// Effect详细数据列表
/// </summary>
[LabelText("Effect详细数据")]
[TabGroup("Effect参数详情")]
[TableList(DrawScrollView = true, MaxScrollViewHeight = 800, MinScrollViewHeight = 800, IsReadOnly = true)]
public List<EffectDetailData> EffectDetailDataList = new List<EffectDetailData>();

/// <summary>
/// 枚举说明数据
/// </summary>
public class EffectEnumData
{
/// <summary>
/// 参数类型信息
/// </summary>
[LabelText("")]
[VerticalGroup("枚举类型全名")]
[HideInInspector]
[ReadOnly]
public string QualifiedEnumType;

/// <summary>
/// 参数类型信息
/// </summary>
[LabelText("")]
[VerticalGroup("枚举名")]
[ReadOnly]
public string EnumType;

/// <summary>
/// 枚举可选值
/// </summary>
[LabelText("")]
[VerticalGroup("枚举可选值")]
[ListDrawerSettings(HideAddButton = true, HideRemoveButton = true)]
public string[] EnumValues;

/// <summary>
/// 枚举值说明
/// </summary>
[LabelText("")]
[VerticalGroup("枚举值说明")]
[TextArea]
public string EnumValueDecs;
}

/// <summary>
/// 枚举详细数据列表
/// </summary>
[LabelText("枚举详细数据")]
[TabGroup("枚举详情")]
[TableList(DrawScrollView = true, MaxScrollViewHeight = 800, MinScrollViewHeight = 500, IsReadOnly = true)]
public List<EffectEnumData> EffectEnumDataList = new List<EffectEnumData>();

******
}
  • OdinEditorWindow实现了基于Odin的自定义编辑器入口。
1
public class EffectDetailEditorWindow : OdinEditorWindow
  • 结合自定义类型 + Public类型成员定义决定自定义编辑器显示内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
public class EffectDetailData
{
******
}

public List<EffectDetailData> EffectDetailDataList = new List<EffectDetailData>();

public class EffectEnumData
{
******
}

public List<EffectEnumData> EffectEnumDataList = new List<EffectEnumData>();
  • 自定义类型需要序列化显示的参数结合Odin序列化标签实现指定方式显示字段。
1
2
3
4
5
6
7
[LabelText("枚举详细数据")]
[VerticalGroup("枚举类型全名")]
[HideInInspector]
[ReadOnly]
[TableColumnWidth(220, Resizable = false)]
[TabGroup("枚举详情")]
[TableList(DrawScrollView = true, MaxScrollViewHeight = 800, MinScrollViewHeight = 500, IsReadOnly = true)]
LabelText -- 表示显示标题
VerticalGroup -- 表示显示分组
HideInInspector -- 表示不显示在编辑器上
ReadOnly -- 表示字段只读不可编辑
TableColumnWidth -- 表示字段单列显示宽度
TabGroup -- 表示标签页分组
TableList -- 表示按表格列表形式显示(DrawScrollView = true, MaxScrollViewHeight = 800, MinScrollViewHeight = 500, IsReadOnly = true -- 是TableList里的细节显示控制)
更多标签详情查看官网:
[OdinDocumentation](https://odininspector.com/documentation)

数据序列化存储

数据序列化存储主要是采用Odin + Unity ScriptableObject
Odin的OdinEditorWindow是继承至EditorWindow,而EditorWindow是继承至ScriptableObject,所以我们直接按序列化ScriptableObject的方式序列化OdinEditorWindow即可。

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
/// <summary>
/// 加载指定文档数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="datafullpath"></param>
/// <returns></returns>
public static T LoadDocumentData<T>(string datafullpath) where T : ScriptableObject
{
if(File.Exists(datafullpath))
{
Debug.Log(string.Format("{0}数据加载成功!", datafullpath));
return AssetDatabase.LoadAssetAtPath<T>(datafullpath);
}
else
{
Debug.Log(string.Format("{0}数据不存在!", datafullpath));
return null;
}
}

/// <summary>
/// 存储指定文档数据
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="documentdata"></param>
/// <param name="datafullpath"></param>
/// <returns></returns>
public static void SaveDocumentData<T>(T documentdata, string datafullpath) where T : ScriptableObject
{
AssetDatabase.CreateAsset(documentdata, datafullpath);
AssetDatabase.SaveAssets();
Debug.Log(string.Format("保存资源:{0}完成!", documentdata));
}

Reference

Odin Asset Store链接
Odin官网
Script Serialization
Unity插件开发基础—浅谈序列化系统
OdinDocumentation

前言

本篇文章是为了记录学习游戏开发过程中,版本强更和资源热更相关知识。

关于资源AssetBundle加载模块相关学习参考:
Unity Resource Manager
AssetBundle资源打包加载管理

热更新

首先还是让我们从What,Why,How三个点来学习理解热更新。

What

什么是热更新?
从前端的角度来说,我个人理解热更新是指无需通过商店审核上传最新版本就能通过游戏内热更下载最新资源和代码的形式更新最新功能。

从服务器的角度来说,可能是无需关闭服务器,不停机状态下修复漏洞,更新资源等,重点是更新逻辑代码。

本人是前端程序,所以侧重点是前端热更这一块,而非服务器热更新。
接下来主要以以下三点来深入学习:

  1. 版本强更(类似于商店下载最新版本的完整包安装更新)
  2. 资源热更(游戏内资源热更新下载替换本地游戏内容)
  3. 代码热更(游戏内代码热更新下载替换本地游戏代码内容)

Why

为什么需要热更新?

  1. 商店审核时间不可控(紧急bug严重修复时会造成阻碍)
  2. 手机游戏更新频繁,改善用户更新体验(不必每次都走商店版本强更)
  3. 快速修复bug和更新功能

How

接下来会针对前面提到的三点来分别讨论:
针对版本强更和资源热更,先来看一下我整理的流程图:
HotUpdateFlowChat

版本强更

版本强更主要是游戏内通过服务器获取最新版本号判定当前游戏版本是否需要更新,然后引导用户更新(可以是引导到对应商店下载,也可以是直接游戏内直接下载安装包)最新的包。

  1. 引导到对应商店下载
    问题:
    需要判断出用户当前下载的安装包是从哪个应用商店下载的,然后跳转到对应商店,如果玩家不是从应用商店下载或者说玩家本地没有对应应用商店,那么就会出现无法成功跳转对应应用商店的情况。
    方案:
    考虑到Android应用商店繁多,基于上述问题,这里我们采用第二种方案,直接引导游戏内CDN下载最新包的形式强更版本(这里指的Android,IOS官方AppStore只有一个本地肯定有AppStore,直接跳转AppStore即可)
  2. 游戏内直接下载(cdn下载)
    游戏内直接下载可以通过下载对应CDN(不同渠道分不同CDN地址即可划分渠道)上的安装包覆盖安装即可。

实战

Android:
通过查询相关资料,了解到Android APK安装在Android N(7)之前主要是通过Itent结合File的形式。
Android N(7)之后主要是通过FileProvider组件

CS层热更下载APK代码:

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
string testresourceurl = "*/HotUpdate.apk";
var webrequest = UnityWebRequest.Get(testresourceurl);
yield return webrequest.SendWebRequest();
if (webrequest.isNetworkError)
{
Debug.LogError(string.Format("{0}安装包下载出错!", testresourceurl));
Debug.LogError(webrequest.error);
if (webrequest.isHttpError)
{
Debug.LogError(string.Format("responseCode : ", webrequest.responseCode));
}
}
else
{
Debug.Log(string.Format("{0} webrequest.isDone:{1}!", testresourceurl, webrequest.isDone));
Debug.Log(string.Format("{0}安装包下载完成!", testresourceurl));
var downloadfilefolderpath = Application.persistentDataPath + "/download/";
var downloadfilesavepath = downloadfilefolderpath + "app.apk";
Debug.Log("downloadfilefolderpath = " + downloadfilefolderpath);
Debug.Log("downloadfilesavepath = " + downloadfilesavepath);
if(!Directory.Exists(downloadfilefolderpath))
{
Directory.CreateDirectory(downloadfilefolderpath);
}
if (File.Exists(downloadfilesavepath))
{
File.Delete(downloadfilesavepath);
}
using (var fs = File.Create(downloadfilesavepath))
{
fs.Write(webrequest.downloadHandler.data, 0, webrequest.downloadHandler.data.Length);
fs.Flush();
fs.Close();
Debug.Log(downloadfilesavepath + "文件写入完成!");
}
}

原生APK安装代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Intent install = new Intent(Intent.ACTION_VIEW);
install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
File apkFile = new File(mContext.getExternalFilesDir(null).getPath() + "/download/" + "app.apk");

Uri uri = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
install.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
install.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
uri = FileProvider.getUriForFile(mContext, mPackagename + ".fileprovider", apkFile);
} else {
uri = Uri.fromFile(apkFile);
}
install.setDataAndType(uri, "application/vnd.android.package-archive");
startActivity(install);

AndroidMainifest.xml定义相关FileProvider:
res下新建xml目录以及file_paths.xml文件
AndroidHotUpdateFilePath

1
2
3
4
5
6
7
8
9
10
//强更部分
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="应用包名.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>

配置file_paths.xml:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<resources>
//下载安装包目录
<paths>
<external-path
name="download"
path="" />
<external-files-path
name="download"
path="" />
</paths>
</resources>

AndroidManifest.xml权限相关设置:

1
2
3
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>

Android相关路径定义知识:
以下内容来源:
关于 Android 7.0 适配中 FileProvider 部分的总结
:内部存储空间应用私有目录下的 files/ 目录,等同于 Context.getFilesDir() 所获取的目录路径;

:内部存储空间应用私有目录下的 cache/ 目录,等同于 Context.getCacheDir() 所获取的目录路径;

:外部存储空间根目录,等同于 Environment.getExternalStorageDirectory() 所获取的目录路径;

:外部存储空间应用私有目录下的 files/ 目录,等同于 Context.getExternalFilesDir(null) 所获取的目录路径;

:外部存储空间应用私有目录下的 cache/ 目录,等同于 Context.getExternalCacheDir();

最后让我们来看下运行效果:
运行界面
下载安装包
版本强更安装

注意事项:

  1. 下载保存APK时需要android.permission.WRITE_EXTERNAL_STORAGE权限
  2. 安装APK时需要android.permission.REQUEST_INSTALL_PACKAGES和android.permission.READ_EXTERNAL_STORAGE权限
  3. Android 6.0以后敏感权限需要主动请求
  4. 从Android 7.0开始,系统修改了安全机制: 限定应用在默认情况下只能访问自身应用数据。所以当我们想通过File对象访问其它package数据时,就需要借助于ContentProvider、FileProvider这些组件,否则会报FileUriExposedException异常。
  5. Android下载和读取安装APK的目录需要是有可读写权限的外部存储目录(e.g. Application.persistentDataPath || Application.temporaryCachePath)

IOS:
IOS正版只有AppStore(暂时不考虑越狱渠道),所以IOS直接打开应用商店跳转即可。
IOS跳转AppStore商店的代码就简单很多了:

1
Application.OpenURL("itms-apps://itunes.apple.com/app/id" + appID);

Android打开商店的代码和IOS类似:

1
Application.OpenURL("market://details?id=" + appID);

Note:
IOS appID可以通过itunes的链接获取到,比如我们的游戏链接为:https://itunes.apple.com/app/id1238589899,1238589899 为IOS的appID
google play 的ID通过商店链接获取到,比如我们的游戏链接为:https://play.google.com/store/apps/details?id=com.oasis.movten.android, com.oasis.movten.android 为 Android的appID

资源热更

资源热更是指在游戏内直接下载最新资源,然后本地游戏通过使用最新下载的资源更新到最新的游戏资源并使用。

资源热更下载老版本Unity提供了WWW接口,但这个接口官方已经不推荐使用了,取而代之的是UnityWebRequest(http访问资源服务器的形式)。

资源下载跟前面提到的版本强更APK下载是一个意思,只不过需要通过比较本地更新的资源数据和服务器上的资源数据对比出需要更新的资源列表。这里不厌其烦的再放一次版本强更和资源热更的流程图加深印象:
HotUpdateFlowChat
代码这里就不贴了,感兴趣的可以直接上Git下载了解:
AssetBundleLoadManager

热更新辅助工具

Tools->HotUpdate->热更新操作工具

HotUpdateToolsUI

主要分为以下4个步骤:

  1. 版本资源文件MD5计算(文件名格式:MD5+版本号+资源版本号+平台+时间戳+.txt)

    AssetBundleMD5Caculation

  2. 对比两个版本的MD5文件信息得出需要热更新的AB文件信息

  3. 执行热更新AB准备操作自动复制需要热更新的AB到热更新准备目录(然后手动拷贝需要强更或热更的资源到真正的热更新目录)

  4. 执行热更新准备操作,生成热更新所需的最新资源热更新信息文件(ResourceUpdateList.txt)和服务器最新版本信息文件(ServerVersionConfig.json)

代码热更

代码热更和版本强更不一样,版本强更一般来说是通过更新下载完整的包来实现整个游戏功能版本更新,而代码热更是指我们通过技术手段(e.g. XLua, ILRuntime, ToLua ……),通过热更代码的形式实现热更功能代码。

主流的方式有两种:

  1. Lua(Lua方案)
    Lua是解析执行,有自己的虚拟机,对于代码热更有天然的优势,我们完全可以以资源热更(AssetBundle)的形式热更Lua代码。
  2. ILRuntime(CS方案)
    这里不是非常了解ILRuntime底层原理,想了解详情的可以参考官网:ILRuntime项目为基于C#的平台(例如Unity)提供了一个纯C#实现,快速、方便且可靠的IL运行时,使得能够在不支持JIT的硬件环境(如iOS)能够实现代码的热更新
    但ILRuntime有一点很大的优势就是开发期也是用CS语言,不用写Lua。

这里因为项目主要用到了XLua,所以后续都是以XLua作为代码热更来学习。
选XLua的原因主要有以下几点:

  1. 腾讯开源,有大公司支持,稳定
  2. 除了支持纯Lua开发,还支持CS通过改写Lua的形式热修复Bug

Note:

  1. 苹果禁止了C#的部分反射操作,禁止JIT(即时编译,程序运行时创建并运行新代码),不允许逻辑热更新,只允许使用AssetBundle进行资源热更新。

XLua

官网:
XLua

待续……

资源服务器

前面提到的版本强更和资源热更都离不开资源服务器,这里说的资源服务器是指静态资源服务器,接下来通过实战学习了解静态资源服务器相关的概念以及静态资源服务器选择和使用。

静态资源服务器:

我们把资源放到指定静态资源服务器上,静态资源服务器开启Http或者其他的连接服务,允许用户通过对应方式(Http或者其他)去访问拉取服务器资源。(个人理解,如果有误欢迎指出)

静态资源服务器是资源热更的一个前提。

CND:

内容分发网络(英语:Content delivery network或Content distribution network,缩写CDN)是指一种透过互联网互相连接的计算机网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、影片、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。

从上面可以看出CDN是独立于静态资源服务器,网络传输层的一个概念,主要目的是为了加快和提供高性能的资源传输。

实战

这里我选择了阿里云的OSS作为静态资源服务器。

OSS详情:

阿里云对象存储服务(Object Storage Service,简称 OSS)为您提供基于网络的数据存取服务。使用 OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种非结构化数据文件。

OSS静态资源服务器搭建:

  1. 注册阿里云账号
  2. 账号实名认证
  3. 开通OSS服务
  4. 创建Bucket
  5. 上传文件

OSS详细使用说明

待续……

Reference

Unity手游之路手游资源热更新策略探讨
Unity 大版本更新之APK的下载与覆盖安装
Unity3D热更新方案总结
使用Intent安装APK方法(兼容Android N)
关于 Android 7.0 适配中 FileProvider 部分的总结
Unity app 如何打开商店

Git

AssetBundleLoadManager

前言

本章节主要目的是学习记录游戏开发中各个模块里的优化知识,深入理解项目设计里的各种底层知识点,避免前期不考虑后期优化成灾的情况。

Note:

  1. 此处并不是要宣扬优化,项目初期不推崇过度优化毕竟产品做出来才是王道,而是通过深入理解核心知识点在项目初期做出正确的设计来避免没必要的后期优化。

UI模块

深入深度学习Unity UI(UGUI)模块的使用,优化,原理知识等。

UI基础

UI核心概念

Canvases are responsible for combining their constituent geometry into batches, generating the appropriate render commands and sending these to Unity’s Graphics system. All of this is done in native C++ code, and is called a rebatch or a batch build.

Geometry is provided to Canvases by Canvas Renderer components.

从前面可以看出UGUI的绘制是以画布为单位的,子Canvas也就是嵌套的关系,大部分情况子Canvas脏了只会触发子Canvas的网格重建不会触发父Canvas的网格重建。

When composing user interfaces in Unity UI, keep in mind that all geometry drawn by a Canvas will be drawn in the Transparent queue.(UGUI的Canvas绘制实在Transpanrent Queue里)

A Graphic is a base class provided by the Unity UI C# library. It is the base class for all Unity UI C# classes that provide drawable geometry to the Canvas system. Most built-in Unity UI Graphics are implemented via the MaskableGraphic subclass, which allows them to be masked via the IMaskable interface.(UGUI里Graphic是提供Canvas里可绘制单位的基类,大部分UGUI组件继承至MaskableGraphic,为了支持Mask过滤。)

The updates of Layout and Graphic components is called a rebuild.(网格重建是因为排版或者Graphic组件有更新导致的,比如UI位置变化(排版),UI图片变化(组件变化))

  1. Batch Building Process(Canvases)
    The batch building process is the process whereby a Canvas combines the meshes representing its UI elements and generates the appropriate rendering commands to send to Unity’s graphics pipeline. The results of this process are cached and reused until the Canvas is marked as dirty, which occurs whenever there is a change to one of its constituent meshes.(网格合并是为了将Canvas下的元素合并到一个网格数据里缓存起来直到UI被标记脏时。主要是为了减少DrawCall,减少GPU压力)
  2. Rebuild Process(Graphics)
    The Rebuild process is where the layout and meshes of Unity UI’s C# Graphic components are recalculated.(网格重建是因为Canvas下的UI有排版或者Graphic组件有变化触发的,目的是为了重新计算绘制所需的网格数据)

Whenever any drawable UI element on a given Canvas changes, the Canvas must re-run the batch building process. This process re-analyzes every drawable UI element on the Canvas, regardless of whether it has changed or not. (任何的可绘制UI变化都会导致Canvas重新执行网格合并,因为Mesh有变化了)

UI合批规则

UI顺序:
Unity UIs are constructed back-to-front, with objects’ order in the hierarchy determining their sort order.
UI是从后往前结合节点层级(Hierarchy)判定的出序列号的,相邻序列号的会检测是否能合批。所以我们要注意合批打断问题(比如相邻节点没有使用同一个图集,材质。相邻UI节点重叠问题等。)

详细学习参考:
Unity 之 UGUI 小总结

UI射线

UI射线响应需要满足下面几个条件:

  1. 目标UI是激活状态
  2. 点击区域是目标UI区域
  3. 目标UI对象本身或父节点有ICanvasRaycastFilter组件设置允许raycast

优化Raycast方案:

  1. 减少不必要的UI响应Raycast(关闭不必要的UI Raycast Target设置)

Note:
Unity 5.4之前有个Bug是即使没有input输入也会每帧检查Raycast造成额外开销。

常见问题

UI的几个常见问题如下:

  1. Excessive GPU fragment shader utilization (i.e. fill-rate overutilization)(过度使用GPU – 片元Shader的使用显示,比如填充率过高)
  2. Excessive CPU time spent rebuilding a Canvas batch(过度使用CPU – Canvas网格重建和合并)
  3. Excessive numbers of rebuilds of Canvas batches (over-dirtying)(过度高频率的触发Canvas网格重建和合并 – 比如大量的动态元素)
  4. Excessive CPU time spent generating vertices (usually from text)(过度的使用CPU – 生成过多的顶点数据,比如文本顶点数量)

从上面可以看出优化UI主要从两个方面下手:

  • CPU(减少Canvas频繁的合并,减少生成过多的顶点数量)
  • GPU(减少Drawcall和填充率)

Profile Tool

  1. Unity Profiler
  2. Unity Frame Debugger
  3. Xcode Intruments
  4. Xcode Frame Debugger
    详情参考:
    Unity UI Profiling Tools

优化建议

  1. 减少不可见UI – 比如全屏不透明UI显示时隐藏背后UI
  2. 简化UI结构 – 比如减少不必要节点 不要使用混合GameObject节点等
  3. 关闭不可见Camera – 比如全屏不透明UI显示时隐藏其他不必要Camera
  4. 划分多Canvas+动静分离 – 比如不同的窗口放到不同的Canvas下避免互相影响。动的元素放在一个Canvas下,静的元素放在一个Canvas下,避免动的元素影响静态元素的网格重建
  5. 使用最新版TextMeshPro来替换原生Text方案(TMP使用SDF技术支持不失真的动态字体大小变化)

Note:

  1. UGUI的Text的文字顶点绘制是单个文字就是一个独立的四边形网格(造成顶点数多)
  2. UGUI的Text动态字体对于不同的大小或者风格(粗体细体)是按不同的文字来处理的(容易造成Canvas的重绘(动态字体纹理重新生成))
  3. 文本变化是会触发网格重建和合并的(因为文本网格数据变化了)

实战

通过前面的学习,我们知道了UI的开销主要在Rebatch和Rebuild上。前者可以理解成通过UGUI合批规则把Canvas下的UI合并到指定数量的DrawCall传递到GPU去。后者可以理解成UI变化后重新计算Mesh。

UI优化的关键也就不言而喻了:

  1. 减少Rebatch
    Canvas没有标记dirty或者Canvas下没有UI Mesh有变化时不会触发Rebatch。
    这也就是为什么要划分Canvas且采用动静分离的原因,目的就是把经常变化的UI分离到单独的Canvas,减少静态UI部分的Rebatch开销。

  2. 减少Rebuild

  3. 降低隐藏激活开销(这一点主要是对于UI显示状态变化时的一种优化)

    根据UWA 六月直播季 | 6.29 Unity UI模块中的优化案例精讲的建议,UGUI最好采用Canvas Group设置Alpha为0的方式来避免SetActive带来的开销以及避免隐藏时的渲染开销

Note:
Unity 5.2版本Unity大力优化了UI Batch部分。

资源模块

资源分很多,比如纹理贴图,图集,材质,Shader,字体,音效,模型,动画等等等。接下来我会针对特定部分来学习针对性的优化知识点。

纹理资源

这里说的纹理资源同时也包含了图集,图集也可以理解成纹理的一种。

字体

待续……

音效

待续……

引用

Relative Study

TextMeshPro相关学习

Unity Conception Part

A guide to optimizing Unity UI
Fundamentals of Unity UI
Unity UI Profiling Tools
Fill-rate, Canvases and input
Optimizing UI Controls
Other UI Optimization Techniques and Tips

Other Part

关于Unity中的UGUI优化,你可能遇到这些问题
Unity 之 UGUI 小总结

UWA 六月直播季 | 6.29 Unity UI模块中的优化案例精讲

前言

本篇文章是为了记录版本控制相关知识,着重学习Git的使用。

版本控制

在项目开发过程中,对项目代码进行维护保存,对于多个人协同工作记录下每一次的变更记录,方便日后查看以及恢复。
主流的版本管理工具比如Tortoise SVN,P4,Git等。

集中式 vs 分布式

这里不对版本管理工具做太深入的学习讲解。
参见:
集中式VS分布式

分布式工作流:

分布式

集中式工作流:

集中式

Note:
Tortoise SVN是集中式。

Git是分布式。

Tortoise SVN

Visual SVN Server

VisualSVN Server allows you to easily install and manage a fully-functional Subversion server on the Windows platform.(VisualSVN Server是一个在WIndows上帮助我们快速搭建Subversion server的工具。)

Tortoise SVN

TortoiseSVN是一个Windows平台下的Subversion用户端软件.

前者是针对Server端,后者是针对Client端。
两者结合使用就能实现版本管理控制。

SVN实战实用
搭建SVN Server

详细设置步骤参考:
VISUALSVN SERVER // Getting started

How to use VisualSVN Server and TortoiseSVN client

个人工作的话,可以只搭建本地服务器。(未测试Jenkins是否能使用本地服务器)
为了多人共享工作(打包IOS需要Mac),我们需要搭建一个SVN服务器用于存放我们的原始文件。
以下以多人共享工作,搭建SVN服务器为例:

  1. 下载安装Visual SVN
  2. 创建SVN Repository
    VisalSVNCreateRepository
  3. 设置用户成员访问读写权限
    VisualSVNUserManager
    VisualSVNGroupManager
  4. Check Out刚才创建的Repository

Note:

Mac上可以试试SmartSVN

SVN典型目录结构

SVN

  • /trunk(主干,用于所有人开发)
  • /branches(分支,用于存放多个分支副本(比如开发过程中为了保存特定节点分出来的))
  • /tags(存放标记副本)

Git

首先要记住的是Git是分布式的,和Tortoise SVN(集中式)不一样。

Git安装

Git download

安装Git没太多说的,直接下载安装即可。

安装好后就能直接使用Git了,下图是安装后通过Git Bash打开的界面:

GitBashUI

Git GUI

不习惯命令行的话,官方也有很多插件支持可视化Git操作。

Git GUI

这里本人用的是GitExtensions

GitExtensions

GitExtension UI操作:

GitExtensionCommandsUI

Note:
结合本人使用体验,TortoiseSVN相比GitExtension更方便也更不容易出问题(比如选取提交数量过多GitExtension卡死。TortoiseSVN操作和SVN操作方式类似,熟悉SVN的更容易上手和理解。)

Git优势

  1. 可离线操作(分布式,可以离线操作。 SVN集中式必须联网和服务器通信)

  2. 本地备份不需要本地存在多份实体文件(有抽象的分支概念)

  3. Small and Fast(小而快)

  4. 开源

Git知识

下面的知识主要参考:

Git 工作区和缓存区

  1. 工作区 – 简单来说,电脑中能看到的目录,就是一个工作区。
  2. 缓存区 – 在真正提交改动前会存放的地方
  3. 版本库(分本地和远程版本库) – 工作区中有一个隐藏目录.git,这个不算工作区,而是Git的版本库。

参考下面这张图详细理解:

GitWorkFlow

更多细节学习了解参考:

Git三区(工作区,缓存区,仓库)的互操作

Git实战

主分支最新+没有冲突提交

  1. git add

    把文件添加进去,实际上是把文件修改添加到暂存区

GitAdd

  1. git commit

    提交更改,实际上就是把暂存区的所有内容提交到当前分支。git commit后还只是提交到自己的分支上,还没有推送到真正的主干分支上。

GitCommit

  1. git push origin master

    git push才是把个人分支的提交推送到主干分支上

落后更新+冲突提交

  1. git pull

    落后分支的话,我们想要提交东西到主分支我们需要先拉去主分支内容

  2. git stash

    缓存冲突文件修改,确保能git pull成功

  3. git pull

    确保正确拉倒主分支

  4. git commit + 解决冲突

    解决冲突,提交缓存区文件倒本地分支

  5. git push

    正式推送到主干分支上

从上面可以看出,要想推送最新修改如果落后或者与主分支修改文件有冲突,我们需要先缓存本地修改到缓存区,然后拉取主干分支之后在进行提交推送修改文件流程。

提交过滤指定文件或文件夹

git提供了一个.gitignore文件,方便我们编写需要过滤的规则:

1
2
3
4
5
6
7
8
9
10
# 文件以及目录过滤
# 过滤library目录
Library/
# 过滤.vs目录
.vs/
# 过滤Temp目录
Temp/
# 过滤cs工程文件
*.csproj
*.sln

详细参考:

gitignore

Git小知识

待添加…..

Reference

Visual SVN
集中式VS分布式
Git Instroduction
Git 工作区和缓存区

前言

本篇文章是为了记录学习了Unity资源加载(Resource & AssetBundle)相关知识后,对于AssetBundle打包加载框架实战的学习记录。因为前一篇学习Unity资源相关知识的文章太长,所以AssetBundle实战这一块单独提出来写一篇。

基础知识学习回顾,参考:
Unity Resource Manager

鸣谢

这里AssetBundle加载管理的框架思路借鉴了Git上的开源库:
tangzx/ABSystem

同时也学习了KEngine相关部分的代码:
mr-kelly/KEngine

AssetBundle打包这一套后续个人参考MotionFramework思路编写的。

Unity官方也推出了一个高度可视化和高度自由度打包方案(个人觉得此工具更适合用于辅助查询打包冗余):
AssetBundles-Browser

本文重点分享,参考ABSystem编写的一套AB加载管理方案实现:
基于AB引用计数的AB加载管理

AssetBundle打包

Note:
这里的AssetBundle打包主要是针对Unity 5.X以及以后的版本来实现学习的。

相关工具

  1. Unity Profiler(Unity自带的新能分析工具,这里主要用于查看内存Asset加载情况)
  2. Unity Studio(Unity AB以及Asset等资源解析查看工具)
  3. DisUnity(解析AB包的工具)

AB打包

AB打包是把资源打包成assetbundle格式的资源。
AB打包需要注意的问题:

  1. 资源冗余
  2. 打包策略
  3. AB压缩格式

资源冗余

这里说的资源冗余是指同一份资源被打包到多个AB里,这样就造成了存在多份同样资源。

资源冗余造成的问题:

  1. 余造成内存中加载多份同样的资源占用内存。
  2. 同一份资源通过多次IO加载,性能消耗。
  3. 导致包体过大。

解决方案:
依赖打包

依赖打包

依赖打包是指指定资源之间的依赖关系,打包时不将依赖的资源重复打包到依赖那些资源的AB里(避免资源冗余)。

在老版(Unity 5之前),官方提供的API接口是通过BuildPipeline.PushAssetDependencies和BuildPipeline.PopAssetDependencies来指定资源依赖来解决资源冗余打包的问题。

在新版(Unity 5以后),官方提供了针对每个Asset在面板上设置AssetBundle Name的形式指定每个Asset需要打包到的最终AB。然后通过 API接口BuildPipeline.BuildAssetBundles()触发AB一键打包(Unity自己会根据设置AB的名字以及Asset之间的使用依赖决定是否将依赖的资源打包到最终AB里)。或者通过API接口BuildPipeline.BuildAssetBundles(*)传入自定义分析的打包结论指定如何打包。

这里值得一提的是Unity 5以后提供的增量打包功能。

增量打包(Unity 5以后)

增量打包是指Unity自己维护了一个叫manifest的文件(前面提到过的记录AB包含的Asset以及依赖的AB关系的文件),每次触发AB打包,Unity只会修改有变化的部分,并将最新的依赖关系写入manifest文件。

*.manifest记录所有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
ManifestFileVersion: 0
CRC: 961902239
AssetBundleManifest:
AssetBundleInfos:
Info_0:
Name: nonuiprefabs/nui_capsulesingletexture
Dependencies:
Dependency_0: materials/mt_singletexturematerial
Info_1:
Name: shaders/sd_shaderlist
Dependencies: {}
Info_2:
Name: materials/mt_singletexturematerial
Dependencies:
Dependency_0: shaders/sd_shaderlist
Dependency_1: textures/tx_brick_diffuse
Info_3:
Name: textures/tx_brick_diffuse
Dependencies: {}
Info_4:
Name: nonuiprefabs/nui_capsulenormalmappingtexture
Dependencies: {}
Info_5:
Name: textures/tx_brick_normal
Dependencies: {}
Info_6:
Name: materials/mt_normalmappingmaterial
Dependencies:
Dependency_0: shaders/sd_shaderlist
Dependency_1: textures/tx_brick_diffuse
Dependency_2: textures/tx_brick_normal

问题:
虽然Unity5提供了增量打包并记录了依赖关系,但从上面的*.manifest可以看出,依赖关系只记录了依赖的AB的名字没有具体到特定的Asset。

最好的证明就是上面我把用到的Shader都打包到sd_shaderlist里。在我打包的资源里,有两个shader被用到了(SingleTextShader和NormalMappingShader),这一点可以通过UnityStudio解压查看sd_shaderlist看到:
sd_shaderlist

从上面可以看出Unity的增量打包只是解决了打包时更新哪些AB的判定,而打包出来的*.manifest文件并不能让我们得知用到了具体哪一个Asset而是AssetBundle。

解决用到哪些Asset这一环节依然是需要我们自己解决的问题,只有存储了用到哪些Asset的信息,我们才能在加载AB的时候对特定Asset做操作(缓存,释放等)。

Note:
每一个AB下面都对应一个.manifest文件,这个文件记录了该AB的asset包含情况以及依赖的AB情况,但这些manifest文件最终不会不打包到游戏里的,只有最外层生成的*.manifest文件(记录了所有AB的打包信息)才会被打包到游戏里,所以才有像前面提到的通过读取.manifest文件获取对应AB所依赖的所有AB信息进行加载依赖并最终加载出所需Asset的例子

依赖Asset信息打包

存储依赖的Asset信息可以有多种方式:

  1. 通过加载依赖AB,依赖Unity自动还原的机制实现依赖Asset加载还原(这正是本博客实现AssetBundle打包以及加载管理的方案)
  2. 存储挂载相关信息到Prefab上
    在设置好AB名字,打包AB之前,将打包对象上用到的信息通过编写[System.Serializable]可序列化标签抽象数据挂载到该Asset对象上,然后打包AB,打包完AB后在运行时利用打包的依赖信息进行依赖Asset加载还原,从而做到对Asset的生命周期掌控。
    DPInfoMono
  3. 创建打包Asset到同名AB里,加载的时候读取
    通过AssetDatabase.CreateAsset()和AssetImporter.GetAtPath()将依赖的信息写入新的Asset并打包到相同AB里,加载时加载出来使用。
    MaterialAssetInfo

打包策略

除了资源冗余,打包策略也很重要。打包策略是指决定各个Asset如何分配打包到指定AB里的策略。打包策略会决定AB的数量,资源冗余等问题。AB数量过多会增加IO负担。资源冗余会导致包体过大,内存中存在多份同样的Asset,热更新资源大小等。

打包策略:

  1. Logical entities(按逻辑(功能)分类 – 比如按UI,按模型使用,按场景Share等功能分类)
    优点:可以动态只更新特定Entity
  2. Object Types(类型分类 – 主要用于同类型文件需要同时更新的Asset)
    优点:只适用于少部分需要经常变化更新的小文件
  3. Concurrent content(加载时机分类 – 比如按Level分类,主要用于游戏里类容固定(Level Based)不会动态变化加载的游戏类型)
    优点:适合Level based这种一层内容一层不变的游戏类型
    缺点:不适合用于动态创建对象的游戏类型

打包策略遵循几个比较基本的准则:

  1. Split frequently-updated Objects into different AssetBundles than Objects that usually remain unchanged(频繁更新的Asset不要放到不怎么会修改的Asset里而应分别放到不同的AB里)
  2. Group together Objects that are likely to be loaded simultaneously(把需要同时加载的Asset尽量打包到同一个AB里)

从上面可以看出,不同的打包策略适用于不同的游戏类型,根据游戏类型选择最优的策略是关键点。

AB压缩格式

AB压缩不压缩问题,主要考虑的点如下:

  1. 加载时间
  2. 加载速度
  3. 资源包体大小
  4. 打包时间
  5. 下载AB时间
    这里就不针对压缩问题做进一步介绍了,主要根据游戏对于各个问题的需求看重点来决定选择(内存与加载性能的抉择)

AB打包相关API

  1. Selection(获取Unity Editor当前勾选对象相关信息的接口)
1
Object[] assetsselections = Selection.GetFiltered(Type, SelectionMode);
  1. AssetDatabase(操作访问Unity Asset的接口)
1
2
3
4
// 获取选中Asset的路径
assetpath = AssetDatabase.GetAssetPath(assetsselections[i]);
// 获取选中Asset的GUID
assetguid = AssetDatabase.AssetPathToGUID(assetpath);
  1. AssetImporter(获取设置Asset的AB名字的接口)
1
2
3
4
5
// 获取指定Asset的Asset设置接口
AssetImporter assetimporter = AssetImporter.GetAtPath(assetpath);
// 设置Asset的AB信息
assetimporter.assetBundleVariant = ABVariantName;
assetimporter.assetBundleName = ABName;
  1. BuildPipeline(AB打包接口)
1
2
3
BuildPipeline.BuildAssetBundles(outputPath, BuildAssetBundleOptions, BuildTarget); 

BuildPipeline.BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

可以看到AB打包Unity 5.X提供了两个主要的接口:

  1. 前者是依赖于设置每个Asset的AB名字,然后一个接口完成增量打包的方案。
    优点:
    自带增量打包
    缺点:
    需要有一套资源AB命名规则。
    开发者可控度低。
  2. 后者是提供给开发者自定义哪些Asset打包到指定AB里的一个接口。
    优点:
    可自行实现指定需求的打包规则。
    开发者可控度高。
    缺点:
    不自带增量打包需要自己实现。

Note:
AssetBundle-Browser也是基于前者的一套打包方案,只不过AssetBundle-Browser实现了高度的Asset资源打包可视化操作与智能分析。

AssetBundle加载管理

实战学习AB加载之前让我们通过一张图先了解下AB与Asset与GameObject之间的关系:
AssetBundleFramework

依赖加载还原

还记得前面说到的依赖的Assest信息打包吗?
这里就需要加载出来并使用进行还原了。
这里接不细说加载还原了,主要就是通过存储的依赖信息把依赖的Asset加载进来并设置回去的过程(可以是手动设置回去也可以是Unity自动还原的方式)。

这里主要要注意的是前面那张大图上给出的各种资源类型在Asset加载还原时采用的方式。
资源加载还原的方式主要有两种:

  1. 复制+引用
    UI – 复制(GameObject) + 引用(Components,Tranform等)
    Material – 复制(材质自身) + 引用(Texture和Shader)

  2. 引用
    Sprite – 引用
    Audio – 引用
    Texture – 引用
    Shader – 引用
    Material – 引用

AB加载相关API

  1. AssetBundle(AB接口)
1
2
3
4
5
6
// 加载本地压缩过的AB
AssetBundle.LoadFromFile(abfullpath)
// 加载AB里的指定Asset
AssetBundle.LoadAsset(assetname);
// 加载AB里的所有Asset
AssetBundle.LoadAllAssets();

AB回收相关API

  1. AssetBundle(AB接口)
1
2
3
4
// 回收AssetBundle并连带加载实例化出来的Asset以及GameObject一起回收
AssetBundle.Unload(true);
// 只回收AssetBundle
AssetBundle.Unload(false);
  1. Resource(资源接口)
1
2
3
4
// 回收指定Asset(这里的Asset不能为GameObject)
Resource.UnloadAsset(asset);
// 回收内存以所有不再有任何引用的Asset
Resources.UnloadUnusedAssets();
  1. GC(内存回收)
1
2
// 内存回收
GC.Collect();

AB回收的方式有两种:

  1. AssetBundle.Unload(false)(基于Asset层面的重用,通过遍历判定的方式去判定Asset是否回收)
  2. AssetBundle.Unload(true)(采取索引技术,基于AB层面的管理,只有AB的引用计数为0时我们直接通过AssetBundle.Unload(true)来卸载AB和Asset资源)

接下来结合这两种方式,实战演练加深理解。

基于Asset重用的AB加载

核心思想:

  1. 打包时存储了依赖的Asset信息,加载时利用存储的依赖信息并还原
  2. 缓存加载进来的Asset的控制权进行重用,卸载AB(AssetBundle.Unload(false))
  3. 加载时通过Clone(复制类型)或者返回缓存Asset(引用类型)进行Asset重用

一下以之前打包的CapsuleNormalMappingTexture.prefab进行详细说明:
CapsuleNormalMappingPrefab.PNG
可以看出CapsuleNormalMappingTexture.prefab用到了如下Asset:

  1. NormalMappingMaterial(材质)
  2. Custom/Texture/NormalMappingShader(Shader)
  3. Brick_Diffuse和Brick_Normal(纹理)

开始加载CapsuleNormalMappingTexture.prefab:
首先让我们看看加载了CapsuleNormalMappingTexture.prefab前的Asset加载情况:
NonUIPrefabLoadedBeforeProfiler
可以看出只有Shader Asset被预先加载进来了(因为我预先把所有Shader都加载进来了)
第一步:
加载CapsuleNormalMappingTexture.prefab对应的AB,因为Prefab是采用复制加引用所以这里需要返回一个通过加载Asset后Clone的一份对象。

1
2
3
4
5
6
nonuiprefabab = loadAssetBundle(abfullname);
var nonuiprefabasset = loadMainAsset(nonuiprefabab);
nonuigo = GameObject.Instantiate(nonuiprefabasset) as GameObject;
// Asest一旦加载进来,我们就可以进行缓存,相应的AB就可以释放掉了
// 后续会讲到相关AB和Asset释放API
nonuiprefabab.Unload(false);

第二步:
还原依赖材质
DPInfoMono

1
2
3
4
5
6
7
8
9
NonUIPrefabDepInfo nonuidpinfo = nonuigo.GetComponent<NonUIPrefabDepInfo>();
var dpmaterials = nonuidpinfo.mDPMaterialInfoList;
for (int i = 0; i < dpmaterials.Count; i++)
{
for(int j = 0; j < dpmaterials[i].mMaterialNameList.Count; j++)
{
MaterialResourceLoader.getInstance().addMaterial(dpmaterials[i].mRenderer, dpmaterials[i].mMaterialNameList[j]);
}
}

第三步:
还原依赖材质的Shader和Texture依赖引用
MaterialAssetInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 根据材质_InfoAsset.asset进行还原材质原始Shader以及Texture信息
var materialinfoasseetname = string.Format("{0}_{1}InfoAsset.asset", materialname, ResourceHelper.CurrentPlatformPostfix);
var materialassetinfo = loadSpecificAsset(materialab, materialinfoasseetname.ToLower()) as MaterialAssetInfo;
// 加载材质依赖的Shader
var materialshader = materialassetinfo.mShaderName;
var shader = ShaderResourceLoader.getInstance().getSpecificShader(materialshader);
material.shader = shader;

// 获取Shader使用的Texture信息进行还原
var materialdptextureinfo = materialassetinfo.mTextureInfoList;
for (int i = 0; i < materialdptextureinfo.Count; i++)
{
// 加载指定依赖纹理
Texture shadertexture = TextureResourceLoader.getInstance().loadTexture(materialdptextureinfo[i].Value);
//设置材质的对应Texture
material.SetTexture(materialdptextureinfo[i].Key, shadertexture);
}

接下让我们看看加载了CapsuleNormalMappingTexture.prefab后的Asset加载情况:
NonUIPrefabLoadedAfterProfiler
可以看出引用的材质和纹理Asest都被加载到内存里了(Shader因为我预先把所有Shader都加载进来了所以就直接重用了没有被重复加载)
第四步:
对缓存的Asset进行判定是否回收(这里以材质为例,判定方式可能多种多样,我这里是通过判定是否有有效引用)
启动一个携程判定特定Material是否不再有有效组件(所有引用组件为空或者都不再使用任何材质)时回收Material Asset

1
Resources.UnloadAsset(materialasset);

接下来让我们看看卸载实例对象后,材质被回收的情况:
MaterialRecycleAssetNumer
MaterialRecycle
可以看到没有被引用的材质Asset被回收了,但内存里的Asset数量却明显增加了。
这里多出来的是我们还没有回收的Texture以及Prefab的GameObject以及Components Asset依然还在内存里。
AfterMaterialRecyleTextureStatus
AfterMaterialRecyleGameObjectStatus
AfterMaterialRecyleTrasformStatus
第五步:
通过切换场景触发置空所有引用将还未回收的Asset变成UnsedAsset或者直接触发Texture Asset回收,然后通过Resources.UnloadUnusedAssets()回收所有未使用的Asset

1
2
3
4
5
6
7
8
9
mNonUIPrefabAssetMap = null;

foreach(var texture in mTexturesUsingMap)
{
unloadAsset(texture.Value);
}
mTexturesUsingMap = null;

Resources.UnloadUnusedAssets();

AfterAssetsRecyleAssetNumber
AfterAssetsRecyleTextureStatus
AfterAssetsRecyleGameObjectStatus
AfterAssetsRecyleTransformStatus
可以看到所有的Texture, GameObject, Transform都被回收了,并且Asset的数量回到了最初的数值。

基于AB引用计数的AB加载管理

这是本文重点分享的部分

方案1:
核心思想:

  1. 基于AB的引用计数 + AssetBundle.Unload(true)
  2. 给每一种资源(e.g. 特效,模型,图片,窗口等)加载都编写统一的资源加载接口(父类抽象)进行自身加载使用的AB引用计数,每个资源负责自身的资源加载管理和返还(主动调用)
  3. 由一个单例管理者统一管理所有加载AB的引用计数信息,负责判定是否可回收

优点:

  1. 严格的AB引用计数加载管理和释放
  2. 可以做到对资源对象的重用减少GC
  3. 对资源对象的重用可以减少AB的重复加载

缺点:

  1. 底层管理的内容比较多(比如基于资源对象的重用),上层灵活度欠缺(相当于对象池已经写在了最底层)
  2. 需要主动去调用返还接口(针对不同资源加载释放时机都需要编写一套对应的代码)

方案2:
核心思想:

  1. 基于AB的引用计数 + AssetBundle.Unload(true)
  2. 绑定加载AB的生命周期判定到Object上(e.g. GameObject,Image,Material等),上层无需关心AB的引用计数,只需绑定AB到对应的对象上即可
  3. 通过单例管理者统一管理判定依赖使用AB的Object列表是否都为空来判定是否可以回收,无需上层管理AB引用计数返还

优点:

  1. 上层只需关心AB加载绑定,无需关心AB引用计数返还问题,上层使用灵活度高

缺点:

  1. AB的返还判定跟绑定的Object有关,Object被回收后,AB容易出现重复加载(可以在上层写部分对象池来减少AB的重复加载)

考虑到希望上层灵活度高一些,个人现在倾向于第二种方案。

接下来基于第二种方案来实战编写资源AB加载的框架。
AB打包这一块采用最新的可视化指定打包策略的方式。

新版AssetBundle加载管理,打包以及热更新

为了弥补以前设计和实现上不足的地方,从而有了新版AssetBundle加载管理和打包的编写。

新版AssetBundle加载管理

老版的资源加载管理缺点:

  1. 面向AssetBundle级别,没有面向Asset级别的加载管理,无法做到Asset级别的加载异步以及Asset级别加载取消的。
  2. 老版AssetDatabase模式要求资源必须在设置AB名字后才能正确使用(因为依赖了AB名字作为加载参数而非资源全路径),无法做到资源导入即可快速使用的迭代开发
  3. 资源加载类型分类(普通,预加载,常驻)设计过于面向切场景的游戏设计,不通用到所有游戏类型
  4. 老版AssetBundle异步加载采用了开携程的方式,代码流程看起来会比较混乱
  5. 老版异步加载没有考虑设计上支持逻辑层加载打断
  6. 老版代码没有涉及考虑动态AB下载的设计(边玩边下)
  7. 资源加载代码设计还比较混乱,不容易让人看懂看明白

综合上面4个问题,新版资源加载管理将支持:

  1. 面向Asset级别加载管理,支持Asset和AssetBundle级别的同步异步加载。
  2. 支持资源导入后AssetDatabase模式马上就能配置全路径加载
  3. 资源加载类型只提供普通和常驻两种(且不支持运行时切换相同Asset或AssetBundle的加载类型,意味着一旦第一次加载设定了类型,就再也不能改变,同时第一次因为加载Asset而加载某个AssetBundle的加载类型和Asset一致),同时提供统一的加载管理策略,细节管理策略由上层自己设计(比如对象池,预加载)
  4. 新版异步加载准备采用监听回调的方式来实现,保证流程清晰易懂
  5. 新版设计请求UID的概念来支持加载打断设计(仅逻辑层面的打断,资源加载不会打断,当所有逻辑回调都取消时,加载完成时会返还索引计数确保资源正确卸载)
  6. 设计上支持动态AB下载(未来填坑)
  7. 加载流程重新设计,让代码更清晰
  8. 保留索引计数(Asset和AssetBundle级别)+对象绑定的设计(Asset和AssetBundle级别)+按AssetBundle级别卸载(依赖还原的Asset无法准确得知所以无法直接卸载Asset)+加载触发就提前计数(避免异步加载或异步加载打断情况下资源管理异常)
  9. 支持非回调式的同步加载返回(通过抽象Loader支持LoadImmediately的方式实现)

Note:

  1. 一直以来设计上都是加载完成后才添加索引计数和对象绑定,这样对于异步加载以及异步打断的资源管理来说是有漏洞的,新版资源加载管理准备设计成提前添加索引计数,等加载完成后再考虑是否返还计数的方式确保异步加载以及异步加载打断的正确资源管理

加载流程设计主要参考:

XAsset

对象绑定加索引计数设计主要参考:

tangzx/ABSystem

类说明

Manager统一管理:

- ModuleManager(单例类 Manager of Manager的管理类)
- ModuleInterface(模块接口类)

资源加载类:

- ResourceLoadMethod(资源加载方式枚举类型 -- 同步 or 异步)
- ResourceLoadMode(资源加载模式 -- AssetBundle or AssetDatabase(**限Editor模式下可切换,支持同步和异步(异步是本地模拟延迟加载来实现的)加载方式**))
- ResourceLoadState(资源加载状态 -- 错误,等待加载, 加载中,完成,取消之类的)
- ResourceLoadType(资源加载类型 -- 正常加载,常驻加载)
- ResourceModuleManager(资源加载模块统一入口管理类)
- AbstractResourceModule(资源加载模块抽象)
- AssetBundleModule(AssetBundle模式下的实际加载管理模块)
- AssetDatabaseModule(AssetDatabase模式下的实际加载管理模块)
- AbstractResourceInfo(资源加载使用信息抽象)
- AssetBundleInfo(AssetBundle资源使用信息)
- AssetInfo(Asset资源使用信息)
- LoaderManager(加载器管理单例类)
- Loadable(资源加载器基类--抽象加载流程)
- AssetLoader(Asset加载器基类抽象)
- BundleAssetLoader(AssetBundle模式下的Asset加载器)
- AssetDatabaseLoader(AssetDatabase模式下的Asset加载器)
- BundleLoader(AssetBundle加载器基类抽象)
- AssetBundleLoader(本地AssetBundle加载器)
- DownloadAssetBundleLoader(动态资源AsserBundle加载器)
- AssetDatabaseAsyncRequest(AssetDatabase模式下异步加载模拟)
- AssetBundlePath(AB资源路径相关 -- 处理多平台以及热更资源加载路径问题)
- ResourceDebugWindow.cs(Editor运行模式下可视化查看资源加载(AssetBundle和AssetDatabase两种都支持)详细信息的辅助工具窗口)
- ResourceConstData(资源打包加载相关常量数据)
- ResourceLoadAnalyse(资源加载统计分析工具)

AB加载管理方案

加载管理方案:

  1. 加载指定资源
  2. 加载自身AB(自身AB加载完通知资源加载层移除该AB加载任务避免重复的加载任务被创建),自身AB加载完判定是否有依赖AB
  3. 有则加载依赖AB(增加依赖AB的引用计数)(依赖AB采用和自身AB相同的加载方式(ResourceLoadMethod),但依赖AB统一采用ResourceLoadType.NormalLoad加载类型)
  4. 自身AB和所有依赖AB加载完回调通知逻辑层可以开始加载Asset资源(AB绑定对象在这一步)
  5. 判定AB是否满足引用计数为0,绑定对象为空,且为NormalLoad加载方式则卸载该AB(并释放依赖AB的计数减一)(通知资源管理层AB卸载,重用AssetBundleInfo对象)
  6. 切场景,递归判定卸载PreloadLoad加载类型AB资源

相关设计:

  1. 依赖AB与被依赖者采用同样的加载方式(ResourceLoadMethod),但加载方式依赖AB统一采用ResourceLoadType.NormalLoad
  2. 依赖AB通过索引计数管理,只要原始AB不被卸载,依赖AB就不会被卸载
  3. 已加载的AB资源加载类型只允许从低往高变(NormalLoad -> Preload -> PermanentLoad),不允许从高往低(PermanentLoad -> Preload -> NormalLoad)

Demo使用说明

先打开资源调试工具

Tools->Debug->资源调试工具

  1. AssetBundle和AssetDatabase资源加载模式切换AssetDatabaseModuleSwitch

  2. AB依赖信息查看界面

    AssetBundleDepInfoUI

  3. AB运行时加载管理详细信息界面

    AssetBundleLoadManagerUI

  4. 加载器信息查看界面

    AssetBundleAsyncUI

  5. 测试界面

    AssetBundleTestUI

  6. 点击加载窗口预制件按钮后:

    1
    2
    3
    4
    5
    6
    7
    8
    ResourceManager.Singleton.getPrefabInstance(
    "Assets/Res/windows/MainWindow.prefab",
    (prefabInstance, requestUid) =>
    {
    mMainWindow = prefabInstance;
    mMainWindow.transform.SetParent(UIRootCanvas.transform, false);
    }
    );

    AssetBundleLoadManagerUIAfterLoadWindow
    可以看到窗口mainwindow依赖于loadingscreen,导致我们加载窗口资源时,loadingscreen作为依赖AB被加载进来了(引用计数为1),窗口资源被绑定到实例出来的窗口对象上(绑定对象MainWindow)

  7. 点击测试异步转同步加载窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 测试异步转同步窗口加载
/// </summary>
public void onAsynToSyncLoadWindow()
{
DIYLog.Log("onAsynToSyncLoadWindow()");
if (mMainWindow == null)
{
onDestroyWindowInstance();
}
AssetLoader assetLoader;
var requestUID = ResourceManager.Singleton.getPrefabInstanceAsync(
"Assets/Res/windows/MainWindow.prefab",
out assetLoader,
(prefabInstance, requestUid) =>
{
mMainWindow = prefabInstance;
mMainWindow.transform.SetParent(UIRootCanvas.transform, false);
}
);
// 将异步转同步加载
assetLoader.loadImmediately();
}
  1. 点击销毁窗口实例对象后
1
2
3
4
5
6
7
8
9
/// <summary>
/// 销毁窗口实例对象
/// </summary>
public void onDestroyWindowInstance()
{
DIYLog.Log("onDestroyWindowInstance()");
GameObject.Destroy(mMainWindow);
}
窗口销毁后可以看到之前加载的资源所有绑定对象都为空了,因为被销毁了(MainWindow被销毁了)

AssetBundleLoadManagerUIAfterDestroyWindow

  1. 等待回收检测回收后
    AssetBundleLoadManagerUIAfterUnloadAB
    上述资源在窗口销毁后,满足了可回收的三大条件(1. 索引计数为0 2. 绑定对象为空 3. NormalLoad加载方式),最终被成功回收。

Note:

读者可能注意到shaderlist索引计数为0,也没绑定对象,但没有被卸载,这是因为shaderlist是被我预加载以常驻资源的形式加载进来的(PermanentLoad),所以永远不会被卸载。

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 加载常驻Shader
/// </summary>
public void onLoadPermanentShaderList()
{
DIYLog.Log("onLoadPermanentShaderList()");
ResourceManager.Singleton.loadAllShader("shaderlist", () =>
{
},
ResourceLoadType.PermanentLoad);
}

新版AssetBundle打包

新版资源打包将支持:

  1. 打包AB的策略由抽象的目录打包策略设定决定

  2. 打包后的AB保留目录结构,确保AB模式和AssetDatabase模式加载都面向Asset路径保持一致性

  3. 支持打包策略级别的AB压缩格式设置(Note: 仅限使用ScriptableBuildPipeline打包模式)。老版AB打包流程AB压缩格式默认由打包面板压缩格式设置决定。

  4. 不支持AB变体功能(ScriptableBuildPipeline也不支持变体功能),AB后缀名统一由打包和加载平台统一添加

  5. 老版AB依赖信息采用原始打包输出的*Manifest文件。新版ScriptableBuildPipeline采用自定义输出打包的CompatibilityAssetBundleManifest文件。

设计主要参考:

MotionFramework

核心AB打包思想和流程:

  1. 通过抽象纯虚拟的打包策略设置(即AB收集打包策略设置界面–设置指定目录的打包策略),做到AB打包策略设置完全抽象AB名字设置无关化(这样一来无需设置AB或清除AB名字,自己完全掌控AB打包策略和打包结论)
  2. 打包时分析打包策略设置的所有有效资源信息,统计出所有有效资源的是否参与打包以及依赖相关等信息,然后结合所有的打包策略设置分析出所有有效Asset的AB打包名字(如果Asset满足多个打包策略设置,默认采用最里层的打包策略设置,找不到符合的采用默认收集打包规则)(这一步是分析关键,下面细说一下详细步骤)
    • 通过自定义设置的打包策略得到所有的有效参与打包路径列表
    • 通过AssetDatabase.FindAssets()结合打包路径列表得出所有需要分析的Asset
    • 通过打包配置信息分析所有Asset是否参与打包以及相关打包信息,得出最终的打包信息列表List
    • 在最后分析得出最后的打包结论之前,这里我个人将AB的依赖信息文件(AssetBuildInfo.asset)的生成和打包信息单独插入在这里,方便AB依赖信息可以跟AB资源一起构建参与热更
    • 最后根据分析得出的打包信息列表List构建真真的打包信息List进行打包
    • AB打包完成后进行一些后续的特殊资源处理(比如视频单独打包。AB打包的依赖文件删除(个人采用自定义生成的AssetBuildInfo.Asset作为依赖加载信息文件)。循环依赖检查。创建打包说明文件等。)
  3. 不同的打包规则通过反射创建每样一个来实现获取对应打包规则的打包AB名字结论获取(采用全路径AB名的方式,方便快速查看资源打包分布)
  4. 然后根据所有有效Asset的所有AB名字打包结论来分析得出自定义的打包结论(即哪些Asset打包到哪个AB名里)
  5. 接着根据Asset的AB打包结论来生成最新的AssetBuildInfo(Asset打包信息,可以理解成我们自己分析得出的Manifest文件,用于运行时加载作为资源加载的基础信息数来源)(手动将AssetBuildInfo添加到打包信息里打包成AB,方便热更新走统一流程)
  6. 最后采用BuildPipeline.BuildAssetBundles(输出目录, 打包信息列表, ……)的接口来手动指定打包结论的方式触发AB打包。

AB打包策略支持了如下几种:

  1. 按目录打包(打包策略递归子目录判定)
  2. 按文件打包(打包策略递归子目录判定)
  3. 按固定名字打包(扩展支持固定名字打包–比如所有Shader打包到shaderlist)(打包策略递归子目录判定)
  4. 按文件或子目录打包(打包策略递归子目录判定,设定目录按文件打包,其他下层目录按目录打包)
  5. 不参与打包(打包策略递归子目录判定)

这里先简单的看下新的AB搜集和打包界面:

AssetBundleCollectWindow

AssetBundleBuildWindow

关于Asset路径与AB路径关联信息存在一个叫assetbundlebuildinfo.asset的ScriptableObejct里(单独打包到assetbuildinfo的AB里),通过Asset路径如何加载到对应AB的关键就在这里。这里和MotionFramework自定义Manifest文件输出不一样,assetbundlebuildinfo.asset只记录AssetPath和AB相关信息映射,不记录AB依赖信息,依赖信息依然采用AB打包生成的*Manifest文件,同时assetbundlebuildinfo.asset采用打包AB的方式(方便和热更新AB走一套机制)

让我们先来看下大致数据信息结构:

AssetBundleBuildInfoView1

AssetBundleBuildInfoView2

2022/1/26支持了资源打包后缀名黑名单可视化配置+资源名黑名单可视化配置

PostFixBlackListAndAssetNameBlackList

2023/2/8底层支持了新版ScriptableBuildPipeline打包工具打包,加快打包速度(需添加SCRIPTABLE_ASSET_BUILD_PIPELINE宏)

Scriptable Build Pipeline

The Scriptable Build Pipeline (SBP) package allows you to control how Unity builds content. The package moves the previously C++-only build pipeline code to a public C# package with a pre-defined build flow for building AssetBundles. The pre-defined AssetBundle build flow reduces build time, improves incremental build processing, and provides greater flexibility than before.

从介绍可以看出Scriptable Build Pipeline是官方推出的新一代Asset Bundle自定义打包管线系统,主要是为了增加AB打包自由度和减少AB打包时间

从老版BuildPipeline.BuildAssetBundles(*)自定义分析打包升级到Scriptable Build Pipeline自定义分析打包,注意事项:

  1. 老版打包参数走BuildAssetBundleOptions设置,SBP打包参数走BundleBuildParameters设置
  2. AB打包结论都是基于自定义分析得到的List只不过SBP通过自定义分析的List构建BundleBuildContent指定AB打包策略
  3. 老版BuildPipeline.BuildAssetBundles()打包依赖信息是通过输出一个Manifest文件来记录AB依赖信息的,而SBP打包依赖信息是通过打包返回的IBundleBuildResults获取打包Bundle信息后构建自定义CompatibilityAssetBundleManifest数据对象实现类*Manifest依赖信息文件。(所以SBP自定义AB打包完成后,我们需要创建CompatibilityAssetBundleManifest文件后再单独打包AB)

SBP自定义打包代码:

AssetBundleBuilder.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
/// <summary>
/// 执行新版Scriptable Build Pipeline AB打包
/// </summary>
/// <param name="outputDirectory"></param>
/// <param name="buildSuccess"></param>
private void DoSBPAssetBundleBuild(string outputDirectory, out bool buildSuccess)
{
var buildParams = MakeBuildParameters();
IBundleBuildResults results;
SBPAssetBundleBuilder.BuildAllAssetBundles(this, outputDirectory, BuildTarget, buildParams, mAllAssetBundleBuildList, out buildSuccess, out results);
CreateSBPReadmeFile(outputDirectory, results);
}

/// <summary>
/// 获取构建参数
/// </summary>
private CustomBuildParameters MakeBuildParameters()
{
CustomBuildParameters bundleBuildParameters = new CustomBuildParameters(BuildTarget, BuildTargetGroup, OutputDirectory);
//bundleBuildParameters.CacheServerHost = "";
//bundleBuildParameters.CacheServerPort = ;
bundleBuildParameters.BundleCompression = GetConfigBuildCompression();
if (IsForceRebuild)
{
// 是否增量打包
bundleBuildParameters.UseCache = !IsForceRebuild;
}
bundleBuildParameters.ContiguousBundles = true;
if (IsAppendHash)
{
bundleBuildParameters.AppendHash = IsAppendHash;
}
if (IsDisableWriteTypeTree)
{
bundleBuildParameters.ContentBuildFlags |= ContentBuildFlags.DisableWriteTypeTree;
}
bundleBuildParameters.ContentBuildFlags |= ContentBuildFlags.StripUnityVersion;
if (IsIgnoreTypeTreeChanges)
{
// SBP不支持BuildAssetBundleOptions.IgnoreTypeTreeChanges
}
// 添加自定义AB压缩格式设置
foreach(var assetBundleBuildInfo in mAllAssetBundleBuildInfoList)
{
bundleBuildParameters.AddAssetBundleCompression(assetBundleBuildInfo.AssetBundleName, assetBundleBuildInfo.Compression);
}
return bundleBuildParameters;
}

SBPAssetBundleBuilder.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


/// <summary>
/// 执行新版Scriptable Build Pipeline自定义AB打包
/// </summary>
/// <param name="assetBundleBuilder">AB打包工具</param>
/// <param name="outputDirectory">输出目录</param>
/// <param name="buildTarget">打包平台</param>
/// <param name="options">打包选项设置</param>
/// <param name="allAssetBundleBuildList">AB打包列表</param>
/// <param name="buildSuccess">打包是否成功</param>
/// <param name="results">打包结果</param>
/// <returns></returns>
public static CompatibilityAssetBundleManifest BuildAllAssetBundles(AssetBundleBuilder assetBundleBuilder, string outputDirectory, BuildTarget buildTarget, CustomBuildParameters buildParams, List<AssetBundleBuild> allAssetBundleBuildList, out bool buildSuccess, out IBundleBuildResults results)
{
ScriptableBuildPipeline.slimWriteResults = true;
ScriptableBuildPipeline.useDetailedBuildLog = false;
ScriptableBuildPipeline.threadedArchiving = true;
var buildContent = new BundleBuildContent(allAssetBundleBuildList);
ReturnCode exitCode = ContentPipeline.BuildAssetBundles(buildParams, buildContent, out results);
buildSuccess = exitCode >= ReturnCode.Success;
if (exitCode < ReturnCode.Success)
{
Debug.LogError($"[BuildPatch] 构建过程中发生错误exitCode:{exitCode}!");
return null;
}
CompatibilityAssetBundleManifest unityManifest = CreateAndBuildAssetBundleManifest(assetBundleBuilder, outputDirectory, buildParams, results, out buildSuccess);
CheckCycleDependSBP(unityManifest);
return unityManifest;
}

/// <summary>
/// 创建并打包AssetBundleManifest
/// Note:
/// 1. 新版Scriptable Build Pipeline打包没有AssetBundleManifest文件,需要自己创建并打包CompatibilityAssetBundleManifest兼容文件
/// </summary>
/// <param name="assetBundleBuilder">AB打包工具</param>
/// <param name="outputDirectory">输出目录</param>
/// <param name="buildParams">打包参数</param>
/// <param name="results">打包结果</param>
/// <param name="buildSuccess">打包是否成功</param>
/// <returns></returns>
private static CompatibilityAssetBundleManifest CreateAndBuildAssetBundleManifest(AssetBundleBuilder assetBundleBuilder, string outputDirectory, CustomBuildParameters buildParams, IBundleBuildResults results, out bool buildSuccess)
{
var outputDirectoryFullPath = Path.GetFullPath(outputDirectory);
var outputDirectoryInfo = new DirectoryInfo(outputDirectoryFullPath);
var manifestName = outputDirectoryInfo.Name;
var manifest = ScriptableObject.CreateInstance<CompatibilityAssetBundleManifest>();
manifest.SetResults(results.BundleInfos);
var manifestPath = buildParams.GetOutputFilePathForIdentifier($"{manifestName}.manifest");
Debug.Log($"manifestPath:{manifestPath}");
var manifestAssetFolderPath = $"Assets/SBPBuildManifest/{buildParams.Target}";
if(!Directory.Exists(manifestAssetFolderPath))
{
Directory.CreateDirectory(manifestAssetFolderPath);
}
var manifestAssetFilePath = Path.Combine(manifestAssetFolderPath, $"{manifestName}.asset");
AssetDatabase.CreateAsset(manifest, manifestAssetFilePath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
var buildContent = new BundleBuildContent(new[]
{
new AssetBundleBuild()
{
assetBundleName = manifestName,
assetBundleVariant = assetBundleBuilder.GetAssetBuildBundleVariant(manifestAssetFilePath),
assetNames = new[] { manifestAssetFilePath },
// Manifest的Asset名强制用固定名字
addressableNames = new[] { ResourceConstData.AssetBundleManifestAssetName },
}
});
var exitCode = ContentPipeline.BuildAssetBundles(buildParams, buildContent, out _);
buildSuccess = exitCode >= ReturnCode.Success;
if(exitCode < ReturnCode.Success)
{
Debug.LogError($"打包AssetBundleManifest失败!eixtCode:{exitCode}");
return null;
}
Debug.Log($"AB的Manifest打包成功!");
return manifest;
}

详情代码参考Github源码:

AssetBundleLoadManager

Note:

  1. 并非老版所有的功能SBP都支持,具体区别参考:SBP Upgrade Guide
  2. Scriptable Build Pipeline不支持变体功能
  3. 打包参数*StripUnityVersion个人测试SBP不起作用,原因不明
  4. 自定义AB打包时,AssetBundleBuild.assetNames必须传Asset全路径(Assets///*)
  5. 自定义AB打包时,AssetBundleBuild.addressableNames传Asset全路径后,老版AB打包支持Asset加载使用多种加载方式(e.g. 全路径,文件名,文件名带后缀等),但SBP AB打包只支持按打包测AssetBundleBuild.addressableNames传设置的方式加载Asset
  6. 个人测试完全删除所有缓存AB重打的前提下,只对比AB打包这一步,SBP比老版AB打包速度要快10倍左右

新版资源热更新流程

类说明

热更类:

1
2
- HotUpdateModuleManager.cs(热更新管理模块单例类)
- TWebRequest.cs(资源下载http抽象类)

版本信息类:

1
2
- VersionConfigModuleManager.cs(版本管理模块单例类)
- VersionConfig.cs(版本信息抽象类)

功能支持

  1. 支持游戏内版本强更(完成 – 暂时限Android,IOS待测试)
  2. 支持游戏内资源热更(完成 – 暂时限Android, IOS待测试)
  3. 支持游戏内代码热更(未做)

热更测试说明

之前是使用的HFS快速搭建的一个资源本地资源服务器,后来使用阿里的ISS静态资源服务器做了一个网络端的资源服务器。

版本强更流程:

  1. 比较包内版本信息和包外版本信息检查是否强更过版本
  2. 如果强更过版本清空包外相关信息目录
  3. 通过资源服务器下载最新服务器版本信息(ServerVersionConfig.json)和本地版本号作对比,决定是否强更版本
  4. 结合最新版本号和资源服务器地址(Json配置)拼接出最终热更版本所在的资源服务器地址
  5. 下载对应版本号下的强更包并安装
  6. 安装完成,退出游戏重进

资源热更流程:

  1. 初始化本地热更过的资源列表信息(暂时存储在:Application.persistentDataPath + “/ResourceUpdateList/ResourceUpdateList.txt”里)

  2. 通过资源服务器下载最新服务器版本信息(ServerVersionConfig.json)和本地资源版本号作对比,决定是否资源热更

  3. 结合最新版本号,最新资源版本号和资源服务器地址(Json配置)拼接出最终资源热更所在的资源服务器地址

  4. 下载对应地址下的AssetBundleMD5.txt(里面包含了对应详细资源MD5信息)

    AssetBundleMD5.txt

    1
    2
    3
    assetbuildinfo.bundle|ca830d174533e87efad18f1640e5301d
    shaderlist.bundle|2ac2d75f7d91fda7880f447e21b2e289
    ******
  5. 根据比较对应地址下的AssetBundleMD5.txt里的资源MD5信息和本地资源MD5信息(包外的MD5信息优先)得出需要更新下载的资源列表

  6. 根据得出的需要更新的资源列表下载对应资源地址下的资源并存储在包外(Application.persistentDataPath + “/Android/“),同时写入最新的资源MD5信息文件(本地AssetBundleMD5.txt)到本地

  7. 直到所有资源热更完成,退出重进游戏

流程图

HotUpdateFlowChat

热更新辅助工具

Tools->HotUpdate->热更新操作工具

HotUpdateToolsUI

主要分为以下2个阶段:

  • 热更新准备阶段:

    1. 每次资源打包会在包内Resource目录生成一个AssetBundleMd5.txt文件用于记录和对比哪些资源需要热更

    AssetBundleMD5File

    1. 执行热更新准备操作,生成热更新所需服务器最新版本信息文件(ServerVersionConfig.json)并将包内对应平台资源拷贝到热更新准备目录

    HotUpdatePreparationFolder

  • 热更新判定阶段

    1. 初始化包内(AssetBundleMd5.txt)和包外(AssetBundleMd5.txt)热更新的AssetBundle MD5信息(先读包内后读包外以包外为准)

    2. 游戏运行拉去服务器版本和资源版本信息进行比较是否需要版本强更或资源热更新

    3. 需要资源热更新则拉去对应最新资源版本的资源MD5信息文件(AssetBundleMD5.txt)进行和本地资源MD5信息进行比较判定哪些资源需要热更新

    4. 拉去所有需要热更新的资源,完成后进入游戏

Note:

  1. 每次打包版本时会拷贝一份AssetBundleMD5.txt到打包输出目录(保存一份方便查看每个版本的资源MD5信息)

热更包外目录结构

PersistentAsset -> HotUpdate -> Platform(资源热更新目录)
PersistentAsset -> HotUpdate -> AssetBundleMd5.txt(记录热更新的AssetBundle路径和MD5信息–兼顾进游戏前资源热更和动态资源热更)(格式:热更AB路径:热更AB的MD5/n热更AB路径:热更AB的MD5******)
PersistentAsset -> Config -> VersionConfig.json(包外版本信息–用于进游戏前强更和热更判定)

PersistentAsset -> HotUpdate -> 版本强更包

资源辅助工具

资源辅助工具五件套:

  • AB删除判定工具

    DeleteRemovedAssetBundle

  • 资源依赖查看工具

    AssetDependenciesBrowser

  • 内置资源依赖统计工具(只统计了*.mat和*.prefab,场景建议做成Prefab来统计)

    BuildInResourceReferenceAnalyze

  • 内置资源提取工具

    BuildInResourceExtraction

  • Shader变体搜集工具

    ShaderVariantsCollection

AB实战总结

  1. AB打包和加载是一个相互相存的过程。
  2. Unity 5提供的增量打包只是提供了更新部分AB的打包机制,*.manifest文件里也只提供依赖的AB信息并非Asset,所以想基于Asset的重用还需要我们自己处理。
  3. Unity并非所有的Asset都是采用复制而是部分采用复制,部分采用引用的形式,只有正确掌握了这一点,我们才能确保Asset真确的重用和回收。
  4. 打包策略是根据游戏类型,根据实际情况而定。
  5. AB压缩格式选择取决于内存和加载性能以及包体大小等方面的抉择。
  6. 资源管理策略而言,主要分为基于Asset管理(AssetBundle.Unload(false))还是基于AB管理(AssetBundle.Unload(true)),前者容易出现内存中多份重复资源,后者需要确保严格的管理机制(比如索引计数))

Reference

tangzx/ABSystem
mr-kelly/KEngine
AssetBundles-Browser

前言

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

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

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

  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