Android:换肤

1,910 阅读4分钟

这里使用的是开源框架Android-skin-loader。这个框架已经停止维护了,能满足基本功能需求。

基础使用

添加依赖

将这个库的lib作为module导入,这样方便你根据自己的需求去添加一些功能。

使用

  • 继承BaseActivity或者BaseFragmentActivity或者BaseFragment
  • 在Application中初始化
public class YourApplication extends Application {
	public void onCreate() {
		super.onCreate();
		// Must call init first 
		SkinManager.getInstance().init(this);
		SkinManager.getInstance().load();
	}
}
  • 在布局中标识需要换肤的view
//命名空间
xmlns:skin="http://schemas.android.com/android/skin"
  <TextView
     skin:enable="true" 
      />
  • 从已生成的皮肤文件中设置皮肤
File skin = new File("skin path");
SkinManager.getInstance().load(skin.getAbsolutePath(),
				new ILoaderListener() {
					@Override
					public void onStart() {
					}

					@Override
					public void onSuccess() {
					}

					@Override
					public void onFailed() {
					}
				});

生成皮肤文件

生成皮肤文件apk

创建一个App module(记住不是library module)。这个module不需要java文件,可以直接将module_name/src/main/java目录删除。然后在res目录下添加你需要更换的资源文件。 记住:需要更换的资源文件必须和主module中的资源文件名字保持一致。 然后直接打包生成apk文件。

复制到主module

将apk文件复制到主module的某个目录下,比如main_module/src/main/assets目录。

更改皮肤文件后缀名

为了防止皮肤文件被用户点击安装,可以将文件后缀改成.skin。或者你自定义一个后缀名。

生成多个皮肤文件

要生成多个皮肤文件,直接在gradle做配置,而无需创建多个module。在skin_module/src目录下创建不同种类的皮肤文件目录,与main同级。这样就可以编译生成不同皮肤的apk。

  • 添加buildType
 android {
     buildTypes{
         bmw {
             
         }
         benz {
             
         }
         toyota {
             
         }
     }
 }

  • 自定义task 自定义一个task,通过获取buildTypes来生成对应的文件夹。

    task createAllBuildTypeChildDir() {
    //遍历main/res下的子目录,然后为不同的buildType生成对应的目录。这里要使用project.rootDir来表示根目录,这样可以自动适配不同的电脑系统
     
     def file = new File("${project.rootDir}/skin_module/src/main/res")
      file.listFiles().each { childFile ->
         def dirName =  childFile.name
          project.extensions.each { extension ->
              extension.getByName("android").properties.each { property ->
                  if (property.key == "buildTypes") {
                      property.value.each { value ->
                          def variantName =  value["name"]
                          if (("debug" != variantName) && ("release" != variantName)){
                              def dest = new File("${project.rootDir}/skin_module/src/"+variantName+"/res",dirName)
                              if (!dest.exists()){
                                  dest.mkdirs()
                              }
                          }
                      }
                  }
              }
          }
      }
      }
    
     
     
    

修改不同文件夹下的资源文件,再去编译对应的皮肤文件即可。

标识需要换肤的view

SkinConfig中定义了命名空间,在需要换肤的布局中添加该命名空间。

public class SkinConfig {
	public  static final String  NAMESPACE = "http://schemas.android.com/android/skin";
}

这个命名空间如何使用到的? 在SkinInflaterFactory的onCreateView方法中先判断了布局中是否存在这个命名空间

boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);

也就是只有在布局中使用了这个命名空间的布局才能被换肤。

SkinInflaterFactory

SkinInflaterFactory实现了Layoutinflater.Factory。这个类在setContentView(layout)方法之前调用,可以过滤并修改我们需要换肤的view。

  • onCreateView
@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		// if this is NOT enable to be skined , simplly skip it 
		boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
        if (!isSkinEnable){
        		return null;
        }
		
		View view = createView(context, name, attrs);
		
		if (view == null){
			return null;
		}
		
		parseSkinAttr(context, attrs, view);
		
		return view;
	}
  • createView
private View createView(Context context, String name, AttributeSet attrs) {
		View view = null;
		try {
			if (-1 == name.indexOf('.')){
				if ("View".equals(name)) {
					view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
				} 
				if (view == null) {
					view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
				} 
				if (view == null) {
					view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
				} 
			}else {
	            view = LayoutInflater.from(context).createView(name, null, attrs);
	        }
		} catch (Exception e) { 
			L.e("error while create 【" + name + "】 : " + e.getMessage());
			view = null;
		}
		return view;
	}

