Windows Shell提取媒体信息

来源:互联网 发布:男的秃顶什么原因 知乎 编辑:程序博客网 时间:2024/06/07 01:07

这个Project有三个有趣而可以参考的地方:

  1. 使用COM接口操作Windows Shell,并提取多媒体文件的标签信息
  2. 编写Dll,并提供对DLL中的类显示调用的支持
  3. 最小化编译时的依赖,即正确地使用#include、理清C/CPP文件和H文件的关系

为了照顾这个Project研究的逻辑思考过程,将这三点按上述顺序排列,虽然我觉得后面的更好玩一点。Moreover, the term Project here refers to its meaning in Visual Studio, rather than the meaning in Engineering of zhuangbility - -||

最后我们将把这个Shell的API按提取多媒体文件标签的这个需要打一个包,形成一个新的库文件以供其他使用。

1. Shell操作

Windows Shell顾名思义就是Windows系统的外衣,能看到的日常操作都由Shell负责,而很多Shell提供的功能都作为系统API放到了DLL文件里可供调用(shell32.dll)。

因为这次要做的是提取多媒体文件的标签信息,并且对这个信息的要求不高,即不需要提取很全面,例如mp3文件只需ID3v1标签即可满足我们的要求。而我们可以看到,WindowsExplorer已经把这些信息提取出来了。类似的,对图片和视频文件它也能提供标签信息。需要注意的是,Windows Shell仅仅提供mp3的ID3v1标签提取,对ID3v2不予支持。即如果媒体只有ID3v2标签,此处读出来的就是空字符串。

 

好了,确定了范围和方向之后,就是如何使用COM接口调用Shell组件读取信息这一步了。

这里有几个概念,Shell即是外壳,Shell的基础是桌面,桌面之下衍生出很多子文件夹,以及系统的“网络”、“控制面板”、“C:/”等文件夹,这些文件夹里又有很多层子文件夹。因此,我们想要获得一首歌的标签信息,需要首先获得桌面文件夹的对象,然后找到对应的目录,然后找到那个目录中的对应文件,然后才能提取文件的信息。

这里需要用到几个接口和结构体:

  • IShellFolder接口,用来定位某个文件夹,并对其下的文件和文件夹进行操作。
  • IShellFolder2接口,从IShellFolder接口继承而来,提供了一些新的功能。
  • ItemIDList,每个文件夹或者文件都维护自己的ItemIDList,里面记录了它们的所有属性,比如文件名、类型、大小、修改时间。也就是说每个文件逻辑上都对应一个二维表,表有一个ID列,有一个值列,每行的记录用链表实现,Windows提供了ItemIDList这样的一个结构。


 

  • EnumIDList,一个文件夹下所有的对象(文件和文件夹),形成了一个有序链表。对这个链表进行遍历即可找到所有的文件。链表的每个节点就是上面的ItemIDList

可以以这样的树状结构来看上述概念:


每个实际的文件夹对应一个IShellFolder,每个IShellFolder可以获得一个EnumIDList,遍历每个EnumIDList可以获得每个ItemIDList,每个ItemIDList就已经与文件一一对应。

上面已经提到,所有文件夹的父文件夹是桌面,于是先获得桌面的IShellFolder2接口对象。

IShellFolder* psfDesktop;
IShellFolder2* psf2Desktop;
SHGetDesktopFolder( &psfDesktop );
psfDesktop->QueryInterface( IID_IShellFolder2, (void**) &psf2Desktop );
psfDesktop->Release();

这里使用SHGetDesktopFolder()函数获得了桌面的IShellFolder接口对象,然后通过COM的QueryInterface()方法实例化了IShellFolder2接口对象。
为什么?首先我们肯定需要一个对应的IShellFolder2接口来提取信息,这个接口是否可以留到调用它的增强功能之前再实例化我没有确认,不过既然它继承了IShellFolder并提供了更多的功能,我就打算从最开始就实例化它。
为什么要用IShellFolder来实例化这个IShellFolder2?QueryInterface()函数按照COM原理是从IUnknown继承来的,因此理论上只要任何一个COM对象都可以通过QueryInterface( IID_IShellFolder2, (void**)&psf2Desktop);来实例化IShellFolder2。使用IShellFolder来担任此工作也是因为SHGetDesktopFolder()使用较方便。
另外,SHGetDesktopFolder获得的psfDesktop一定是与桌面绑定的,而此时我们实例化的psf2Desktop是否已经与桌面相关了我没有确认。

