QSkinLoader

Project Url: qqliu10u/QSkinLoader
Introduction: 一个支持多种场景的 Android 换肤框架。基本原理是通过代理 LayoutInflater 的 View 创建过程解析皮肤相关属性(background/src/textColor 等),将皮肤相关属性设置到 View 的 Tag 内,在切换皮肤时寻找对应的皮肤来完成实时刷新动作。此方案具有代码及 XML 侵入性小、功能完善(支持 Activity/Dialog/悬浮窗/PopWindow/Fragment 等)、无需重启 Activity、支持自定义属性换肤、同时支持资源内换肤和独立资源包(下载后换肤)等优点。接口按流式编程设计,个人感觉,比目前几种换肤框架好用一些。
More: Author   ReportBugs   
Tags:

如何在一个成熟的应用内换肤? 请参见文章:链接

README 分三部分:基本简介、使用方法、框架由来与架构设计。 如果不嫌麻烦,还可以去看文章夜间模式方案调研QSkinLoader 框架介绍

效果图

QSkinLoader 实现夜间模式效果图

基本简介:

QSkinLoader 是一个支持多种场景的 Android 换肤框架。基本原理是通过代理 LayoutInflater 的 View 创建过程解析皮肤相关属性(background/src/textColor 等),将皮肤相关属性设置到 View 的 Tag 内,在切换皮肤时寻找对应的皮肤来完成实时刷新动作。此方案具有代码及 XML 侵入性小、功能完善(支持 Activity/Dialog/悬浮窗/PopWindow 等)、无需重启 Activity、支持自定义属性换肤、同时支持资源内换肤和独立资源包(下载后换肤)等优点。

使用方法

基本使用

由于可以自定义皮肤资源加载过程,QSkinLoader 框架内并未提供当前皮肤的保存逻辑(不能支持 loadCurrentSkin 之类的接口)。因此建议使用框架时封装两个类:一个负责保存当前皮肤(保存皮肤其实就是 SharePreference 持久化存储,此处略去),一个负责与框架交互,如下:

public void init(Context context) {
    SkinManager.getInstance().init(context);
}

public void switchSkinMode(OnSkinChangeListener listener) {
    mIsSwitching = true;
    mIsDefaultMode = !mIsDefaultMode;
    refreshSkin(listener);
}

public void refreshSkin(OnSkinChangeListener listener) {
    if (mIsDefaultMode) {
        //恢复到默认皮肤
        SkinManager.getInstance().restoreDefault(SkinConstant.DEFAULT_SKIN, new MyLoadSkinListener(listener));
    } else {
        changeSkin(listener);
    }
}

private void changeSkin(OnSkinChangeListener listener) {
    SkinManager.getInstance().loadSkin("_night",
            new SuffixResourceLoader(mContext),
            new MyLoadSkinListener(listener));
}

具体代码此处不完全贴出了,工程内有详细的代码。

1. 框架初始化

在 Application 创建过程中执行框架的初始化:

// 初始化皮肤框架
SkinChangeHelper.getInstance().init(this);
//初始化上次缓存的皮肤
SkinChangeHelper.getInstance().refreshSkin(null);

初始化了框架后需要调用 refreshSkin 来加载上一次的皮肤设置,refreshSkin 加载完成皮肤后会通知各 Activity 界面刷新皮肤设置,由于此处在 Application 初始化时调用,可能加载完成皮肤设置后界面仍未初始化,这并不无影响,因为 Activity 初始化时会主动执行一次换肤操作,弥补此过程的缺失。

2. Activity 初始化与各生命周期调用

因为换肤一般是整个应用都需要执行的过程,此处建议实现一个基础类(BaseActivity)来封装换肤相关逻辑,此类建议实现接口 ISkinActivity,告知是否支持换肤,以及在换肤操作触发后如果界面不在前台是否立刻换肤:

@Override
public boolean isSupportSkinChange() {
    //告知当前界面是否支持换肤:true 支持换肤,false 不支持
    return true;
}

@Override
public boolean isSwitchSkinImmediately() {
    //告知当切换皮肤时,是否立刻刷新当前界面;true 立刻刷新,false 表示在界面 onResume 时刷新;
    //减轻换肤时性能压力
    return false;
}