if(-1==name.indexOf('.')这一句是判断布局中的View是否包含完全路径名。比如TextView,Button等。因此在生成这些View的对象时,需要补全路径。

  • parseSkinAttr
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
   	List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
   	
   	for (int i = 0; i < attrs.getAttributeCount(); i++){
   		String attrName = attrs.getAttributeName(i);
   		String attrValue = attrs.getAttributeValue(i);
   		//判断该attr在换肤时是否支持更换,支持换肤的attr由用户来决定
   		if(!AttrFactory.isSupportedAttr(attrName)){
   			continue;
   		}
   		
   	    if(attrValue.startsWith("@")){
   			try {
   			//该attrValue对应的资源id
   				int id = Integer.parseInt(attrValue.substring(1));
   				//该attrValue对应的资源名字
   				String entryName = context.getResources().getResourceEntryName(id);
   				//该attrValue对应的资源类型,比如color,string,drawable等
   				String typeName = context.getResources().getResourceTypeName(id);
   				//根据attrName构造一个SkinAttr对象
   				SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
   				if (mSkinAttr != null) {
   					viewAttrs.add(mSkinAttr);
   				}
   			} catch (NumberFormatException e) {
   				e.printStackTrace();
   			} catch (NotFoundException e) {
   				e.printStackTrace();
   			}
   	    }
   	}
   	if(!ListUtils.isEmpty(viewAttrs)){
   		SkinItem skinItem = new SkinItem();
   		skinItem.view = view;
   		skinItem.attrs = viewAttrs;
   		mSkinItems.add(skinItem);
   		if(SkinManager.getInstance().isExternalSkin()){
   			//通过SkinAttr的实现类实现换肤功能
   			skinItem.apply();
   		}
   	}
   }

SkinManager

负责初始化以及切换皮肤。换肤的方法为load(String path,ILoaderListener listener)

PackageManager mPm = context.getPackageManager();
PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
skinPackageName = mInfo.packageName;

AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);

Resources superRes = context.getResources();
//重新构造一个Resource对象,这个Resource对象是皮肤包的。
Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());

加载皮肤包时,顺便重新构造了皮肤包的对应资源对象Resource。这个可以帮我们获取皮肤包中的资源。以便在主module中动态切换某个资源。 例如:我的项目中在换肤时,要切换支付二维码中间的logo。在SkinManager中新增方法。

//resId是图片在主module中的资源名字。比如:R.drawable.icon_app
public Bitmap getLogo(int resId){
		Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resId);
		if (null!=bitmap&&isDefaultSkin){
			return bitmap;
		}
		//资源的名字。比如:icon_app
		String resName = context.getResources().getResourceEntryName(resId);
		//资源在皮肤包中的实际id。这里是通过皮肤包的Resource来获取的。
		int trueResId = mResources.getIdentifier(resName, "drawable", skinPackageName);
		bitmap = BitmapFactory.decodeResource(mResources, trueResId);
		return bitmap;
	}

自定义SkinAttr

这个开源库对控件的切换支持并不完善,ImageView就不支持切换。我们可以继承SkinAttr来实现对ImageView换肤的支持。

public class ImageAttr extends SkinAttr {
    @Override
    public void apply(View view) {
        if (view instanceof ImageView){
            if (attrName.equals(IMAGE_SRC)) {
                ((ImageView) view).setImageDrawable(SkinManager.getInstance().getDrawable(attrValueRefId));
            }
        }
    }
}

AttrFactory中增加对ImageView的支持。

public class AttrFactory {
	public static final String IMAGE_SRC = "src";
	public static SkinAttr get(String attrName, int attrValueRefId, String attrValueRefName, String typeName){
	SkinAttr mSkinAttr = null;
	 if (IMAGE_SRC.equals(attrName)){
	 //生成ImageAttr
			mSkinAttr = new ImageAttr();
		}
	}
	...
	
	}
public static boolean isSupportedAttr(String attrName){
		return BACKGROUND.equals(attrName) || TEXT_COLOR.equals(attrName)
				||LIST_SELECTOR.equals(attrName) || DIVIDER.equals(attrName)
				//支持ImageView
				||IMAGE_SRC.equals(attrName);
	}