接下来的工作就是定位到文件上,我们需要获得文件的ItemIDList。

LPITEMIDLIST pTargetPathID;
IShellFolder2* psf2Folder;
// 定位文件所在的文件夹,wFilePath为文件夹路径
psf2Desktop->ParseDisplayName( ::GetActiveWindow(), NULL, wFilePath, NULL, &pTargetPathID, NULL );
// 将定位得到的文件夹路径绑定到IShellFolder2接口的对象上去
psf2Desktop->BindToObject( pTargetPathID, NULL, IID_IShellFolder2, (void**) &psf2Folder );
// 此时psf2Folder就已经指向对应的文件夹了,接下来我们需要找到文件。
// 枚举这个文件夹下的内容放到pEnum这个链表里
LPENUMIDLIST pEnum;
psf2Folder->EnumObjects( ::GetActiveWindow(), SHCONTF_NONFOLDERS, &pEnum );
STRRET retFile;
char szFilename[ MAX_PATH ];
while ( pEnum->Next( 1, &pFileItemID, &uEleFetched ) == S_OK ) {
ZeroMemory( szFilename, MAX_PATH );
// 按照完整文件名格式获得文件名
psf2Folder->GetDisplayNameOf( pFileItemID, SHGDN_FORPARSING, &retFile );
StrRetToBuf( &retFile, pFileItemID, szFilename, MAX_PATH );
if ( m_sTargetFile.compare( szFilename ) == 0 ) break;
}

此时我们获得了指定文件的ItemIDList,既然属性都在里面,那就可以开始提取了。

// get title, column 21
::CoInitialize( NULL );
HRESULT hr = psf2Folder->GetDetailsOf(pFileItemID, 21, &shDetail );
if ( hr == S_OK ) {
ZeroMemory( szContent, MAX_PATH );
StrRetToBuf( &(shDetail.str), pFileItemID, szContent, MAX_PATH );
m_sTitle = string( szContent );
}

这样就获得了音乐文件的标题,存入了m_sTitle成员变量里。GetDetailsOf()函数中的数字即是ID号,至于当前文件夹支持多少ID号,可以给第一个参数以NULL,然后使用循环打印m_sTitle就能知道当前ID对应什么信息。即:

for ( int id = 0; id < 1000; ++id ) {
psf2Folder->GetDetailsOf( NULL, id, &shDetail );
// 打印shDetail内容
}

另外,使用GetDetailsEx()函数可以不用使用ID号,但我做了XP到Win7的迁移后发现GetDetailsEx()好像也没有能跨越平台障碍,所以索性还是用GetDetailsOf()了。注意上面提取标题时的::CoInitialize( NULL);这表示初始化COM对象。没有这一句,所有的文件夹都只能提取出前几个ID对应的文件名、类型、修改时间、大小等基本信息,无法提取出标题、专辑等特别的信息。一个文件能提取出什么样的信息与所使用的IShellFolder2有关。

此外注意GetDetailsOf()的平台差异,WinXP上提取出来的东西比较贫乏,Vista和Win7能提取的标签就很丰富,但是与WinXP相同的部分在ID编号上有变化。所以这个方法需要对XP和Vista做两套平台的库文件,并需要在运行时检查系统的版本号,动态载入不同的库文件。

2. Dll调用

Dll(即dynamic link library)在编译后至少会有a.dll和a.lib两个文件。这样导入DLL就有三种方式:

  1. 使用lib直接链接;
  2. 使用lib并启用delay load;
  3. 使用dll动态导入。

粗略地说,lib中记录了dll的函数入口,编译自己程序时链接器里加入lib即可在运行时使用dll内的函数。这样的程序在启动时就会载入dll,如果目标机器上不存在,那么就会给出“应用程序不能运行,需要重新安装”之类的提示。而delayload是VC6之后较新的版本提供的功能,即将dll的载入延迟到需要调用它的函数的时候。如果目标机器没有dll,那程序依然能够启动,但是要执行函数的时候会发生不友好的异常错误。而使用dll动态导入,就是在代码里载入dll的导出函数,程序可以在需要时载入它,一些实现不同语言、添加插件等功能就可以使用这种方式来实现。下面主要说第三种方式。

使用C语言即可调用系统API来动态导入dll。首先LoadLibrary()载入Dll返回句柄,GetProcAddress()使用句柄返回函数指针,FreeLibrary()使用句柄释放dll。这三个DLL套装的详细用法和示例可以查阅MSDN。但是,它们只能导出函数,而在C++里需要导出一个类时,就得用其他办法了。

首先,按照DLL的一贯做法,导出函数和导出类都要有__declspec(dllexport),在导入的地方声明这些函数时,相对地要有__declspec(dllimport)。因此我们使用了这样的一个宏定义:

#ifndef _NMP_API_
#ifdef _WINSHELLLIBRARY_EXPORTS_
#define _NMP_API_ __declspec(dllexport)
#else
#define _NMP_API_ __declspec(dllimport)
#endif
#endif

然后就可以使用如下的方式声明导出类:

class _NMP_API_ CAudioInfo : public CMediaInfo { ... };

使用如下方式声明导出函数(extern "C"的作用见本节最后):

extern "C" _NMP_API_ CAudioInfo* GetAudioInfo();
extern "C" _NMP_API_ CAudioInfo* GetAudioInfoByFilename( const char* );

在这个头文件对应的CPP实现文件里首先加上:

#define _WINSHELLLIBRARY_EXPORTS_
#include "MediaInfo.h"

然后按照原有方式实现CAudioInfo类,按照如下方式实现导出的函数:

extern "C" _NMP_API_ CAudioInfo* GetAudioInfo() {
    return ( new CAudioInfo() );
}

按照原本方法,在头文件里添加对应的指针:

typedef CAudioInfo* (*LPNMPGetAudioInfo)();
typedef CAudioInfo* (*LPNMPGetAudioInfoByFilename)( const char* );

这样,通过导出函数,我们就能获得对应的类的指针,这样既可实现导出类。并且此时这个头文件我们就可以用到需要调用dll的地方了。在调用DLL的cpp里,如下:

#include "..//WinShellLibrary//MediaInfo.h"
...
HMODULE hMediaDll = LoadLibrary( "..//RELEASE//WinShellLibrary.dll" );
char szAnsiName[] = "E://AAA//BBB.mp3";
LPNMPGetAudioInfoByFilename pfnAudio;
pfnAudio = (LPNMPGetAudioInfoByFilename) ::GetProcAddress( hMediaDll, "GetAudioInfoByFilename" );
CAudioInfo* audio = pfnAudio( szAnsiName );
// 此处添加调用该类的对象的应用
delete image;
FreeLibrary( hMediaDll );

上述代码段中,通过函数指针pfnAudio来执行函数的调用。

仅仅这样,把上述思想应用到实际时,编译依然会报错。还缺什么呢?DLL文件作为一个独立的Project可以正常地Build,但是调用DLL的文件却无法链接成功。在链接时无法找到对CAudioInfo类的成员函数,这里我做了一个测试,一个类成员函数仅做类内声明,在类外却并不实现它的话,这个cpp编译是正常的,但如果这个成员函数被调用了,linker就会提示找不到。这说明类成员函数仅仅声明是可以通过编译的,但是调用时链接器无法找到它。反观上面的调用DLL的cpp,我们也是仅仅把头文件包含进来,这不是一样的效果吗?

那么,怎么才能让成员函数在外面被调用?

这里又有很多种办法,我采取了其一,其他的方法可以参考最后的参考资料。参考《C++Primer》第四版,15.2.4,类内的虚函数编译后会有一个VTable表,因此加了virtual关键字的非纯虚函数,在编译时一定会被要求有实现,链接时可以通过VTable里的指针来找到对应函数。

所以,将所有要导出的成员函数,包括析构函数,都加上virtual关键字(因为delete操作会调用析构函数),之后就可以正常编译了。

3. 最小化编译依赖

这需要我们理顺Project里各个.h和.cpp文件之间的关系。比如我们建立这样两个类,放到四个文件里:

A.h

A.cpp

B.h

B.cpp

#pragma once
#include "b.h"

class A {
public:
A(void);
~A(void);

B* m_b;
};

#include "StdAfx.h" #include "A.h"

A::A(void) { }

A::~A(void) { }

#pragma once
#include "a.h"

class B {
public:
B(void);
~B(void);

A* m_a;
};

#include "StdAfx.h"
#include "B.h"

B::B(void) { }

B::~B(void) { }

很简单的两个类,每个类内有一个指向对方类一个对象的指针。也许这两个类的设计有点问题,但也确有这种可能——比如数据库两个表是一对一的关系,而我们使用C++来对这两个表进行面向对象的抽象,那可能就会形成这种类的设计思路。按照以前的想法,很正常啊,A类里要有一个B类的指针,那就在开始把b.h包含进来,B类要有个A类指针,那就也把a.h包含进来吧。

