posts - 156,  comments - 5252,  trackbacks - 0

引言

标准的MMORPG游戏资源均非常庞大,包括数十甚至上百幅地图,几十种魔法,几百种精灵外加一堆的配置文件和音乐音效等等。Silverlight作为嵌在浏览器中的插件,如能合理的将资源分类处理以布局,不仅能减少最终客户端(XAP)容量,同时也是完美的用户体验;俗话说:动态好,静态快。没错,这就是本节我将向大家讲解的:Silverlight - MMORPG游戏资源布局之动静结合。

4.1游戏资源静态布局(交叉参考:场景编辑器让游戏开发更美好)

游戏中的对象很多,如数据库的记录一样,我们可以按个体区别赋予它们各自一个Code(代号)以标识,同时给以相应的配置文件以描述该对象资源结构。

以精灵为例,3.2中主角用到的精灵素材我们可以将其Code定义为0号,那么全新的资源布局结构整理如下:

同时3.2中精灵站立、跑动时的结束帧(EndFrame)等我是以硬编码的形式填写,因此新结构中每个精灵对象的Info.xml配置文件目前必须包含如下信息:

<?xml version="1.0" encoding="utf-8" ?>
<Sprite
  FullName
="双刀"
  Speed
="6"
  BodyWidth
="150"
  BodyHeight
="150"
  CenterX
="75"
  CenterY
="125"
  StandEndFrame="3"
  StandEffectFrame="-1"
  StandInterval="300"
  RunEndFrame="5"
  RunEffectFrame="-1"
  RunInterval="120"
  AttackEndFrame="4"
  AttackEffectFrame="2"
  AttackInterval="180"
  CastingEndFrame="4"
  CastingEffectFrame="4"
  CastingInterval="150"
/>

   针对最后的这些Frame帧描述,我们可以在Logic项目中建立一个名为SpriteFrames的帧信息结构体以提高可读性:

代码
namespace Logic.Struct {
    
public struct SpriteFrames {
        
public int StandEndFrame { getset; }
        
public int StandEffectFrame { getset; }
        
public int StandInterval { getset; }
        
public int RunEndFrame { getset; }
        
public int RunEffectFrame { getset; }
        
public int RunInterval { getset; }
        
public int AttackEndFrame { getset; }
        
public int AttackEffectFrame { getset; }
        
public int AttackInterval { getset; }
        
public int CastingEndFrame { getset; }
        
public int CastingEffectFrame { getset; }
        
public int CastingInterval { getset; }
    }
}

    根据以上新增内容,在Sprite精灵类中一一补上相应的属性:

代码
        /// <summary>
        
/// 获取或设置名字
        
/// </summary>
        public string FullName { getset; }

        
/// <summary>
        
/// 获取或设置身体宽
        
/// </summary>
        public double BodyWidth {
            
get { return this.Width; }
            
set { this.Width = value; }
        }

        
/// <summary>
        
/// 获取或设置身体高
        
/// </summary>
        public double BodyHeight {
            
get { return this.Height; }
            
set { this.Height = value; }
        }

        
/// <summary>
        
/// 获取或设置各动作帧信息
        
/// </summary>
        public SpriteFrames Frames { getset; }

    最后在某精灵的Code被修改时解析对应的Info.xml配置并将值取出赋予相关属性:

        int _Code;
        
/// <summary>
        
/// 获取或设置代号(标识)
        
/// </summary>
        public int Code {
            
get { return _Code; }
            
set {
                _Code 
= value;
                
//通过LINQ2XML解析配置文件
                XElement xSprite = Global.LoadXML(string.Format("Sprite/{0}/Info.xml", value)).DescendantsAndSelf("Sprite").Single();
                FullName 
= xSprite.Attribute("FullName").Value;
                Speed 
= (double)xSprite.Attribute("Speed");
                BodyWidth 
= (double)xSprite.Attribute("BodyWidth");
                BodyHeight 
= (double)xSprite.Attribute("BodyHeight");
                Center 
= new Point((double)xSprite.Attribute("CenterX"), (double)xSprite.Attribute("CenterY"));
                Frames 
= new SpriteFrames() {
                    StandEndFrame 
= (int)xSprite.Attribute("StandEndFrame"),
                    StandEffectFrame 
= (int)xSprite.Attribute("StandEffectFrame"),
                    StandInterval 
= (int)xSprite.Attribute("StandInterval"),
                    RunEndFrame 
= (int)xSprite.Attribute("RunEndFrame"),
                    RunEffectFrame 
= (int)xSprite.Attribute("RunEffectFrame"),
                    RunInterval 
= (int)xSprite.Attribute("RunInterval"),
                    AttackEndFrame 
= (int)xSprite.Attribute("AttackEndFrame"),
                    AttackEffectFrame 
= (int)xSprite.Attribute("AttackEffectFrame"),
                    AttackInterval 
= (int)xSprite.Attribute("AttackInterval"),
                    CastingEndFrame 
= (int)xSprite.Attribute("CastingEndFrame"),
                    CastingEffectFrame 
= (int)xSprite.Attribute("CastingEffectFrame"),
                    CastingInterval 
= (int)xSprite.Attribute("CastingInterval"),
                };
            }
        }

    这里我用到了LINQ TO XMLXAP中的xml文件进行解析;需要注意的是使用时必须在项目中添加对System.XML.LINQ的引用,同时还要using System.Linq程序集。

另外基于性能的考虑,CanvasBackground同样可以填充图片。因而我们可以将精灵中的body类型该为ImageBrush,3.2节为基础修改后的代码如下:

        #region 构造

        
ImageBrush body = new ImageBrush();
        DispatcherTimer dispatcherTimer 
= new DispatcherTimer();
        
public Sprite() {
            
this.Background = body;
            
this.Loaded += new RoutedEventHandler(Sprite_Loaded);
        }

        
private void Sprite_Loaded(object sender, EventArgs e) {
            
Stand();
            dispatcherTimer.Tick 
+= new EventHandler(dispatcherTimer_Tick);
            dispatcherTimer.Start();
            
this.Loaded -= Sprite_Loaded;
        }

        
int currentFrame, startFrame, endFrame;
        
void dispatcherTimer_Tick(object sender, EventArgs e) {
            
if (currentFrame > endFrame) { currentFrame = startFrame; }
            
body.ImageSource = Global.GetProjectImage(string.Format("Sprite/{0}/{1}-{2}-{3}.png", ID, (int)State, (int)Direction, currentFrame));
            currentFrame
++;
        }

        
#endregion

    当然,其中我还在Global中添加了两个静态方法分别用于上面的XML文件与Image图像的加载:

        /// <summary>
        
/// 项目Resource资源路径
        
/// </summary>
        public static string ProjectPath(string uri) {
            
return string.Format(@"/{0};component/Res/{1}", ProjectName, uri);
        }

        
/// <summary>
        
/// 获取项目Resource中的xml文件
        
/// </summary>
        
/// <param name="uri">相对路径</param>
        
/// <returns>XElement</returns>
        public static XElement LoadXML(string uri) {
            
return XElement.Load(ProjectPath(uri));
        }

        
/// <summary>
        
/// 获取项目Resource中的图片
        
/// </summary>
        
/// <param name="uri">相对路径</param>
        
/// <returns>BitmapImage</returns>
        public static BitmapImage GetProjectImage(string uri) {
            
return new BitmapImage(new Uri(ProjectPath(uri), UriKind.Relative)) {
                CreateOptions 
= BitmapCreateOptions.None
            };
        }

    到此一个全新的静态资源布局结构(编译后的所有资源均存于主XAP)就完成了。

4.2游戏资源动态布局(交叉参考:创建基于场景编辑器的新游戏Demo  动态资源  三国策(Demo) 之 “江山一统”)

Silverlight中通过WebClinet下载的资源与浏览器共用网页缓存这一特性为我们动态布局游戏资源提供了相当的便利。

所谓动态资源布局即资源文件均存放于服务器网站目录下,根据时时的需求去下载。

4.1的代码为基础,我们首先将Silverlight主项目中的Res文件夹完整的复制到Web项目中的ClientBin目录下,然后删除掉主项目中的Res文件夹下的所有文件,之后新建一个名为Model的文件夹以保存精灵模型。

以精灵的呈现为例,大致思路是当它第一次呈现时,首先以模型的形式出现,此时我们会通过WebClinet队列下载该精灵的配置及图片等资源,一旦全部下载完成时精灵才以真实面目展现:

