Version: 2021.3
에셋 검사
리소스 폴더

문자열과 텍스트

문자열과 텍스트 취급 문제는 Unity 프로젝트에서 성능 문제를 주로 유발하는 원인 중 하나입니다. C#에서 모든 문자열은 변하지 않습니다. 문자열을 조작하면 전체 문자열이 새로 할당됩니다. 이것은 상대적으로 비용이 비싸며, 반복적인 문자열 연결은 크기가 큰 문자열, 큰 데이터세트 또는 빠르게 반복하는 루프에서 수행될 때 성능 문제가 발생할 수 있습니다.

또한, N개의 문자열 연결은 N–1개의 중간 문자열을 할당해야 하므로, 연쇄적인 연결은 관리되는 메모리에 큰 부담을 줄 수 있습니다.

문자열이 빠르게 반복하는 루프나 매 프레임에서 연결되어야 할 경우 실제 연결 작업을 하기 위해 StringBuilder를 사용합니다. 또한 StringBuilder 인스턴스는 불필요한 메모리 할당을 좀더 최소화하기 위해 재사용될 수 있습니다.

Microsoft는 C# 에서 문자열 작업에 대한 베스트 프랙티스 리스트를 제공하고 있습니다. MSDN 웹사이트인 msdn.microsoft.com를 참조하십시오.

로케일 강제 변환 및 서수 비교

문자열 관련 코드에서 종종 발견되는 중요한 성능 문제 중의 하나는 속도가 느린 기본 문자열 API를 의도치 않게 사용했을 때 발생합니다. API는 업무용 애플리케이션용으로 만들어졌으며 텍스트에 있는 문자를 고려하여 여러가지 다른 문화적, 언어적 규칙으로 문자열을 다루려고 시도합니다.

For example, the following example code returns true when run under the US-English locale, but returns false for many European locales.

Note: As of Unity 5.3 and 5.4, Unity’s scripting runtimes always run under the US English (en-US) locale:

    String.Equals("encyclopedia", "encyclopædia");

For most Unity projects, this is entirely unnecessary. It’s roughly ten times faster to use the ordinal comparison type, which compares strings in a manner familiar to C and C++ programmers: by simply comparing each sequential byte of the string, without regard for the character represented by that byte.

서수 문자열 비교 방식으로 바꾸는 것은 간단한데, String.Equals의 마지막 인수로 StringComparison.Ordinal를 작성하기만 하면 됩니다.

myString.Equals(otherString, StringComparison.Ordinal);

비효율적인 빌트인 문자열 API

Beyond switching to ordinal comparisons, certain C# String APIs are known to be extremely inefficient. Among these are String.Format, String.StartsWith and String.EndsWith. String.Format is difficult to replace, but the inefficient string comparison methods are trivially optimized away.

While Microsoft’s recommendation is to pass StringComparison.Ordinal into any string comparison that doesn’t need to be adjusted for localization, Unity benchmarks show that the impact of this is relatively minimal compared to a custom implementation.

메서드 십만 개의 짧은 문자열 처리 시간(ms)
String.StartsWith, 기본값 137
String.EndsWith, default culture 542
String.StartsWith, 서수 115
String.EndsWith, 서수 34
커스텀 StartsWith으로 교체 4.5
커스텀 EndsWith으로 교체 4.5

String.StartsWithString.EndsWith 둘 다 다음의 예제와 같이, 간단하게 핸드 코딩한 것으로 교체할 수 있습니다.


    public static bool CustomEndsWith(this string a, string b)
        {
            int ap = a.Length - 1;
            int bp = b.Length - 1;
    
            while (ap >= 0 && bp >= 0 && a [ap] == b [bp])
            {
                ap--;
                bp--;
            }
    
            return (bp < 0);
        }

        public static bool CustomStartsWith(this string a, string b)
        {
            int aLen = a.Length;
            int bLen = b.Length;
    
            int ap = 0; int bp = 0;
    
            while (ap < aLen && bp < bLen && a [ap] == b [bp])
            {
                ap++;
                bp++;
            }
    
            return (bp == bLen);
        }

정규 표현식

While Regular Expressions are a powerful way to match and manipulate strings, they can be extremely performance-intensive. Further, due to the C# library’s implementation of Regular Expressions, even simple Boolean IsMatch queries allocate large transient datastructures “under the hood.” This transient managed memory churn should be deemed unacceptable, except during initialization.

If regular expressions are necessary, it’s strongly recommended to not use the static Regex.Match or Regex.Replace methods, which accept the regular expression as a string parameter. These methods compile the regular expression on-the-fly and don’t cache the generated object.

