news 2026/9/15 15:02:40

WTF-Solidity 教程精讲:ERC4626 代币化金库标准——从标准接口到金库合约实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WTF-Solidity 教程精讲:ERC4626 代币化金库标准——从标准接口到金库合约实战

WTF-Solidity 教程精讲:ERC4626 代币化金库标准——从标准接口到金库合约实战

【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity

本文基于 WTF-Solidity 极简入门教程第 51 讲(Languages/en/51_ERC4626_en/readme.md),深入讲解 DeFi 新一代标准 ERC4626(代币化金库标准)。你将理解金库(Vault)合约在 DeFi 乐高中的作用、ERC4626 如何扩展 ERC20 实现收益金库标准化,并完整掌握 IERC4626 接口的 16 个函数与 2 个事件,最终能用 Solidity 手写一个可运行的极简代币化金库合约,并在 Remix 中完成部署与存取款全流程验证。

为什么需要金库标准

DeFi 常被比喻为"货币乐高",通过组合多个协议可以创造新的协议。但金库合约(Vault Contract)缺乏统一标准,各家实现五花八门,一个收益聚合器往往需要针对不同的 DeFi 项目编写大量对接接口,严重制约了 DeFi 的可组合性。

金库合约是 DeFi 乐高的基础组件,它允许你把基础资产(代币)质押到合约中,换取一定收益,典型应用场景包括:

  • 收益农场(Yield Farming):在 Yearn Finance 中质押USDT获取利息。
  • 借贷(Borrow/Lend):在 AAVE 中出借ETH,获取存款利息并可在抵押后借出其他资产。
  • 质押(Stake):在 Lido 中质押ETH参与 ETH 2.0 质押,获得可以生息的stETH

ERC4626:代币化金库标准概述

注:上图位于 Languages/en/51_ERC4626_en/img/51-1.png,展示了 ERC4626 金库"收资产、发份额、投策略"的核心架构。

ERC4626 扩展了 ERC20 代币标准,旨在推动收益金库的标准化,使 DeFi 能够轻松扩展。它带来了三大优点:

  1. 代币化(Tokenization):ERC4626 继承了 ERC20,向金库存款时,你将得到同样符合 ERC20 标准的金库份额。例如在 Lido 质押 ETH,会自动获得stETH作为你的份额。
  2. 更好的流通性(Better Liquidity):由于份额被代币化,你可以在不取回基础资产的情况下,利用金库份额做其他事情。以 Lido 的stETH为例,你可以直接在 Uniswap 上为它提供流动性或进行交易,而无需取出其中的 ETH。
  3. 更好的可组合性(Better Composability):有了统一标准后,用一套接口即可与所有 ERC4626 金库交互,让基于金库的应用、插件和工具开发变得更容易。

可以说,ERC4626 对 DeFi 的重要性不亚于 ERC721 对 NFT 的重要性。

ERC4626 的四大核心逻辑

  1. ERC20 继承:ERC4626 继承 ERC20,金库份额就是用 ERC20 代币代表的。用户将特定的 ERC20 基础资产(如 WETH)存入金库,合约为其铸造特定数量的金库份额代币;当用户提取基础资产时,合约销毁相应数量的份额代币。asset()函数返回金库基础资产的代币地址。
  2. 存款逻辑:允许用户存入基础资产,并铸造相应数量的金库份额。相关函数为deposit()mint()deposit(uint assets, address receiver)让用户存入assets单位资产并铸造相应份额给receivermint(uint shares, address receiver)类似,区别是以"要铸造的份额数量"为参数。
  3. 提款逻辑:允许用户销毁金库份额,并提取金库中相应数量的基础资产。相关函数为withdraw()redeem(),前者以"取出基础资产数量"为参数,后者以"销毁的金库份额"为参数。
  4. 会计与限额逻辑:其余函数用于统计金库资产、设定存款/提款限额,以及换算存款/提款对应的基础资产与金库份额数量。

IERC4626 接口合约全解