当然这需要一些比较复杂的下载逻辑,首先我们在解决方案中新建一个名为DownloadHelper的类库,并在其内部编写两个类Downloader(下载者)DownloadQueue(下载队列)

代码
namespace DownloadHelper {

    
/// <summary>
    
/// Web资源下载者
    
/// </summary>
    public sealed class Downloader {

        
/// <summary>
        
/// 已下载的资源路径字典
        
/// </summary>
        static Dictionary<stringbool> res = new Dictionary<stringbool>();

        
/// <summary>
        
/// 获取或设置下载对象代号
        
/// </summary>
        public int TargetCode { getset; }

        
/// <summary>
        
/// 资源下载中
        
/// </summary>
        public event EventHandler<DownloaderEventArgs> Loading;

        
/// <summary>
        
/// 资源下载完成
        
/// </summary>
        public event EventHandler<DownloaderEventArgs> Completed;

        
string uri = string.Empty;
        
/// <summary>
        
/// 通过WebClient下载资源
        
/// </summary>
        public void Download(string uri) {
            
this.uri = uri;
            
//假如该路径资源还未下载过
            if (!res.ContainsKey(uri)) {
                WebClient webClient 
= new WebClient();
                webClient.OpenReadCompleted 
+= new OpenReadCompletedEventHandler(webClient_OpenReadCompleted);
                webClient.OpenReadAsync(
new Uri(uri, UriKind.Relative), uri);
                res.Add(uri, 
false);
                
if (Loading != null) { Loading(thisnew DownloaderEventArgs() { Uri = uri }); }
            } 
else {
                
//假如该路径资源已下载完成
                if (res[uri]) {
                    
if (Completed != null) { Completed(thisnew DownloaderEventArgs() { Uri = uri }); }
                } 
else {
                    
//假如该路径资源正在下载,则需要等待,每隔1秒检测一次是否已下载完成
                    DispatcherTimer timer = new DispatcherTimer() { Interval = TimeSpan.FromSeconds(1) };
                    timer.Tick 
+= new EventHandler(timer_Tick);
                    timer.Start();
                    
if (Loading != null) { Loading(thisnew DownloaderEventArgs() { Uri = uri }); }
                }
            }
        }

        
void webClient_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e) {
            
//该路径资源已下载完成
            WebClient webClient = sender as WebClient;
            webClient.OpenReadCompleted 
-= webClient_OpenReadCompleted;
            
string uri = e.UserState.ToString();
            res[uri] 
= true;
            
//Completed中捕获stream可实现任意文件类型转化
            if (Completed != null) { Completed(thisnew DownloaderEventArgs() { Uri = uri, Stream = e.Result }); }
        }

        
void timer_Tick(object sender, EventArgs e) {
            
if (res[uri]) {
                DispatcherTimer dispatcherTimer 
= sender as DispatcherTimer;
                dispatcherTimer.Stop();
                dispatcherTimer.Tick 
-= timer_Tick;
                
if (Completed != null) { Completed(thisnew DownloaderEventArgs() { Uri = uri }); }
            }
        }

    }
}

 