@Override
public void handleSkinChange() {
    //当前界面在换肤时收到的回调,可以在此回调内做一些其他事情;
    //比如:通知 WebView 内的页面切换到夜间模式等
}

然后在 Activity 的 onCreate 中执行 IActivitySkinEventHandler 的初始化与配置工作:

//初始化并配置 IActivitySkinEventHandler,应在 IActivitySkinEventHandler.onCreate 之前执行
mSkinEventHandler = SkinManager.newActivitySkinEventHandler()
        .setSwitchSkinImmediately(isSwitchSkinImmediately())
        .setSupportSkinChange(isSupportSkinChange())
        .setWindowBackgroundResource(getWindowBackgroundResource())
        .setNeedDelegateViewCreate(false);
//通知框架 onCreate 事件
mSkinEventHandler.onCreate(this);

其中,setWindowBackgroundResource用于设置 Activity 的背景色,在换肤时会寻找对应的背景色替换之,此处传入的不能是色值,只支持引用,类似 R.color.xx。 setNeedDelegateViewCreate用于设置是否需要代理 View 创建,因为 LayoutInflater 的代理 View 创建 Factory 只支持设置一次,如果外部已经设置了 Factory,则框架内再次设置会引起崩溃,所以框架使用配置与回调来处理此问题。具体见高级使用部分。

其他生命周期回调基本类似,挑两个做实例,如下:

@Override
protected void onResume() {
    super.onResume();
    //皮肤相关,此通知放在此处,尽量让子类的 view 都添加到 view 树内
    if (mFirstTimeApplySkin) {
        mSkinEventHandler.onViewCreated();
        mFirstTimeApplySkin = false;
    }
    //皮肤相关
    mSkinEventHandler.onResume();
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);

    //皮肤相关
    mSkinEventHandler.onWindowFocusChanged(hasFocus);
}

3. XML 配置

QSkinLoader 只支持引用型资源换肤,所有的颜色定义都应定义在 colors.xml 内,在使用时引用。 对于一个布局,需要定义一个 skin 的命名空间:

xmlns:skin="http://schemas.android.com/android/skin"

然后对所有需要换肤的 View 增加属性:

skin:enable="true"

即可完成换肤配置。举例如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:skin="http://schemas.android.com/android/skin"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    skin:enable="true"
    android:gravity="center_vertical"
    android:background="@color/color_background">
    <ImageView
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:src="@mipmap/ic_launcher"/>

    <TextView
        android:id="@+id/list_item_text_view"
        android:layout_width="100dp"
        android:layout_height="50dp"
        android:textColor="@color/color_text"
        skin:enable="true"/>
</LinearLayout>

在这段布局内,框架代理创建 Linearlayout 时会解析其 background 属性,代理创建 View 时不解析任何属性,代理创建 TextView 时会解析 textColor 属性。

4. 图片蒙层

对 ImageView/ImageButton 可以配置属性:

skin:drawShadow="@color/night_shadow_color"

来支持图片蒙层,night_shadow_color 是一个颜色引用,在默认情况下建议使用透明色,同时在皮肤包内定义此值为另一个色值(不必须是半透明色)。 需要注意的是:蒙层的原理是 ImageView 的 ColorFilter,有时候对 ImageView 设置 ColorFilter 会失效。但是对 Drawable 设置 ColorFilter 基本都会生效,所以如果是对 ImageView 的 Src 属性做蒙层,建议使用框架内的 ShadowImageView 替代 ImageView。如下:

<org.qcode.qskinloader.view.ShadowImageView
            android:id="@+id/logo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            skin:enable="true"
            skin:drawShadow="@color/news_pic_night_shadow_color"/>

5. 换肤与恢复默认皮肤

先来看看换肤操作:

SkinManager.getInstance().loadSkin("_night",
            new SuffixResourceLoader(mContext),
            new MyLoadSkinListener(listener));

这是基于资源后缀的换肤方式,对于 R.color.color_text,切换到夜间模式时,框架会去找 R.color.color_text_night 作为夜间模式的资源。 QSkinLoader 换肤框架还支持另一种默认的换肤方式——APK 资源换肤,也就是将资源文件定义在独立的 APK 文件内,此文件可从服务端下载,从而真正实现动态换肤。此方式对现有工程的影响比较小,非常值得推荐。具体方式如下:

