1

Unity UGUI开发,0GC更新视图

 6 months ago
source link: https://blog.uwa4d.com/archives/USparkle_UGUI0GC.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Unity UGUI开发,0GC更新视图

【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);       
    }    
}
1.png

自定义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进行绑定。下面是代码示例。

2.png
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); }
    }

}
3.png

以上所有代码都已经在我的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)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK