【通识】宇树G1_29DOF速度跟踪训练—逐章学习手册

学习方式比较简单有效:用kimi code / claude code直接基于源码设计一份教学文档,目标是一步步把我教会,形式采用互动教学形式即可

学习方式:逐章进行,每章结束后需确认理解,才进入下一章。

学习过程中请随时打断提问。

经过这个过程,基本就完成了从0-->1的训练pipeline的认知。


第1章 项目定位与训练入口

1.1 学习目标

  • 理解 unitree_rl_lab 在整个技术栈中的位置
  • 掌握正确的训练启动命令
  • 理解任务注册机制(gym.register

1.2 技术栈层级

复制代码
┌─────────────────────────────────────────┐
│  PyTorch + RSL-RL(PPO算法库)           │  ← 神经网络训练
├─────────────────────────────────────────┤
│  unitree_rl_lab(本层)                  │  ← G1任务、自定义奖励、课程学习
├─────────────────────────────────────────┤
│  Isaac Lab(中间层)                     │  ← ManagerBasedRLEnv、8大Manager
├─────────────────────────────────────────┤
│  Omniverse Isaac Sim(底层)             │  ← PhysX物理引擎、USD场景、GPU并行
└─────────────────────────────────────────┘

关键理解 :你平时改配置、调奖励,99% 的时间都在 unitree_rl_lab 这一层

1.3 训练入口

脚本位置extern/unitree_rl_lab/unitree_rl_lab.sh

bash 复制代码
cd extern/unitree_rl_lab

# 训练
./unitree_rl_lab.sh -t --task=Unitree-G1-29dof-Velocity --num_envs=4096

# 推理
./unitree_rl_lab.sh -p --task=Unitree-G1-29dof-Velocity --num_envs=1

-t 实际调用的是 scripts/rsl_rl/train.py-p 调用的是 scripts/rsl_rl/play.py

1.4 任务注册机制

源码位置source/unitree_rl_lab/unitree_rl_lab/tasks/locomotion/robots/g1/29dof/__init__.py

python 复制代码
gym.register(
    id="Unitree-G1-29dof-Velocity",
    entry_point="isaaclab.envs:ManagerBasedRLEnv",
    kwargs={
        "env_cfg_entry_point": "velocity_env_cfg:RobotEnvCfg",      # 训练配置
        "play_env_cfg_entry_point": "velocity_env_cfg:RobotPlayEnvCfg",  # 推理配置
        "rsl_rl_cfg_entry_point": "rsl_rl_ppo_cfg:BasePPORunnerCfg",     # PPO算法配置
    },
)
入口点 配置类 用途
env_cfg_entry_point RobotEnvCfg 训练环境配置(num_envs=4096,课程学习控制指令范围)
play_env_cfg_entry_point RobotPlayEnvCfg 推理环境配置(num_envs=32,指令范围直接用limit)
rsl_rl_cfg_entry_point BasePPORunnerCfg PPO网络结构与超参

1.5 本章 Checkpoint(必须掌握)

  1. unitree_rl_labisaac_lab 的分工是什么?
  2. 正确的训练启动命令是什么?
  3. 三个 entry_point 分别对应什么?
  4. 为什么训练和推理用不同的环境配置类?

1.6 自测问题

Q1: 如果我想修改奖励函数权重,应该改哪一层的代码?

Q2: RobotPlayEnvCfgRobotEnvCfg 的核心区别是什么?


第2章 环境架构:ManagerBasedRLEnv

2.1 学习目标

  • 理解 Manager-Based 设计哲学
  • 掌握 8 大 Manager 的职责分工
  • 理解关键时间参数(dt, decimation, episode_length_s)的关系

2.2 八大 Manager

复制代码
ManagerBasedRLEnv
├── SceneManager        → 场景实体(地形、机器人、传感器)
├── ObservationManager  → 构造观测向量
├── ActionManager       → 接收动作、驱动机器人
├── RewardManager       → 计算奖励
├── TerminationManager  → 判断终止
├── EventManager        → 随机化/重置/推力
├── CommandManager      → 生成速度指令
└── CurriculumManager   → 调整课程难度

2.3 配置类对应关系

RobotEnvCfg 中:

python 复制代码
scene: RobotSceneCfg = RobotSceneCfg(...)          # ← SceneManager
observations: ObservationsCfg = ObservationsCfg()  # ← ObservationManager
actions: ActionsCfg = ActionsCfg()                 # ← ActionManager
commands: CommandsCfg = CommandsCfg()              # ← CommandManager
rewards: RewardsCfg = RewardsCfg()                 # ← RewardManager
terminations: TerminationsCfg = TerminationsCfg()  # ← TerminationManager
events: EventCfg = EventCfg()                      # ← EventManager
curriculum: CurriculumCfg = CurriculumCfg()        # ← CurriculumManager

2.4 关键时间参数

python 复制代码
self.sim.dt = 0.005          # 物理步长 = 5ms,PhysX 以 200Hz 运行
self.decimation = 4          # 每 4 个物理步做一次 RL 决策
self.episode_length_s = 30.0 # Episode 最大时长 30 秒

计算关系

  • step_dt = decimation × sim.dt = 4 × 0.005 = 0.02s = 20ms
  • 策略决策频率 = 50Hz
  • max_episode_length = episode_length_s / step_dt = 30 / 0.02 = 1500

时间线图解

复制代码
时间(ms):   0    5    10   15   20   25   30   35   40 ...
PhysX步:    █    █    █    █    █    █    █    █    █   (200Hz)
                     ↑
               策略决策点 (50Hz)

2.5 为什么 episode_length_s = 30.0?

代码注释说明:

python 复制代码
self.episode_length_s = 30.0  # 加长: 把持续转向累积失稳临界点(实测8~22s)包进训练时长

团队实测机器人在持续转向时,约 8~22 秒 会出现累积失稳。Episode 加长到 30 秒,强迫策略学会长时间稳定转向。

2.6 本章 Checkpoint

  1. 8 大 Manager 分别管什么?
  2. sim.dtdecimationstep_dt 的关系是什么?策略实际运行频率是多少?
  3. 一个 Episode 最多多少步?怎么算出来的?
  4. 为什么 episode_length_s 要设成 30.0 而不是更短?

第3章 场景配置 RobotSceneCfg

3.1 学习目标

  • 理解地形生成配置
  • 理解物理材质与摩擦随机化
  • 理解传感器的作用

3.2 地形配置

python 复制代码
COBBLESTONE_ROAD_CFG = terrain_gen.TerrainGeneratorCfg(
    size=(8.0, 8.0),           # 每个地形块 8m × 8m
    num_rows=9, num_cols=21,   # 9×21 = 189 个地形块
    horizontal_scale=0.1,
    vertical_scale=0.005,
    sub_terrains={"flat": terrain_gen.MeshPlaneTerrainCfg(proportion=0.5)},
)

当前以平地为主(proportion=0.5),课程学习开启后会逐步增加难度地形。

3.3 物理材质

python 复制代码
physics_material=sim_utils.RigidBodyMaterialCfg(
    static_friction=1.0,
    dynamic_friction=1.0,
)

启动时 EventManager 会将摩擦随机化到 [0.3, 1.0],增强 Sim-to-Real 泛化。

3.4 传感器

传感器 用途
contact_forces 检测接触力,用于步态奖励、脚底打滑、不期望接触
height_scanner 从 torso 向下射线扫描地形高度(当前未在观测中使用)

3.5 本章 Checkpoint

  1. 当前地形配置以什么为主?
  2. 摩擦系数在运行时会被随机化到什么范围?目的是什么?
  3. contact_forces 传感器在哪些奖励函数中被用到?

第4章 观测构造 ObservationsCfg

4.1 学习目标

  • 掌握 Policy 和 Critic 的观测组成
  • 理解历史缓冲区 history_length=5 的作用
  • 理解 scalenoise 的含义

4.2 Policy 观测(Actor 输入)

观测项 维度 含义 缩放/噪声
base_ang_vel 3 机体角速度 scale=0.2, noise=±0.2
projected_gravity 3 重力在机体坐标系投影 noise=±0.05
velocity_commands 3 速度指令 vx, vy, ωz
joint_pos_rel 29 关节位置相对默认姿态 noise=±0.01
joint_vel_rel 29 关节速度 scale=0.05, noise=±1.5
last_action 29 上一步动作

基础维度 :3 + 3 + 3 + 29 + 29 + 29 = 96

4.3 历史缓冲区

python 复制代码
self.history_length = 5
self.concatenate_terms = True

实际输入维度 = 96 × 5 = 480

原理:机器人的运动具有时序性,最近 5 帧的观测拼接后,策略能感知速度、加速度趋势。

4.4 Critic 特权观测

Critic 比 Policy 多看到 base_lin_vel(世界坐标系线速度)。

为什么 Policy 不能看? 因为真机没有全局定位系统(GPS/动捕),这是 Sim-to-Real 的 gap。

4.5 本章 Checkpoint

  1. Policy 观测共多少维?各项分别是什么?
  2. 加上历史缓冲区后,实际输入是多少维?
  3. Critic 比 Policy 多看到什么?为什么 Policy 不能看?
  4. scale=0.2noise=±0.2 分别是什么意思?

第5章 动作输出 ActionsCfg

5.1 学习目标

  • 理解 JointPositionAction 的含义
  • 理解 scale=0.25use_default_offset=True

5.2 动作机制

python 复制代码
JointPositionAction = mdp.JointPositionActionCfg(
    asset_name="robot", joint_names=[".*"], scale=0.25, use_default_offset=True
)

实际执行

复制代码
q_target = q_default + 0.25 × action
  • 策略输出 action ∈ [-1, 1](29 维)
  • scale=0.25 弧度 ≈ ±14.3°
  • use_default_offset=True 表示以默认姿态为基准

5.3 为什么用位置控制?

  1. 位置控制 + 底层 PD 更稳定,仿真不容易发散
  2. 真机 G1 底层控制器也接受位置指令
  3. 策略只需决定"关节去哪里",不用处理复杂力矩动力学

5.4 本章 Checkpoint

  1. 策略输出 29 维动作,范围是多少?
  2. 实际发给电机的目标位置怎么算?
  3. scale=0.25 对应多少度?
  4. 为什么用位置控制而不是力矩控制?

第6章 指令系统 CommandsCfg

6.1 学习目标

  • 理解 ranges vs limit_ranges
  • 理解指令采样流程
  • 理解课程学习的本质

6.2 指令配置

python 复制代码
base_velocity = mdp.UniformLevelVelocityCommandCfg(
    resampling_time_range=(5.0, 20.0),   # 每 5~20 秒重新采样
    rel_standing_envs=0.02,               # 2% 环境指令为 0
    ranges=Ranges(lin_vel_x=(-0.1, 0.1), lin_vel_y=(-0.1, 0.1), ang_vel_z=(-0.3, 0.3)),
    limit_ranges=Ranges(lin_vel_x=(-0.5, 2.0), lin_vel_y=(-0.4, 0.4), ang_vel_z=(-1.0, 1.0)),
)

6.3 ranges vs limit_ranges

参数 作用
ranges 课程学习当前实际使用的指令范围
limit_ranges 指令范围的硬上限,课程学习不会超越

初始 vs 上限

  • vx: [-0.1, 0.1][-0.5, 2.0]
  • vy: [-0.1, 0.1][-0.4, 0.4]
  • ωz: [-0.3, 0.3][-1.0, 1.0]

6.4 采样流程

  1. Episode 开始,每个环境独立采样持续时长 T ∈ [5, 20]
  2. [0, T] 内指令保持不变
  3. 达到 T 后重新采样(在 ranges 范围内均匀采样)
  4. 2% 环境被强制设为 0 指令(站立)

6.5 本章 Checkpoint

  1. rangeslimit_ranges 的区别是什么?
  2. 指令多久重新采样一次?
  3. 课程学习的本质是什么?
  4. 当前 vx 的初始范围和上限分别是多少?

第7章 奖励函数详解 RewardsCfg

7.1 学习目标

  • 掌握 18 项 reward 的数学公式、weight、作用
  • 理解各项 reward 对训练行为的影响
  • 掌握调参的基本逻辑

7.2 Task Rewards(核心)

track_lin_vel_xy_yaw_frame_exp
复制代码
vel_yaw = quat_apply_inverse(yaw_quat(root_quat), root_lin_vel_w)
error = ||cmd_xy - vel_yaw_xy||²
reward = exp(-error / 0.25)     # std=0.5, weight=1.0
track_ang_vel_z_exp
复制代码
error = (ωz_cmd - ωz_actual)²
reward = exp(-error / 0.25)     # std=0.5, weight=1.0

7.3 Base Penalties

名称 Weight 公式 作用
alive +0.15 固定值 存活奖励
lin_vel_z_l2 -2.0 -vz² 惩罚上下弹跳
ang_vel_xy_l2 -0.03 -(ωx²+ωy²) 惩罚 roll/pitch 角速度
joint_vel_l2 -0.001 -Σqvel² 惩罚关节过快
joint_acc_l2 -2.5e-7 -Σqacc² 惩罚加速度过冲
action_rate_l2 -0.05 -Σ(Δa)² 关键:惩罚动作抖动
joint_pos_limits -5.0 超限 L1 硬约束
energy -2e-5 qvel×qtorque

7.4 Joint Deviation

名称 Weight 目标关节
joint_deviation_arms -0.1 shoulder, elbow, wrist
joint_deviation_waists -1.0 waist_yaw, roll, pitch
joint_deviation_legs -1.0 hip_roll, hip_yaw

7.5 Robot Penalties

名称 Weight 公式
flat_orientation_l2 -5.0 -(1 + grav_z)²
base_height_l2 -10.0 -(h - 0.78)²

7.6 Feet Rewards

名称 Weight 关键参数
gait +0.5 period=0.8s, offset=0,0.5, threshold=0.55
feet_slide -0.2 接触时水平滑动速度
feet_clearance +1.0 target=0.1m, std=0.05

7.7 Other

名称 Weight 说明
undesired_contacts -1.0 非 ankle 部位触地惩罚

7.8 调参逻辑

现象 调参方向
站着不动 提高 track_lin_vel_xy weight,降低 action_rate 惩罚
高频抖动 提高 action_rate 惩罚
容易摔倒 提高 flat_orientation 和 base_height 惩罚
外八/内八 提高 joint_deviation_legs 惩罚

7.9 本章 Checkpoint

  1. 哪两项是核心任务奖励?它们的数学形式是什么?
  2. action_rate_l2 的作用是什么?weight 变大或变小分别有什么影响?
  3. base_height_l2 的目标高度是多少?为什么 weight 这么重?
  4. feet_gait 的周期、两条腿偏移、stance 比例分别是多少?
  5. 如果机器人站着不动,应该怎么调参?

第8章 终止条件 TerminationsCfg

8.1 学习目标

  • 掌握三项终止条件
  • 理解 episode 长度计算

8.2 终止条件

python 复制代码
class TerminationsCfg:
    time_out = DoneTerm(func=mdp.time_out, time_out=True)
    base_height = DoneTerm(func=mdp.root_height_below_minimum, params={"minimum_height": 0.2})
    bad_orientation = DoneTerm(func=mdp.bad_orientation, params={"limit_angle": 0.8})
条件 触发机制 含义
time_out 达到 30 秒 正常结束,视为成功
base_height 高度 < 0.2m 摔倒或趴地
bad_orientation 倾斜 > 0.8 rad (≈46°) 严重失稳

8.3 本章 Checkpoint

  1. 三项终止条件分别是什么?
  2. bad_orientation 的角度限制是多少弧度?约多少度?
  3. time_out 对应的步数是多少?怎么算的?

第9章 事件与域随机化 EventCfg

9.1 学习目标

  • 理解三种事件触发模式(startup/reset/interval)
  • 掌握每项域随机化的作用

9.2 事件分类

startup(环境启动时执行一次)
事件 参数 作用
physics_material friction ∈ 0.3, 1.0 随机化摩擦系数
add_base_mass mass ∈ -1.0, +3.0 kg 随机化躯干质量
reset(Episode 终止后重置)
事件 参数 作用
reset_base position ∈ -0.5, 0.5m, yaw ∈ -π, π 随机初始位置和朝向
reset_robot_joints velocity ∈ -1, 1 随机初始关节状态
interval(训练过程中定时触发)
事件 间隔 参数 作用
push_robot 3~6 秒随机 velocity ∈ -0.5, 0.5 m/s 随机外力推动

9.3 本章 Checkpoint

  1. 三种事件触发模式分别是什么?各举一例。
  2. 摩擦系数被随机化到什么范围?
  3. 推力事件多久触发一次?有什么作用?

第10章 课程学习 CurriculumCfg

10.1 学习目标

  • 理解课程学习的触发机制
  • 掌握 lin_vel_cmd_levels 的源码逻辑

10.2 配置

python 复制代码
class CurriculumCfg:
    terrain_levels = CurrTerm(func=mdp.terrain_levels_vel)
    lin_vel_cmd_levels = CurrTerm(mdp.lin_vel_cmd_levels)
    ang_vel_cmd_levels = CurrTerm(mdp.ang_vel_cmd_levels)

10.3 线速度课程源码

python 复制代码
def lin_vel_cmd_levels(env, env_ids, reward_term_name="track_lin_vel_xy"):
    command_term = env.command_manager.get_term("base_velocity")
    ranges = command_term.cfg.ranges
    limit_ranges = command_term.cfg.limit_ranges

    reward_term = env.reward_manager.get_term_cfg(reward_term_name)
    reward = torch.mean(env.reward_manager._episode_sums[reward_term_name][env_ids]) / env.max_episode_length_s

    if reward > reward_term.weight * 0.5:  # 跟踪奖励 > 0.5 时升级
        delta_command = torch.tensor([-0.1, 0.1], device=env.device)
        ranges.lin_vel_x = torch.clamp(
            torch.tensor(ranges.lin_vel_x, device=env.device) + delta_command,
            limit_ranges.lin_vel_x[0], limit_ranges.lin_vel_x[1]
        ).tolist()
        ranges.lin_vel_y = torch.clamp(...).tolist()

    return torch.tensor(ranges.lin_vel_x[1], device=env.device)

升级条件 :当 track_lin_vel_xy 平均每步奖励 > weight × 0.5 = 0.5 时,指令范围扩展 ±0.1

10.4 本章 Checkpoint

  1. 课程学习在什么时候检查升级?
  2. 升级的条件是什么?
  3. 每次升级指令范围扩大多少?
  4. 指令范围的上限是什么?

第11章 PPO 算法配置

11.1 学习目标

  • 掌握网络结构
  • 理解 PPO 关键超参

11.2 网络结构

Actor(策略网络)

python 复制代码
actor = RslRlMLPModelCfg(
    hidden_dims=[512, 256, 128],
    activation="elu",
    stochastic=True,
    init_noise_std=1.0,
    noise_std_type="scalar",
)

Critic(价值网络)

python 复制代码
critic = RslRlMLPModelCfg(
    hidden_dims=[512, 256, 128],
    activation="elu",
    stochastic=False,
)

11.3 PPO 超参

python 复制代码
algorithm = RslRlPpoAlgorithmCfg(
    class_name="PPO",
    clip_param=0.2,
    entropy_coef=0.01,
    num_learning_epochs=5,
    num_mini_batches=4,
    learning_rate=1.0e-3,
    schedule="adaptive",
    gamma=0.99,
    lam=0.95,
    desired_kl=0.01,
    max_grad_norm=1.0,
)

11.4 关键参数

参数 说明
num_steps_per_env 24 每轮收集 24 步
num_mini_batches 4 分成 4 份
num_learning_epochs 5 复用 5 个 epoch
schedule adaptive KL > 0.015 时 lr 减半;KL < 0.005 时 lr 增加

11.5 本章 Checkpoint

  1. Actor 和 Critic 的网络结构是什么?
  2. clip_param=0.2 是什么意思?
  3. schedule=adaptive 如何调整学习率?
  4. 每轮产生多少条 transition?mini-batch 多大?

第12章 训练数据流

12.1 学习目标

  • 理解从 runner.learn()env.step() 的完整数据流

12.2 训练循环三阶段

阶段1: Rollout(数据收集)
python 复制代码
for step in range(24):
    obs = env.get_observations()      # 获取观测
    actions = policy(obs)             # Actor 输出动作
    env.step(actions)                 # 执行动作
    # 内部:ActionManager → PhysX×4 → RewardManager → TerminationManager
    buffer.store(obs, actions, reward, done, values, log_probs)
阶段2: GAE 计算
python 复制代码
advantages = GAE(rewards, values, dones, gamma=0.99, lam=0.95)
returns = advantages + values
阶段3: PPO 更新
python 复制代码
for epoch in range(5):
    for mini_batch in data.split(4):
        ratio = exp(new_log_probs - old_log_probs)
        surrogate1 = ratio * advantages
        surrogate2 = clip(ratio, 0.8, 1.2) * advantages
        policy_loss = -min(surrogate1, surrogate2).mean()
        value_loss = MSE(critic(obs), returns)
        total_loss = policy_loss + value_loss + entropy_loss
        total_loss.backward()
        optimizer.step()

12.3 本章 Checkpoint

  1. 训练循环分哪三个阶段?
  2. env.step() 内部发生了什么?
  3. PPO 的 ratio 是什么?为什么需要 clip
  4. 自适应学习率在什么情况下会调整?

第13章 G1 29DOF 机器人配置

13.1 学习目标

  • 掌握 29 个关节的名称和顺序
  • 理解执行器分组

13.2 关节顺序(按 joint_sdk_names)

# 关节名 部位 执行器
1 left_hip_pitch_joint 左腿 N7520-14.3
2 left_hip_roll_joint 左腿 N7520-22.5
3 left_hip_yaw_joint 左腿 N7520-14.3
4 left_knee_joint 左腿 N7520-22.5
5 left_ankle_pitch_joint 左脚 N5020-16
6 left_ankle_roll_joint 左脚 N5020-16
7-10 right_hip/knee 右腿 同上
11-12 right_ankle 右脚 N5020-16
13 waist_yaw_joint 腰部 N7520-14.3
14-15 waist_roll/pitch 腰部 N5020-16
16-22 左臂 7 DOF 左臂 N5020-16/W4010-25
23-29 右臂 7 DOF 右臂 N5020-16/W4010-25

13.3 本章 Checkpoint

  1. G1 有多少个关节?
  2. 腿部哪些关节使用 N7520-22.5 执行器?
  3. 踝关节的执行器型号是什么?

第14章 关键公式与参数速查表

14.1 奖励速查

名称 Weight 公式
track_lin_vel_xy +1.0 exp(-error²/0.25)
track_ang_vel_z +1.0 exp(-error²/0.25)
alive +0.15 固定
lin_vel_z_l2 -2.0 -vz²
ang_vel_xy_l2 -0.03 -(ωx²+ωy²)
action_rate_l2 -0.05 -Σ(Δa)²
flat_orientation -5.0 -(1+grav_z)²
base_height -10.0 -(h-0.78)²
gait +0.5 stance 匹配度
feet_clearance +1.0 exp(-error/0.05)
undesired_contacts -1.0 非 ankle 触地

14.2 时间参数速查

参数
sim.dt 0.005 s (200Hz)
decimation 4
step_dt 0.02 s (50Hz)
episode_length_s 30.0 s
max_episode_length 1500 steps
num_envs 4096
num_steps_per_env 24
batch_size 98304
mini_batch_size 24576

14.3 PPO 超参速查

参数
hidden_dims 512, 256, 128
activation elu
clip_param 0.2
entropy_coef 0.01
learning_rate 1.0e-3
gamma 0.99
lam 0.95
desired_kl 0.01

相关推荐
nbtang20261 小时前
每日AI新闻推送 | 2026年6月12日
人工智能
邵宇然1 小时前
轻量级推理引擎开发:从模型加载到推理执行的 Rust 实战
人工智能
装不满的克莱因瓶2 小时前
掌握语义分割经典模型 FCN——从像素分类到端到端分割的奠基之作
人工智能·python·深度学习·算法·机器学习·分类·数据挖掘
ACP广源盛139246256732 小时前
GSV5600@ACP#多接口协议转换芯片,物理 AI 便携终端的互联核心
大数据·人工智能·分布式·嵌入式硬件·spark
لا معنى له2 小时前
NeoVerse: Enhancing 4D World Model with in-the-wild Monocular Videos
人工智能·笔记·机器学习·语言模型
147API2 小时前
Fable 5访问暂停后,模型接入层不能再只写死一个模型名
大数据·人工智能·api·claude
KaMeidebaby2 小时前
卡梅德生物技术快报 | 噬菌体展示 12 肽文库在蛋白表位定位中的应用与实验数据
大数据·人工智能·架构·spark·新浪微博
JIAXIN_culture2 小时前
甘肃景观工程定制服务FAQ:企业如何选对合作方?
大数据·人工智能
青绿蓝LCA低碳研究院2 小时前
环保的本质:从“末端修补”到“系统重构”的生存范式转移 - 蓝色星球
大数据·人工智能·经验分享·重构