FishPlayer

一个喜欢摸鱼的废物

0%

Unity资源链接

在实际项目中,我们很多资源都是打到AB里了,这个情况下很多Prefab,Scriptable上想要配置一些资源只能填资源的路径。但是填路径真的很麻烦,万一资源发生位置变更就麻烦了。
在这个情况下想到的一个方案就是做一个 “资源链接” 对象,在面板上仍然绘制UnityObject,但是实际存储的是路径。

结合之前的项目里的需求大概看了下,具体的实现思路真的挺简单的,在编辑器时存放资源引用和资源的guid,资源构建时通过guid补充资源的实际路径。
这个思路下有两个痛点:
1 无法处理资源重命名时候会 偶尔 导致的guid变更情况
2 构建时的路径补充实现麻烦

第1个痛点其实是实现完成了才发现的,暂时还没想清楚怎么处理,因为一旦guid发生变更那其实这份资源的缓存可能已经无效了,找回会比较麻烦,除非监听更多的资源变更事件以及构建自己的资源数据库。

关于第2个痛点的路径补充,我有两种方法:
1 无论如何这个 资源链接 对象总需要一个宿主。这个宿主最终会是一个UnityObject或能继续在父级上找到一个UnityObject。那么只需要在宿主上实现一个资源构建前会调用的方法,在这个方法里完成路径的补充就好。
2 或者用我之前的笔记提到的,做文本的匹配再替换

在下面我会选择第2种方式来简单实现以下,实际工程中最好是先实现第1种比较好,因为有可能永远也不会遇到需要用到第2种方法解决的情形。

资源链接对象

首先是资源链接对象本身,做了一个泛型基类,对于目标的资源类型,需要新实现一个空子类而已,行为在基类已经都达成了。
这个泛型基类是为了之后的编辑器绘制而使用的妥协方案。

真机运行时只需要path,guid和资源缓存都是编辑器时才有的字段

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

using System;
using UnityEngine;
using UnityObject = UnityEngine.Object;
#if UNITY_EDITOR
using UnityEditor;
#endif

[Serializable]
public abstract class AssetPath<T> where T : UnityObject
{
public static implicit operator string(AssetPath<T> p) => p.Pathlink;

[SerializeField]
protected string m_path;

#if UNITY_EDITOR
[SerializeField]
protected string m_guid;

[SerializeField]
protected UnityObject m_assetCache;

#endif

public string Pathlink
{
get
{
#if UNITY_EDITOR // in editor time, it is no need to save the actual path, just use guid to get the path
if (Application.isPlaying)
{
m_path = AssetDatabase.GUIDToAssetPath(m_guid);
return m_path;
}
string path = AssetDatabase.GUIDToAssetPath(m_guid);
return path;
#else // in build, just return path
return m_path;
#endif
}
}

}

[Serializable]
public class PrefabPath : AssetPath<GameObject> { }
[Serializable]
public class MaterialPath : AssetPath<Material> { }
[Serializable]
public class ScriptableObjectPath : AssetPath<ScriptableObject> { }

资源链接相关的编辑器绘制

这边就没什么难度的,获取到序列化的字段然后画出来就好了!
刚才提到的泛型只是为了在下面绘制 ObjectField 时用来便捷获取资源类型的。

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

[CustomPropertyDrawer(typeof(AssetPath<>), true)]
public class AssetPathDrawer : PropertyDrawer
{
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
float baseHeight = base.GetPropertyHeight(property, label);
return 3f * baseHeight; // we need 3 line to show stuff
}

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
EditorGUI.BeginProperty(position, label, property);

// Draw label
position = EditorGUI.PrefixLabel(position, GUIUtility.GetControlID(FocusType.Passive), label);

float elementHeight = base.GetPropertyHeight(property, label);
SerializedProperty guidProperty = property.FindPropertyRelative("m_guid");
SerializedProperty pathProperty = property.FindPropertyRelative("m_path");

GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
buttonStyle.alignment = TextAnchor.MiddleLeft;
Rect rect = new Rect(position.x, position.y, position.width, elementHeight);
string guidText = guidProperty.stringValue;
if (GUI.Button(rect, new GUIContent($"Guid: {guidText}"), buttonStyle))
{
GUIUtility.systemCopyBuffer = guidText; // copy to clipboard
Debug.Log($"Copy guid text '{guidText}' to clipboard");
}

rect = new Rect(position.x, position.y + elementHeight, position.width, elementHeight);
string pathText = pathProperty.stringValue;
if (GUI.Button(rect, new GUIContent($"Path: {pathText}"), buttonStyle))
{
GUIUtility.systemCopyBuffer = pathText; // copy to clipboard
Debug.Log($"Copy path text '{guidText}' to clipboard");
}

rect = new Rect(position.x, position.y + elementHeight * 2, position.width, elementHeight);
SerializedProperty assetProperty = property.FindPropertyRelative("m_assetCache");
if (null != assetProperty)
{
UnityObject assetValue = assetProperty.objectReferenceValue;
Type assetType;
if (null == assetValue)
{
Type fieldType = fieldInfo.FieldType;
Type[] genericTypes = fieldType.BaseType.GenericTypeArguments;
if (null == genericTypes || 0 == genericTypes.Length)
{
assetType = typeof(UnityObject);
}
else
{
assetType = genericTypes[0];
}
}
else
{
assetType = assetValue.GetType();
}
UnityObject next = EditorGUI.ObjectField(rect, assetValue, assetType, false); // do not allow scene obj so it will actually be asset
if (next != assetValue) // value has changed, set guid and path
{
pathText = AssetDatabase.GetAssetPath(next);
guidText = AssetDatabase.AssetPathToGUID(pathText);
pathProperty.stringValue = pathText;
guidProperty.stringValue = guidText;
assetProperty.objectReferenceValue = next;
}
}