IERC4626 接口合约(源码见 Languages/en/51_ERC4626_en/IERC4626.sol)共包含2 个事件

  • Deposit事件:存款时触发。
  • Withdraw事件:取款时触发。

接口还包含16 个函数,按功能分为4 大类

  • 元数据(Metadata)
    • asset():返回金库基础资产代币地址,用于存款与取款。
  • 存款/提款逻辑(Deposit/Withdrawal Logic)
    • deposit():用户存入assets单位基础资产,合约铸造shares单位金库份额给receiver,释放Deposit事件。
    • mint():用户指定想获得的shares份额,函数计算出需存入的assets基础资产并转出,再给receiver铸造指定份额,释放Deposit事件。
    • withdraw()owner销毁shares份额,合约将相应基础资产发送给receiver,释放Withdraw事件。
    • redeem()owner销毁shares份额,合约将相应基础资产发给receiver,释放Withdraw事件。
  • 会计逻辑(Accounting Logic)
    • totalAssets():返回金库中管理的基础资产总额(须包含利息与费用)。
    • convertToShares():返回用一定数额基础资产可换取的金库份额(不含费用与滑点)。
    • convertToAssets():返回用一定数额金库份额可换取的基础资产(不含费用与滑点)。
    • previewDeposit():模拟当前链上环境存款assets可获得的金库份额(考虑费用,须不大于同交易实际获得值;可与convertToAssets差值计算滑点)。
    • previewMint():模拟当前链上环境铸造shares份额需要存款的基础资产数量。
    • previewWithdraw():模拟当前链上环境提取assets基础资产需要赎回的份额。
    • previewRedeem():模拟当前链上环境销毁shares份额能赎回的基础资产数量。
  • 存款/提款限额逻辑(Deposit/Withdrawal Limit Logic)
    • maxDeposit():返回某地址单次存款的最大基础资产数额。
    • maxMint():返回某地址单次铸造的最大金库份额。
    • maxWithdraw():返回某地址单次取款可提取的最大基础资产。
    • maxRedeem():返回某地址单次赎回可销毁的最大金库份额(无其他限制时应等于balanceOf(owner))。

接口合约完整代码如下(对应仓库 IERC4626.sol):

