WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇
WebRTC视频 01 - 视频采集整体架构
WebRTC视频 02 - 视频采集类 VideoCaptureModule
[WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇](本文)
WebRTC视频 04 - 视频采集类 VideoCaptureDS 中篇
WebRTC视频 05 - 视频采集类 VideoCaptureDS 下篇
一、前言:
前面两篇文章我们介绍了WebRtc的视频采集架构,并且,分析了所有关键类之间如何相互协调,一直分析到操作VideoCaptureDS这个类为止。心中有了框架,接下来我们分析具体的点,就是VideoCaptureDS再往下如何操作硬件的。
二、流程图:
其实主要干了几件事:
- 连接CaptureFilter的输出Pin到SinkFilter的输入Pin,这样数据就源源不断从输出Pin到输入Pin了。
- CaptureFilter是由DirectShow提供的,可以通过CaptureFilter来控制DirectShow完成视频采集,而SinkFilter是webrtc自己构造的。
- 入口函数还记得吗?是VideoCaptureDS::Init()。
三、COM编程方法介绍:
CreateClassEnumerator:
CreateClassEnumerator
是DirectShow API中的一个函数,它用于创建一个枚举器对象,该对象可用于枚举系统中注册的所有DirectShow滤波器的类标识符(CLSID)。
该函数的原型通常是:
HRESULT CreateClassEnumerator(
REFCLSID clsidDeviceClass,
IEnumMoniker **ppEnumMoniker,
DWORD dwFlags
);
clsidDeviceClass
: 指定要枚举的设备类别的 CLSID。传入NULL
时,将枚举所有的设备类别。ppEnumMoniker
: 指向IEnumMoniker
接口指针的指针。枚举器将通过该指针返回。dwFlags
: 可选的标志,用于指定枚举器的行为。
IEnumMoniker:
IEnumMoniker
接口是COM编程中的一个接口,用于枚举 IMoniker
接口的集合。IEnumMoniker
接口中的 Next
方法用于检索指定数量的Moniker对象。
下面是 IEnumMoniker
接口的 Next
方法的一般原型:
HRESULT Next(
ULONG celt,
IMoniker **rgelt,
ULONG *pceltFetched
);
celt
: 指定要检索的Moniker对象数量。rgelt
: 用于输出枚举的Moniker对象的指针数组。pceltFetched
: 指向一个ULONG
变量的指针,用于返回实际成功检索的Moniker对象数量。
Next
方法会尝试从枚举器的当前位置检索指定数量的Moniker对象,并将它们填充到提供的 rgelt
数组中。成功获取的Moniker对象数量将通过 pceltFetched
参数返回。如果成功检索了指定数量的Moniker对象,则返回 S_OK
,否则返回 S_FALSE
。
BindToStorage:
IMoniker::BindToStorage
是一个用于将 Moniker 绑定到存储对象的方法。在 COM 编程中,Moniker 是用于标识和定位对象的抽象机制,而 BindToStorage
允许将 Moniker 解析为存储对象,从而可以访问该对象的数据。
具体来说,IMoniker::BindToStorage
方法的作用是将 Moniker 绑定到存储器,并返回一个指向该存储器对象的接口指针,以便可以访问存储器中所包含的数据。这个方法通常用于从 Moniker 获取实际对象的数据或属性。
下面是 IMoniker::BindToStorage
方法的一般原型:
HRESULT BindToStorage(
IBindCtx *pbc,
IMoniker *pmkToLeft,
REFIID riid,
void **ppvObj
);
pbc
: 指向绑定上下文对象的指针,用于控制绑定操作的一些方面。pmkToLeft
: 在某些情况下可能用到,表示左侧的 Moniker 对象。riid
: 指定所请求接口的 IID(接口标识符)。ppvObj
: 用于返回存储器对象的接口指针的指针。
通过调用 IMoniker::BindToStorage
方法,可以通过 Moniker 定位并访问存储器对象中的数据。这在 COM 编程中特别有用,特别是在处理对象链接和嵌入(OLE)等场景中。
IPropertyBag:
IPropertyBag
是 COM 编程中的一个接口,用于提供一种机制,允许通过属性名称来检索和设置属性值。它通常用于在 COM 对象之间传递属性信息,并提供一种灵活的方式来访问和操作属性。
下面是 IPropertyBag
接口的一般原型:
interface IPropertyBag : IUnknown
{
virtual HRESULT Read(LPCOLESTR pszPropName, VARIANT *pVar, IErrorLog *pErrorLog) = 0;
virtual HRESULT Write(LPCOLESTR pszPropName, VARIANT *pVar) = 0;
};
Read
: 通过属性名称读取属性值,并将其存储在传入的VARIANT
结构中。如果属性不存在或读取失败,可以使用IErrorLog
接口来记录错误信息。Write
: 根据属性名称设置属性值,传入要设置的属性值的VARIANT
结构。
通过 IPropertyBag
接口,可以实现一种通用的属性存储和检索机制,使得 COM 对象之间可以方便地传递和共享属性信息。这种机制在许多场景下非常有用,特别是在配置对象、持久化对象属性、或者在不同组件之间传递配置信息等方面。
BindToObject:
BindToObject
是 COM 编程中常用的一个方法,通常用于将 Moniker 绑定到对象,从而获取对象的接口指针。这个方法通常由 IMoniker
接口提供,可以用于实现对象的定位和访问。
下面是 IMoniker::BindToObject
方法的一般原型:
HRESULT BindToObject(
IBindCtx *pbc,
IMoniker *pmkToLeft,
REFIID riidResult,
void **ppvResult
);
pbc
: 指向绑定上下文对象的指针,用于控制绑定操作的一些方面。pmkToLeft
: 在某些情况下可能用到,表示左侧的 Moniker 对象。riidResult
: 请求的接口的 IID(接口标识符)。ppvResult
: 用于返回绑定到的对象的接口指针的指针。
通过调用 IMoniker::BindToObject
方法,可以将 Moniker 解析为一个对象,并获取该对象的接口指针。这个方法在 COM 编程中常用于实现对象的定位和访问,特别是在处理对象链接、远程过程调用(RPC)和其他需要动态定位对象的场景中。
三、CaptureFilter:
1、作用:
CaptureFilter就是控制DirectShow完成视频采集的。
2、获取CaptureFilter:
代码入口:
int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) {
// ...
// 构造CaptureFilter
_captureFilter = _dsInfo.GetDeviceFilter(deviceUniqueIdUTF8);
if (!_captureFilter) {
RTC_LOG(LS_INFO) << "Failed to create capture filter.";
return -1;
}
// ...
}
可以看出,是通过DeviceInfoDS
的对象 _dsInfo
来获取CaptureFilter
的。
看看如何获取CaptureFilter的:
// 获取CaptureFilter走这儿,其中productUniqueIdUTF8和productUniqueIdUTF8Length都传入的0
IBaseFilter* DeviceInfoDS::GetDeviceFilter(const char* deviceUniqueIdUTF8,
char* productUniqueIdUTF8,
uint32_t productUniqueIdUTF8Length) {
const int32_t deviceUniqueIdUTF8Length = (int32_t)strlen(
(char*)deviceUniqueIdUTF8); // UTF8 is also NULL terminated
if (deviceUniqueIdUTF8Length > kVideoCaptureUniqueNameLength) {
RTC_LOG(LS_INFO) << "Device name too long";
return NULL;
}
// enumerate all video capture devices
RELEASE_AND_CLEAR(_dsMonikerDevEnum);
// CreateClassEnumerator 是获取视频采集设备的枚举器到_dsMonikerDevEnum
HRESULT hr = _dsDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory,
&_dsMonikerDevEnum, 0);
if (hr != NOERROR) {
RTC_LOG(LS_INFO) << "Failed to enumerate CLSID_SystemDeviceEnum, error 0x"
<< rtc::ToHex(hr) << ". No webcam exist?";
return 0;
}
// reset之后就可以从0开始遍历了
_dsMonikerDevEnum->Reset();
ULONG cFetched;
IMoniker* pM;
IBaseFilter* captureFilter = NULL;
bool deviceFound = false;
// 使用Moniker遍历所有视频采集设备
while (S_OK == _dsMonikerDevEnum->Next(1, &pM, &cFetched) && !deviceFound) {
IPropertyBag* pBag;
// 获取对象的Bag接口,通过这个Bag接口后续获取属性
hr = pM->BindToStorage(0, 0, IID_IPropertyBag, (void**)&pBag);
if (S_OK == hr) {
// Find the description or friendly name.
// 先找设备唯一标识,找不到就去找设备描述,再找不到就去找设备名
VARIANT varName;
VariantInit(&varName);
// 判断我们是否要获取设备唯一Id(UniqueId)
if (deviceUniqueIdUTF8Length > 0) {
hr = pBag->Read(L"DevicePath", &varName, 0);
if (FAILED(hr)) {
hr = pBag->Read(L"Description", &varName, 0);
if (FAILED(hr)) {
hr = pBag->Read(L"FriendlyName", &varName, 0);
}
}
if (SUCCEEDED(hr)) {
// 将设备路径进行 UTF-8 编码转换
char tempDevicePathUTF8[256];
// 临时存储 UTF-8 编码的设备路径
tempDevicePathUTF8[0] = 0;
// 将获取的devicePath保存到tempDevicePathUTF8当中
WideCharToMultiByte(CP_UTF8, 0, varName.bstrVal, -1,
tempDevicePathUTF8, sizeof(tempDevicePathUTF8),
NULL, NULL);
// 比较下是否为我们想要找的device
if (strncmp(tempDevicePathUTF8, (const char*)deviceUniqueIdUTF8,
deviceUniqueIdUTF8Length) == 0) {
// We have found the requested device
// 找到了请求的设备
deviceFound = true;
// 获取CaptureFilter接口到captureFilter
hr =
pM->BindToObject(0, 0, IID_IBaseFilter, (void**)&captureFilter);
if
FAILED(hr) {
RTC_LOG(LS_ERROR) << "Failed to bind to the selected "
"capture device "
<< hr;
}
// 如果产品唯一标识存在且长度大于 0,获取设备名称,我们调用的时候传入的Null和0,这儿不会执行
if (productUniqueIdUTF8 &&
productUniqueIdUTF8Length > 0) // Get the device name
{
GetProductId(deviceUniqueIdUTF8, productUniqueIdUTF8,
productUniqueIdUTF8Length);
}
}
}
}
VariantClear(&varName);
pBag->Release();
}
pM->Release();
}
return captureFilter;
}
- 我们找到第一个就直接退出了,不会找出所有设备;
- 我们调用的时候productUniqueIdUTF8使用的缺省值NULL,productUniqueIdUTF8Length使用的缺省值0,因此不会执行GetProductId;
至此,我们VideoCaptureDS就持有了CaptureFilter了。
3、添加CaptureFilter到FilterGraph:
我们所有的Filter都必须添加到FilterGraph,这样,FilterGraph才能控制我们完成一些业务逻辑。
int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) {
// 省略部分代码...
// 构造CaptureFilter
_captureFilter = _dsInfo.GetDeviceFilter(deviceUniqueIdUTF8);
if (!_captureFilter) {
RTC_LOG(LS_INFO) << "Failed to create capture filter.";
return -1;
}
// Get the interface for DirectShow's GraphBuilder
// 创建FilterGraph,并返回IGraphBuilder接口
HRESULT hr = CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER,
IID_IGraphBuilder, (void**)&_graphBuilder);
if (FAILED(hr)) {
RTC_LOG(LS_INFO) << "Failed to create graph builder.";
return -1;
}
// 获取IMediaControl接口,用于控制数据的流转
hr = _graphBuilder->QueryInterface(IID_IMediaControl, (void**)&_mediaControl);
if (FAILED(hr)) {
RTC_LOG(LS_INFO) << "Failed to create media control builder.";
return -1;
}
// 将前面构造好的CaptureFilter添加到FilterGraph当中
hr = _graphBuilder->AddFilter(_captureFilter, CAPTURE_FILTER_NAME);
if (FAILED(hr)) {
RTC_LOG(LS_INFO) << "Failed to add the capture device to the graph.";
return -1;
}
// 省略部分代码...
}
4、获取输出Pin:
前面我们枚举整个终端的视频采集设备,找到了我们请求的设备,并返回了CaptureFilter。我们CaptureFilter也有很多Pin,因此,如法炮制,继续枚举CaptureFilter的Pin,找到我们想要的输出Pin。
入口函数:
int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) {
// 省略部分代码...
// 获取CaptureFilter的输出Pin
_outputCapturePin = GetOutputPin(_captureFilter, PIN_CATEGORY_CAPTURE);
if (!_outputCapturePin) {
RTC_LOG(LS_INFO) << "Failed to get output capture pin";
return -1;
}
}
注入我们要的输出Pin类型是PIN_CATEGORY_CAPTURE
/**
* 获取输出引脚
* @param filter 表示是哪个Filter的引脚
* @param Category 表示引脚的种类
*/
IPin* GetOutputPin(IBaseFilter* filter, REFGUID Category) {
HRESULT hr;
IPin* pin = NULL;
IEnumPins* pPinEnum = NULL;
// 获得枚举pin的接口到pPinEnum中
filter->EnumPins(&pPinEnum);
if (pPinEnum == NULL) {
return NULL;
}
// get first unconnected pin
hr = pPinEnum->Reset(); // set to first pin 让从0开始枚举
// 遍历每个pin
while (S_OK == pPinEnum->Next(1, &pin, NULL)) {
// 获取这个pin的方向
PIN_DIRECTION pPinDir;
pin->QueryDirection(&pPinDir);
if (PINDIR_OUTPUT == pPinDir) // This is an output pin
{
// 判断pin的类型,是否为我们想要的
// GUID_NULL表示任意类型
if (Category == GUID_NULL || PinMatchesCategory(pin, Category)) {
pPinEnum->Release();
return pin;
}
}
pin->Release();
pin = NULL;
}
pPinEnum->Release();
return NULL;
}
其实逻辑也很简单了,就是遍历这个CaptureFilter的所有Pin,判断下是不是输出pin,如果是,再判断下pin类型是否为我们想要的,都符合就找到了。
那么,如何判断pin类型是否为我们想要的呢?
/**
* 判断pin类型是否匹配
*/
BOOL PinMatchesCategory(IPin* pPin, REFGUID Category) {
BOOL bFound = FALSE;
// 获取IKsPropertySet接口到pKs当中
IKsPropertySet* pKs = NULL;
HRESULT hr = pPin->QueryInterface(IID_PPV_ARGS(&pKs));
if (SUCCEEDED(hr)) {
GUID PinCategory;
DWORD cbReturned;
// 从AMPROPSETID_Pin这个属性集中,获取AMPROPERTY_PIN_CATEGORY属性,
// 将属性数据存放于PinCategory当中,实际返回的数据大小存于cbReturned中
hr = pKs->Get(AMPROPSETID_Pin, AMPROPERTY_PIN_CATEGORY, NULL, 0,
&PinCategory, sizeof(GUID), &cbReturned);
// 判断返回的数据和我们要存储的数据大小是否一致,一致表示找到了目标pin
if (SUCCEEDED(hr) && (cbReturned == sizeof(GUID))) {
bFound = (PinCategory == Category);
}
pKs->Release();
}
return bFound;
}
发现它是去获取我们请求的AMPROPERTY_PIN_CATEGORY这种类型的Pin的属性是否和我们请求的一直,一直就认为类型一致。
四、SinkFilter:
前面已经创建好了输入数据的Filter,也就是CaptureFilter,并将它加入到了FilterGraph当中,同时找到了合适的输出Pin,准备输出数据,我们现在就创建一个输出Filter,也就是SinkFilter,接收CaptureFilter采集的数据。
注意:之前讲的CaptureFilter是由DirectShow提供的,而SinkFilter是webrtc自己构造的;
入口函数:
int32_t VideoCaptureDS::Init(const char* deviceUniqueIdUTF8) {
// Create the sink filte used for receiving Captured frames.
// 开始构造CaptureSinkFilter
sink_filter_ = new ComRefCount<CaptureSinkFilter>(this);
// 将CaptureSinkFilter加入到GraphicBuilder当中
hr = _graphBuilder->AddFilter(sink_filter_, SINK_FILTER_NAME);
if (FAILED(hr)) {
RTC_LOG(LS_INFO) << "Failed to add the send filter to the graph.";
return -1;
}
// 获取SinkFilter的输入pin
_inputSendPin = GetInputPin(sink_filter_);
if (!_inputSendPin) {
RTC_LOG(LS_INFO) << "Failed to get input send pin";
return -1;
}
return 0;
}
发现我们是创建了一个CaptureSinkFilter对象,并让GraphicBuilder将自己管理起来,最后获取SinkFilter的输入Pin,既然SinkFilter是自己构建的,我们看看它的类长什么样:
1、CaptureSinkFilter:
class CaptureSinkFilter : public IBaseFilter {
public:
CaptureSinkFilter(VideoCaptureImpl* capture_observer);
HRESULT SetRequestedCapability(const VideoCaptureCapability& capability);
// Called on the capture thread.
// Filter采集到数据之后,通过这个方法传给上层
void ProcessCapturedFrame(unsigned char* buffer,
size_t length,
const VideoCaptureCapability& frame_info);
void NotifyEvent(long code, LONG_PTR param1, LONG_PTR param2);
bool IsStopped() const;
// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv) override;
// IPersist
STDMETHOD(GetClassID)(CLSID* clsid) override;
// IMediaFilter.
STDMETHOD(GetState)(DWORD msecs, FILTER_STATE* state) override;
STDMETHOD(SetSyncSource)(IReferenceClock* clock) override;
STDMETHOD(GetSyncSource)(IReferenceClock** clock) override;
STDMETHOD(Pause)() override;
STDMETHOD(Run)(REFERENCE_TIME start) override;
STDMETHOD(Stop)() override;
// IBaseFilter
STDMETHOD(EnumPins)(IEnumPins** pins) override; // 遍历所有引脚
STDMETHOD(FindPin)(LPCWSTR id, IPin** pin) override;
STDMETHOD(QueryFilterInfo)(FILTER_INFO* info) override;
STDMETHOD(JoinFilterGraph)(IFilterGraph* graph, LPCWSTR name) override;
STDMETHOD(QueryVendorInfo)(LPWSTR* vendor_info) override;
protected:
virtual ~CaptureSinkFilter();
private:
SequenceChecker main_checker_;
const rtc::scoped_refptr<ComRefCount<CaptureInputPin>> input_pin_;
VideoCaptureImpl* const capture_observer_;
FILTER_INFO info_ RTC_GUARDED_BY(main_checker_) = {};
// Set/cleared in JoinFilterGraph. The filter must be stopped (no capture)
// at that time, so no lock is required. While the state is not stopped,
// the sink will be used from the capture thread.
IMediaEventSink* sink_ = nullptr;
FILTER_STATE state_ RTC_GUARDED_BY(main_checker_) = State_Stopped;
};
- capture_observer_: 是一个观察者,sinkFilter获取到数据之后,通过这个observer传给上层;
- state_: sinkFilter的状态,初始为stoped,运行之后就是started;
- input_pin_:sinkFilter的输入pin,真正获取数据的地方;
- ProcessCapturedFrame // Filter采集到数据之后,通过这个方法传给上层;
- EnumPins // 遍历所有引脚
2、输入Pin:
class CaptureInputPin : public IMemInputPin, public IPin {
public:
CaptureInputPin(CaptureSinkFilter* filter);
HRESULT SetRequestedCapability(const VideoCaptureCapability& capability);
// Notifications from the filter.
void OnFilterActivated();
void OnFilterDeactivated();
protected:
virtual ~CaptureInputPin();
private:
CaptureSinkFilter* Filter() const;
HRESULT AttemptConnection(IPin* receive_pin, const AM_MEDIA_TYPE* media_type);
std::vector<AM_MEDIA_TYPE*> DetermineCandidateFormats(
IPin* receive_pin,
const AM_MEDIA_TYPE* media_type);
void ClearAllocator(bool decommit);
HRESULT CheckDirection(IPin* pin) const;
// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv) override;
// clang-format off
// clang isn't sure what to do with the longer STDMETHOD() function
// declarations.
// IPin
// 用于连接某个pin
STDMETHOD(Connect)(IPin* receive_pin,
const AM_MEDIA_TYPE* media_type) override;
// 当与某个pin连接成功之后,回调这个方法,查看能否与某个pin进行连接
STDMETHOD(ReceiveConnection)(IPin* connector,
const AM_MEDIA_TYPE* media_type) override;
STDMETHOD(Disconnect)() override;
STDMETHOD(ConnectedTo)(IPin** pin) override;
STDMETHOD(ConnectionMediaType)(AM_MEDIA_TYPE* media_type) override;
STDMETHOD(QueryPinInfo)(PIN_INFO* info) override;
STDMETHOD(QueryDirection)(PIN_DIRECTION* pin_dir) override;
STDMETHOD(QueryId)(LPWSTR* id) override;
STDMETHOD(QueryAccept)(const AM_MEDIA_TYPE* media_type) override;
STDMETHOD(EnumMediaTypes)(IEnumMediaTypes** types) override;
STDMETHOD(QueryInternalConnections)(IPin** pins, ULONG* count) override;
STDMETHOD(EndOfStream)() override;
STDMETHOD(BeginFlush)() override;
STDMETHOD(EndFlush)() override;
STDMETHOD(NewSegment)(REFERENCE_TIME start, REFERENCE_TIME stop,
double rate) override;
// IMemInputPin
// 分配一个内存分配器(因为有些Filter是虚拟的,必须靠这个来 IMemInputPin 这些方法管理内存
STDMETHOD(GetAllocator)(IMemAllocator** allocator) override;
STDMETHOD(NotifyAllocator)(IMemAllocator* allocator, BOOL read_only) override;
STDMETHOD(GetAllocatorRequirements)(ALLOCATOR_PROPERTIES* props) override;
// 获取当前引脚的数据(比如CaptureSinkFilter调用这个接口获取)
STDMETHOD(Receive)(IMediaSample* sample) override;
STDMETHOD(ReceiveMultiple)(IMediaSample** samples, long count,
long* processed) override;
STDMETHOD(ReceiveCanBlock)() override;
// clang-format on
SequenceChecker main_checker_;
SequenceChecker capture_checker_;
// 用户请求的能力
VideoCaptureCapability requested_capability_ RTC_GUARDED_BY(main_checker_);
// Accessed on the main thread when Filter()->IsStopped() (capture thread not
// running), otherwise accessed on the capture thread.
// 最终最接近用户请求能力的真实能力
VideoCaptureCapability resulting_capability_;
DWORD capture_thread_id_ = 0;
// 内存分配器
rtc::scoped_refptr<IMemAllocator> allocator_ RTC_GUARDED_BY(main_checker_);
// 与当前pin连接的外部pin
rtc::scoped_refptr<IPin> receive_pin_ RTC_GUARDED_BY(main_checker_);
std::atomic_bool flushing_{false};
std::atomic_bool runtime_error_{false};
// Holds a referenceless pointer to the owning filter, the name and
// direction of the pin. The filter pointer can be considered const.
// pin信息
PIN_INFO info_ = {};
// 每个pin都有自己支持的媒体类型,不支持的会拒绝掉
AM_MEDIA_TYPE media_type_ RTC_GUARDED_BY(main_checker_) = {};
};
我基本都写注释了,但是,还有几点需要注意:
-
IMemInputPin: 是与内存相关的,因为有些引脚是物理引脚,有些引脚是虚拟的,比如CaptureSinkFilter就是虚拟的Filter,虚拟的就会涉及到内存的申请释放,IMemInputPin就是定义这些方法的;
-
IPin就是实际的引脚;
-
当调用CaptureInputPin的Receive获取CaptureInputPin的数据之后,就可以交给CaptureSinkFilter,再通过其ProcessCapturedFrame 传给capture_observer_;
至于InputPin的枚举获取和之前CaptureFilter的OutputPin逻辑一样,不再赘述。
五、连接Filter:
Filter连接是一个比较复杂的流程,打算单独写一篇介绍,读者可以先思考几个问题:
- 每个Filter都有自己支持的能力,那么这俩Filter要连起来,分别应该选择哪个能力?
- 两个Filter之间要传递数据怎么传递?存储数据的Buffer由哪个Filter管理?
- 需要创建多大的Buffer,依据是什么?大了浪费空间,小了不够存。