SkinUtils.copyAssetSkin(mContext);
File skin = new File(SkinUtils.getTotalSkinPath(mContext));
SkinManager.getInstance().loadAPKSkin(
        skin.getAbsolutePath(), new MyLoadSkinListener(listener));

当然也可以写成:

SkinManager.getInstance().loadSkin(skin.getAbsolutePath(),
            new APKResourceLoader(mContext),
            new MyLoadSkinListener(listener));

此时对于资源 R.color.color_text,框架会去 skin 路径的 APK 文件内寻找对于的资源 R.color.color_text,找不到就继续使用当前应用的 color_text 资源。 自定义皮肤加载过程见高级使用部分。

那么怎么恢复默认皮肤呢?

//恢复到默认皮肤
SkinManager.getInstance().restoreDefault(SkinConstant.DEFAULT_SKIN,
        new MyLoadSkinListener(listener));

DEFAULT_SKIN 值对框架而言并无意义,框架只是把此值回调到 ILoadSkinListener 使外部知道当前加载的是默认皮肤,所以此值是在框架外定义的。

6. 动态创建 View 的皮肤设置

上文中指出的使用方式是基于 XML 配置的,如果是在 Java 代码内如何使用呢? QSkinLoader 框架提供了一个帮助类 ISkinViewHelper 来添加/删除 View 的皮肤属性。此类设计为链式编程方式,提供的接口有:

ISkinViewHelper setViewAttrs(String attrName, int resId);
ISkinViewHelper setViewAttrs(DynamicAttr... dynamicAttrs);
ISkinViewHelper setViewAttrs(SkinAttr... skinAttrs);
ISkinViewHelper setViewAttrs(List<DynamicAttr> dynamicAttrs);

ISkinViewHelper addViewAttrs(String attrName, int resId);
ISkinViewHelper addViewAttrs(DynamicAttr... dynamicAttrs);
ISkinViewHelper addViewAttrs(SkinAttr... skinAttrs);
ISkinViewHelper addViewAttrs(List<DynamicAttr> dynamicAttrs);

ISkinViewHelper cleanAttrs(boolean clearChild);
void applySkin(boolean applyChild);

如果 View 本身已经有了皮肤属性,setViewAttrs 接口会替换已有的皮肤属性,而 addViewAttrs 不会覆盖已有属性,而是在已有的皮肤属性内添加新的属性。 cleanAttrs 会清除 View 的所有皮肤属性,如果传入 clearChild 为 true 则遍历所有子元素清除皮肤属性,false 只清除自身属性。 applySkin 则对当前 View 应用皮肤设置,如果传入 applyChild 为 true 则遍历所有子元素应用皮肤,false 只应用自身。 假设对一个 TextView,动态设置 View 的皮肤属性大致如下:

SkinManager
    .with(textview)
    .setViewAttrs(SkinAttrName.BACKGROUND, R.color.white)
    .addViewAttrs(SkinAttrName.TEXT_COLOR, R.color.black)
    .applySkin(false);

所有框架支持的属性名称都定义在 SkinAttrName 内,如果需要扩展属性支持,建议参考自定义 View 属性处理器部分。

7. 特定 View 的换肤管理

上面的换肤过程都是对 Activity 的 View 树做遍历换肤操作的,树根是:

activity.findViewById(android.R.id.content);

所有不在这颗树内的 View 都不能换肤,哪些 View 不在换肤范围呢? Dialog 的 View、popWindow 的 View、悬浮窗(WindowManager 上直接加 View),目前这三类 View 要换肤都应该使用特定 View 的换肤管理模块。 需要注意的是:Dialog 的交互具有排他型,通常在换肤操作时是不展示的,所以一般可以在 show 接口调用时做换肤,而不使用 IWindowViewManager。 怎么对特定 View 进行换肤管理呢? 框架提供了 IWindowViewManager 接口来提供特定 View 的管理,支持链式编程,接口如下:

IWindowViewManager addWindowView(View view);
IWindowViewManager removeWindowView(View view);
IWindowViewManager clear();
void applySkinForViews(boolean applyChild);
List<View> getWindowViewList();

接口比较简单,主要是增加/删除/清空全局 View 列表和应用皮肤的操作。 使用如下:

View popView = LayoutInflater.from(mContext).inflate(
    R.layout.news_list_item_pop, null);
SkinManager.getInstance().applySkin(popView, true);
SkinManager
        .getWindowViewManager()
        .addWindowView(popView);
popupWindow = new PopupWindow(popView, popWidth, popHeight);

通常不建议使用 applySkinForViews 接口,因为它会遍历所有全局 View 列表的 View 做遍历,所以替代方式是先对当前 View 做属性设置,再添加到框架内管理,从而在下次换肤时接口换肤事件。 IWindowViewManager 内的 View 是弱引用存储的,所以不会发生内存泄露,但建议在 View 无用的时候从框架内移除特定 View。

高级使用

1. 自定义 View 属性处理器

当项目需要自定义 View 时,一般都会自定义一些属性,这些属性框架是不支持换肤的,此时需要自定义属性处理器并注册到框架内。自定义属性处理器实现接口 ISkinAttrHandler,实现方法:

void apply(View view,
        SkinAttr skinAttr,
        IResourceManager resourceManager);

下面是一个示例: 若有一个自定义 CustomTextView,使用属性 defTextColor 来定义文字颜色,如下:

<org.qcode.demo.ui.customattr.CustomTextView
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:gravity="center"
        android:text="自定义的文字颜色和背景 1"
        app:defBackground="@color/color_background"
        app:defTextColor="@color/color_text"
        skin:enable="true" />

则其自定义属性处理器为:

public class DefTextColorAttrHandler implements ISkinAttrHandler {
    public static final String DEF_TEXT_COLOR = "defTextColor";

    @Override
    public void apply(View view, SkinAttr skinAttr, IResourceManager resourceManager) {
        if(!(view instanceof CustomTextView)) {
            //防止在错误的 View 上设置了此属性
            return;
        }
        CustomTextView tv = (CustomTextView) view;
        if (RES_TYPE_NAME_COLOR.equals(skinAttr.mAttrValueTypeName)) {
            if (SkinConstant.RES_TYPE_NAME_COLOR.equals(
            skinAttr.mAttrValueTypeName)) {
                try {
                    //先尝试按照 int 型颜色解析
                    int textColor = resourceManager.getColor(
                            skinAttr.mAttrValueRefId, 
                            skinAttr.mAttrValueRefName);
                    tv.setTextColor(textColor);

                } catch (Resources.NotFoundException ex) {
                    //不是 int 型则按照 ColorStateList 引用来解析
                    ColorStateList textColor = 
                    resourceManager.getColorStateList(
                            skinAttr.mAttrValueRefId, 
                            skinAttr.mAttrValueRefName);
                    tv.setTextColor(textColor);
                }
            }
        }
    }
}

定义了属性处理器后,再注册到框架内,注册需要在 setContentView 之前

SkinManager.getInstance().registerSkinAttrHandler(
        DEF_TEXT_COLOR, new DefTextColorAttrHandler());

注意:自定义属性处理器不一定就是与皮肤相关的属性的处理,也可以是换肤过程中需要对 View 进行的特定处理。比如 RecyclerView 换肤的时候要清除内部 View 缓存(因为其 onBindViewHolder 不是每次子 View 显示时都回调),此时,可以定义如下的属性处理器:

