UFL7-3——UI管理器的异步加载优化

为什么要进行异步加载优化

我们之前制作UI管理器时,加载资源时是在测试模式下,始终使用的是编辑器同步加载模式
若真正使用异步加载时,可能会存在报错风险

举例重现问题:

  1. 构建AB包

    image

  2. 采用异步加载方式加载AB包中的UI面板资源

    ABResManager​的isDebug​设置为false​,使得加载会选择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
    using UnityEngine;
    using UnityEngine.Events;

    /// <summary>
    /// 用于进行加载AB相关资源的整合,在开发中可以通过EditorResManager去加载对应资源去进行测试
    /// </summary>
    public class ABResManager : BaseManager<ABResManager>
    {
    private bool isDebug = false;

    private ABResManager() { }

    public void LoadResAsync<T>(string abName, string resName, UnityAction<T> callBack, bool isSync = false) where T : Object
    {
    #if UNITY_EDITOR
    if (isDebug)
    {
    //我们自定义了一个AB包中资源的管理方式,对应文件夹名就是包名
    T res = EditorResManager.Instance.LoadEditorRes<T>($"{abName}/{resName}");
    callBack?.Invoke(res as T);
    }
    else
    {
    ABManager.Instance.LoadResAsync<T>(abName, resName, callBack, isSync);
    }
    #else
    ABManager.Instance.LoadResAsync<T>(abName, resName, callBack, isSync);
    #endif
    }
    }
  3. 同一帧显示两次UI面板

    在实际开发时,有可能会不同地方出现先显示UI面板,然后加载尚未结束的时候再次调用显示同一面板的方法,
    这里使用最极端的情况,在同一帧加载两次UI面板

    1
    2
    3
    4
    5
    void Start()
    {
    UIManager.Instance.ShowPanel<BeginPanel>();
    UIManager.Instance.ShowPanel<BeginPanel>();
    }

    输出:image

    可以发现,这样调用会导致重复加载和重复添加的问题,字典会因为重复加载而报错,Canvas​对象上会挂载两个同一面板

  4. 同一帧显示又隐藏UI面板

    在实际开发时,有可能会不同地方出现先显示UI面板,然后加载尚未结束的时候调用隐藏同一面板的方法,

    1
    2
    3
    4
    5
    void Start()
    {
    UIManager.Instance.ShowPanel<BeginPanel>();
    UIManager.Instance.HidePanel<BeginPanel>();
    }

    面板会无视隐藏面板方法继续显示:image

    由于尚未加载成功,因此隐藏不会生效,面板依然会加载出来

优化异步加载问题

