使用Shader進行UGUI的優化

Zerone羽發表於2018-03-22
1. 前言
近期斷斷續續地做了一些優化的工作,包括資源載入、ui優化、效果分級各個方面。優化本身是一件瑣碎且耗神的事情,需要經歷問題定位、原因探查、優化方案設計和實現、效果驗證、資源修改多個步驟,也會涉及到各個職位之間的配合和協調。在這其中,可能帶來較大工作量的是對於之前普遍使用的一些方法/控制元件的優化,如果無法相容之前的使用介面,可能會給美術和程式帶來較大的迭代工作量。


UI是這其中可能越早發現問題收益越高的一塊內容,所以整理一下這段時間做了一些基於Shader來進行優化的思路和方法,以及分享一下自己構建的代替ugui提供的通用控制元件的那些Component,希望在專案中前期的同學可以提前發現類似的問題進行儘早的改進。


2. 優化目標

ugui已經提供了非常豐富的控制元件來給我們使用,但是出於通用性的考慮,其中很多控制元件的效能和效果都可能存在一些問題,又或者在頻繁更改ui數值的需求下會引發持續的Mesh重建導致CPU的消耗。我們期望通過一些簡單的Shader邏輯來提升效果或者提高效率,主要會集中在如下幾個方面:


  • 降低Draw Call;
  • 減少Overdraw;
  • 減少UI Mesh重建次數和範圍。

接下來的內容,我們就從具體的優化內容上來分享下使用簡單的Shader進行UGUI優化的過程。

3. 小地圖

在我們遊戲中,玩家移動的時候右上角會一直有小地圖的顯示,這個功能在最初的實現方案中是使用ugui的mask元件來做的,給了一個方形的mask元件,然後根據玩家位置計算出地圖左下角的位置進行移動。這種實現方式雖然簡單,但是會有兩個問題:

  • Overdraw特別大,幾乎很多時候會有整個螢幕的overdraw;

  • 玩家在移動過程中,因為一直在持續移動圖片的位置(做了適當的降頻處理),所以會一直有UI的Mesh重建過程。




當時的prefab已經被修改了,我簡單模擬一下使用Mask的方法帶來的Overdraw的效果如下圖所示:

 

使用Mask元件帶來的Overdraw的問題



在上圖中可以看到,左側是小地圖在螢幕中的效果,右側是選擇Overdraw檢視之後的效果,整張圖片都會有一個繪製的過程,佔據幾乎整個螢幕(白框),而且Mask也是需要一次繪製過程,這樣就是兩個Drawcall。其實這裡ui同學為了表現品質感,在小地圖上又蒙了一層半透的外框效果,消耗更大一些。



針對這一問題,首先對於矩形的地圖,可以使用執行效率更高一些的RectMask2D元件,但這並不能有本質的提升,解決Overdraw最根本的方法還是不要繪製那麼大的貼圖然後通過蒙版或者clip的方式去掉,這是很浪費的方法。有過基本Shader概念的朋友應該可以想到修改uv的方法,這也是我們採用的方法——思路很簡單,就做一個和要顯示的大小一樣的RawImage控制元件,然後賦給它一個特殊的材質,在vs裡面修改要顯示的區域的uv就可以做到想要的效果。



直接貼出來Shader程式碼如下:


