写在前面Unity最强大的地方之一是它扩展性非常强的编辑器。Unite Europe 2016上有一个视频专门讲编辑器编程的: 这里大概记录一下里面的关键点。 场景一关注点: - 绘制重要区域,Gizmos.DrawXXX
- OnDrawGizmos和OnDrawGizmosSelected回调函数
- 点击Gizmos按钮就可以在Game视图也看到线框了

void OnDrawGizmos()
{
Gizmos.color = new Color( 1f, 0f, 0f, 1f );
Gizmos.DrawWireCube( transform.position + BoxCollider.center, BoxCollider.size );
Gizmos.color = new Color( 1f, 0f, 0f, 0.3f );
Gizmos.DrawCube( transform.position + BoxCollider.center, BoxCollider.size );
}
void OnDrawGizmosSelected()
{
Gizmos.color = new Color( 1f, 1f, 0f, 1f );
Gizmos.DrawWireCube( transform.position + BoxCollider.center, BoxCollider.size );
Gizmos.color = new Color( 1f, 1f, 0f, 0.3f );
Gizmos.DrawCube( transform.position + BoxCollider.center, BoxCollider.size );
}
场景二关注点: - 组织面板上的参数,添加滑动条、Header、空白等
 [Space( 10 )]
public float MaximumHeight;
public float MinimumHeight;
[Header( "Safe Frame" )]
[Range( 0f, 1f )]
public float SafeFrameTop;
[Range( 0f, 1f )]
public float SafeFrameBottom;
注意到上面面板的最小面有个Camera Height,调节它可以改变摄像机的高度。这个改变是可以发生在编辑器模式下的,而且也不需要脚本添加ExecuteInEditor。这是通过实现自定义的Editor脚本来实现的: using UnityEngine;
using UnityEditor;
using System.Collections;
[CustomEditor( typeof( GameCamera ) )]
public class GameCameraEditor : Editor
{
GameCamera m_Target;
public override void OnInspectorGUI()
{
m_Target = (GameCamera)target;
DrawDefaultInspector();
DrawCameraHeightPreviewSlider();
}
void DrawCameraHeightPreviewSlider()
{
GUILayout.Space( 10 );
Vector3 cameraPosition = m_Target.transform.position;
cameraPosition.y = EditorGUILayout.Slider( "Camera Height", cameraPosition.y, m_Target.MinimumHeight, m_Target.MaximumHeight );
if( cameraPosition.y != m_Target.transform.position.y )
{
Undo.RecordObject( m_Target, "Change Camera Height" );
m_Target.transform.position = cameraPosition;
}
}
}
场景三关注点: - 自定义绘制List对象
- 使用serializedObject来修改参数的话Unity会自动有各种帮助函数,例如自动添加Undo功能
- 如果直接修改参数的话,需要使用EditorUtility.SetDirty来告诉Unity需要保存数据
- BeginChangeCheck()和EndChangeCheck()会检测它们之间的GUI有没有被修改,如果修改了的话可以据此修改参数
- Undo.RecordObject可以为下一步修改添加Undo/Redo
- EditorUtility.DisplayDialog可以打开内置对话框
 首先在面板上隐藏默认的List绘制方法,使用HideInInspector隐藏属性: public class PistonE03 : MonoBehaviour
{
public float Speed;
public Vector3 AddForceWhenHittingPlayer;
[HideInInspector]
public List<PistonState> States = new List<PistonState>();
......
为了让PistonState可以显示在面板上,需要序列化PistonState:
[System.Serializable]
public class PistonState
{
public string Name;
public Vector3 Position;
}
实现自定义的绘制方程: [CustomEditor( typeof( PistonE03 ) )]
public class PistonE03Editor : Editor
{
PistonE03 m_Target;
public override void OnInspectorGUI()
{
m_Target = (PistonE03)target;
DrawDefaultInspector();
DrawStatesInspector();
}
void DrawStatesInspector()
{
GUILayout.Space( 5 );
GUILayout.Label( "States", EditorStyles.boldLabel );
for( int i = 0; i < m_Target.States.Count; ++i )
{
DrawState( i );
}
DrawAddStateButton();
}
DrawDefaultInspector:先绘制默认的,DrawStatesInspector:自定义绘制面板函数。
DrawState函数: void DrawState( int index )
{
if( index < 0 || index >= m_Target.States.Count )
{
return
}
// 在我们的serializedObject中找到States变量
// serializedObject允许我们方便地访问和修改参数,Unity会提供一系列帮助函数。例如,我们可以通过serializedObject来修改组件值,而不是直接修改,Unity会自动创建Undo和Redo功能
SerializedProperty listIterator = serializedObject.FindProperty( "States" )
GUILayout.BeginHorizontal()
{
// 如果是在实例化的prefab上修改参数,我们可以模仿Unity默认的途径来让修改过的而且未被Apply的值显示成粗体
if( listIterator.isInstantiatedPrefab == true )
{
//The SetBoldDefaultFont functionality is usually hidden from us but we can use some tricks to
//access the method anyways. See the implementation of our own EditorGUIHelper.SetBoldDefaultFont
//for more info
EditorGUIHelper.SetBoldDefaultFont( listIterator.GetArrayElementAtIndex( index ).prefabOverride )
}
GUILayout.Label( "Name", EditorStyles.label, GUILayout.Width( 50 ) )
// BeginChangeCheck()和EndChangeCheck()会检测它们之间的GUI有没有被修改
EditorGUI.BeginChangeCheck()
string newName = GUILayout.TextField( m_Target.States[ index ].Name, GUILayout.Width( 120 ) )
Vector3 newPosition = EditorGUILayout.Vector3Field( "", m_Target.States[ index ].Position )
// 如果修改了的话EndChangeCheck()就会返回true,此时我们就可以进行一些操作例如存储变化的数值
if( EditorGUI.EndChangeCheck() )
{
//Create an Undo/Redo step for this modification
Undo.RecordObject( m_Target, "Modify State" )
m_Target.States[ index ].Name = newName
m_Target.States[ index ].Position = newPosition
// 如果我们直接修改属性,而没有通过serializedObject,那么Unity并不会保存这些数据,Unity只会保存那些标识为dirty的属性
EditorUtility.SetDirty( m_Target )
}
EditorGUIHelper.SetBoldDefaultFont( false )
if( GUILayout.Button( "Remove" ) )
{
EditorApplication.Beep()
// 可以很方便的显示一个包含特定按钮的对话框,例如是否同意删除
if( EditorUtility.DisplayDialog( "Really?", "Do you really want to remove the state '" + m_Target.States[ index ].Name + "'?", "Yes", "No" ) == true )
{
Undo.RecordObject( m_Target, "Delete State" )
m_Target.States.RemoveAt( index )
EditorUtility.SetDirty( m_Target )
}
}
}
GUILayout.EndHorizontal()
场景四关注点: - 可排序的数组面板,通过使用ReorderableList来实现的,以及它的各个回调函数
 using UnityEngine;
using UnityEditor;
using UnityEditorInternal;
using System.Collections;
[CanEditMultipleObjects]
[CustomEditor( typeof( PistonE04Pattern ) )]
public class PistonE04PatternEditor : Editor
{
ReorderableList m_List;
PistonE03 m_Piston;
void OnEnable()
{
if( target == null )
{
return;
}
FindPistonComponent();
CreateReorderableList();
SetupReoirderableListHeaderDrawer();
SetupReorderableListElementDrawer();
SetupReorderableListOnAddDropdownCallback();
}
void FindPistonComponent()
{
m_Piston = ( target as PistonE04Pattern ).GetComponent<PistonE03>();
}
void CreateReorderableList()
{
m_List = new ReorderableList(
serializedObject,
serializedObject.FindProperty( "Pattern" ),
true, true, true, true );
}
void SetupReoirderableListHeaderDrawer()
{
m_List.drawHeaderCallback =
( Rect rect ) =>
{
EditorGUI.LabelField(
new Rect( rect.x, rect.y, rect.width - 60, rect.height ),
"State" );
EditorGUI.LabelField(
new Rect( rect.x + rect.width - 60, rect.y, 60, rect.height ),
"Delay" );
};
}
void SetupReorderableListElementDrawer()
{
m_List.drawElementCallback =
( Rect rect, int index, bool isActive, bool isFocused ) =>
{
var element = m_List.serializedProperty.GetArrayElementAtIndex( index );
rect.y += 2;
float delayWidth = 60;
float nameWidth = rect.width - delayWidth;
EditorGUI.PropertyField(
new Rect( rect.x, rect.y, nameWidth - 5, EditorGUIUtility.singleLineHeight ),
element.FindPropertyRelative( "Name" ), GUIContent.none );
EditorGUI.PropertyField(
new Rect( rect.x + nameWidth, rect.y, delayWidth, EditorGUIUtility.singleLineHeight ),
element.FindPropertyRelative( "DelayAfterwards" ), GUIContent.none );
};
}
void SetupReorderableListOnAddDropdownCallback()
{
m_List.onAddDropdownCallback =
( Rect buttonRect, ReorderableList l ) =>
{
if( m_Piston.States == null || m_Piston.States.Count == 0 )
{
EditorApplication.Beep();
EditorUtility.DisplayDialog( "Error", "You don't have any states defined in the PistonE03 component", "Ok" );
return;
}
var menu = new GenericMenu();
foreach( PistonState state in m_Piston.States )
{
menu.AddItem( new GUIContent( state.Name ),
false,
OnReorderableListAddDropdownClick,
state );
}
menu.ShowAsContext();
};
}
void OnReorderableListAddDropdownClick( object target )
{
PistonState state = (PistonState)target;
int index = m_List.serializedProperty.arraySize;
m_List.serializedProperty.arraySize++;
m_List.index = index;
SerializedProperty element = m_List.serializedProperty.GetArrayElementAtIndex( index );
element.FindPropertyRelative( "Name" ).stringValue = state.Name;
element.FindPropertyRelative( "DelayAfterwards" ).floatValue = 0f;
serializedObject.ApplyModifiedProperties();
}
public override void OnInspectorGUI()
{
GUILayout.Space( 5 );
EditorGUILayout.PropertyField( serializedObject.FindProperty( "DelayPatternAtBeginning" ) );
serializedObject.ApplyModifiedProperties();
serializedObject.Update();
m_List.DoLayoutList();
serializedObject.ApplyModifiedProperties();
}
}
场景五关注点:  using UnityEngine;
using UnityEditor;
using System.Collections;
public class PreviewPlaybackWindow : EditorWindow
{
[MenuItem( "Window/Preview Playback Window" )]
static void OpenPreviewPlaybackWindow()
{
EditorWindow.GetWindow<PreviewPlaybackWindow>( false, "Playback" );
}
float m_PlaybackModifier;
float m_LastTime;
void OnEnable()
{
EditorApplication.update -= OnUpdate;
EditorApplication.update += OnUpdate;
}
void OnDisable()
{
EditorApplication.update -= OnUpdate;
}
void OnUpdate()
{
if( m_PlaybackModifier != 0f )
{
PreviewTime.Time += ( Time.realtimeSinceStartup - m_LastTime ) * m_PlaybackModifier;
Repaint();
SceneView.RepaintAll();
}
m_LastTime = Time.realtimeSinceStartup;
}
void OnGUI()
{
float seconds = Mathf.Floor( PreviewTime.Time % 60 );
float minutes = Mathf.Floor( PreviewTime.Time / 60 );
GUILayout.Label( "Preview Time: " + minutes + ":" + seconds.ToString( "00" ) );
GUILayout.Label( "Playback Speed: " + m_PlaybackModifier );
GUILayout.BeginHorizontal();
{
if( GUILayout.Button( "|<", GUILayout.Height( 30 ) ) )
{
PreviewTime.Time = 0f;
SceneView.RepaintAll();
}
if( GUILayout.Button( "<<", GUILayout.Height( 30 ) ) )
{
m_PlaybackModifier = -5f;
}
if( GUILayout.Button( "<", GUILayout.Height( 30 ) ) )
{
m_PlaybackModifier = -1f;
}
if( GUILayout.Button( "||", GUILayout.Height( 30 ) ) )
{
m_PlaybackModifier = 0f;
}
if( GUILayout.Button( ">", GUILayout.Height( 30 ) ) )
{
m_PlaybackModifier = 1f;
}
if( GUILayout.Button( ">>", GUILayout.Height( 30 ) ) )
{
m_PlaybackModifier = 5f;
}
}
GUILayout.EndHorizontal();
}
为了在编辑器状态下可以查看到cube的运动,我们还需要实现OnDrawGizmos来绘制一些线框表示运动。原理就是使用PreviewTime.Time来控制运动。 场景六关注点: - 在Scene视图中,鼠标的位置绘制特定的Handle
 using UnityEngine;
using UnityEditor;
using System.Collections;
[InitializeOnLoad]
public class LevelEditorE06CubeHandle : Editor
{
public static Vector3 CurrentHandlePosition = Vector3.zero;
public static bool IsMouseInValidArea = false;
static Vector3 m_OldHandlePosition = Vector3.zero;
static LevelEditorE06CubeHandle()
{
SceneView.onSceneGUIDelegate -= OnSceneGUI;
SceneView.onSceneGUIDelegate += OnSceneGUI;
}
void OnDestroy()
{
SceneView.onSceneGUIDelegate -= OnSceneGUI;
}
static void OnSceneGUI( SceneView sceneView )
{
if( IsInCorrectLevel() == false )
{
return;
}
bool isLevelEditorEnabled = EditorPrefs.GetBool( "IsLevelEditorEnabled", true );
{
if( UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE06" )
{
isLevelEditorEnabled = true;
}
}
if( isLevelEditorEnabled == false )
{
return;
}
UpdateHandlePosition();
UpdateIsMouseInValidArea( sceneView.position );
UpdateRepaint();
DrawCubeDrawPreview();
}
static bool IsInCorrectLevel()
{
return UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE06"
|| UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE07"
|| UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE08"
|| UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE09";
}
static void UpdateIsMouseInValidArea( Rect sceneViewRect )
{
bool isInValidArea = Event.current.mousePosition.y < sceneViewRect.height - 35;
if( isInValidArea != IsMouseInValidArea )
{
IsMouseInValidArea = isInValidArea;
SceneView.RepaintAll();
}
}
static void UpdateHandlePosition()
{
if( Event.current == null )
{
return;
}
Vector2 mousePosition = new Vector2( Event.current.mousePosition.x, Event.current.mousePosition.y );
Ray ray = HandleUtility.GUIPointToWorldRay( mousePosition );
RaycastHit hit;
if( Physics.Raycast( ray, out hit, Mathf.Infinity, 1 << LayerMask.NameToLayer( "Level" ) ) == true )
{
Vector3 offset = Vector3.zero;
if( EditorPrefs.GetBool( "SelectBlockNextToMousePosition", true ) == true )
{
offset = hit.normal;
}
CurrentHandlePosition.x = Mathf.Floor( hit.point.x - hit.normal.x * 0.001f + offset.x );
CurrentHandlePosition.y = Mathf.Floor( hit.point.y - hit.normal.y * 0.001f + offset.y );
CurrentHandlePosition.z = Mathf.Floor( hit.point.z - hit.normal.z * 0.001f + offset.z );
CurrentHandlePosition += new Vector3( 0.5f, 0.5f, 0.5f );
}
}
static void UpdateRepaint()
{
if( CurrentHandlePosition != m_OldHandlePosition )
{
SceneView.RepaintAll();
m_OldHandlePosition = CurrentHandlePosition;
}
}
static void DrawCubeDrawPreview()
{
if( IsMouseInValidArea == false )
{
return;
}
Handles.color = new Color( EditorPrefs.GetFloat( "CubeHandleColorR", 1f ), EditorPrefs.GetFloat( "CubeHandleColorG", 1f ), EditorPrefs.GetFloat( "CubeHandleColorB", 0f ) );
DrawHandlesCube( CurrentHandlePosition );
}
static void DrawHandlesCube( Vector3 center )
{
Vector3 p1 = center + Vector3.up * 0.5f + Vector3.right * 0.5f + Vector3.forward * 0.5f;
Vector3 p2 = center + Vector3.up * 0.5f + Vector3.right * 0.5f - Vector3.forward * 0.5f;
Vector3 p3 = center + Vector3.up * 0.5f - Vector3.right * 0.5f - Vector3.forward * 0.5f;
Vector3 p4 = center + Vector3.up * 0.5f - Vector3.right * 0.5f + Vector3.forward * 0.5f;
Vector3 p5 = center - Vector3.up * 0.5f + Vector3.right * 0.5f + Vector3.forward * 0.5f;
Vector3 p6 = center - Vector3.up * 0.5f + Vector3.right * 0.5f - Vector3.forward * 0.5f;
Vector3 p7 = center - Vector3.up * 0.5f - Vector3.right * 0.5f - Vector3.forward * 0.5f;
Vector3 p8 = center - Vector3.up * 0.5f - Vector3.right * 0.5f + Vector3.forward * 0.5f;
Handles.DrawLine( p1, p2 );
Handles.DrawLine( p2, p3 );
Handles.DrawLine( p3, p4 );
Handles.DrawLine( p4, p1 );
Handles.DrawLine( p5, p6 );
Handles.DrawLine( p6, p7 );
Handles.DrawLine( p7, p8 );
Handles.DrawLine( p8, p5 );
Handles.DrawLine( p1, p5 );
Handles.DrawLine( p2, p6 );
Handles.DrawLine( p3, p7 );
Handles.DrawLine( p4, p8 );
}
}
场景七关注点:  using UnityEngine
using UnityEditor
using System.Collections
[InitializeOnLoad]
public class LevelEditorE07ToolsMenu : Editor
{
//This is a public variable that gets or sets which of our custom tools we are currently using
//0 - No tool selected
//1 - The block eraser tool is selected
//2 - The "Add block" tool is selected
public static int SelectedTool
{
get
{
return EditorPrefs.GetInt( "SelectedEditorTool", 0 )
}
set
{
if( value == SelectedTool )
{
return
}
EditorPrefs.SetInt( "SelectedEditorTool", value )
switch( value )
{
case 0:
EditorPrefs.SetBool( "IsLevelEditorEnabled", false )
Tools.hidden = false
break
case 1:
EditorPrefs.SetBool( "IsLevelEditorEnabled", true )
EditorPrefs.SetBool( "SelectBlockNextToMousePosition", false )
EditorPrefs.SetFloat( "CubeHandleColorR", Color.magenta.r )
EditorPrefs.SetFloat( "CubeHandleColorG", Color.magenta.g )
EditorPrefs.SetFloat( "CubeHandleColorB", Color.magenta.b )
//Hide Unitys Tool handles (like the move tool) while we draw our own stuff
Tools.hidden = true
break
default:
EditorPrefs.SetBool( "IsLevelEditorEnabled", true )
EditorPrefs.SetBool( "SelectBlockNextToMousePosition", true )
EditorPrefs.SetFloat( "CubeHandleColorR", Color.yellow.r )
EditorPrefs.SetFloat( "CubeHandleColorG", Color.yellow.g )
EditorPrefs.SetFloat( "CubeHandleColorB", Color.yellow.b )
//Hide Unitys Tool handles (like the move tool) while we draw our own stuff
Tools.hidden = true
break
}
}
}
static LevelEditorE07ToolsMenu()
{
SceneView.onSceneGUIDelegate -= OnSceneGUI
SceneView.onSceneGUIDelegate += OnSceneGUI
// EditorApplication.hierarchyWindowChanged可以让我们知道是否在编辑器加载了一个新的场景
EditorApplication.hierarchyWindowChanged -= OnSceneChanged
EditorApplication.hierarchyWindowChanged += OnSceneChanged
}
void OnDestroy()
{
SceneView.onSceneGUIDelegate -= OnSceneGUI
EditorApplication.hierarchyWindowChanged -= OnSceneChanged
}
static void OnSceneChanged()
{
if( IsInCorrectLevel() == true )
{
Tools.hidden = LevelEditorE07ToolsMenu.SelectedTool != 0
}
else
{
Tools.hidden = false
}
}
static void OnSceneGUI( SceneView sceneView )
{
if( IsInCorrectLevel() == false )
{
return
}
DrawToolsMenu( sceneView.position )
}
static bool IsInCorrectLevel()
{
return UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE07"
|| UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE08"
|| UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name == "GameE09"
}
static void DrawToolsMenu( Rect position )
{
// 通过使用Handles.BeginGUI(),我们可以开启绘制Scene视图的GUI元素
Handles.BeginGUI()
//Here we draw a toolbar at the bottom edge of the SceneView
// 这里我们在Scene视图的底部绘制了一个工具条
GUILayout.BeginArea( new Rect( 0, position.height - 35, position.width, 20 ), EditorStyles.toolbar )
{
string[] buttonLabels = new string[] { "None", "Erase", "Paint" }
// GUILayout.SelectionGrid提供了一个按钮工具条
// 通过把它的返回值存储在SelectedTool里可以让我们根据不同的按钮来实现不同的行为
SelectedTool = GUILayout.SelectionGrid(
SelectedTool,
buttonLabels,
3,
EditorStyles.toolbarButton,
GUILayout.Width( 300 ) )
}
GUILayout.EndArea()
Handles.EndGUI()
}
}
场景八关注点: - 可以在场景七的基础上,点击相应按钮后增加或删除Cube
新的编辑器脚本逻辑和场景七类似,重点在于回调函数OnSceneGUI: static void OnSceneGUI(SceneView sceneView)
{
if (IsInCorrectLevel() == false)
{
return
}
if (LevelEditorE07ToolsMenu.SelectedTool == 0)
{
return
}
// 通过创建一个新的ControlID我们可以把鼠标输入的Scene视图反应权从Unity默认的行为中抢过来
// FocusType.Passive意味着这个控制权不会接受键盘输入而只关心鼠标输入
int controlId = GUIUtility.GetControlID(FocusType.Passive)
// 如果是鼠标左键被点击同时没有其他特定按键按下的话
if (Event.current.type == EventType.mouseDown &&
Event.current.button == 0 &&
Event.current.alt == false &&
Event.current.shift == false &&
Event.current.control == false)
{
if (LevelEditorE06CubeHandle.IsMouseInValidArea == true)
{
if (LevelEditorE07ToolsMenu.SelectedTool == 1)
{
// 如果选择的是erase按键(从场景七的静态变量SelectedTool判断得到),移除Cube
RemoveBlock(LevelEditorE06CubeHandle.CurrentHandlePosition)
}
if (LevelEditorE07ToolsMenu.SelectedTool == 2)
{
/// 如果选择的是add按键(从场景七的静态变量SelectedTool判断得到),添加Cube
AddBlock(LevelEditorE06CubeHandle.CurrentHandlePosition)
}
}
}
// 如果按下了Escape,我们就自动取消选择当前的按钮
if (Event.current.type == EventType.keyDown &&
Event.current.keyCode == KeyCode.Escape)
{
LevelEditorE07ToolsMenu.SelectedTool = 0
}
// 把我们自己的controlId添加到默认的control里,这样Unity就会选择我们的控制权而非Unity默认的Scene视图行为
HandleUtility.AddDefaultControl
场景九关注点: - 使用Scriptable Object把一些Prefab预览在Scene视图上
 Scriptable Object是一个相当于自定义Assets对象的类。下面是LevelBlocks的定义。它包含了一个LevelBlockData的数组来存储可选的Prefab对象。 using UnityEngine;
using System.Collections;
using System.Collections.Generic;
[System.Serializable]
public class LevelBlockData
{
public string Name;
public GameObject Prefab;
}
[CreateAssetMenu]
public class LevelBlocks : ScriptableObject
{
public List<LevelBlockData> Blocks = new List<LevelBlockData>();
}
我们之后就可以在Hierency视图创建一个LevelBlock资源,Editor类则会加载这个资源来得到相应的数据。 static LevelEditorE09ScriptableObject()
{
SceneView.onSceneGUIDelegate -= OnSceneGUI;
SceneView.onSceneGUIDelegate += OnSceneGUI;
//Make sure we load our block database. Notice the path here, which means the block database has to be in this specific location so we can find it
//LoadAssetAtPath is a great way to load an asset from the project
m_LevelBlocks = AssetDatabase.LoadAssetAtPath<LevelBlocks>( "Assets/E09 - Scriptable Object/LevelBlocks.asset" );
}
Unite 2016上还有另一个专门讲Scriptable Object的视频,强烈建议看一下: |