Unity UGUI开发,0GC更新视图

发布时间 2023-12-27 10:38:02作者: UWATech

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


前段时间在优化Unity游戏项目,发现在战斗场景中,UI需要更新大量内容,比如血量、伤害、各种技能效果等等,由于战斗比较激烈,一直在高频更新UI视图,通过UWA深度分析发现字符拼接产生的垃圾收集也不少。于是就想优化一下,分析了一下产生GC的原因,大概有下面几个方面。

  1. UI文字显示更新时字符串的拼接产生的GC。
  2. 数字类型转为字符串类型分配的GC(比如血量变化都必须由数字转为文字再显示)。
  3. 值类型在转文字时的装箱拆箱(比如使用String.format拼接字符串,都存在这个问题)。

我们游戏UI文字显示都是使用TMP控件做的,看了下TMP的源码,TMP_Text控件是支持通过char[]或者StringBuilder更新的,这样就完全可以绕过String,直接通过StringBuilder或者char[]去更新UI,而不必转为字符串了。

下面是TMP_Text.cs中的源码,为了测试0GC效果,我将文件中SetText()函数和StringBuilderToIntArray()函数中UNITY_EDITOR这个宏定义的代码块注释了。

public void SetText(StringBuilder text)
{
      m_inputSource = TextInputSources.SetCharArray;

      //#if UNITY_EDITOR
      //// Set the text in the Text Input Box in the Unity Editor only.
      //m_text = text.ToString();
      //#endif

      StringBuilderToIntArray(text, ref m_TextParsingBuffer);

      m_isInputParsingRequired = true;
      m_havePropertiesChanged = true;
      m_isCalculateSizeRequired = true;

      SetVerticesDirty();
      SetLayoutDirty();
}

  

有了方案,下面就只需要解决前面提到的3个问题即可。

第一个问题,所有字符串拼接都使用StringBuilder即可,StringBuilder可以完全多次复用,Unity的UI刷新都在主线程,也不存在线程安全问题,全局使用一个StringBuilder。

第二个问题,数字类型转字符串,数字由0-9和小数点这几个固定字符组成,数字类型转字符串改为数字类型转char[]即可,char[]也全局复用,将数字转为char[],然后写入到StringBuilder中。

第三个问题,数字在String.format或者StringBuilder.AppendFormat时会转为Object对象,这存在装箱拆箱问题。这就需要实现一个支持泛型参数的格式化追加函数。比如:StringBuilder.AppendFormat<TP1,TP2,TP3... TPn>()

所以重点在于解决第二和第三个问题,我阅读了C#官方有关StringBuilder.AppendFormat()的代码,需要在格式化同时还避免装箱拆箱,避免GC的类型主要是基本数字类型、DateTime类型、TimeSpan类型,其他的你要乐意可以支持一下Unity的Vector2-4,别的也就没有了。中间的具体过程我不多说,最终任务就3个,数字转字符串是通过NumberFormatter.NumberToString()函数实现,需要在这个基础上改造为无GC的方式。DateTime和TimeSpan的格式化由DateTimeFormat.cs和TimeSpanFormat.cs类实现,同样需要改造。

上源码:

改造前原函数如下,会将数字类型value直接转为string类型,必须在堆上为string对象分配内存:

public static string NumberToString (string format, uint value, IFormatProvider fp)
{
    NumberFormatter inst = GetInstance (fp);
    inst.Init (format, value, Int32DefPrecision);
    string res = inst.IntegerToString (format, fp);
    inst.Release();
    return res;
}

  

Mono库源码:
https://github.com/mono/mono/blob/main/mcs/class/corlib/System/NumberFormatter.cs

改造后函数如下,在数字类型value转换过程中,避免生成string,而是直接将char或者ReadOnlySpan写入到StringBuilder中,这里需要注意,所有的相关的函数都改一遍。

public static void NumberToString(ReadOnlySpan<char> format, uint value, IFormatProvider fp, StringBuilder result)
{
    NumberFormatter inst = GetInstance(fp);
    inst.Init(format, value, Int32DefPrecision);
    inst.IntegerToString(format, fp, result);
    inst.Release();
}

  

改造后源码:
https://github.com/vovgou/loxodon-framework/blob/master/Loxodon.Framework.TextFormatting/Assets/LoxodonFramework/TextFormatting/Runtime/Framework/TextFormatting/NumberFormatter.cs

  • TimeSpanFormat改造前
    与NumberFormatter原理相同,在Format过程中尽量避免产生新的字符串,避免字符串拼接。