class RecyclerViewClearSubAttrHandler implements ISkinAttrHandler {
    @Override
    public void apply(View view, SkinAttr skinAttr, IResourceManager resourceManager) {
        Field declaredField = view.getDeclaredField("mRecycler");
        ......
        RecyclerView.RecycledViewPool recycledViewPool = recyclerView.getRecycledViewPool();
        recycledViewPool.clear();
}

此处代码有删减,具体见框架内的 RecyclerViewClearSubAttrHandler 处理器。使用时如下:

SkinAttr clearSubAttr = new SkinAttr(SkinAttrName.CLEAR_RECYCLER_VIEW);
SkinManager
        .with(view)
        .addViewAttrs(clearSubAttr);

2. 自定义皮肤资源加载

框架默认支持资源名称后缀加载、APK 加载、Android UIMode Configuration 变化三种换肤方式。集成方式如下:

//后缀加载
SkinManager.getInstance().loadSkin("_night",
                new SuffixResourceLoader(mContext),
                new MyLoadSkinListener(listener));

//APK 皮肤包
SkinManager.getInstance().loadAPKSkin(
                skin.getAbsolutePath(), 
                new MyLoadSkinListener(listener));

//Android UI Configuration 变化
SkinManager.getInstance().loadSkin(
                ConfigChangeResourceLoader.MODE_NIGHT,
                new ConfigChangeResourceLoader(mContext),
                new MyLoadSkinListener(listener));

如果项目准备采用其他的加载方式,可以通过自定义皮肤资源加载过程来实现。自定义皮肤资源加载的核心是实现 IResourceLoader 接口,接口只有一个方法:

void loadResource(String skinIdentifier,
                      ILoadResourceCallback loadCallBack);

也就是定义了从皮肤标识符 skinIdentifier 加载资源,并在加载过程中通过 loadCallBack 对外通知加载过程:

public interface ILoadResourceCallback {
    void onLoadStart(String identifier);
    void onLoadSuccess(String identifier, IResourceManager result);
    void onLoadFail(String identifier, int errorCode);
}

加载开始和失败没啥可说的,主要是加载完成后,需要返回一个资源管理类 IResourceManager。这个类定义了如何从指定的换肤流程中抽取对应的皮肤资源:

public interface IResourceManager {
    String getSkinIdentifier();
    Drawable getDrawable(int resId, String resName) throws Resources.NotFoundException;
    int getColor(int resId, String resName) throws Resources.NotFoundException;
    ColorStateList getColorStateList(int resId, String resName) throws Resources.NotFoundException;
}

整个过程比较简单,自定义一个加载过程,再返回一个资源管理类即可。下面以后缀资源加载的方式做个示例(摘录部分代码,具体见工程):

public class SuffixResourceLoader implements IResourceLoader {
    private String mSkinSuffix;

    @Override
    public void loadResource(final String skinIdentifier,
                final ILoadResourceCallback loadCallBack) {
        //通知加载开始
        loadCallBack.onLoadStart(skinIdentifier);
        //后缀存下,加载过程就结束了,不像 apk 加载,还需要操作 AssetManager
        mSkinSuffix = skinIdentifier;
        //通知加载结束,返回一个资源管理类 SuffixResourceManager
        loadCallBack.onLoadSuccess(skinIdentifier,
                    new SuffixResourceManager(mContext, mSkinSuffix));
    }
}
public class SuffixResourceManager implements IResourceManager {
    private Context mContext;
    private Resources mResources;
    private String mSkinSuffix;
    private String mPackageName;

    private HashMapCache<String, Integer> mColorCache
            = new HashMapCache<String, Integer>(true);

    public SuffixResourceManager(Context context, String skinSuffix) {
        mContext = context;
        mPackageName = mContext.getPackageName();
        mResources = mContext.getResources();
        mSkinSuffix = skinSuffix;
    }

    @Override
    public int getColor(int resId, String resName) {
        String trueResName = resName + mSkinSuffix;
        //找到名字+后缀的 id,读取颜色
        int trueResId = mResources.getIdentifier(
                trueResName,
                SkinConstant.RES_TYPE_NAME_COLOR,
                mPackageName);
        int trueColor = mResources.getColor(trueResId);
        return trueColor;
    }
    ......
}

3.解决与其他代理 View 创建过程的冲突

上文也简要的提到了此问题,对每个 Activity 的 LayoutInflater 的 setFactory 接口(代理 View 创建与属性解析)只能调用一次,而换肤框架是依赖此操作来完成皮肤属性解析的,因此我们需要设计一套方案在确保框架外已经代理了 LayoutInflater 后还能保证换肤功能的可用性。 我们需要保证两点:

  • 如果框架外需要代理 View 创建,则框架应被告知不能代理 View 创建,并且提供一个帮助类在外部创建 View 创建时完成属性解析;
  • 如果框架外不需要代理 View 创建,但需要解析属性,则提供接口在 View 创建前后对外回调; 对于第一点,可以通过 IActivitySkinEventHandler.setNeedDelegateViewCreate 来告知框架不代理 View 创建,解析属性的帮助类也可以从 IActivitySkinEventHandler 内取到,如下:
    LayoutInflater.from(this).setFactory(new LayoutInflater.Factory() {
      @Override
      public View onCreateView(String name, Context context, AttributeSet attrs) {
          View view = createView(name, context, attrs);
          //创建 View 后通知框架解析属性
          ISkinAttributeParser parser =
              mSkinEventHandler.getSkinAttributeParser();
          if (parser.isSupportSkin(name, context, attrs)) {
              parser.parseAttribute(view, name, context, attrs);
          }
          return view;
      }
    });
    
    核心代码就是这段解析属性的逻辑。

对于第二点,我们提供接口 IViewCreateListener 来监听 View 创建过程:

public interface IViewCreateListener {
        View beforeCreate(String name, Context context, AttributeSet attrs);
        void afterCreated(View view, String name, Context context, AttributeSet attrs);
}

beforeCreate 在 View 创建之前执行,可以拦截框架的 View 创建过程,自己创建 View,afterCreated 在框架创建 View 后执行,用于框架外进一步处理。 此接口应通过 IActivitySkinEventHandler.setViewCreateListener()设置到框架内使用。

- 各种 View 的换肤应用

1. ViewPager

ViewPager 使用时,应在 PagerAdapter 的 instantiateItem 回调中对创建的 View 应用当前的皮肤。

mViewPager.setAdapter(new PagerAdapter() {
    ......
    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        View view = onCreatePagerView(position);
        container.addView(view);
        //每次实例化某个 View 时都对其应用皮肤设置
        SkinManager.with(view).applySkin(true);
        return view;
    }
});

2. ListView/GridView

ListView/GridView 都继承 AbsListView,并使用 BaseAdapter 作为适配器,其换肤方法为:

listView.setAdapter(new BaseAdapter() {
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if(null == convertView) {
            convertView = onCreateContentView(position);
        }
        //每次某个子元素需要展示时,都应用当前皮肤设置
        SkinManager.with(convertView).applySkin(true);
        return convertView;
    }
});

需要注意的是,如果 ListView 存在 HeaderView 或 FooterView 时,只使用上面的方法是不完善的,如果换肤时 HeaderView/FooterView 不在 ListView 内展示,则换肤失效,此时应调用 ListView.mRecycler.clear()方法清除 View 缓存,具体见上一篇文章

3. RecyclerView

上一章也大致讲了 RecyclerView 换肤的注意事项,由于 RecyclerView 滑动时,其子元素出现的过程不一定会伴有 onBindViewHolder 回调,导致我们有时出现两种皮肤并存的问题。因此,使用 RecyclerView 时换肤一定要清除 RecyclerView 的缓存。

recyclerView.setAdapter(new RecyclerView.Adapter() {
    ......
    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        //此回调非必执行,但是执行时还是要应用皮肤设置
        SkinManager.with(holder.itemView).applySkin(true);
    }
});

为了应对 RecyclerView 清除缓存的问题,框架内定义了一个特殊的属性处理器 RecyclerViewClearSubAttrHandler,其作用就是在换肤时,清除 RecyclerView 内的 View 缓存。具体使用方式如下:

SkinManager
    .with(recyclerView)
    .addViewAttrs(new SkinAttr(SkinAttrName.CLEAR_RECYCLER_VIEW));

附录

此框架脱胎于项目需要实现夜间模式的需求,在文章中,我们列举了常见的几种实现夜间模式切换的方案,并大致对比了一下各种方案的优缺点,此处不再一一列举。仅大致摘录夜间模式的需求分析如下:

夜间模式需要对屏幕上的文字/图片/视频三种表现形式做特殊处理,具体细化如下: 1)对界面背景,白色等浅色背景应该变成黑色/灰色之类的深色背景,以此降低屏幕亮度减少视觉刺激; 2)对文字,因背景色变深,文字颜色需变浅,以形成对比效果; 3)对图片,对图片加蒙层,避免加载浅色图片带来的视觉刺激; 4)对视频,通常在播放界面增加亮度变化功能,由用户来决定屏幕亮度。

具体技术选型与框架设计可见文章:http://blog.csdn.net/u013478336/article/details/53083054

Apps
About Me
GitHub: Trinea
Facebook: Dev Tools