EditorGUI.EndProperty();
}
}

资源构建前的路径补充

在构建前通过guid或缓存的资源,去把路径刷新,然后就可以安心打构建了。

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

public static class BundlePreprocess_AssetPath
{
private static void PreProcess_AssetPathSoftLink()
{
/*
* @Hiko
*
* Need to match case below, then we get guid, parse actual path
* m_path: Assets/XX/resource.xx
* m_guid: 382c74c3721d4f3480e557657b6cbc27
*/
void DoTextReplace(string[] assetGUIDs)
{
// I have 0 knowledge of Regex, so I ask AI do it :)
// HACK put 4 space here is becuz there is at least 4 spc before the field name if you take a look into asset yaml file
string patternAsSingleField = @"\s*\s\s\s\s\m_path:\s*(?<path>.*)?\s*m_guid:\s*(?<guid>[a-fA-F0-9]*)?";
// if it is a array element, there should be a '-'
string patternAsArrayElement = @"\s*-\m_path:\s*(?<path>.*)?\s*m_guid:\s*(?<guid>[a-fA-F0-9]*)?";
for (int i = 0, assetCount = assetGUIDs.Length; i < assetCount; i++)
{
string assetGuid = assetGUIDs[i];
string assetPath = AssetDatabase.GUIDToAssetPath(assetGuid);
if (assetPath.StartsWith("Packages"))
{
continue; // dun know hot to use asset search fitler, skip asset in package here
}
string assetFullpath = Application.dataPath + assetPath.Substring("Assets".Length);
string assetText = File.ReadAllText(assetFullpath);
string resultText = assetText;

// AssetPath as normal field
MatchCollection matchCollection = Regex.Matches(assetText, patternAsSingleField, RegexOptions.Multiline);
ulong execCount = 0;
int matchCount = matchCollection.Count;
if (0 < matchCount)
{
for (int matchIndex = 0; matchIndex < matchCount; matchIndex++)
{
Match match = matchCollection[matchIndex];
if (match.Success)
{
string matchContent = match.Value;
int spaceCount = matchContent.IndexOf("p") - 1;
string guidString = match.Groups["guid"].Value;
if (string.IsNullOrEmpty(guidString) || 36 > guidString.Length) // invalid guid
{
continue;
}
string space = new string(' ', spaceCount); // add space
string path = AssetDatabase.GUIDToAssetPath(guidString);
string result1 = $"m_path: {path}";
string result2 = $"m_guid: {guidString}";
string result = $"\n{space}{result1}\n{space}{result2}";
resultText = resultText.Replace(matchContent, result);
++execCount;
}
}
}

// Asset Path as array element
assetText = resultText;
matchCollection = Regex.Matches(assetText, patternAsArrayElement, RegexOptions.Multiline);
matchCount = matchCollection.Count;
if (0 < matchCount)
{
for (int matchIndex = 0; matchIndex < matchCount; matchIndex++)
{
Match match = matchCollection[matchIndex];
if (match.Success)
{
string matchContent = match.Value;
int spaceCount = matchContent.IndexOf("p") - 1;
string guidString = match.Groups["guid"].Value;
if (string.IsNullOrEmpty(guidString) || 36 > guidString.Length) // invalid guid
{
continue;
}
string space = new string(' ', spaceCount); // add space
string path = AssetDatabase.GUIDToAssetPath(guidString);
string result1 = $"- m_path: {path}";
string result2 = $"m_guid: {guidString}";
string result = $"\n{new string(' ', spaceCount - 2)}{result1}\n{space}{result2}";
resultText = resultText.Replace(matchContent, result);
++execCount;
}
}
}

if (0 < execCount) // has changes
{
File.WriteAllText(assetFullpath, resultText);
}
}
}

// do stuff for SO first
string[] guids = AssetDatabase.FindAssets($"t: {nameof(ScriptableObject)}");
DoTextReplace(guids);
// then prefab
guids = AssetDatabase.FindAssets($"t: prefab");
DoTextReplace(guids);
// finally scenes
guids = AssetDatabase.FindAssets($"t: scene");
DoTextReplace(guids);
// refresh assets
AssetDatabase.Refresh();
}
}

这边如果使用的让宿主来刷新的方法,那我觉得会有一个需要注意的小点。
这个情况下刷新操作其实应该注意对资源处理的顺序,比如先处理 ScriptableObject 再到 Prefab,最后才是 Scene
这个操作的实质是修改 asset 然后再把修改结果写回磁盘,宿主对修改的处理方式可能会和我们想的不太一样。

比如说 Scene 中很可能会包含 prefab,如果先让 Scene 文件执行这个刷新,如果监测和检查没有做充分的情况下会抓到 Scene 里的 Prefab 然后让其在 Scene 里做了更改,并因此在 Scene 文件了多写了一行 override 信息。
同样的,Nested Prefab 也会遇到同样的问题。至于如何排布资源处理的顺序可能还得根据实际项目的情况来指定具体的规则。

但是!!!如果用第一种方法就不会有这个问题,因为仅匹配目标文本的情况下,不会产生多余的修改,所有匹配到的位置都是这个资源链接直接的宿主。