// SPDX-License-Identifier: MIT // Author: 0xAA from WTF Academy pragma solidity ^0.8.34; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; /** * @dev ERC4626 "Tokenized Vaults Standard" interface contract * https://eips.ethereum.org/EIPS/eip-4626. */ interface IERC4626 is IERC20, IERC20Metadata { /*////////////////////////////////////////////////////////////// event //////////////////////////////////////////////////////////////*/ // triggered when depositing event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); // triggered when withdrawing event Withdraw( address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares ); /*////////////////////////////////////////////////////////////// metadata //////////////////////////////////////////////////////////////*/ /** * @dev returns the address of the underlying asset token of the vault (used for deposit and withdrawal) * - has to be ERC20 token contract address * - cannot revert */ function asset() external view returns (address assetTokenAddress); /*////////////////////////////////////////////////////////////// deposit/withdraw logic //////////////////////////////////////////////////////////////*/ /** * @dev deposit function: user deposit ${assets} units of underlying asset to vault, * and the contract mints ${shares} unit vault share to receiver's address * * - has to emit Deposit event * - if asset cannot be deposited succuessfully, must revert. e.g. when deposit amount exceeds limit */ function deposit(uint256 assets, address receiver) external returns (uint256 shares); /** * @dev mint function: users deposit ${assets} units of the underlying asset * and the contract mints the corresponding amount of the vault's shares to the receiver's address * - has to emit Deposit event * - if it cannot mint, must revert. e.g. minting amount exceeds limit */ function mint(uint256 shares, address receiver) external returns (uint256 assets); /** * @dev withdraw function: owner address burns ${share} units of the vault's shares, * and the contract transfers the corresponding amount of the underlying asset to the receiver address * * - emit Withdraw event * - if all assets cannot be withdrew, it will revert */ function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); /** * @dev redeem function: owner address burns ${share} units of the vault's shares, * and the contract transfers the corresponding amount of the underlying asset to the receiver address * * - emit Withdraw event * - if vault's share cannot be redeemed, then revert */ function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); /*////////////////////////////////////////////////////////////// Accounting Logic //////////////////////////////////////////////////////////////*/ /** * @dev returns the total amount of underlying asset tokens managed in the vault * * - include interest * - include fee * - cannot revert */ function totalAssets() external view returns (uint256 totalManagedAssets); /** * @dev returns the amount of vault shares that can be obtained by using a certain amount of the underlying asset * - do not include fee * - do not include slippage * - cannot revert */ function convertToShares(uint256 assets) external view returns (uint256 shares); /** * @dev returns the amount of underlying asset that can be obtained by using a certain amount of vault shares * * - do not include fee * - do not include slippage * - cannot revert */ function convertToAssets(uint256 shares) external view returns (uint256 assets); /** * @dev used by both on-chain and off-chain users to simulate the amount of vault shares they can obtain by depositing a certain amount of the underlying asset in the current on-chain environment * * - the return value should be close to and not greater than the vault amount obtained by depositing in the same transaction * - do not consider about restrictions like maxDeposit, assume that user deposit will succeed * - consider fee * - cannot revert * NOTE: use the difference of the return values of convertToAssets and previewDeposit to calculate slippage */ function previewDeposit(uint256 assets) external view returns (uint256 shares); /** * @dev used by both on-chain and off-chain users to simulate the amount of underlying asset needed to mint a certain amount of vault shares in the current on-chain environment * - the return value should be close to and not less than the deposit amount required to mint a certain amount of vault amount in the same transaction. * - do not consider about restrictions like maxMint, assume that user mint transaction will succeed * - consider fee * - cannot revert */ function previewMint(uint256 shares) external view returns (uint256 assets); /** * @dev used by both on-chain and off-chain users to simulate the amount of vault shares they need to redeem to withdraw a certain amount of the underlying asset in the current on-chain environment * - the return value should be close to and not greater than the vault share needed to redeem a certain amount of underlying asset withdrawn in the same transaction. * - do not consider about restrictions like maxWithdraw, assume that user withdraw transaction will succeed * - consider fee * - cannot revert */ function previewWithdraw(uint256 assets) external view returns (uint256 shares); /** * @dev used by on-chain and off-chain users to simulate the amount of underlying asset they can redeem by burning a certain amount of vault shares in the current on-chain environment * - the return value should be close to and not less than the amount of underlying asset that can be redeemed by the vault amount burnt in the same transaction. * - do not consider about restrictions like maxRedeem, assume that user redeem transaction will succeed * - consider fee * - cannot revert */ function previewRedeem(uint256 shares) external view returns (uint256 assets); /*////////////////////////////////////////////////////////////// deposit/widthdrawal limit logic //////////////////////////////////////////////////////////////*/ /** * @dev returns the maximum amount of underlying asset that can be deposited in a single transaction for a given user address. * - if there is max deposit limit, return value should be a finite value * - return value should not be greater than 2 ** 256 - 1 * - cannot revert */ function maxDeposit(address receiver) external view returns (uint256 maxAssets); /** * @dev returns the maximum vault amount that can be minted in a single transaction for a given user address. * - if there is max mint limit, return value should be a finite value * - return value should not be greater than 2 ** 256 - 1 * - cannot revert */ function maxMint(address receiver) external view returns (uint256 maxShares); /** * @dev returns the maximum amount of underlying asset that can be withdrawn in a single transaction for a given user address. * - return value should be a finite value * - cannot revert */ function maxWithdraw(address owner) external view returns (uint256 maxAssets); /** * @dev returns the maximum vault amount that can be redeemed in a single transaction for a given user address. * - return value should be a finite value * - if there are no other restrictions, the return value should be balanceOf(owner) * - cannot revert */ function maxRedeem(address owner) external view returns (uint256 maxShares); }