代码
namespace DownloadHelper {

    
/// <summary>
    
/// Web资源下载队列(简单实现)
    
/// </summary>
    public sealed class DownloadQueue {

        
/// <summary>
        
/// 已下载的文件路径字典
        
/// </summary>
        static Dictionary<stringbool> res = new Dictionary<stringbool>();

        
/// <summary>
        
/// 获取或设置流程序号
        
/// </summary>
        public int Index { getset; }

        
/// <summary>
        
/// 获取或设置下载对象代号
        
/// </summary>
        public int TargetCode { getset; }

        
/// <summary>
        
/// 等待资源下载完成中
        
/// </summary>
        public event EventHandler Waiting;

        
/// <summary>
        
/// 资源下载完成
        
/// </summary>
        public event EventHandler Completed;

        
string key;
        
int uriNum, count;
        List
<string> uris;
        
/// <summary>
        
/// 依据资源地址列表队列下载资源文件
        
/// </summary>
        
/// <param name="uris">资源地址列表</param>
        public void Download(string key, List<string> uris) {
            
this.key = key;
            
//假如此下载队列未下载过
            if (!res.ContainsKey(key)) {
                
if (uris.Count == 0) {
                    
if (Completed != null) { Completed(thisnull); }
                } 
else {
                    
this.uris = uris;
                    uriNum 
= uris.Count;
                    Download(count);
                    res.Add(key, 
false);
                }
            } 
else {
                
//假如该队列资源已下载完成
                if (res[key]) {
                    
if (Completed != null) { Completed(thisnull); }
                } 
else {
                    
//假如该路径资源正在下载,则需要等待,每隔1秒检测一次是否已下载完成
                    DispatcherTimer timer = new DispatcherTimer() { Interval = TimeSpan.FromSeconds(1) };
                    timer.Tick 
+= new EventHandler(timer_Tick);
                    timer.Start();
                    
if (Waiting != null) { Waiting(thisnull); }
                }
            }
        }

        
void Download(int index) {
            Downloader downloader 
= new Downloader();
            downloader.Completed 
+= new EventHandler<DownloaderEventArgs>(downloader_Completed);
            downloader.Download(uris[index]);
        }

        
void downloader_Completed(object sender, DownloaderEventArgs e) {
            Downloader downloader 
= sender as Downloader;
            downloader.Completed 
-= downloader_Completed;
            count
++;
            
if (count < uriNum) {
                Download(count);
            } 
else {
                res[key] 
= true;
                
if (Completed != null) { Completed(thisnull); }
            }
        }

        
void timer_Tick(object sender, EventArgs e) {
            
if (res[key]) {
                DispatcherTimer dispatcherTimer 
= sender as DispatcherTimer;
                dispatcherTimer.Stop();
                dispatcherTimer.Tick 
-= timer_Tick;
                
if (Completed != null) { Completed(thisnull); }
            }
        }

    }
}

(注:这两个类目前只是简单实现,达到目的为主,后续课程还会进一步做代码优化。大致原理是以静态资源下载状态字典(static Dictionary<string, bool> res)为依据,分三种情况(未下载、下载中、已下载)作出不同的判断处理。)

剩下的工作是在精灵类内添加一个名为Code的属性并封装逻辑:当该值改变时首先通过Downloader去下载该精灵代号的配置文件:

        int _Code = -1;
        
/// <summary>
        
/// 获取或设置代号
        
/// </summary>
        public int Code {
            
get { return _Code; }
            
set {
                
if (_Code != value) {
                    _Code 
= value;
                    Downloader downloader = new Downloader() { TargetCode = value };
                    downloader.Completed += new EventHandler<DownloaderEventArgs>(downloader_Completed);
                    downloader.Download(Global.WebPath(string.Format("Sprite/{0}/Info.xml", value)));
                }
            }
        }

配置文件下载完成后我们接着编写类似4.1的逻辑对该xml文件进行解析,将获取的数据赋值到精灵属性;此时还会得到该精灵模型的代号属性:ModelCode - 用以选择对应的模型首先呈现。最后判断该代号的精灵资源是否下载过,如果没有则创建下载队列去顺次下载所需资源:

代码
        /// <summary>
        
/// 已下载的资源代号
        
/// </summary>
        static List<int> loadedCodes = new List<int>();

        
int index = 0;
        
int bodyCode;
        
bool IsResReady = false;
        
