在C++中反射调用.NET(三)
在.NET与C++之间传输集合数据
上一篇《在C++中反射调用.NET(二)》中,我们尝试了反射调用一个返回DTO对象的.NET方法,今天来看看如何在.NET与C++之间传输集合数据。
使用非泛型集合的委托方法
先看看.NET类中的一个返回列表数据的方法:
//返回List或者数组,不影响 C++调用
public List<IUserInfo> GetUsers(string likeName)
{
List<IUserInfo> users = new List<NetLib.IUserInfo>();
for (int i = ; i < ; i++)
{
IUserInfo userinfo = GetUserByID(i);
userinfo.Name += likeName;
users.Add(userinfo);
}
//return users.ToArray();
return users;
}
public IUserInfo GetUserByID(int userId)
{
IUserInfo userinfo= EntityBuilder.CreateEntity<IUserInfo>();
userinfo.ID = userId;
userinfo.Name = "姓名_" + userId;
userinfo.Birthday = new DateTime(, , ); return userinfo;
}
该方法没有什么复杂业务逻辑,就是将传递进来的参数给DTO对象,创建包含10个这样的对象的列表并返回而已。
对于 GetUsers方法,我们可以创建下面的委托方法来绑定:
Func<String, IEnumerable> fun;
注意这里使用的是非泛型的 IEnumerable接口,在C++需要使用下面这个命名空间:
using namespace System::Collections;
那么为何不能使用泛型集合呢?
using namespace System::Collections::Generic;
因为在C++端,没有直接引用用户项目的.NET程序集,并不知道泛型集合类型的具体类型,IUserInfo这个接口无法直接访问,好在IEnumerable<T>也是继承 IEnumerable 的,所以可以当做非泛型对象在C++中访问,因此创建上面的委托方法是可行的。
C++中的列表对象list
下面看看完整的C++/CLI反射调用的代码:
std::list<CppUserInfo> GetUsers(String^ likeName)
{
//调用.NET方法,得到结果
MethodInfo^ method = dotnetObject->GetType()->GetMethod("GetUsers", BindingFlags::Public | BindingFlags::Instance);
Func<String^, IEnumerable^>^ fun = (Func<String^, IEnumerable^>^)Delegate::CreateDelegate(Func<String^, IEnumerable^>::typeid,
this->dotnetObject, method);
IEnumerable^ result = fun(likeName); std::list<CppUserInfo> cppResult; for each (Object^ item in result)
{
Func<String^, Object^>^ entityProp = EntityHelper::EntityCallDelegate(item);
CppUserInfo user;
user.ID = (int)entityProp("ID");
user.Name = (String^)entityProp("Name");
user.Birthday = Convert2CppDateTime((DateTime^)entityProp("Birthday")); cppResult.push_back(user);
} return cppResult;
}
在C++中,常常使用 list来表示一个列表数据,例如上面方法中的代码:
std::list<CppUserInfo> cppResult;
为此C++需要包含以下头文件:
#include <list>
要将一个对象添加到列表结尾,像下面这样调用即可:
cppResult.push_back(user);
在上一篇中已经讲述了如何从.NET对象转换给C++本地结构体,所以这个转换代码可以直接拿来用,综合起来,要从.NET集合得到C++的列表对象,像下面这样使用:
std::list<CppUserInfo> cppResult; for each (Object^ item in result)
{
Func<String^, Object^>^ entityProp = EntityHelper::EntityCallDelegate(item);
CppUserInfo user;
user.ID = (int)entityProp("ID");
user.Name = (String^)entityProp("Name");
user.Birthday = Convert2CppDateTime((DateTime^)entityProp("Birthday")); cppResult.push_back(user);
}
C++传递集合数据给.NET
前面讲了从.NET反射调用获得一个集合,看起来比较容易,但是从C++反射调用时候传递一个集合就不容易了。注意,这里传递的还是.NET的集合,所以这里需要做3件事情:
1,首先构建一个.NET集合对象;
2,转换C++本机结构数据到.NET集合元素;
3,反射调用.NET方法,传递数据过去。
先看要反射调用的.NET方法定义:
public bool SaveUsers(IList<IUserInfo> users)
{
UserDb.AddRange(users);
return true;
}
方法非常简单,没有什么业务逻辑,接受一个列表接口的数据,然后返回一个布尔值。
在C++端看来,SaveUsers方法的参数对象是一个泛型集合,但是具体是什么对象并不知道,所以需要反射出泛型集合的类型,同时还需要构建这样一个泛型集合对象实例。
在本例中,要得到IUserInfo 这个泛型集合的类型,可以通过下面的代码:
MethodInfo^ method = dotnetObject->GetType()->GetMethod("SaveUsers", BindingFlags::Public | BindingFlags::Instance);
array<ParameterInfo^>^ pars = method->GetParameters();
Type^ paraType= pars[]->ParameterType;
Type^ interfaceType = paraType->GetGenericArguments()[];
注意上面的代码中使用了C++/CLI的数组类型 array<Type^>^ ,而不是C++标准库的数组,因此不要引用下面的命名空间:
using namespace std;
否则VS会提示数组定义缺少参数。
创建泛型List实例
我们使用List来做集合对象,在C#中,我们可以通过下面的方式得到List泛型的类型,然后进一步创建泛型对象实例:
Type t= typeof(List<>);
但是,对应的C++/CLI写法却无法通过编译:
Type^ t=List<>::typeid;
VS总是提示List缺少类型参数,不过像下面这样子是可以的:
Type^ t2= List<IUserInfo>::typeid;
但是IUserInfo 类型正是我们要动态反射的,事先并不知道,所以一时不知道在C++/CLI中如何构建List泛型的具体实例,MS你不能这么坑好么?
既然无法直接解决,只好曲线救国了,通过类型名字,来创建类型:
String^ listTypeName = System::String::Format("System.Collections.Generic.List`1[{0}]", interfaceType->FullName);
可惜,这种方式不成功,只好一步步来了,先创建基本的List泛型类型:
String^ listTypeName = "System.Collections.Generic.List`1";
Type^ listType = System::Type::GetType(listTypeName);
成功,在此基础上,创建真正的泛型List对象实例就可以了,完整代码如下:
static Type^ CreateGenericListType(Type^ interfaceType)
{
//直接这样创建泛型List不成功:
// String^ listTypeName = System::String::Format("System.Collections.Generic.List`1[{0}]", interfaceType->FullName);
String^ listTypeName = "System.Collections.Generic.List`1";
Type^ listType = System::Type::GetType(listTypeName); Type^ generListType = listType->MakeGenericType(interfaceType);
return generListType;
} static IList^ CreateGenericList(Type^ interfaceType)
{
Type^ generListType = CreateGenericListType(interfaceType); Object^ listObj = System::Activator::CreateInstance(generListType, nullptr);
IList^ realList = (IList^)listObj;
return realList;
}
在方法 CreateGenericListType得到只是一个泛型List的类型,但我们并不知道这个List具体的形参类型,所以这个泛型List还是无法直接使用,幸好,泛型List也是继承自非泛型的IList接口的,所以在 CreateGenericList 方法中将泛型List对象转换成IList接口对象,之后就可以愉快的使用List对象了。
IList^ realList = CreateGenericList(interfaceType);
realList->Add(CurrEntity);//CurrEntity 是interfaceType 类型的动态实体类
反射静态方法
在上一篇中,我们在一个.NET方法中通过接口动态创建实体类,用的是下面的方式:
IUserInfo userinfo= EntityBuilder.CreateEntity<IUserInfo>();
CreateEntity是EntityBuilder的静态方法,现在我们需要在C++/CLI中,反射调用此方法。
为什么要反射创建实体类?
因为CreateGenericList(interfaceType) 创建的是一个泛型List对象,要求它的成员是一个实体类。
Object^ CreateEntityFromInterface(Type^ interfaceType)
{
MethodInfo^ method = this->entityBuilderType->GetMethod("CreateEntity", BindingFlags::Public | BindingFlags::Static);
MethodInfo^ genMethod = method->MakeGenericMethod(interfaceType);
Object^ entity = genMethod->Invoke(nullptr, nullptr);
this->CurrEntity = entity;
return entity;
}
注意,由于是反射调用静态方法,并且调用方法时候并不需要参数,所以Invoke方法的参数为空。
在C++/CLI中,用nullptr表示空引用,跟C#的null作用一样。
反射调用索引器
SOD实体类可以通过索引器来访问对象属性,例如下面的C#代码:
int id=(int)CurrEntity["ID"];
CurrEntity["Name"]="张三";
string name=(string)CurrEntity["Name"];//张三
下面,我们研究如何通过索引器来给实体类的属性赋值:
我们定义一个 EntityHelper的C++/CLI类,在中间添加下面的代码:
private:
Type^ entityBuilderType;
MethodInfo^ mset;
Object^ _CurrEntity;
//Action<String^, Object^>^ idxAction; void SetPropertyValue(Object^ entity, MethodInfo^ propMethod, String^ propName, Object^ value)
{
array<Object^>^ paraArr = gcnew array<Object^>{propName, value};
propMethod->Invoke(entity, paraArr);
} public: void set(Object^ value)
{
this->mset = _CurrEntity->GetType()->GetMethod("set_Item", BindingFlags::Public | BindingFlags::Instance);
//this->idxAction= (Action<String^, Object^>^)Delegate::CreateDelegate(Action<String^, Object^>::typeid, _CurrEntity, this->mset);
} void SetPropertyValue(String^ propName, Object^ value)
{
this->SetPropertyValue(this->CurrEntity, this->mset, propName, value);
//参数类型为 Object的委托,可能没有性能优势,反而更慢。
//this->idxAction(propName, value);
}
对索引器的访问,实际上就是调用类的 set_Item 方法,VS编译器会给包含索引器的对象生成这个方法,一般来说我们会对要反射调用的方法创建一个委托,但是实验证明,对索引器使用委托方法调用,反而效率不如直接反射调用,即下面的代码:
void SetPropertyValue(Object^ entity, MethodInfo^ propMethod, String^ propName, Object^ value)
{
array<Object^>^ paraArr = gcnew array<Object^>{propName, value};
propMethod->Invoke(entity, paraArr);
}
注:C++/CLI 的数组,也可以通过{ } 进行初始化。
一切准备就绪,下面可以通过以下步骤提交集合数据给.NET方法了:
1,反射.NET方法,获取参数的泛型形参类型;
2,创建此泛型形参的泛型List对象实例;
3,遍历C++集合(列表list),将结构数据赋值给动态创建的实体类对象;
4,添加动态实体类到泛型List对象集合内;
5,反射调用.NET方法,提交数据。
//示例1:直接调用.NET强类型的参数方法
//仅仅适用于有一个参数的情况并且要求是泛型类型参数
bool SaveUsers(std::list<CppUserInfo> users)
{
MethodInfo^ method = dotnetObject->GetType()->GetMethod("SaveUsers", BindingFlags::Public | BindingFlags::Instance);
array<ParameterInfo^>^ pars = method->GetParameters();
Type^ paraType= pars[]->ParameterType;
Type^ interfaceType = paraType->GetGenericArguments()[]; IList^ realList = CreateGenericList(interfaceType);
Object^ userObj = helper->CreateEntityFromInterface(interfaceType); for each (CppUserInfo user in users)
{
helper->CurrEntity = ((ICloneable^)userObj)->Clone();//使用克隆,避免每次反射
helper->SetPropertyValue("ID", user.ID);
helper->SetPropertyValue("Name", gcnew String(user.Name));
helper->SetPropertyValue("Birthday", Covert2NetDateTime(user.Birthday)); realList->Add(helper->CurrEntity);
} Object^ result= method->Invoke(dotnetObject, gcnew array<Object^>{ realList});
return (bool)result;
}
使用弱类型集合传输数据
当委托遇到协变和逆变
看看下面两个委托方法,哪个可以绑定到本文说的这个.NET方法:
bool SaveUsers(IList<IUserInfo> users){ }
Func<List<IUserInfo>,bool> fun;
Func<List<Object>,bool> fun2;
很明显,委托方法 fun2不能绑定,因为参数是 in 的,不是方法out的,所以调用的参数类型不能使用派生程度更小的类型;
再看看下面这种情况:
List<IUserInfo> GetUsers(string likeName){ } Func<string,IEnumerable<IUserInfo>> fun;
Func<string,IEnumerable> fun2;
这里,fun,fun2都可以绑定到方法上,因为泛型方法的形参作为返回值,是out的,可以使用派生程度更小的类型。
这是不是很熟悉的泛型类型的 协变和逆变?
我们知道,反射的时候,利用委托绑定要反射的方法,能够大大提高方法的调用效率,所以对于我们的方法参数,如果调用的时候无法获知具体的类型,从而无法正确构造合适的委托方法,不如退而求其次,让被调用的方法参数采用弱类型方式,这样就可以构造对应的委托方法了。
因此,对我们.NET方法中的 SaveUsers 进行改造:
public bool SaveUsers(IList<IUserInfo> users)
{
UserDb.AddRange(users);
return true;
} public IUserInfo CreateUserObject()
{
return EntityBuilder.CreateEntity<IUserInfo>();
} public bool SaveUsers2(IEnumerable<Object> para)
{
var users = from u in para
select u as IUserInfo; return SaveUsers (users.ToList());
}
这里增加一个方法 SaveUsers2,它采用IEnumerable<Object> ,而不是更为具体的 IList<IUserInfo>,那么采用下面的方式构造方法 SaveUsers2 对应的委托方法就可以了:
MethodInfo^ method = dotnetObject->GetType()->GetMethod("SaveUsers2", BindingFlags::Public | BindingFlags::Instance);
Func<System::Collections::Generic::IEnumerable<Object^>^,bool>^ fun2 =
(Func<System::Collections::Generic::IEnumerable<Object^>^, bool>^)Delegate::CreateDelegate(System::Func<Collections::Generic::IEnumerable<Object^>^, bool>::typeid,
this->dotnetObject, method);
这样要构造一个泛型List就不必像之前的方法那么麻烦了:
System::Collections::Generic::List<Object^>^ list = gcnew System::Collections::Generic::List<Object^>;
反射调用SaveUser2完整的代码如下:
//示例2:调用.NET弱类型的参数方法,以便通过委托方法调用
//构建委托方法比较容易,适用于参数数量多于1个的情况,
bool SaveUsers2(std::list<CppUserInfo> users)
{
MethodInfo^ method = dotnetObject->GetType()->GetMethod("SaveUsers2", BindingFlags::Public | BindingFlags::Instance);
Func<System::Collections::Generic::IEnumerable<Object^>^,bool>^ fun2 =
(Func<System::Collections::Generic::IEnumerable<Object^>^, bool>^)Delegate::CreateDelegate(System::Func<Collections::Generic::IEnumerable<Object^>^, bool>::typeid,
this->dotnetObject, method); Object^ userObj = CreateUserObject();
System::Collections::Generic::List<Object^>^ list = gcnew System::Collections::Generic::List<Object^>; for each (CppUserInfo user in users)
{
helper->CurrEntity = ((ICloneable^)userObj)->Clone();//使用克隆,避免每次反射
helper->SetPropertyValue("ID", user.ID);
helper->SetPropertyValue("Name", gcnew String(user.Name));
helper->SetPropertyValue("Birthday", Covert2NetDateTime(user.Birthday)); list->Add(helper->CurrEntity);
} bool result = fun2(list);
return result;
}
性能测试
C++/CLI 反射性能测试
为了测试 C++/CLI 反射调用两种方案(直接反射调用,委托方法调用)的效率,我们循环1000次测试,下面是测试代码:
NetLibProxy::UserProxy^ proxy = gcnew NetLibProxy::UserProxy("..\\NetLib\\bin\\Debug\\NetLib.dll");
std::list<CppUserInfo> list = proxy->GetUsers("张");
System::Console::WriteLine("C++ Get List data From .NET function,OK."); System::Diagnostics::Stopwatch^ sw = gcnew System::Diagnostics::Stopwatch;
sw->Start();
for (int i = ; i<; i++)
proxy->SaveUsers(list);
sw->Stop();
System::Console::WriteLine("1,1000 loop,C++ Post List data To .NET function,OK.use time(ms):{0}",sw->ElapsedMilliseconds); sw->Restart();
for(int i=;i<;i++)
proxy->SaveUsers2(list);
sw->Stop();
System::Console::WriteLine("2,1000 loop,C++ Post List data To .NET function,OK..use time(ms):{0}", sw->ElapsedMilliseconds);
不调试,直接执行:
C++ Get List data From .NET function,OK.
, loop,C++ Post List data To .NET function,OK.use time(ms):
, loop,C++ Post List data To .NET function,OK..use time(ms):
可见,虽然在.NET程序端,我们使用了弱类型的泛型集合,综合起来还是反射+委托方法执行,效率要高。
所以如果你能够适当对要调用的.NET方法进行封装,那么可采用使用弱类型集合传输数据的方案,否则,就在C++/CLI端多写2行代码,使用强类型传输数据的方案。
与.NET直接调用和反射的性能比较
在本篇的方案中,都是C++反射来调用.NET方法的,如果都是在.NET应用程序中直接调用或者反射.NET方法,性能差距有多少呢?
我们模拟文中 C++/CLI的UserProxy,写一个.NET中的 UserProxy:
struct UserStruct
{
public int ID;
public string Name;
public DateTime Birthday;
} class UserProxy
{
User user;
public UserProxy()
{
user = new User();
} public List<UserStruct> GetUsers(string likeName)
{
List<UserStruct> result = new List<NetApp.UserStruct>();
var list = user.GetUsers(likeName);
foreach (var item in list)
{
UserStruct us;
us.ID = item.ID;
us.Name = item.Name;
us.Birthday = item.Birthday; result.Add(us);
}
return result;
} public bool SaveUsers(IList<UserStruct> users)
{
List<IUserInfo> list = new List<IUserInfo>();
IUserInfo userObj = user.CreateUserObject();
foreach (var item in users)
{
IUserInfo currUser = (IUserInfo)((ICloneable)userObj).Clone();
currUser.ID = item.ID;
currUser.Name = item.Name;
currUser.Birthday = item.Birthday; list.Add(currUser);
}
bool result = user.SaveUsers(list);
return result;
} Object CreateUserObject()
{
MethodInfo method = user.GetType().GetMethod("CreateUserObject", BindingFlags.Public | BindingFlags.Instance);
Func<Object> fun = (Func<Object>)Delegate.CreateDelegate(typeof( Func<Object>), user, method);
return fun();
} //反射+委托
public bool SaveUsers2(IList<UserStruct> users)
{
MethodInfo method = user.GetType().GetMethod("SaveUsers2", BindingFlags.Public | BindingFlags.Instance);
Func<System.Collections.Generic.IEnumerable<Object>, bool> fun2 = (Func<System.Collections.Generic.IEnumerable<Object>, bool>)Delegate.CreateDelegate(typeof( System.Func<System.Collections.Generic.IEnumerable<Object>, bool>),
user, method); List<IUserInfo> list = new List<IUserInfo>();
object userObj = CreateUserObject();
foreach (var item in users)
{
IUserInfo currUser = (IUserInfo)((ICloneable)userObj).Clone();
currUser.ID = item.ID;
currUser.Name = item.Name;
currUser.Birthday = item.Birthday; list.Add(currUser);
} bool result = fun2(list);
return result;
}
}
.Net UserProxy
然后同样循环1000此调用,直接执行,看执行结果:
, loop,.NET Post List data To .NET function,OK.use time(ms):
, loop,.NET Reflection Post List data To .NET function,OK.use time(ms):
可见,.NET 平台内调用,反射+委托的性能是接近于直接方法调用的。
综合对比,C++/CLI中反射调用.NET,比起在.NET平台内部反射调用,性能没有很大的差距,所以C++/CLI中反射调用.NET是一个可行的方案。
总结
C++/CLI是一种很好的混合编写本机代码与.NET托管代码的技术,使用它反射调用.NET方法也是一种可行的方案,结合PDF.NET SOD框架的实体类特征,可以更加方便的简化C++/CLI反射代码的编写并且提高C++代码与.NET代码通信的效率。
(全文完)
在C++中反射调用.NET(三)的更多相关文章
- 在C++中反射调用.NET(二)
反射调用返回复杂对象的.NET方法 定义数据接口 上一篇在C++中反射调用.NET(一)中,我们简单的介绍了如何使用C++/CLI并且初步使用了反射调用.NET程序集的简单方法,今天我们看看如何在C+ ...
- 在C++中反射调用.NET(一)
为什么要在C++中调用.NET 一般情况下,我们常常会在.NET程序中调用C/C++的程序,使用P/Invoke方式进行调用,在编写代码代码的时候,首先要导入DLL文件,然后在根据C/C++的头文件编 ...
- [LinqPad妙用]-在Net MVC中反射调用LinqPad中的Dump函数
LinqPad有个非常强大的Dump函数.这篇讲解一下如何将Dump函数应用在.Net MVC Web开发中. 先看效果: 一.用.Net Reflector反编译LinqPad.exe,找出Dump ...
- Java反射机制(三):调用对象的私有属性和方法
一. 通过反射调用类中的方法 在正常情况下,得到类的对象后,我们就可以直接调用类中的方法了,如果要想调用的话,则肯定必须清楚地知道要调用的方法是什么,之后通过Class类中的getMethod方法,可 ...
- Java实现Qt的SIGNAL-SLOT机制(保存到Map中,从而将它们关联起来,收到信号进行解析,最后反射调用)
SIGNAL-SLOT是Qt的一大特色,使用起来十分方便.在传统的AWT和Swing编程中,我们都是为要在 监听的对象上添加Listener监听器.被监听对象中保存有Listener的列表,当相关事件 ...
- 反射调用与Lambda表达式调用
想调用一个方法很容易,直接代码调用就行,这人人都会.其次呢,还可以使用反射.不过通过反射调用的性能会远远低于直接调用——至少从绝对时间上来看的确是这样.虽然这是个众所周知的现象,我们还是来写个程序来验 ...
- .NET中反射机制的使用与分析
.NET中反射机制的使用与分析 [日期:2008-06-30] 来源: 作者:志伟 .NET反射的定义:审查元数据并收集关于它的类型信息的能力. 元数据是一种二进制信息,用以对存储在公共语言 ...
- java中反射学习整理
转载请注明:http://blog.csdn.net/j903829182/article/details/38405735 反射主要是指程序能够訪问.检測和改动它本身的状态或行为的一种能力. jav ...
- 通过Java反射调用方法
这是个测试用的例子,通过反射调用对象的方法. TestRef.java import java.lang.reflect.Method; import java.lang.reflect.In ...
随机推荐
- hibernate---一对一双向外键关联 (重要)
husband--wife: one to one 双向外键关联: 主导方: @OneToOne @JoinColumn(name="wifeId") 被主导方: @OneToOn ...
- php中var_dump() 打印出一个对象的时候,信息怎么看?
php 的一个依赖注入容器, 说白了,就是用php 的反射类,来在运行的时候动态的分析类具有的函数,以及动态分析函数的参数, 从而实例化类,并执行类的方法. 另外,php 中的 typehint 还是 ...
- DNS服务器搭建(主、从、缓)
主dns服务器搭建 在本机上搭建一个管理hngd.com域名的域名服务器1. 确保安装好以下bind域名服务器 [root@主人 ~]# rpm -qa |grep ^bindbind-chroot- ...
- mysql之SQL---存储过程
1.存储过程简介 我们常用的操作数据库语言SQL语句在执行的时候需要要先编译,然后执行,而存储过程(Stored Procedure)是一组为了完成特定功能的SQL语句集,经编译后存储在数据库中,用 ...
- label同时设置sizeToFit,NSTextAlignmentCenter不起作用
问题:label要多行显示,按照这样子设置,iOS9以上work,iOS8无用 self.bookNameLabel.lineBreakMode = NSLineBreakByCharWrapping ...
- Unity3D ——强大的跨平台3D游戏开发工具(六)
第十一章 制作炮台的旋转 大家知道,炮台需要向四周不同的角度发射炮弹,这就需要我们将炮台设置成为会旋转的物体,接下来我们就一起制作一个会旋转的炮台. 第一步:给炮台的炮筒添加旋转函数. 给炮台的炮筒部 ...
- Ubuntu切换默认语言
不得不说,从Ubuntu到Debian,又到CentOS 7,我胡汉三又回来了... 然后又装了个中文版的Ubuntu16.04LTS,不得不说,Ubuntu对中文的支持真的很好 不过,还是不太习惯, ...
- 那就用pthon来写个跳板机吧
1.需求 程序一: 1.后台管理 - 堡垒机上创建用户和密码(堡垒机root封装的类,UserProfile表) - .bashrc /usr/bin/python3 /data/bastion.py ...
- bzoj 3611[Heoi2014]大工程 虚树+dp
题意: 给一棵树 每次选 k 个关键点,然后在它们两两之间 新建 C(k,2)条 新通道. 求: 1.这些新通道的代价和 2.这些新通道中代价最小的是多少 3.这些新通道中代价最大的是多少 分析:较常 ...
- CSS设置图片居中的方法
如果是应用了表格,那么设置单元格为align="center"就可以使其中的一切内容居中.如果没有应用表格要想设置图片居中就有点困难了.困难来自不按"常规出牌" ...