手写极简 ERC4626 金库合约

设计约定

仓库中的教学合约 Languages/en/51_ERC4626_en/ERC4626.sol(与 51_ERC4626/ERC4626.sol 同源)实现了一个极简代币化金库,遵循以下约定:

  • 构造函数:初始化基础资产的合约地址、金库份额代币的名称与符号。注意份额代币的名称/符号应与基础资产关联,例如基础资产叫WTF,金库份额最好叫vWTF
  • 存款:用户存入x单位基础资产,铸造x单位(等量)金库份额。
  • 提款:用户销毁x单位金库份额,提取x单位(等量)基础资产。

完整实现与逐段剖析

// SPDX-License-Identifier: MIT pragma solidity >=0.8.0; import {IERC4626} from "./IERC4626.sol"; import {ERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /** * @dev ERC4626 "Tokenized Vaults Standard" contract. * FOR TEACHING PURPOSE ONLY, DO NOT USE IN PRODUCTION */ contract ERC4626 is ERC20, IERC4626 { /*////////////////////////////////////////////////////////////// state variables //////////////////////////////////////////////////////////////*/ ERC20 private immutable _asset; // uint8 private immutable _decimals; constructor( ERC20 asset_, string memory name_, string memory symbol_ ) ERC20(name_, symbol_) { _asset = asset_; _decimals = asset_.decimals(); } /** @dev See {IERC4626-asset}. */ function asset() public view virtual override returns (address) { return address(_asset); } /** * See {IERC20Metadata-decimals}. */ function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) { return _decimals; } /*////////////////////////////////////////////////////////////// deposit/withdrawal logic //////////////////////////////////////////////////////////////*/ /** @dev See {IERC4626-deposit}. */ function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) { // use previewDeposit() to calculate vault share to be retained shares = previewDeposit(assets); // transfer first then mint, prevent reentrancy attack _asset.transferFrom(msg.sender, address(this), assets); _mint(receiver, shares); // emit Deposit event emit Deposit(msg.sender, receiver, assets, shares); } /** @dev See {IERC4626-mint}. */ function mint(uint256 shares, address receiver) public virtual returns (uint256 assets) { // use previewDeposit() to calculate amount of underlyting asset that needs to be deposited assets = previewMint(shares); // transfer first then mint, prevent reentrancy attack _asset.transferFrom(msg.sender, address(this), assets); _mint(receiver, shares); // emit Deposit event emit Deposit(msg.sender, receiver, assets, shares); } /** @dev See {IERC4626-withdraw}. */ function withdraw( uint256 assets, address receiver, address owner ) public virtual returns (uint256 shares) { // use previewWithdraw() to calculate vault share that will be burnt shares = previewWithdraw(assets); // if caller is not owner, check and update allownance if (msg.sender != owner) { _spendAllowance(owner, msg.sender, shares); } // burn first then transfer, prevent reentrancy attack _burn(owner, shares); _asset.transfer(receiver, assets); // emit Withdraw event emit Withdraw(msg.sender, receiver, owner, assets, shares); } /** @dev See {IERC4626-redeem}. */ function redeem( uint256 shares, address receiver, address owner ) public virtual returns (uint256 assets) { // use previewRedeem() to calculate the amount of underlying asset that can be redeemed assets = previewRedeem(shares); // if caller is not owner, check and update allownance if (msg.sender != owner) { _spendAllowance(owner, msg.sender, shares); } // burn first then transfer, prevent reentrancy attack _burn(owner, shares); _asset.transfer(receiver, assets); // emit Withdraw event emit Withdraw(msg.sender, receiver, owner, assets, shares); } /*////////////////////////////////////////////////////////////// accounting logic //////////////////////////////////////////////////////////////*/ /** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual returns (uint256){ // returns balance of underlying asset for this contract return _asset.balanceOf(address(this)); } /** @dev See {IERC4626-convertToShares}. */ function convertToShares(uint256 assets) public view virtual returns (uint256) { uint256 supply = totalSupply(); // if supply is 0, then mint vault share at 1:1 ratio // if supply is not 0, then mint vault share at actual ratio return supply == 0 ? assets : assets * supply / totalAssets(); } /** @dev See {IERC4626-convertToAssets}. */ function convertToAssets(uint256 shares) public view virtual returns (uint256) { uint256 supply = totalSupply(); // if supply is 0, then redeem underlying asset at 1:1 ratio // if supply is not 0, then redeem underlying asset at actual ratio return supply == 0 ? shares : shares * totalAssets() / supply; } /** @dev See {IERC4626-previewDeposit}. */ function previewDeposit(uint256 assets) public view virtual returns (uint256) { return convertToShares(assets); } /** @dev See {IERC4626-previewMint}. */ function previewMint(uint256 shares) public view virtual returns (uint256) { return convertToAssets(shares); } /** @dev See {IERC4626-previewWithdraw}. */ function previewWithdraw(uint256 assets) public view virtual returns (uint256) { return convertToShares(assets); } /** @dev See {IERC4626-previewRedeem}. */ function previewRedeem(uint256 shares) public view virtual returns (uint256) { return convertToAssets(shares); } /*////////////////////////////////////////////////////////////// DEPOSIT/WITHDRAWAL LIMIT LOGIC //////////////////////////////////////////////////////////////*/ /** @dev See {IERC4626-maxDeposit}. */ function maxDeposit(address) public view virtual returns (uint256) { return type(uint256).max; } /** @dev See {IERC4626-maxMint}. */ function maxMint(address) public view virtual returns (uint256) { return type(uint256).max; } /** @dev See {IERC4626-maxWithdraw}. */ function maxWithdraw(address owner) public view virtual returns (uint256) { return convertToAssets(balanceOf(owner)); } /** @dev See {IERC4626-maxRedeem}. */ function maxRedeem(address owner) public view virtual returns (uint256) { return balanceOf(owner); } }