internal static String Format(TimeSpan value, String format, IFormatProvider formatProvider)

  

C#官方源码:
https://github.com/microsoft/referencesource/blob/master/mscorlib/system/globalization/timespanformat.cs

改造后的函数:

internal static void Format(TimeSpan value, ReadOnlySpan<char> format, IFormatProvider formatProvider, StringBuilder result)

  

改造后源码:
https://github.com/vovgou/loxodon-framework/blob/master/Loxodon.Framework.TextFormatting/Assets/LoxodonFramework/TextFormatting/Runtime/Framework/TextFormatting/TimeSpanFormat.cs

  • DateTimeFormat
    DateTimeFormat修改相对麻烦,因为DateTimeFormat依赖了很多其他类,而C#官方底层很多代码是Native的或者都是Internal的类、方法、属性等,我无法直接使用,所以我只能将其他类中的函数或者属性剥离出来,拷贝到DateTimeFormat类中,另外还有一些特殊的日期类型,比如希伯来、日本等等类型需要处理。

修改前函数:

internal static String Format(DateTime dateTime, String format, DateTimeFormatInfo dtfi) {
    return Format(dateTime, format, dtfi, NullOffset);
}

  

C#官方源码:
https://github.com/microsoft/referencesource/blob/master/mscorlib/system/globalization/datetimeformat.cs

修改后函数:

internal static void Format(DateTime dateTime, ReadOnlySpan<char> format, StringBuilder result)
{
    Format(dateTime, format, DateTimeFormatInfo.GetInstance(null), NullOffset, result);
}

  

修改后的代码:
https://github.com/vovgou/loxodon-framework/blob/master/Loxodon.Framework.TextFormatting/Assets/LoxodonFramework/TextFormatting/Runtime/Framework/TextFormatting/DateTimeFormat.cs

就此,数字类型、DateTime、TimeSpan这几个类型的格式化改造完毕。

扩展StringBuilder,增加支持泛型参数的AppendFormat<TP1..TPn>函数。

StringBuilder本身是有AppendFormat函数的,但是参数是object[]类型,会导致值类型对象的装箱拆箱,new object[]有堆内存分配。所以我们需要扩展一个支持泛型参数的格式化追加函数AppendFormat<TP1..TPn>(),以避免垃圾回收开销。