[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sampler2D _MainTex;
fixed4 _UVScaleOffset;
sampler2D _BlendTexture;
      
v2f vert(appdata_t IN)    
{    
    v2f OUT;    
    OUT.vertex = mul(UNITY_MATRIX_MVP, IN.vertex);    
    OUT.texcoord = IN.texcoord;
    //計算uv偏移
    OUT.offsetcoord.xy = OUT.texcoord.xy * _UVScaleOffset.zw + _UVScaleOffset.xy;
#ifdef UNITY_HALF_TEXEL_OFFSET    
    OUT.vertex.xy -= (_ScreenParams.zw-1.0);    
#endif    
    return OUT; 
} 
      
fixed4 frag(v2f IN) : SV_Target    
{    
    half4 color = tex2D(_MainTex, IN.offsetcoord);
    half4 blendColor = tex2D(_BlendTexture, IN.texcoord);
    color.rgb = blendColor.rgb + color.rgb * (1 - blendColor.a);
    return color;
}




核心的程式碼就只有加粗的那一句,給uv一個整體的縮放之後再加上左下角的偏移。之後C#邏輯就只需要根據地圖的大小和玩家所在的位置計算出想要顯示的uv縮放和偏移值就可以了。玩家移動的時候只需要修改材質的引數,這也不會導致UI的mesh重建,一箭雙鵰,解決兩個問題。


小地圖的外框也在材質中一併做了,減少一個draw call。最終的效果如下圖所示:


優化後的Overdraw對比圖





這裡需要注意的是,對於image控制元件的material進行賦值時,如果它在一個Mask控制元件之下,可能會遇到賦值失效的問題,採用materialForRendering或者強制更新材質的方式可能會有新的Material的建立過程導致記憶體分配,這些在優化之後可能帶來問題的點也是需要優化後進行驗證的。
4. Mask的使用
除了小地圖部分,遊戲中比如頭像、技能介面等處都大量地使用了Mask。當然通常情況下Mask不會帶來像小地圖那麼高Overdraw,但是因為ugui中的Mask需要一遍繪製過程,因此對於Drawcall的增加還是會有不少。而且Mask也存在邊緣鋸齒的問題,效果上UI同學也不夠滿意,因此我們針對像頭像這樣單張的Mask也進行了一下優化。

這裡補充兩點:

  • 在那篇文章的最後提到,我們自己拷貝了一個ThorImage類,開放部分介面然後繼承。我們後來改成了從Image直接繼承的方式,否則之前編寫的遊戲邏輯要在程式碼上相容兩種Image,會比較煩,這些向前相容的需求也是在優化過程中需要額外考慮和處理的點。



  • 針對滾動列表這樣需要Mask的地方,一方面建議UI同學使用Rect Mask 2D元件,另外一方面為了邊緣的漸變效果為UI引入了Soft Mask 外掛來提供邊緣的漸變處理。放一張Soft Mask自己的效果對比截圖,需要類似效果的朋友可以自己購買。




Soft Mask 外掛效果對比

UWA針對我們專案的深度優化報告裡提到:Soft Mask外掛的Component在Disable邏輯裡有明顯的效能消耗,目前我們未開始針對這塊進行優化,不過只在ui關閉的時候才有,所以優先順序也比較低,想嘗試的朋友可以提前評估下效能。


5. 基於DoTween的動畫效果優化

在遊戲中,UI比較大量地使用了DoTween外掛製作動畫效果來強調一些需要醒目提醒玩家的資訊。DoTween是一個非常好用的外掛,無論是對於程式還是對於UI來說,都可以經過簡單的操作來實現較為好的動畫效果。


然而,對於UGUI來說,DoTween往往意味著持續的Canvas的重建,因為動畫通常是位置、旋轉和縮放的變化,這些都會導致其所在的Canvas在動畫過程中一直有重建操作,比如我們遊戲中會有的如下圖所示的旋轉提醒的效果:


 


這一效果在DoTween中通過不斷改變圖片的旋轉來實現的,在我們profile過程中發現了可疑的持續canvas重建,最後通常會定位到類似這樣介面動畫的地方。使用Shader進行優化,只需要把旋轉的過程拿到Shader的vs階段來做,同樣是修改uv資訊,材質程式碼的vert函式如下:


[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
v2f vert(appdata_t IN)
{    
    v2f OUT;    
    OUT.vertex = mul(UNITY_MATRIX_MVP, IN.vertex);
    OUT.texcoord = IN.texcoord;
 
    OUT.texcoord.xy -= 0.5;
    //避免時間過長的時候影響精度
    half t = fmod(_Time.y, 2 * UNITY_PI);
    t *= _RotationSpeed;
    half s, c;
    sincos(t, s, c);
    half2x2 rotationMatrix = half2x2(c, -s, s, c);
    OUT.texcoord.xy = mul(OUT.texcoord.xy, rotationMatrix);
    OUT.texcoord.xy += 0.5;
 
#ifdef UNITY_HALF_TEXEL_OFFSET    
    OUT.vertex.xy -= (_ScreenParams.zw-1.0);    
#endif
    OUT.color = IN.color;    
    return OUT; 
}




注意,這裡因為要求UI控制元件使用的是一張RawImage,因此uv的中心點就認為了是(0.5, 0.5)位置。通過引數可以做到從C#中傳遞uv的偏移和縮放資訊從而相容Image,但是因為材質不同導致本來就無法合批,所以使用Image和Atlas帶來的優勢就沒有了,所以這裡只簡單地支援RawImage。


Tips:注意所使用貼圖的邊緣處理,因為uv的旋轉可能會導致超過之前0和1的範圍。首先貼圖的取樣方式要使用Clamp的方式,其次貼圖的邊緣要留出幾個畫素的透明區域,保證即使在裝置上貼圖壓縮之後依然可以讓邊緣的效果正確。


另外使用材質進行優化的地方是自動尋路的提示效果:


 


最初UI想使用DoTween來製作,但是覺得工作量有點大所以想找程式寫DoTween的程式碼進行開發,為了減少ui的Canvas重建,使用材質來控制每一個字的縮放過程。同樣是在vert函式中針對uv進行修改即可:


[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
v2f vert(appdata_t IN)
{
        v2f OUT;
        UNITY_SETUP_INSTANCE_ID(IN);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
 
        //根據時間和配置引數對頂點進行縮放操作
        OUT.worldPosition = IN.vertex;
        half t = fmod((_Time.y - _TimeOffset) * _TimeScale, 1);
        t = 4 * (t - t * t);
        OUT.worldPosition.xy -= _VertexParmas.xy;
        OUT.worldPosition.xy *= (1 + t*_ScaleRatio);
        OUT.worldPosition.xy += _VertexParmas.xy;
        OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
 
        OUT.texcoord = IN.texcoord;
 
        OUT.color = IN.color * _Color;
        return OUT;
}


Tips:這裡使用UWA群裡一位朋友之前提供的三角函式近似的方法來略微減少一下指令消耗:



sin函式的近似模擬




這一效果的實現不像之前的旋轉效果那麼簡單,只有Shader的修改就可以了。這裡需要額外處理的部分有如下幾點:

1. 逐個縮放的效果需要控制“自動尋路中...”這句話中的每一個字,因此在這個效果中每一個字都是一個Text控制元件。正在縮放的字使用特殊的縮放材質來繪製,其他的字依然使用預設的UI材質繪製,這意味著需要2個DrawCall來實現整體效果。


2. vert函式中需要獲取字型的中心點和長寬大小,然後進行縮放計算,也就是引數_VertexParmas的內容。經過測試,在ugui中,頂點的位置資訊worldPosition是其相對於Canvas的位置資訊,因此這裡需要在C#中進行計算,計算過程藉助RectTransformUtility的ScreenPointToLocalPointInRectangle函式:

[AppleScript] 純文字檢視 複製程式碼
 
1
2
Vector2 tempPos;[/color][/size][/font][/align][font=微軟雅黑][size=3][color=#2f4f4f]RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentCanvas.transform as RectTransform,
                    ParentCanvas.worldCamera.WorldToScreenPoint(img.transform.position),
                    ParentCanvas.worldCamera, out tempPos);

這裡的ParentCanvas是當前控制元件向上遍歷找到的第一個Canvas物件。

Tips:這裡,搞清楚了vs中頂點的worldPosition對應的屬性之後,可以做很多有趣的事情,包括之前的旋轉效果也可以不再旋轉uv而是對頂點位置進行旋轉。
3. 關於_Time,它的y值與C#中對應的是Time.timeSinceLevelLoad。這裡為了實現介面一開始的時候第一個字是從0開始放大的,需要從C#傳遞給Shader一個起始時間,通過_Time.y - _TimeOffset 來確保起始效果。


最後直接貼一下C#部分的程式碼好了,也很簡單,提供給UI配置縮放尺寸、縮放持續時間和間隔等引數,然後通過協程來控制字型的材質引數:

[AppleScript] 純文字檢視 複製程式碼
 
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using DG.Tweening;
using ThorUtils;
using System.Collections;
 
namespace KEngine.UI
{
    public class UIAutoExpand : MonoBehaviour
    {
        public float ScaleDuration = 1;
        public float CycleInterval = 5;
        public float ScaleRatio = 0.5f;
        private Canvas ParentCanvas;
 
        private Image[] images;
        private Vector4[] meshSizes;
        private int currentScaleIdx = -1;
 
        private Coroutine playingCoroutine;
         
        private static Material UIExpandMat = null;
        private static int VertexParamsID = -1;
        private static int TimeOffsetID = -1;
        private static int TimeScaleID = -1;
        private static int ScaleRatioID = -1;
 
        void Awake()
        {
            //初始化靜態變數
            if (UIExpandMat == null)
            {
                UIExpandMat = new Material(Shader.Find("ThorShader/UI/UIExpand"));
                VertexParamsID = Shader.PropertyToID("_VertexParmas");
                TimeOffsetID = Shader.PropertyToID("_TimeOffset");
                TimeScaleID = Shader.PropertyToID("_TimeScale");
                ScaleRatioID = Shader.PropertyToID("_ScaleRatio");
            }
 
            UIExpandMat.SetFloat(TimeScaleID, 1 / ScaleDuration);
            UIExpandMat.SetFloat(ScaleRatioID, ScaleRatio);
 
            if (ParentCanvas == null)
            {
                Transform trans = transform;
                while(trans != null)
                {
                    ParentCanvas = trans.GetComponent<Canvas>();
                    if (ParentCanvas != null)
                    {
                        break;
                    }
                    trans = trans.parent;
                }
            }
            if (ParentCanvas == null || ParentCanvas.worldCamera == null)
            {
                Debug.LogError("The parent canvas of UIAutoExpand could not be empty!");
                return;
            }
            images = GetComponentsInChildren<Image>();
            if (images.Length > 0)
            {
                meshSizes = new Vector4[images.Length];
            }
            Vector2 tempPos;
            for (int i = 0; i < images.Length; ++i)
            {
                Image img = images;
                tempPos.x = 0;
                tempPos.y = 0;
                RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentCanvas.transform as RectTransform,
                    ParentCanvas.worldCamera.WorldToScreenPoint(img.transform.position),
                    ParentCanvas.worldCamera, out tempPos);
                meshSizes.x = tempPos.x;
                meshSizes.y = tempPos.y;
                meshSizes.z = 0;
                meshSizes.w = 0;
            }
        }
 
        private void OnEnable()
        {
            playingCoroutine = StartCoroutine(PlayExpandAni());
        }
 
        private void OnDisable()
        {
            if (playingCoroutine != null)
            {
                StopCoroutine(playingCoroutine);
            }
        }
 
        private void OnDestroy()
        {
            StartScaleImage(-1);
            if (playingCoroutine != null)
            {
                StopCoroutine(playingCoroutine);
            }
        }
 
        private void StartScaleImage(int idx)
        {
            if (images == null)
            {
                return;
            }
            if (currentScaleIdx > -1 && currentScaleIdx < images.Length)
            {
                Image preImg = images[currentScaleIdx];
                preImg.material = null;
            }
            if (idx < 0 || idx >= images.Length)
            {
                return;
            }
            currentScaleIdx = idx;
            Image curImg = images[idx];
            UIExpandMat.SetVector(VertexParamsID, meshSizes[idx]);
            curImg.material = UIExpandMat;
        }
 
        private IEnumerator PlayExpandAni()
        {
            while (true)
            {
                for (int i = 0; i < images.Length; ++i)
                {
                    UIExpandMat.SetFloat(TimeOffsetID, Time.timeSinceLevelLoad);
                    StartScaleImage(i);
                    yield return Yielders.GetWaitForSeconds(ScaleDuration);
                }
                StartScaleImage(-1);
                yield return Yielders.GetWaitForSeconds(CycleInterval);
            }
        }
    }
}

Tips:這裡使用了 Shader.PropertyToID 方法來減少給material賦值過程中的消耗,對於攜程使用了一個Yielders類減少頻繁的記憶體分配。
總之,基於Shader來對持續的DoTween動畫進行優化,可以大大減少Canvas重建的機率。而Shader中基於頂點和_Time屬性進行動畫計算的消耗非常少,比如通常的Image只有四個頂點而已,再配合部分C#程式碼提供給材質必須的引數,就可以實現更加複雜的ui動畫。尤其對於會長時間存在的動畫效果,如果可以善用Shader可以做到兼顧效果和效率。
6. 進度條
在進行戰鬥中的Profile的時候也是發現了每幀都有一個Canvas重建的過程,排查後發現是用於顯示倒數計時效果的進度條在持續地被更新導致的。
UGUI的進度條控制元件功能非常通用,但是層次很複雜,包括Background、Fill Area和Handle Slide Area三個部分。它是實現原理是基於Mesh的修改:
 
從上面的gif可以看出,當Slider的value更改的時候,mesh會跟著調整。這可以做到一些UI想要的效果,比如讓Fill中的圖是一張九宮格的形式,就可以做出比較好看的進度條效果,保證拉伸之後的效果是正確的。
當你需要一條可能持續變化的進度條一直在顯示的時候,比如倒數計時進度,持續的Canvas重建就不可避免。
針對具體的需求,通過Shader來進行一個簡單的ProgressBar也非常容易,通過對於alpha的控制就可以做到擷取的效果:
[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fixed4 frag(v2f IN) : SV_Target
{
        half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
 
#if _ISVERTICAL_ON
        float uvValue = IN.texcoord.y - _UVRect.y;
        float totalValue = _UVRect.w;
#else
        float uvValue = IN.texcoord.x - _UVRect.x;
        float totalValue = _UVRect.z;
#endif
 
#if _ISREVERSE_ON
        uvValue = totalValue - uvValue;
#endif
                                 
        color.a *= uvValue / totalValue < _Progress;
 
        color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
        #ifdef UNITY_UI_ALPHACLIP
        clip (color.a - 0.001);
        #endif
 
        return color;
}

這次是在ps階段進行處理,當然也可以在vs中模擬頂點的縮放效果或者處理uv的偏移。為了支援垂直和反向,這裡通過兩個巨集來進行控制。在實現了條狀的進度條,然後準備根據UI的具體需求進行效果上優化的時候,UI同學表示設計方案修改了變成了圓形的進度條(=_=),而且是兩個方向同時展示進度,最終效果如下圖所示的效果:
 
[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
fixed4 frag(v2f IN) : SV_Target
{
        half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
        float theta = atan2((IN.texcoord.y - _UVRect.y)/_UVRect.w - 0.5,
                            (IN.texcoord.x + 1E-18 - _UVRect.x)/_UVRect.z-0.5);
[/color][/size][/font][align=left][font=微軟雅黑][size=3][color=#2f4f4f]
[/color][/size][/font][/align][font=微軟雅黑][size=3][color=#2f4f4f]
[align=left]#ifdef IS_SYMMETRY[/align]
[align=left]        color.a *= ((1 - _Progress) * UNITY_PI < abs(theta));[/align]
[align=left]#else[/align]
[align=left]        color.a *= ((1 - _Progress) * UNITY_PI * 2 < theta + UNITY_PI);[/align]
[align=left]#endif[/align]
 
[align=left]        color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);[/align]
[align=left]#ifdef UNITY_UI_ALPHACLIP[/align]
[align=left]        clip(color.a - 0.001);[/align]
[align=left]#endif[/align]
 
[align=left]        return color;[/align]
[align=left]}

這裡用了一個消耗比較大的atan2指令來進行弧度值的計算,支援對稱和非對稱的兩種方式,對稱的方式用於上面的特殊進度條,非對稱的方式用於下面這種環形的進度條。



 

UGUI針對Image提供了Filled的Image Type來做環形進度條的效果,其原理是根據角度來更改Mesh實現的。
為了相容Image中使用的Atlas,這裡需要將uv資訊設定給材質:
[AppleScript] 純文字檢視 複製程式碼
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
/// <summary>
/// 更新貼圖的uv值到材質中
/// 注意:需要在Image更新的時候呼叫本邏輯
/// </summary>
public void UpdateImageUV()
{
    if (relativeImage != null)
    {
        Vector4 uv = (relativeImage.overrideSprite != null) ?
            DataUtility.GetOuterUV(relativeImage.overrideSprite) : defaultUV;
        uv.z = uv.z - uv.x;
        uv.w = uv.w - uv.y;
        relativeImage.material.SetVector(UVRectId, uv);
    }
}

對於進度條的修改,尤其是環形進度條,是在Shader的ps階段做的,因此消耗可能還比較大,和Mesh重建的過程的消耗我沒有做具體的對比,相當於拿GPU換取CPU的消耗,有可能在某些裝置上還不夠划算。這個具體使用哪種方法更好,或者是否需要繼續優化就看讀者自己具體的專案需求了。


7. 總結





針對UGUI的優化零零散散也做了不少,上面討論到的是其中影響相對大的部分,另外一大塊內容是在UGUI中使用特效,這塊和本次部落格的主題關係不大就不放一起聊了。


可以看到,雖然從結果上看,這些優化後使用的Shader技術都非常非常簡單,大都是一些uv計算或者頂點位置的計算,相對於需要進行光照陰影等計算的3D Shader,UI中使用的Shader簡直連入門都算不上,但是通過合理地使用它,配合部分C#程式碼邏輯,可以實現兼顧效果和效率的UI控制元件功能。
另外,雖然從結果看很簡單,彷彿每一個Shader都只需要1-2個小時就可以完成,但是在排查問題和思考優化方法的過程中其實也花費了很多精力,有很多的糾結和思考。這些方案的對比和思考的過程由於時間關係沒有全部反映在這篇部落格裡,但你從Tips和一些隻言片語中也可以窺見到一些當時的心路歷程……除此之外,由於專案已經到了中後期,還有不少時間花費在新控制元件的易用性和向前相容的方面,以讓UI和程式同學可以用盡量少的時間來完成對於之前資源的優化工作。


最後,我想坦誠地說,對於UGUI和基於Shader的方案,我沒有進行定量的效能對比測試,所以也不能保證基於Shader的方法都一定效率更高,比如最後圓形的Mask就可能會是一個反例。我能做到的是儘量公正地從原理角度分析兩者之間在Overdraw、Drawcall和Canvas重建方面的效能差異,也可能有考慮不全的地方,歡迎大家一起討論~


最後的Tips:除了一些“傻X”bug引發的“神級”優化之外,大部分的優化都是瑣碎而且成效不會那麼直接、顯著的工作。比如上面這些內容可能花費了我大約2個周左右的時間,還需要推進UI和程式對於已有的資源進行修改,而且面臨著需求變更的問題……然而,面對優化工作還是那句話——“勿以惡小而為之,勿以善小而不為。”





另外,讓團隊中的每一個人都瞭解更多更深入的技術原理,擁有對於效能消耗的警覺,才不至於讓問題在最後Profile的時刻集中爆發出來,而是被消化在日常開發的點點滴滴之中,這也是我對於理想團隊的期(huan)待(xiang)。



知乎@Funny David

相關文章