关键实现点解析

  • 份额换算公式(ERC4626.sol):convertToSharestotalSupply() == 0时按1:1铸造份额,否则按assets * supply / totalAssets()的当前汇率换算;convertToAssets与之互为逆运算。这正是"早期 1:1、收益累积后按净值兑换"的代币化金库核心。
  • 防重入顺序deposit/mint采用"先transferFrom转资产、后_mint铸份额"的顺序(ERC4626.sol);withdraw/redeem采用"先_burn销毁、后transfer转出"的顺序(ERC4626.sol),避免重入攻击。
  • 授权校验withdraw/redeem在调用者不是owner时通过_spendAllowance(owner, msg.sender, shares)校验并扣减 ERC20 授权额度,复用继承自 ERC20 的授权机制(教学版 ERC20 实现见 Languages/en/51_ERC4626_en/ERC20.sol)。
  • 无限制默认值maxDeposit/maxMint直接返回type(uint256).max表示无上限;maxWithdraw按份额折算,maxRedeem直接返回balanceOf(owner)
  • 小数位对齐:构造函数从基础资产读取decimals()并存入 immutable 变量,decimals()函数重写为返回基础资产的小数位,保证份额与资产数量级一致。

教学版与 OpenZeppelin 实现的差距

仓库内嵌了 OpenZeppelin Contracts(lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol),对照其源码可清晰看到教学版省略的两类关键工程细节:

  • 四舍五入方向(Rounding Direction):ERC4626 标准要求会计函数明确向上/向下取整,OpenZeppelin 用Math.mulDiv等库在"存款向用户有利、提款向协议有利"的方向取整,防止精度损耗被套利;教学版直接整除,未做方向控制。
  • 通胀攻击(Inflation Attack)防护:空金库或接近空的金库中,攻击者可通过"捐赠"抬高份额价格、front-run 首笔存款来偷取价值。OpenZeppelin 从 v4.9 起引入可配置的虚拟资产/虚拟份额(_decimalsOffset())来抑制该攻击——源码注释明确指出"默认偏移量(0)即可让攻击无利可图,更大的偏移量会使攻击成本呈数量级上升";同时其实现还定义了ERC4626ExceededMaxDepositERC4626ExceededMaxMintERC4626ExceededMaxWithdrawERC4626ExceededMaxRedeem等自定义错误用于限额 revert。