public static class StringBuilderExtensions
    {
        private const int FORMAT_SPAN_SIZE = 128;
        private static readonly object EMPTY = new object();

        [ThreadStatic]
        private static StringBuilder result = new StringBuilder(128);

        public static StringBuilder AppendFormat<T>(this StringBuilder builder, string format, T[] values)
        {
            return AppendFormat(builder, format, values, GetFormatter<T>());
        }

        public static StringBuilder AppendFormat<T>(this StringBuilder builder, string format, T value)
        {
            return AppendFormat(builder, format, value, GetFormatter<T>());
        }

        public static StringBuilder AppendFormat<T0, T1>(this StringBuilder builder, string format, T0 t0, T1 t1)
        {
            return AppendFormat(builder, format, 2, t0, t1, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2)
        {
            return AppendFormat(builder, format, 3, t0, t1, t2, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3)
        {
            return AppendFormat(builder, format, 4, t0, t1, t2, t3, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4)
        {
            return AppendFormat(builder, format, 5, t0, t1, t2, t3, t4, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5)
        {
            return AppendFormat(builder, format, 6, t0, t1, t2, t3, t4, t5, EMPTY, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5, T6>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6)
        {
            return AppendFormat(builder, format, 7, t0, t1, t2, t3, t4, t5, t6, EMPTY, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5, T6, T7>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6, T7 t7)
        {
            return AppendFormat(builder, format, 8, t0, t1, t2, t3, t4, t5, t6, t7, EMPTY, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5, T6, T7, T8>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6, T7 t7, T8 t8)
        {
            return AppendFormat(builder, format, 9, t0, t1, t2, t3, t4, t5, t6, t7, t8, EMPTY);
        }

        public static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5, T6, T7, T8, T9>(this StringBuilder builder, string format, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6, T7 t7, T8 t8, T9 t9)
        {
            return AppendFormat(builder, format, 10, t0, t1, t2, t3, t4, t5, t6, t7, t8, t9);
        }

        private static StringBuilder AppendFormat<T0, T1, T2, T3, T4, T5, T6, T7, T8, T9>(StringBuilder builder, string format, int paramCount, T0 t0, T1 t1, T2 t2, T3 t3, T4 t4, T5 t5, T6 t6, T7 t7, T8 t8, T9 t9)
        {
            if (format == null)
                throw new ArgumentNullException("format");

            int pos = 0;
            int len = format.Length;
            char ch = '\x0';
            Span<char> formatSpan = stackalloc char[FORMAT_SPAN_SIZE];
            int formatIndex = 0;
            while (true)
            {
                while (pos < len)
                {
                    ch = format[pos];

                    pos++;
                    if (ch == '}')
                    {
                        if (pos < len && format[pos] == '}') // Treat as escape character for }}
                            pos++;
                        else
                            FormatError();
                    }

                    if (ch == '{')
                    {
                        if (pos < len && format[pos] == '{') // Treat as escape character for {{
                            pos++;
                        else
                        {
                            pos--;
                            break;
                        }
                    }

                    builder.Append(ch);
                }

                if (pos == len)
                    break;

                pos++;
                if (pos == len || (ch = format[pos]) < '0' || ch > '9')
                    FormatError();
                int index = 0;
                do
                {
                    index = index * 10 + ch - '0';
                    pos++;
                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                } while (ch >= '0' && ch <= '9' && index < 1000000);

                if (index >= paramCount)
                    throw new FormatException("The index of the format is out of range.");

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                bool leftJustify = false;
                int width = 0;
                if (ch == ',')
                {
                    pos++;
                    while (pos < len && format[pos] == ' ')
                        pos++;

                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                    if (ch == '-')
                    {
                        leftJustify = true;
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    }
                    if (ch < '0' || ch > '9')
                        FormatError();
                    do
                    {
                        width = width * 10 + ch - '0';
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    } while (ch >= '0' && ch <= '9' && width < 1000000);
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                formatIndex = 0;
                if (ch == ':')
                {
                    pos++;
                    while (true)
                    {
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                        if (!IsValidFormatChar(ch))
                            break;

                        formatSpan[formatIndex++] = ch;
                        pos++;                      
                    }
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                if (ch != '}')
                    FormatError();
                pos++;

                ReadOnlySpan<char> fmt = formatSpan.Slice(0, formatIndex);
                switch (index)
                {
                    case 0:
                        Format(fmt, t0, result.Clear());
                        break;
                    case 1:
                        Format(fmt, t1, result.Clear());
                        break;
                    case 2:
                        Format(fmt, t2, result.Clear());
                        break;
                    case 3:
                        Format(fmt, t3, result.Clear());
                        break;
                    case 4:
                        Format(fmt, t4, result.Clear());
                        break;
                    case 5:
                        Format(fmt, t5, result.Clear());
                        break;
                    case 6:
                        Format(fmt, t6, result.Clear());
                        break;
                    case 7:
                        Format(fmt, t7, result.Clear());
                        break;
                    case 8:
                        Format(fmt, t8, result.Clear());
                        break;
                    case 9:
                        Format(fmt, t9, result.Clear());
                        break;
                    default:
                        throw new NotSupportedException();
                }

                int pad = width - result.Length;
                if (!leftJustify && pad > 0)
                    builder.Append(' ', pad);
                AppendStringBuilder(builder, result);
                result.Clear();
                if (leftJustify && pad > 0)
                    builder.Append(' ', pad);
            }
            return builder;
        }

        private static StringBuilder AppendFormat<T>(StringBuilder builder, string format, T value, IFormatter formatter)
        {
            if (format == null)
                throw new ArgumentNullException("format");

            int pos = 0;
            int len = format.Length;
            char ch = '\x0';
            Span<char> formatSpan = stackalloc char[FORMAT_SPAN_SIZE];
            int formatIndex = 0;
            while (true)
            {
                while (pos < len)
                {
                    ch = format[pos];

                    pos++;
                    if (ch == '}')
                    {
                        if (pos < len && format[pos] == '}') // Treat as escape character for }}
                            pos++;
                        else
                            FormatError();
                    }

                    if (ch == '{')
                    {
                        if (pos < len && format[pos] == '{') // Treat as escape character for {{
                            pos++;
                        else
                        {
                            pos--;
                            break;
                        }
                    }

                    builder.Append(ch);
                }

                if (pos == len)
                    break;

                pos++;
                if (pos == len || (ch = format[pos]) < '0' || ch > '9')
                    FormatError();
                int index = 0;
                do
                {
                    index = index * 10 + ch - '0';
                    pos++;
                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                } while (ch >= '0' && ch <= '9' && index < 1000000);
                if (index >= 1)
                    throw new FormatException("The index of the format is out of range.");

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                bool leftJustify = false;
                int width = 0;
                if (ch == ',')
                {
                    pos++;
                    while (pos < len && format[pos] == ' ')
                        pos++;

                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                    if (ch == '-')
                    {
                        leftJustify = true;
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    }
                    if (ch < '0' || ch > '9')
                        FormatError();
                    do
                    {
                        width = width * 10 + ch - '0';
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    } while (ch >= '0' && ch <= '9' && width < 1000000);
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;
                //object arg = args[index];
                formatIndex = 0;
                if (ch == ':')
                {
                    pos++;
                    while (true)
                    {
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                        if (!IsValidFormatChar(ch))
                            break;

                        formatSpan[formatIndex++] = ch;
                        pos++;                       
                    }
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                if (ch != '}')
                    FormatError();
                pos++;

                ReadOnlySpan<char> fmt = formatSpan.Slice(0, formatIndex);
                Format(fmt, value, formatter, result.Clear());

                int pad = width - result.Length;
                if (!leftJustify && pad > 0)
                    builder.Append(' ', pad);
                AppendStringBuilder(builder, result);
                result.Clear();
                if (leftJustify && pad > 0)
                    builder.Append(' ', pad);
            }
            return builder;
        }

        private static StringBuilder AppendFormat<T>(StringBuilder builder, string format, T[] values, IFormatter formatter)
        {
            if (format == null)
                throw new ArgumentNullException("format");

            int pos = 0;
            int len = format.Length;
            char ch = '\x0';
            Span<char> formatSpan = stackalloc char[FORMAT_SPAN_SIZE];
            int formatIndex = 0;
            while (true)
            {
                while (pos < len)
                {
                    ch = format[pos];

                    pos++;
                    if (ch == '}')
                    {
                        if (pos < len && format[pos] == '}') // Treat as escape character for }}
                            pos++;
                        else
                            FormatError();
                    }

                    if (ch == '{')
                    {
                        if (pos < len && format[pos] == '{') // Treat as escape character for {{
                            pos++;
                        else
                        {
                            pos--;
                            break;
                        }
                    }

                    builder.Append(ch);
                }

                if (pos == len)
                    break;

                pos++;
                if (pos == len || (ch = format[pos]) < '0' || ch > '9')
                    FormatError();
                int index = 0;
                do
                {
                    index = index * 10 + ch - '0';
                    pos++;
                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                } while (ch >= '0' && ch <= '9' && index < 1000000);
                if (index >= values.Length)
                    throw new FormatException("The index of the format is out of range.");

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                bool leftJustify = false;
                int width = 0;
                if (ch == ',')
                {
                    pos++;
                    while (pos < len && format[pos] == ' ')
                        pos++;

                    if (pos == len)
                        FormatError();
                    ch = format[pos];
                    if (ch == '-')
                    {
                        leftJustify = true;
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    }
                    if (ch < '0' || ch > '9')
                        FormatError();
                    do
                    {
                        width = width * 10 + ch - '0';
                        pos++;
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                    } while (ch >= '0' && ch <= '9' && width < 1000000);
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;
                T value = values[index];

                formatIndex = 0;
                if (ch == ':')
                {
                    pos++;
                    while (true)
                    {
                        if (pos == len)
                            FormatError();
                        ch = format[pos];
                        if (!IsValidFormatChar(ch))
                            break;

                        formatSpan[formatIndex++] = ch;
                        pos++;                      
                    }
                }

                while (pos < len && (ch = format[pos]) == ' ')
                    pos++;

                if (ch != '}')
                    FormatError();
                pos++;

                ReadOnlySpan<char> fmt = formatSpan.Slice(0, formatIndex);
                Format(fmt, value, formatter, result.Clear());

                int pad = width - result.Length;
                if (!leftJustify && pad > 0)
                    builder.Append(' ', pad);
                AppendStringBuilder(builder, result);
                result.Clear();
                if (leftJustify && pad > 0)
                    builder.Append(' ', pad);
            }
            return builder;
        }

        private static bool IsValidFormatChar(char ch)
        {
            if (ch == 123 || ch == 125)//{ } 
                return false;

            if ((ch >= 32 && ch <= 122) || ch == 124)
                return true;
            return false;
        }

        private static void Format<T>(ReadOnlySpan<char> format, T value, IFormatter formatter, StringBuilder builder)
        {
            if (formatter is IFormatter<T> genericFormatter)
                genericFormatter.Format(format, value, builder);
            else
                formatter.Format(format, value, builder);
        }

        private static void Format<T>(ReadOnlySpan<char> format, T value, StringBuilder builder)
        {
            IFormatter formatter = GetFormatter<T>();
            if (formatter is IFormatter<T> genericFormatter)
                genericFormatter.Format(format, value, builder);
            else
                formatter.Format(format, value, builder);
        }

        private static StringBuilder AppendStringBuilder(StringBuilder builder, StringBuilder value)
        {
            int len = value.Length;
            for (int i = 0; i < len; i++)
            {
                builder.Append(value[i]);
            }
            return builder;
        }

        private static void FormatError()
        {
            throw new FormatException("Invalid Format");
        }
    }

  

到目前为止已经支持了一个支持字符串格式化,且完全0GC的StringBuilder。关于使用示例如下:

using System;
using System.Text;
using UnityEngine;
using Loxodon.Framework.TextFormatting;//make sure to first import the required namespace
public class Example : MonoBehaviour
{
    StringBuilder builder = new StringBuilder();
    void Update()
    {
        builder.Clear();
        builder.AppendFormat<DateTime,int>("Now:{0:yyyy-MM-dd HH:mm:ss} Frame:{0:D6}", DateTime.Now,Time.frameCount);
        builder.AppendFormat<float>("{0:f2}", Time.realtimeSinceStartup);       
    }    
}

  

 

自定义TextMeshPro控件

既然花了大量时间做了一个0GC的StringBuilder,那么也就不在乎再多花点时间去扩展TextMeshPro控件了。我们项目中,前端同事经常会使用表达式绑定去更新UI视图,比如战斗中的各种事件提示:伤害100、吸血50、游戏时间倒计时等等,都是字符串和数字的拼接,使用表达式绑定虽然方便,但是使用是有成本的,在IL2CPP编译下不支持JIT,表达式解析需要依赖反射,性能并不好。所以我干脆写了一个支持格式化功能的文本控件FormattableTextMeshProUGUI和一个文本模版控件TemplateTextMeshProUGUI,这样即确保了0GC、高性能、又兼顾了使用的方便性。

以下是使用表达式绑定的例子,即存在反射,又有字符串拼接:

bindingSet.Bind(health).For(v => v.text).ToExpression(vm => string.Format("血量{0}",vm.Hero.Health));
bindingSet.Bind(damage).For(v => v.text).ToExpression(vm => string.Format("伤害{0}",vm.Ability.Damage));

  

  • FormattableTextMeshProUGUI
  public class FormattableTextMeshProUGUI : TextMeshProUGUI
    {
        internal static StringBuilder BUFFER = new StringBuilder();
        [SerializeField]
        protected string m_Format = "{0}";
        [SerializeField]
        protected int m_ParameterCount = 1;
        private Parameters m_Parameters;
        public string Format
        {
            get { return this.m_Format; }
            set { this.m_Format = value; }
        }

        public int ParameterCount
        {
            get { return this.m_ParameterCount; }
            set { this.m_ParameterCount = value; }
        }

        protected override void OnEnable()
        {
            base.OnEnable();
            Initialize();
        }

        public override void SetAllDirty()
        {
            base.SetAllDirty();
            Initialize();
        }

        protected virtual void Initialize()
        {
            SetText(BUFFER.Clear().Append(m_Format));
        }

        public ArrayParameters<T> AsArray<T>()
        {
            if (m_Parameters == null)
                m_Parameters = new ArrayParameters<T>(this, this.ParameterCount);

            if (m_Parameters is ArrayParameters<T> parameters)
                return parameters;

            throw new NotSupportedException($"The current parameter type has been set to \"{m_Parameters.GetType()}\" and cannot be converted to other types.");
        }

        public GenericParameters<P1> AsParameters<P1>()
        {
            if (m_Parameters == null)
                m_Parameters = new GenericParameters<P1>() { Text = this };

            if (m_Parameters is GenericParameters<P1> parameters)
                return parameters;

            throw new NotSupportedException($"The current parameter type has been set to \"{m_Parameters.GetType()}\" and cannot be converted to other types.");
        }

        public GenericParameters<P1, P2> AsParameters<P1, P2>()
        {
            if (m_Parameters == null)
                m_Parameters = new GenericParameters<P1, P2>() { Text = this };

            if (m_Parameters is GenericParameters<P1, P2> parameters)
                return parameters;

            throw new NotSupportedException($"The current parameter type has been set to \"{m_Parameters.GetType()}\" and cannot be converted to other types.");
        }

        public GenericParameters<P1, P2, P3> AsParameters<P1, P2, P3>()
        {
            if (m_Parameters == null)
                m_Parameters = new GenericParameters<P1, P2, P3>() { Text = this };

            if (m_Parameters is GenericParameters<P1, P2, P3> parameters)
                return parameters;

            throw new NotSupportedException($"The current parameter type has been set to \"{m_Parameters.GetType()}\" and cannot be converted to other types.");
        }

        public GenericParameters<P1, P2, P3, P4> AsParameters<P1, P2, P3, P4>()
        {
            if (m_Parameters == null)
                m_Parameters = new GenericParameters<P1, P2, P3, P4>() { Text = this };

            if (m_Parameters is GenericParameters<P1, P2, P3, P4> parameters)
                return parameters;

            throw new NotSupportedException($"The current parameter type has been set to \"{m_Parameters.GetType()}\" and cannot be converted to other types.");
        }
    }

  

FormattableTextMeshProUGUI控件的AsParameters<>()函数可以转为一个泛型参数集,支持1-4个不同参数,也可以通过AsArray()创建一个泛型数组,通过泛型参数集或者泛型数组和ViewModel进行绑定。下面是代码示例。

 

 

public class FormattableTextMeshProUGUIExample : MonoBehaviour
{
    public FormattableTextMeshProUGUI paramBinding1;

    private ExampleViewModel viewModel;

    private void Start()
    {
        ApplicationContext context = Context.GetApplicationContext();
        IServiceContainer container = context.GetContainer();
        BindingServiceBundle bundle = new BindingServiceBundle(context.GetContainer());
        bundle.Start();

        BindingSet<FormattableTextMeshProUGUIExample, ExampleViewModel> bindingSet = this.CreateBindingSet<FormattableTextMeshProUGUIExample, ExampleViewModel>();

        //Create a parameter collection using AsParameters<P1, P2, ...>(). It supports 1-4 parameters 
        //without the need for value type boxing/unboxing or string concatenation, ensuring a GC-free 
        //experience. For testing the 0GC effect on a mobile device, if testing in Unity Editor, please 
        //modify the source code of the TextMeshPro plugin by removing any code related to 
        //StringBuilder.ToString() in the functions TMP_Text.SetText and TMP_Text.StringBuilderToIntArray.
        //format:The format follows the same formatting parameters as string.Format(), for example: DateTime - Example1, {0:yyyy-MM-dd HH:mm:ss}, FrameCount: {1}
        bindingSet.Bind(paramBinding1.AsParameters<DateTime, int>()).For(v => v.Parameter1).To(vm => vm.Time);
        bindingSet.Bind(paramBinding1.AsParameters<DateTime, int>()).For(v => v.Parameter2).To(vm => vm.FrameCount);            
        bindingSet.Build();

        this.viewModel = new ExampleViewModel();
        this.viewModel.Time = DateTime.Now;
        this.viewModel.FrameCount = 1;
        this.SetDataContext(this.viewModel);
    }
}

  

除了上面的使用方法外,还支持另外一种使用方式,在脚本FormattableTextMeshProUGUIExample中定义一个类型为GenericParameters<DateTime,int>的参数集变量,在UnityEditor中将FormattableTextMeshProUGUI拖放到下图脚本的属性paramBinding1上(我扩展了编辑器,支持将FormattableTextMeshProUGUI对象拖放到泛型参数集上)。然后将参数集与视图模型绑定。与第一种方式本质是一样的,都是通过创建一个泛型参数集和视图模型绑定。

public class FormattableTextMeshProUGUIExample : MonoBehaviour
{
    public GenericParameters<DateTime,int> paramBinding1;//参数绑定示例1,支持1-4个不同参数

    private ExampleViewModel viewModel;

    private void Start()
    {
        ApplicationContext context = Context.GetApplicationContext();
        IServiceContainer container = context.GetContainer();
        BindingServiceBundle bundle = new BindingServiceBundle(context.GetContainer());
        bundle.Start();

        BindingSet<FormattableTextMeshProUGUIExample , ExampleViewModel> bindingSet = this.CreateBindingSet<FormattableTextExample, ExampleViewModel>();

        //使用AsParameters<P1,P2,...>() 函数创建一个参数集合,然后绑定,支持1-4个参数,没有值对象的装箱拆箱,没有字符串拼接,降低GC,使用TMP文本可以完全无GC
        //format:格式与string.Format()的格式化参数相同如:DateTime:Example1,{0:yyyy-MM-dd HH:mm:ss}, FrameCount:{1}
        bindingSet.Bind(paramBinding1).For(v => v.Parameter1).To(vm => vm.Time);
        bindingSet.Bind(paramBinding1).For(v => v.Parameter2).To(vm => vm.FrameCount);

        bindingSet.Build();

        this.viewModel = new ExampleViewModel();
        this.viewModel.Time = DateTime.Now;
        this.viewModel.FrameCount = 1;
        this.SetDataContext(this.viewModel);
    }
}

  

从以上这两个示例可以看出,值类型的参数都采用了泛型类型,不会有装箱拆箱操作,同时因为文本控件内部使用的是StringBuilder.AppendFormat<>()函数,而且一直在复用StringBuilder,这都避免了内存分配,所以整个UI的更新可以实现完全0GC的效果。

  • TemplateTextMeshProUGUI
  public class TemplateTextMeshProUGUI : TextMeshProUGUI
    {
        [SerializeField]
        [TextArea(5, 10)]
        private string m_Template;
        private object data;
        private TextTemplateBinding templateBinding;

        protected TextTemplateBinding Binding
        {
            get
            {
                if (templateBinding == null)
                    templateBinding = new TextTemplateBinding(SetText);
                return templateBinding;
            }
        }

        public string Template
        {
            get { return this.m_Template; }
            set
            {
                if (string.Equals(this.m_Template, value))
                    return;

                this.m_Template = value;
                Binding.Template = this.m_Template;
            }
        }
        public object Data
        {
            get { return this.data; }
            set
            {
                if (Equals(this.data, value))
                    return;

                this.data = value;
                Binding.Data = this.data;
            }
        }

        protected override void OnEnable()
        {
            base.OnEnable();
            Initialize();
        }

        public override void SetAllDirty()
        {
            base.SetAllDirty();
            Initialize();
        }

        protected virtual void Initialize()
        {
            SetText(BUFFER.Clear().Append(m_Template));
        }

        protected override void OnDestroy()
        {
            if (templateBinding != null)
            {
                templateBinding.Dispose();
                templateBinding = null;
            }
            base.OnDestroy();
        }
    }

  

这个控件比格式化文本控件更强大,更好用。支持将一个ViewModel对象或者子对象绑定到TemplateTextMeshProUGUI.Data属性,模版控件内置了路径解析和数据绑定功能,能自动通过文本模板{}中间的VM属性的路径(如:{Hero.AttackDamage})创建绑定代理,自动监听VM属性的改变来更新控件的文本内容,使用时只需要将Data属性和ViewModel绑定即可。

文本模版格式:Frame:{FrameCount:D6},Health:{Hero.Health:D4} AttackDamage:{Hero.AttackDamage} Armor:{Hero.Armor}

其中FrameCount、Hero是绑定到Data的对象的属性。Health、AttackDamage和Armor是Hero对象的属性。FrameCount后面的D6是帧数这个数字类型的格式化参数。

public class FormattableTextMeshProUGUIExample : MonoBehaviour
{
    public FormattableTextMeshProUGUI paramBinding1;//参数绑定示例1,支持1-4个不同参数
    public GenericParameters<DateTime, int> paramBinding2;//参数绑定的另外一种方式,支持1-4个不同参数
    public FormattableTextMeshProUGUI arrayBinding;//也可以使用 ArrayParameters<float>
    public TemplateTextMeshProUGUI template;//模版绑定

    private ExampleViewModel viewModel;

    private void Start()
    {
        ApplicationContext context = Context.GetApplicationContext();
        IServiceContainer container = context.GetContainer();
        BindingServiceBundle bundle = new BindingServiceBundle(context.GetContainer());
        bundle.Start();

        BindingSet<FormattableTextMeshProUGUIExample, ExampleViewModel> bindingSet = this.CreateBindingSet<FormattableTextMeshProUGUIExample, ExampleViewModel>();

        //使用AsParameters<P1,P2,...>() 函数创建一个参数集合,然后绑定,支持1-4个参数,没有值对象的装箱拆箱,没有字符串拼接,无GC(请在手机上测试,Editor下需要修改TMP_Text.SetText和TMP_Text.StringBuilderToIntArray的源码,关闭调试代码)
        //format:格式与string.Format()的格式化参数相同如:DateTime:Example1,{0:yyyy-MM-dd HH:mm:ss}, FrameCount:{1}
        bindingSet.Bind(paramBinding1.AsParameters<DateTime, int>()).For(v => v.Parameter1).To(vm => vm.Time);
        bindingSet.Bind(paramBinding1.AsParameters<DateTime, int>()).For(v => v.Parameter2).To(vm => vm.FrameCount);

        //本质上与上面的例子是相同的,只是另外一种用法
        //format:Example2,{0:yyyy-MM-dd HH:mm:ss}, FrameCount:{1}
        bindingSet.Bind(paramBinding2).For(v => v.Parameter1).To(vm => vm.Time);
        bindingSet.Bind(paramBinding2).For(v => v.Parameter2).To(vm => vm.FrameCount);

        //使用AsArray<T>() 获得一个数组然后进行绑定,支持多个类型相同的参数,没有值对象的装箱拆箱,没有字符串拼接,无GC(请在手机上测试,Editor下需要修改TMP_Text.SetText和TMP_Text.StringBuilderToIntArray的源码,关闭调试代码)
        //format:MoveSpeed:{0:f4}  AttackSpeed:{1:f2}
        bindingSet.Bind(arrayBinding.AsArray<float>()).For(v => v[0]).To(vm => vm.Hero.MoveSpeed);
        bindingSet.Bind(arrayBinding.AsArray<float>()).For(v => v[1]).To(vm => vm.Hero.AttackSpeed);

        //使用文本模版(TemplateTextMeshProUGUI)绑定,直接将一个对象绑定到模板的Data属性上即可。
        //文本模版格式与string.Format类似,仅需要将{0},{1}中的数字,替换为对象属性名即可
        //template text:当前时间:{Time:yyyy-MM-dd HH:mm:ss} 
        bindingSet.Bind(template).For(v => v.Template).To(vm => vm.Template);//模版可以绑定,也可以在编辑器上配置
        bindingSet.Bind(template).For(v => v.Data).To(vm => vm);
        bindingSet.Build();

        this.viewModel = new ExampleViewModel();
        this.viewModel.Template = "Template,Frame:{FrameCount:D6},Health:{Hero.Health:D4} AttackDamage:{Hero.AttackDamage} Armor:{Hero.Armor}";
        this.viewModel.Time = DateTime.Now;
        this.viewModel.TimeSpan = TimeSpan.FromSeconds(0);
        this.viewModel.Hero = new Hero();
        this.SetDataContext(this.viewModel);
    }

    void Update()
    {
        viewModel.Time = DateTime.Now;
        viewModel.FrameCount = Time.frameCount;
        viewModel.Hero.Health = (Time.frameCount % 1000) / 10;
    }
}

public class ExampleViewModel : ObservableObject
{
    private DateTime time;
    private TimeSpan timeSpan;
    private string template;
    private int frameCount;
    private Hero hero;
    public DateTime Time
    {
        get { return this.time; }
        set { this.Set(ref time, value); }
    }

    public TimeSpan TimeSpan
    {
        get { return this.timeSpan; }
        set { this.Set(ref timeSpan, value); }
    }

    public int FrameCount
    {
        get { return this.frameCount; }
        set { this.Set(ref frameCount, value); }
    }

    public string Template
    {
        get { return this.template; }
        set { this.Set(ref template, value); }
    }

    public Hero Hero
    {
        get { return this.hero; }
        set { this.Set(ref hero, value); }
    }
}

public class Hero : ObservableObject
{
    private float attackSpeed = 95.5f;
    private float moveSpeed = 2.4f;
    private int health = 100;
    private int attackDamage = 20;
    private int armor = 30;

    public float AttackSpeed
    {
        get { return this.attackSpeed; }
        set { this.Set(ref attackSpeed, value); }
    }

    public float MoveSpeed
    {
        get { return this.moveSpeed; }
        set { this.Set(ref moveSpeed, value); }
    }

    public int Health
    {
        get { return this.health; }
        set { this.Set(ref health, value); }
    }

    public int AttackDamage
    {
        get { return this.attackDamage; }
        set { this.Set(ref attackDamage, value); }
    }

    public int Armor
    {
        get { return this.armor; }
        set { this.Set(ref armor, value); }
    }

}

  

 

以上所有代码都已经在我的MVVM框架中开源,可以从我的GitHub仓库中签出试用。

Loxodon.Framework.TextFormatting插件包括所有针对StringBuilder.AppendFormat<>()支持的代码:
https://github.com/vovgou/loxodon-framework/tree/master/Loxodon.Framework.TextFormatting

Loxodon.Framework.TextMeshPro插件是针对TextMeshPro控件的自定义和扩展:
https://github.com/vovgou/loxodon-framework/tree/master/Loxodon.Framework.TextMeshPro


这是侑虎科技第1519篇文章,感谢作者Loxodon Studio供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/cocowolf

再次感谢Loxodon Studio的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)