编译——6个错误。再看一遍源码,哪有语法错误啊,这让人怎么改?

于是我们需要明白.h和.cpp文件的意义,参考《Exceptional C++》的Item 26到Item30。首先,.h文件是头文件,header文件,头文件是干什么的?包含用的,头文件不会参与编译,只有在.cpp里用.h时,.h里内容才有意义。#include"A.h"意义是原封不动地把a.h文件的内容在这一行完全展开。既然编译器只会去编译.cpp文件,并且在cpp中将.h文件展开,那我们自己展开来看看?以a.cpp为例,A.h要展开,又遇到了b.h要展开,好吧继续展开,b.h又要展开a.h?因为有#pragmaonce的预编译指令,于是展开工作到此结束。

最后,在a.cpp完全展开之后,”b.h”留在展开的内容的最上面,好了,b.h文件内容是什么呢,有个A*m_a,A是什么?A不是个类吗,不是包含过了吗?很遗憾,在最后展开的文件里,A的内容在下面呢,因为#pragmaonce作祟,最后需要的a.h没有展开,那就去掉a.h的#pragma once呢?那A就会是一个重复定义的类,同样收到一堆错误。

诶?重复定义?是不是可以有办法解决了?定义是完全写出类或者全局函数的内容,声明则是通知编译器这个东西类型和名字是什么。也就是说,把declaration放到前面,把implementation放到后面,不就结了?前面指的当然就是类的头文件里,后面指的就是CPP文件。于是修改代码如下:

A.h

A.cpp

B.h

B.cpp

#pragma once
class B;
class A {
public:
A(void);
~A(void);

B* m_b;
};

#include "StdAfx.h"

#include "b.h"
#include "A.h"

A::A(void) { }

A::~A(void) { }

#pragma once
class A;
class B {
public:
B(void);
~B(void);

A* m_a;
};

#include "StdAfx.h"

#include "a.h"
#include "B.h"

B::B(void) { }

B::~B(void) { }

在.h文件中,只留下最简单的声明,在cpp文件中如果用到了再包含要使用的东西。这样即成功编译。其实在上例中,就算去掉.cpp文件中对对方类的包含也能通过,因为没有对m_a,m_b成员进行操作。

在这个提取文件信息的项目中,我自己的机器是Win7+VS2008,但是工作的机器是XP+VC6。对IShellFolder2的操作是在Windows SDK里才有的,VC6出的比较早,最后的更新是到Windows 2003的一个SDK。WindowsSDK也是后来更名的,之前叫做PlatformSDK。机房机器装的VC6没有办法使用一些Shell相关的函数和接口,也没有shlwapi.h和shlwapi.lib等文件了。

于是我采用了这个减少编译依赖的方法去做。首先,因为按照第二点的思路制作的DLL文件仍然需要在调用它的Project里包含DLL的.h文件,这是库文件的必然。但是VC6没法认这个有一些Shell接口成员声明的.h文件。按照《CodeComplete》(代码大全)第二版一书6.2节关于隐藏类实现达成良好封装的叙述,将所有有关Shell操作的接口形成一个单独的实现类CMediaImp,将CMediaImp的声明放到这个类里,将此类的成员放到该类的实现文件中。这样在.h文件里就没有了Shell的内容,但cpp在编译时能正常找到Shell的操作。

此时将这个库编译成DLL,并随库提供DLL的.h头文件,交给使用该库的程序员,他在工作的机器环境VC6上就能正常编译使用这个库了。反之,如果不这么做的话,DLL是正常了,但是该程序员在引用了随库的头文件时依然会遇到编译无法通过,缺少Shell接口相关声明的问题。

4. 小结

至此,项目结束。附上一些较好的参考材料:

  1.  
    1. DLL导出类,显示链接到DLL中的类
    2. 一步一步教你DLL,第四部分,DLL动态导入
    3. DLL很简单,第一部分,第二部分,第三部分,第四部分
    4. Shell操作,在应用程序中集成外壳的上下文菜单
    5. 《Exceptional C++》, Item 26 ~ Item 30

 

 

 

本文转载自:http://hi.baidu.com/ecluytj/blog/item/de28cdbfbb2e4d0318d81f4d.html

原创粉丝点击