因此,教学合约仅用于理解标准逻辑,严禁用于生产;生产环境建议直接使用 OpenZeppelin 的成熟实现。

Remix 实战演示

以下演示使用 Remix 中第二个账户0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2来部署合约和调用函数,完整走通"部署 → 授权 → 存款 → 提款"闭环:

  1. 部署基础资产 ERC20:部署仓库附带的 ERC20.sol,将代币名称和符号均设为WTF,并给自己铸造10000枚代币。
  2. 部署 ERC4626 金库:部署 ERC4626.sol,构造参数中基础资产地址填WTF合约地址,名称和符号均设为vWTF
  3. 授权:调用ERC20合约的approve()函数,将代币授权给ERC4626合约(金额需大于后续存款额)。
  4. deposit 存款:调用ERC4626合约的deposit(),存款1000枚代币;再调用balanceOf()确认金库份额变为1000

  1. mint 存款:调用mint()存入另一笔1000枚代币(此处教学版 1:1 汇率下mint(1000, receiver)对应存入 1000),调用balanceOf()确认份额变为2000
  2. withdraw 提款:调用withdraw()提取1000枚代币,调用balanceOf()确认份额降至1000
  3. redeem 赎回:调用redeem()赎回剩余1000枚代币,调用balanceOf()确认份额归零。

以上截图均为教程仓库中的真实 Remix 运行截图(Languages/en/51_ERC4626_en/img/),依次对应部署 ERC20、部署 ERC4626、approve 授权、deposit、mint、withdraw、redeem 的完整操作链。

小结

本讲系统介绍了代币化金库标准ERC4626,并手写了一个可将基础资产按 1:1 汇率转换为金库份额代币的极简金库合约。核心要点回顾:

  • ERC4626 继承 ERC20,通过2 个事件 + 16 个函数(元数据、存款/提款、会计、限额四大类)统一了收益金库的标准接口;
  • 教学合约通过convertToShares/convertToAssets的汇率换算、"先转账后铸造/先销毁后转账"的防重入顺序、ERC20 授权复用等技巧,浓缩了金库逻辑的精华;
  • 生产环境必须处理四舍五入方向通胀攻击等问题,应优先采用 OpenZeppelin 的正式实现(可对比仓库 lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol 学习)。

ERC4626 为 DeFi 提升了流动性与可组合性,未来将逐渐普及。接下来不妨思考:基于这个金库标准,你会构建什么应用?

【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 15:00:15

AI运动耳机:耳道里的微型生理监测站

1. 这不是耳机,是贴在耳道里的运动生理监测站“从播放声音到感知身体状态,AI 耳机开始成为运动终端”——这句话刚看到时,我下意识摸了摸自己正在用的AirPods Pro,心想:它连我跑步时心率准不准都测不准,怎么…

作者头像 李华
网站建设 2026/9/15 14:59:58

常德建筑轮廓GIS数据清洗、拓扑修复与白模生成实操

简介:这是一份2022年常德市建筑轮廓GIS矢量数据包,面向城市规划、地理信息相关专业学生与从业者,可用于城市空间结构分析、建筑密度评估及公共服务设施布局等场景。压缩包共6个文件,包含核心矢量文件shp、几何索引shx、属性表dbf、…

作者头像 李华
网站建设 2026/9/15 14:59:56

Loop:用一次鼠标滑动管好所有 macOS 窗口

Loop:用一次鼠标滑动管好所有 macOS 窗口 【免费下载链接】Loop Window management made elegant. 项目地址: https://gitcode.com/GitHub_Trending/lo/Loop 下午第三杯咖啡时,你又在十几个窗口之间来回拖拽标题栏。Loop 是一款免费开源的 macOS …

作者头像 李华