이 예제는 무해해 보이는 한 줄 코드입니다.


Regex.Match(myString, "foo");

하지만, 이 코드가 실행될 때마다 5KB 정도의 가비지가 생성됩니다. 간단한 리펙토링으로 대부분의 가비지를 제거할 수 있습니다.


var myRegExp = new Regex("foo");

myRegExp.Match(myString);

In this example, each call to myRegExp.Match “only” results in 320 bytes of garbage. While this is still expensive for a simple matching operation, it’s a considerable improvement over the previous example.

Therefore, if the regular expressions are invariant string literals, it’s considerably more efficient to precompile them by passing them as the first parameter of the Regex object’s constructor. These precompiled Regexes should then be reused.

XML, JSON과 그 외 긴 형식의 텍스트 파싱

Parsing text is often one of the heaviest operations that occurs at loading time. Sometimes, the time spent parsing text can outweigh the time spent loading and instantiating Assets.

The reasons behind this depend on the specific parser used. C#’s built-in XML parser is extremely flexible, but as a result, it’s not optimizable for specific data layouts.

Many third-party parsers are built on reflection. While reflection is an excellent choice during development (because it allows the parser to rapidly adapt to changing data layouts), it’s notoriously slow.

Unity has introduced a partial solution with its built-in JSONUtility API, which provides an interface to Unity’s serialization system that reads/emits JSON. In most benchmarks, it’s faster than pure C# JSON parsers, but it has the same limitations as other interfaces to Unity’s serialization system – it can’t serialized many complex data types, such as Dictionaries, without additional code.

Note: See the ISerializationCallbackReceiver interface for one way to add the additional processing necessary to convert to/from complex data types during Unity’s serialization process.

텍스트 데이터 구문 분석으로 인하여 성능 문제를 겪는 경우, 다음의 세 가지 대안을 고려하십시오.

방법 1: 빌드할 때 구문 분석

텍스트 구문 분석의 부하를 피하는 가장 좋은 방법은, 런타임 시점에 텍스트 구문 분석을 완전히 제거하는 방법입니다. 일반적으로 이는, 일종의 빌드 단계를 통해 텍스트 데이터를 바이너리 포맷으로 “베이킹”한다는 것을 의미합니다.

이러한 방법을 선택하는 대부분의 개발자들은 데이터를 ScriptableObject 파생 클래스 계층 구조 등으로 이동시킨 후, 에셋 번들을 통해 데이터를 배포합니다. ScriptableObject의 사용에 관련된 훌륭한 강연이 있습니다. YouTube에 있는 Richard Fine’s Unite 2016 talk를 참조하십시오.

This strategy offers the best possible performance, but is only suitable for data that doesn’t need to be generated dynamically. it’s best suited for game design parameters and other content.

방법 2: 분할 및 지연 로드

두 번째 방법은 구문 분석되어야 하는 데이터를 작은 부분으로 분할하는 것입니다. 데이터를 분할하면 이를 구문 분석하는 데 필요한 비용이 다수의 프레임으로 분산됩니다. 이상적으로는 사용자에게 원하는 경험을 제공하기 위해 필요한 데이터의 특정 부분을 파악하고, 해당 데이터만 로드하는 것입니다.

간단한 예제를 들어보겠습니다. 만약 프로젝트가 플랫폼 게임인 경우 모든 레벨 데이터를 거대한 덩어리 하나로 직렬화할 필요는 없습니다. 각각의 레벨마다 개별 에셋으로 데이터를 분할하고, 레벨을 세그먼트 별로 세분화했다면, 플레이어가 레벨에 접근할 때 데이터를 구문 분석할 수 있게 됩니다.

이는 쉬워보이지만, 현실적으로는 툴 코드에 대한 상당한 투자를 필요로 하며, 데이터 구조를 재구성해야 할 수도 있습니다.

방법 3: 스레드

For data that’s parsed entirely into plain C# objects, and doesn’t require any interaction with Unity APIs, it’s possible to move the parsing operations to worker threads.

This option can be extremely powerful on platforms with a significant number of cores. However, it requires careful programming to avoid creating deadlocks and race conditions.

Note: iOS devices have at most 2 cores. Most Android devices have from 2 to 4. This technique of more interest when building for standalone and console build targets.

Projects that choose to implement threading use the built-in C# Thread and ThreadPool classes (see msdn.microsoft.com) to manage their worker threads, along with the standard C# synchronization classes.

에셋 검사
리소스 폴더