主要制作思路:

  1. 造成问题的关键点

    由于异步加载,字典容器中没有及时存储将要显示的面板对象,我们需要在显示面板时,一开始就存储面板的相关信息
    这样不管是二次显示还是隐藏,都能够知道是否已经在加载面板了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 主要是用于里氏替换原则,在字典中,用父类容器装载子类对象
    private abstract class BasePanelInfo { }

    // 用于存储面板消息和加载完成的回调函数
    private class PanelInfo<T> : BasePanelInfo where T : BasePanel
    {
    public T panel;
    public UnityAction<T> callBack;

    public PanelInfo(UnityAction<T> callBack)
    {
    this.callBack += callBack;
    }
    }

    // 用于存储所有的面板对象
    private Dictionary<string, BasePanelInfo> panelDic = new Dictionary<string, BasePanelInfo>();
  2. 分情况考虑问题(异步加载中 和 异步加载结束)

    • 显示相关

      1. 若加载中想要显示,应该记录回调,加载结束后统一调用
      2. 若加载结束后想显示,直接显示
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      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
      // 用于存储面板消息和加载完成的回调函数
      private class PanelInfo<T> : BasePanelInfo where T : BasePanel
      {
      public T panel;
      public UnityAction<T> callBack;

      public PanelInfo(UnityAction<T> callBack)
      {
      this.callBack += callBack;
      }
      }

      // 用于存储所有的面板对象
      private Dictionary<string, BasePanelInfo> panelDic = new Dictionary<string, BasePanelInfo>();

      public void ShowPanel<T>(E_UILayer layer = E_UILayer.Middle,
      UnityAction<T> callBack = null,
      bool isSync = false) where T : BasePanel
      {
      //获取面板名,预设体名必须与面板类名一致
      string panelName = typeof(T).Name;
      //存在面板
      if (panelDic.ContainsKey(panelName))
      {
      //取出消息
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
      //正在异步加载中
      if (panelInfo.panel == null)
      {
      //将回调添加到记录中
      if (callBack != null)
      panelInfo.callBack += callBack;
      }
      //已经加载结束
      else
      {
      panelInfo.panel.ShowMe();
      //直接执行回调,直接传递出去即可
      callBack?.Invoke(panelInfo.panel);
      }
      return;
      }

      //不存在面板,先存入字典当中占位,之后如果又显示,我才能得到字典中的消息进行判断
      panelDic.Add(panelName, new PanelInfo<T>(callBack));
      ABResManager.Instance.LoadResAsync<GameObject>("ui", panelName, (res) =>
      {
      //层级的处理
      Transform layerObj = GetLayerObj(layer);
      //避免没有按照指定规则传递参数,避免为空
      if (layerObj == null)
      layerObj = middleLayer;
      //将面板预设体创建到对应父对象下,并且保持原本的缩放大小
      GameObject panelObj = GameObject.Instantiate(res, layerObj, false);
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>; //取出消息
      T panel = panelObj.GetComponent<T>(); //获取对应的UI控件返回出去
      panel.ShowMe(); //显示面板时执行的默认方法
      panelInfo.panel = panel; //将加载出来的panel记录到panelInfo内
      panelInfo.callBack?.Invoke(panel); //传递到外部使用
      panelInfo.callBack = null; //清空回调,避免内存泄露
      }, isSync);
      }
    • 隐藏相关

      1. 若加载中想要隐藏,应该改变标识,并清空回调
      2. 若加载结束想要隐藏,直接隐藏
      3. 若压根没有,不用处理

      同时,也要修改显示的逻辑,如果加载完毕后发现隐藏标识为true​,则不执行后续逻辑
      如果在加载期间,隐藏后又执行了显示方法,则将隐藏标识设置为false​,并添加回调,这样加载出来过后不会忽略后续隐藏逻辑

      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
      // 用于存储面板消息和加载完成的回调函数
      private class PanelInfo<T> : BasePanelInfo where T : BasePanel
      {
      public T panel;
      public UnityAction<T> callBack;

      public PanelInfo(UnityAction<T> callBack)
      {
      this.callBack += callBack;
      }
      }

      // 用于存储所有的面板对象
      private Dictionary<string, BasePanelInfo> panelDic = new Dictionary<string, BasePanelInfo>();

      public void HidePanel<T>() where T : BasePanel
      {
      string panelName = typeof(T).Name;
      if (panelDic.ContainsKey(panelName))
      {
      //取出消息
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
      //如果存在,但正在加载
      if (panelInfo.panel == null)
      {
      //修改隐藏标识,标识这个面板将要隐藏,因为要隐藏,因此将回调置空
      panelInfo.isHide = true;
      panelInfo.callBack = null;
      }
      //已经加载结束
      else
      {
      //销毁后从容器中移除
      panelInfo.panel.HideMe();
      GameObject.Destroy(panelInfo.panel.gameObject);
      panelDic.Remove(panelName);
      }
      }
      }

      public void ShowPanel<T>(E_UILayer layer = E_UILayer.Middle,
      UnityAction<T> callBack = null,
      bool isSync = false) where T : BasePanel
      {
      //获取面板名,预设体名必须与面板类名一致
      string panelName = typeof(T).Name;
      //存在面板
      if (panelDic.ContainsKey(panelName))
      {
      //取出消息
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
      //正在异步加载中
      if (panelInfo.panel == null)
      {
      //将回调添加到记录中
      panelInfo.isHide = false;
      if (callBack != null)
      panelInfo.callBack += callBack;
      }
      //已经加载结束
      else
      {
      panelInfo.panel.ShowMe();
      //直接执行回调,直接传递出去即可
      callBack?.Invoke(panelInfo.panel);
      }
      return;
      }

      //不存在面板,先存入字典当中占位,之后如果又显示,我才能得到字典中的消息进行判断
      panelDic.Add(panelName, new PanelInfo<T>(callBack));
      ABResManager.Instance.LoadResAsync<GameObject>("ui", panelName, (res) =>
      {
      //取出消息
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
      if (panelInfo.isHide)
      {
      panelDic.Remove(panelName);
      return;
      }
      //层级的处理
      Transform layerObj = GetLayerObj(layer);
      //避免没有按照指定规则传递参数,避免为空
      if (layerObj == null)
      layerObj = middleLayer;
      //将面板预设体创建到对应父对象下,并且保持原本的缩放大小
      GameObject panelObj = GameObject.Instantiate(res, layerObj, false);

      T panel = panelObj.GetComponent<T>(); //获取对应的UI控件返回出去
      panel.ShowMe(); //显示面板时执行的默认方法
      panelInfo.panel = panel; //将加载出来的panel记录到panelInfo内
      panelInfo.callBack?.Invoke(panel); //传递到外部使用
      panelInfo.callBack = null; //清空回调,避免内存泄露
      }, isSync);
      }
    • 获取相关

      1. 若加载中想要获取,应该等待加载结束后再处理获取逻辑
      2. 若加载结束想要获取,直接获取
      3. 若压根没有,不用处理
      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
      // 用于存储面板消息和加载完成的回调函数
      private class PanelInfo<T> : BasePanelInfo where T : BasePanel
      {
      public T panel;
      public UnityAction<T> callBack;

      public PanelInfo(UnityAction<T> callBack)
      {
      this.callBack += callBack;
      }
      }

      public void GetPanel<T>(UnityAction<T> callBack) where T : BasePanel
      {
      string panelName = typeof(T).Name;

      if (panelDic.ContainsKey(panelName))
      {
      //取出消息
      PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
      if (panelInfo.panel == null)
      {
      //加载中,应该等待加载结束,再通过回调传递给外部去使用
      panelInfo.callBack += callBack;
      }
      //加载结束,并且不处于将要隐藏的状态
      else if (!panelInfo.isHide)
      {
      callBack?.Invoke(panelInfo.panel as T);
      }

      }
      }

使用示例

在同一帧调用显示面板方法,面板只会加载和显示一次,但两个回调函数都会执行,并且不会报错

1
2
3
4
5
6
7
8
9
10
11
void Start()
{
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第一次调用:" + panel.name);
});
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第二次调用:" + panel.name);
});
}

输出:image

在同一帧调用完显示方法后,再去隐藏面板,面板不会显示,并且回调也不执行

1
2
3
4
5
6
7
8
9
10
11
12
void Start()
{
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第一次调用:" + panel.name);
});
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第二次调用:" + panel.name);
});
UIManager.Instance.HidePanel<BeginPanel>();
}

在同一帧调用完显示方法后,再去隐藏面板,再去显示面板,面板依然会显示,前面的回调不执行,后面的回调会执行
在面板加载时调用获取面板,则获取面板传入的回调方法会在面板加载完后执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Start()
{
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第一次调用:" + panel.name);
});
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("第二次调用:" + panel.name);
});
UIManager.Instance.HidePanel<BeginPanel>();
UIManager.Instance.ShowPanel<BeginPanel>(callBack: (panel) =>
{
print("隐藏后重新调用:" + panel.name);
});
UIManager.Instance.GetPanel<BeginPanel>((panel) =>
{
print("获取到面板要执行的逻辑:" + panel.name);
});
}

输出:image

主要解决的问题

  1. 同一帧连续显示同一面板,避免重复进行异步加载后回调重复往字典中添加面板数据
  2. 同一帧 显示 ——> 隐藏 ——> 显示 同一面板问题,面板能够正常显示
  3. 获取面板时如果正在加载中,等加载结束后再获取处理逻辑

具体代码

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
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

/// <summary>
/// 层级枚举
/// </summary>
public enum E_UILayer
{
Bottom,
Middle,
Top,
System,
}