void downloader_Completed(object sender, DownloaderEventArgs e) {
            Downloader downloader 
= sender as Downloader;
            downloader.Completed 
-= downloader_Completed;
            
string key = string.Format("Sprite{0}", downloader.TargetCode);
            
if (e.Stream != null) { Global.ResInfos.Add(key, XElement.Load(e.Stream)); }
            
//通过LINQ2XML解析配置文件
            XElement config = Global.ResInfos[key].DescendantsAndSelf("Sprite").Single();
            FullName 
= config.Attribute("FullName").Value;
            Speed 
= (double)config.Attribute("Speed");
            BodyWidth 
= (double)config.Attribute("BodyWidth");
            BodyHeight 
= (double)config.Attribute("BodyHeight");
            Center 
= new Point((double)config.Attribute("CenterX"), (double)config.Attribute("CenterY"));
            Frames 
= new SpriteFrames() {
                StandEndFrame 
= (int)config.Attribute("StandEndFrame"),
                StandEffectFrame 
= (int)config.Attribute("StandEffectFrame"),
                StandInterval 
= (int)config.Attribute("StandInterval"),
                RunEndFrame 
= (int)config.Attribute("RunEndFrame"),
                RunEffectFrame 
= (int)config.Attribute("RunEffectFrame"),
                RunInterval 
= (int)config.Attribute("RunInterval"),
                AttackEndFrame 
= (int)config.Attribute("AttackEndFrame"),
                AttackEffectFrame 
= (int)config.Attribute("AttackEffectFrame"),
                AttackInterval 
= (int)config.Attribute("AttackInterval"),
                CastingEndFrame 
= (int)config.Attribute("CastingEndFrame"),
                CastingEffectFrame 
= (int)config.Attribute("CastingEffectFrame"),
                CastingInterval 
= (int)config.Attribute("CastingInterval"),
            };
            
ModelCode = (int)config.Attribute("ModelCode");
            
if (State == SpriteState.Stand) { Stand(); }
            Coordinate 
= new Point(Coordinate.X + 0.000001, Coordinate.Y);
            dispatcherTimer.Start();
            
//假如没有下载过代号精灵的图片资源则开始下载所需资源
            index++;
            
if (loadedCodes.Contains(downloader.TargetCode)) {
                bodyCode 
= Code;
                IsResReady 
= true;
            }
else{
                IsResReady 
= false;
                
DownloadQueue downloadQueue = new DownloadQueue() { Index = index, TargetCode = downloader.TargetCode };
                downloadQueue.Completed += new EventHandler(downloadQueue_Completed);
                
//解析精灵图片资源地址表
                List<string> uris = new List<string>();
                
int n = 0;
                
for (int i = 0; i < (int)config.Attribute("StateNum"); i++) {
                    
switch (i) {
                        
case 0: n = Frames.StandEndFrame; break;
                        
case 1: n = Frames.RunEndFrame; break;
                        
case 2: n = Frames.AttackEndFrame; break;
                        
case 3: n = Frames.CastingEndFrame; break;
                    }
                    
for (int j = 0; j < (int)config.Attribute("DirectionNum"); j++) {
                        
for (int k = 0; k <= n; k++) {
                            uris.Add(Global.WebPath(
string.Format("Sprite/{0}/{1}-{2}-{3}.png", Code, i, j, k)));
                        }
                    }
                }
                
downloadQueue.Download(key, uris);
            }
        }

    由于资源动态下载这整个过程是异步的,因此队列完成后还需增加一个额外的判断:当前下载好的精灵资源是否就是玩家最后一次提交的代号精灵(涉及到资源异步与换装同步问题),一致则bodyCode = Code;并且IsResReady=true

        void downloadQueue_Completed(object sender, EventArgs e) {
            DownloadQueue downloadQueue 
= sender as DownloadQueue;
            downloadQueue.Completed 
-= downloadQueue_Completed;
            
//由于资源加载是异步,因此呈现时以最新的index资源为准
            if (downloadQueue.Index == index) { 
                bodyCode 
= downloadQueue.TargetCode;
                IsResReady 
= true;
            }
            loadedCodes.Add(downloadQueue.TargetCode);
        }

最后我们还得修改精灵身体图片切换逻辑以适应动态资源情况(GetWebImage方法在Global中):

        int currentFrame, startFrame, endFrame;
        
void dispatcherTimer_Tick(object sender, EventArgs e) {
            
if (currentFrame > endFrame) { currentFrame = startFrame; }
            
body.ImageSource =
                IsResReady
                
? Global.GetWebImage(string.Format("Sprite/{0}/{1}-{2}-{3}.png", bodyCode, (int)State, (int)Direction, currentFrame))
                : Global.GetProjectImage(string.Format("Model/Sprite/{0}/{1}-{2}-{3}.png", ModelCode, (int)State, (int)Direction, currentFrame));
            currentFrame
++;
        }

就这样完成了整个动态资源配置流程。本节以精灵控件为例,按照面向对象的思想将所有资源加载配置逻辑封装在Code属性中,使用起来方便快捷。

4.3游戏资源压缩整合

4.2以理想的方式实现了游戏资源的动态加载,然而每个代号精灵的图片资源有上百张之多,通过WebClinet队列下载效率与体验均不是很好,呈现延迟较大。于是我们想到了以个体精灵对象图片资源为独立单位压缩成zip格式:

每次加载指定代号精灵时只需下载一次它的zip资源包即可: 

            //假如没有下载过代号精灵的图片资源则开始下载所需资源
            index++;
            
if (bodyImages.ContainsKey(downloader.TargetCode)) {
                IsResReady 
= true;
            } 
else {
                IsResReady 
= false;
                Downloader zipDownloader 
= new Downloader() { Index = index, TargetCode = downloader.TargetCode };
                zipDownloader.Completed 
+= new EventHandler<DownloaderEventArgs>(zipDownloader_Completed);
                
zipDownloader.Download(Global.WebPath(string.Format("Sprite/{0}/Body.zip", Code)));
            }

这里我通过一个静态的资源字典来缓存zip数据流:

        /// <summary>
        
/// 已下载的Zip数据流
        
/// </summary>
        
static Dictionary<int, StreamResourceInfo> bodyImages = new Dictionary<int, StreamResourceInfo>();

每次资源下载完成后同样判断是否为最后切换的角色,并将该资源数据流信息缓存:

        void zipDownloader_Completed(object sender, DownloaderEventArgs e) {
            Downloader zipDownloader 
= sender as Downloader;
            zipDownloader.Completed 
-= zipDownloader_Completed;
            
if (zipDownloader.Index == index) {
                IsResReady 
= true;
            }
            if (!bodyImages.ContainsKey(zipDownloader.TargetCode)) {
                bodyImages.Add(zipDownloader.TargetCode, new StreamResourceInfo(e.Stream, null));
            }
        }

精灵图片切帧方法则修改如下:

        int currentFrame, startFrame, endFrame;
        
void dispatcherTimer_Tick(object sender, EventArgs e) {
            
if (currentFrame > endFrame) { currentFrame = startFrame; }
            
if (IsResReady) {
                BitmapImage bitmapImage = new BitmapImage();
                bitmapImage.SetSource(Application.GetResourceStream(bodyImages[Code], new Uri(string.Format("{0}-{1}-{2}.png", (int)State, (int)Direction, currentFrame), UriKind.Relative)).Stream);
                body.ImageSource = bitmapImage;
            } 
else {
                 body.ImageSource 
= Global.GetProjectImage(string.Format("Model/Sprite/{0}/{1}-{2}-{3}.png", ModelCode, (int)State, (int)Direction, currentFrame));
            }
            currentFrame
++;
        }

直观看来,在呈现效果上已经接近完美;然而背后却隐藏着巨大危机:内存会随着不断涌现出的新种类精灵而迅速飚升,同时每次图片呈现时都通过数据流的形式去赋值效率极低。对于少量精灵同时存在的场合此方法还勉强接受,但并不适合大制作中以群为单位的精灵展示。

解决方案1:对具体到每一张图片进行缓存而非StreamResourceInfo,然而内存同样会随着对象类别的增多而持续增长。

解决方案2:类似2.1中精灵使用整图素材可同样实现仅需一次下载的效果,性能折中。

    解决方案3:这也是目前我正在深入研究的终极解决方案,由于最近实在太忙暂时未能实现。大致思路是:精灵等对象的图片资源均封装进各自的XAP中,动态下载指定代号对象的XAP资源包后(将其中的资源合并到主XAP包的资源中?),通过类似加载主程序XAPResource的方式("/...;component/Res/...")去获取该XAP中的路径图片;无论是效率、性能还是用户体验均一条龙完美无暇。期待大家的一同参与,望实现者予赐教(MEF似乎有类似模块?)

本课小结:Silverlight游戏中的资源如果全部打包于主XAP中是非常不友好的用户体验;对肯定会用到的资源以静态模式布局放在主XAP,而其他大部分资源则根据玩家的时时需求去动态下载,这才是最完美的Silverlight MMORPG资源布局解决方案。至于动态部分的资源以何种模式去压缩整合这是未来长期需要优化的模块,仁者见仁智者见智,期待所有Silverlight高手加入到我们游戏开发的行列,为打造最强大的Silverlight - MMORPG游戏引擎而奋斗!

本课源码点击进入目录下载

课后作业

作业说明

参考资料:中游在线[WOWO世界] Silverlight C# 游戏开发:游戏开发技术

教程Demo在线演示地址http://silverfuture.cn

posted on 2010-08-03 00:16 深蓝色右手 阅读(...) 评论(...) 编辑 收藏