/// <summary>
/// 管理所有UI面板的管理器,注意:面板预设体名要和面板名一致
/// </summary>
public class UIManager : BaseManager<UIManager>
{
/// <summary>
/// 主要是用于里氏替换原则,在字典中,用父类容器装载子类对象
/// </summary>
private abstract class BasePanelInfo { }

/// <summary>
/// 用于存储面板消息和加载完成的回调函数
/// </summary>
/// <typeparam name="T">面板的类型</typeparam>
private class PanelInfo<T> : BasePanelInfo where T : BasePanel
{
public T panel;
public UnityAction<T> callBack;
public bool isHide;

public PanelInfo(UnityAction<T> callBack)
{
this.callBack += callBack;
}
}

private Camera uiCamara;
private Canvas uiCanvas;
private EventSystem uiEventSystem;
//层级父对象
private Transform bottomLayer;
private Transform middleLayer;
private Transform topLayer;
private Transform systemLayer;

/// <summary>
/// 用于存储所有的面板对象
/// </summary>
private Dictionary<string, BasePanelInfo> panelDic = new Dictionary<string, BasePanelInfo>();

private UIManager()
{
//动态创建唯一的Canvas和EventSystem(摄像机)
uiCamara = GameObject.Instantiate(ResManager.Instance.Load<GameObject>("UI/UICamera")).GetComponent<Camera>();
//UI摄像机过场景不可移除,它专门用来渲染UI面板
GameObject.DontDestroyOnLoad(uiCamara);
//动态创建Canvas,并设置使用的UI摄像机,设置过场景不可移除
uiCanvas = GameObject.Instantiate(ResManager.Instance.Load<GameObject>("UI/Canvas")).GetComponent<Canvas>();
uiCanvas.worldCamera = uiCamara;
GameObject.DontDestroyOnLoad(uiCanvas);
//找到层级父对象
bottomLayer = uiCanvas.transform.Find("Bottom");
middleLayer = uiCanvas.transform.Find("Middle");
topLayer = uiCanvas.transform.Find("Top");
systemLayer = uiCanvas.transform.Find("System");
//动态创建EventSystem
uiEventSystem = GameObject.Instantiate(ResManager.Instance.Load<GameObject>("UI/EventSystem")).GetComponent<EventSystem>();
GameObject.DontDestroyOnLoad(uiEventSystem);
}

/// <summary>
/// 获取对应层级的父对象
/// </summary>
/// <param name="layer">层级枚举值</param>
/// <returns>层级父对象</returns>
public Transform GetLayerObj(E_UILayer layer)
{
switch (layer)
{
case E_UILayer.Bottom:
return bottomLayer;
case E_UILayer.Middle:
return middleLayer;
case E_UILayer.Top:
return topLayer;
case E_UILayer.System:
return systemLayer;
default:
return null;
}
}

/// <summary>
/// 显示面板
/// </summary>
/// <typeparam name="T">面板的类型</typeparam>
/// <param name="layer">面板要显示在哪个层级下</param>
/// <param name="callBack">面板加载出来后的接收面板的回调函数</param>
/// <param name="isSync">是否同步加载 默认为false</param>
public void ShowPanel<T>(E_UILayer layer = E_UILayer.Middle, UnityAction<T> callBack = null, bool isSync = false) where T : BasePanel
{
//获取面板名,预设体名必须与面板类名一致
string panelName = typeof(T).Name;
//存在面板
if (panelDic.ContainsKey(panelName))
{
//取出消息
PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
//正在异步加载中
if (panelInfo.panel == null)
{
//将回调添加到记录中
panelInfo.isHide = false;
if (callBack != null)
panelInfo.callBack += callBack;
}
//已经加载结束
else
{
panelInfo.panel.ShowMe();
//直接执行回调,直接传递出去即可
callBack?.Invoke(panelInfo.panel);
}
return;
}

//不存在面板,先存入字典当中占位,之后如果又显示,我才能得到字典中的消息进行判断
panelDic.Add(panelName, new PanelInfo<T>(callBack));
ABResManager.Instance.LoadResAsync<GameObject>("ui", panelName, (res) =>
{
//取出消息
PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
if (panelInfo.isHide)
{
panelDic.Remove(panelName);
return;
}
//层级的处理
Transform layerObj = GetLayerObj(layer);
//避免没有按照指定规则传递参数,避免为空
if (layerObj == null)
layerObj = middleLayer;
//将面板预设体创建到对应父对象下,并且保持原本的缩放大小
GameObject panelObj = GameObject.Instantiate(res, layerObj, false);
T panel = panelObj.GetComponent<T>(); //获取对应的UI控件返回出去
panel.ShowMe(); //显示面板时执行的默认方法
panelInfo.panel = panel; //将加载出来的panel记录到panelInfo内
panelInfo.callBack?.Invoke(panel); //传递到外部使用
panelInfo.callBack = null; //清空回调,避免内存泄露
}, isSync);
}

/// <summary>
/// 隐藏面板
/// </summary>
/// <typeparam name="T">面板名</typeparam>
public void HidePanel<T>() where T : BasePanel
{
string panelName = typeof(T).Name;
if (panelDic.ContainsKey(panelName))
{
//取出消息
PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
//如果存在,但正在加载
if (panelInfo.panel == null)
{
//修改隐藏标识,标识这个面板将要隐藏,因为要隐藏,因此将回调置空
panelInfo.isHide = true;
panelInfo.callBack = null;
}
//已经加载结束
else
{
//销毁后从容器中移除
panelInfo.panel.HideMe();
GameObject.Destroy(panelInfo.panel.gameObject);
panelDic.Remove(panelName);
}
}
}

/// <summary>
/// 获取面板
/// </summary>
/// <typeparam name="T">面板名</typeparam>
public void GetPanel<T>(UnityAction<T> callBack) where T : BasePanel
{
string panelName = typeof(T).Name;

if (panelDic.ContainsKey(panelName))
{
//取出消息
PanelInfo<T> panelInfo = panelDic[panelName] as PanelInfo<T>;
if (panelInfo.panel == null)
{
//加载中,应该等待加载结束,再通过回调传递给外部去使用
panelInfo.callBack += callBack;
}
//加载结束,并且不处于将要隐藏的状态
else if (!panelInfo.isHide)
{
callBack?.Invoke(panelInfo.panel as T);
}

}
}
}