返回顶部

Android外部存储之DocumentProvider

前一段时间有不少用户反映客户端无法在外置SD上缓存视频,刚开始还怀疑是用户的SD卡自身损坏导致的,后来经调查才发现原来是Google
从4.4版本开始,Android开始限制第三方应用在外置SD卡中公共目录的写权限,当你的应用需要在外置SD卡公共目录中写入应用数据时,就必须使用Google提供的SAF存储访问框架进行访问。
虽然问题的根源找到了,但适配之路仍是非常的坎坷,特此记录下来与君共勉。

SAF

Storage Access Framework (SAF)是google在Android4.4开始引入的一项存储访问框架,其意是为了当用户需要访问文档,图片或其他文件信息时提供一个统一简单的访问入口,可以方便高效的访问不同文件内容提供商提供的文件信息。SAF共包含三个部分:

  • DocumentProvider,一个可以管理文件内容的存储服务,可以自定义管理文件的类型,目录等。
  • Client app,接收从DocumentProvider的返回的Uri进行文件操作,是完全自定义的。
  • Picker,一个系统提供的可配置的文件选择器,显示所有DocumentProvider的内容,并授权用户所选内容的读写权限,不可以自定义。

局限性

在使用SAF框架时除了需要兼容两套文件操作,还需要适配和解决SAF自身的一些局限性问题。

  1. 对Android 4.4支持有限
    google一开始在4.4+版本上引入的SAF并不是十分的完善。通过Picker UI获取写权限后,只能对已存在的文件执行删除和内容修改操作,无法创建文件,也不能对文件目录进行任何操作。原因主要在于:

    • 在4.4版本上, SAF对外只提供了Intent.ACTION_OPEN_DOCUMENT (类似于ACTION_GET_CONTENT), 用户只能从Picker UI中选择文件而无法从Picker选择目录,从而导致第三方应用无法获取到文件目录的写权限。直至从5.0开始SAF提供了Intent.ACTION_OPEN_DOCUMENT_TREE 后才支持了从Picker UI中选择文件目录。
    • 在4.4版本上, SAF并不支持Intent.FLAG_GRANT_PREFIX_URI_PERMISSION即前缀匹配模式,若没有该属性,则每次授权都只能针对当前的选择对象,就算对同一目录下的文件进行写操作,每个文件都需要进行一次权限申请,非常的繁琐。
    • Picker UI是系统提供的统一交互界面,不能进行任何自定义行为,同时第三方应用和DocumentProvider之间只能通过PickerUI进行通信,无法直接通信,所以就算自定义DocumentProvider也无法绕过Picker UI的操作限制。
  2. 权限获取

    从5.0开始,SAF提供了Intent.FLAG_GRANT_PREFIX_URI_PERMISSION前缀匹配模式,同时支持了Intent.ACTION_OPEN_DOCUMENT_TREE,这样当第三方应用获取了某一个文件目录的操作权限后,对该目录下的子目录和文件都拥有了相同的操作权限。所以为了统一处理,第三方应用在使用SAF时必须引导用户选择在外置SD卡的根目录进行权限授予,这样第三方应用就获取了整个外置SD卡的读写权限并且只需要授权一次。
    但需要注意的是通过Intent.ACTION_OPEN_DOCUMENT,Picker UI返回的是一个DocumentUri, 而通过Intent.ACTION_OPEN_DOCUMENT_TREE,Picker UI返回的是一个TreeDocumentUri,而TreeDocumentUri只有才会被赋予Intent.FLAG_GRANT_PREFIX_URI_PERMISSION。

    @Override
    void onTaskFinished(Uri... uris) {
    ... ...
    if (mState.action == ACTION_GET_CONTENT) {
      intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        } else if (mState.action == ACTION_OPEN_TREE) {
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
                | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                |Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION                       
                |Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
        } else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
            ... ...
        } else {
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
             | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
             |Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
        }
     ... ...
    }

    为了可以直接利用Intent.FLAG_GRANT_PREFIX_URI_PERMISSION带来的便利,SAF提供了一组特定类型的tree uri,使用规则相当于:

    content://provider的authority/tree/treeDocumentId/document/documentId
    即 content://com.example/tree/12/document/24/children

    其中treeDocumentId是之前已授权目录的TreeDocumentUri中的documentId,document后的dcoumentId则是实际访问的documentId, 因为已授权目录的TreeDocumentUri是已知的,所以treeDocumentId可以通过DocumentsContract.getTreeDocumentId方法得到。 但对于需实际访问的documentId,则仍需要我们自己去构建。

  3. TreeDocumentFile
    DocumentFile为DocumentProvider模仿一套和传统File一样的操作接口,便于兼容传统File操作接口。其中TreeDocumentFile是专为TreeDocumentUri设计的,相当于是一个Directory,可以方便的对文件目录进行相关操作。
    并且TreeDocumentFile只能通过DocumentFile.fromTreeUri(Context context, Uri treeUri)方法来进行构造。源码片段如下:

        // TreeDocumentFile.java
        TreeDocumentFile(DocumentFile parent, Context context, Uri uri) {
        super(parent);
        mContext = context;
        mUri = uri;
    }
        //DocumentFile.java
        public static DocumentFile fromTreeUri(Context context, Uri treeUri) {
        final int version = Build.VERSION.SDK_INT;
        if (version >= 21) {
            return new TreeDocumentFile(null, context, DocumentsContractApi21.prepareTreeUri(treeUri));
        } else {
            return null;
        }
        //DocumentsContractApi21.java
        public static Uri prepareTreeUri(Uri treeUri) {
        return DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
        }
    }

    需要注意上面的DocumentsContractApi21.prepareTreeUri方法,该方法在内部调用了DocumentsContract.buildDocumentUriUsingTree()方法,但它传进去的documentId参数却仅是treeUri的TreeDocumentId,这意味如果treeUri是根据上节权限获取所述方式构建出的Uri,则得到TreeDocumentFile永远只表示了外置SD卡的根目录, 所以一般情况下TreeDocumentFile并不可用。

  4. 两套File操作
    考虑到android中主存储是可以通过动态权限申请 ( 6.0以下在AndroidManifest中 ) 就可以获取读写权限并且DocumengProvider获取权限的要求略微复杂,所有这样就不可避免的需要在代码中维护两套File操作(同一套api),这样在一定程度上增加了维护成本和构建成本。
    同时由于TreeDocumentFile我们自己难以构造,而SingleDocumentFile对于Directory属性的方法都不支持,所以有些常用的api就需要我们自己封装了。

    • mkdirs
      DocumengProvider虽然提供了createDocument方法,但需要注意的是调用createDocument方法的前提是父目录必须存在,否在会创建失败,所以我们封装mkdirs时需要从根目录开始层层判断父目录是否存在,这一点尤为的麻烦。

           @Override
      public String createDocument(String docId, String mimeType, String displayName)
              throws FileNotFoundException {
          displayName = FileUtils.buildValidFatFilename(displayName);
          final File parent = getFileForDocId(docId);
          if (!parent.isDirectory()) {
              throw new IllegalArgumentException("Parent document isn't a directory");
          }
       ... ...   
      }    
    • renameTo
      DocumengProvider虽然直接提供了renameDocument的方法,但renameTo的对象只能在同级目录下,不能更换父目录,因为输入参数只有displayName,这个是和File的rename不一样的,使用时需要格外的注意。

          @Override
      public String renameDocument(String docId, String displayName) throws FileNotFoundException {
          displayName = FileUtils.buildValidFatFilename(displayName);
          final File before = getFileForDocId(docId);
          final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
          if (!before.renameTo(after)) {
              throw new IllegalStateException("Failed to rename to " + after);
          }
         ... ... 
      }
  5. 国产ROM
    国产ROM有时候会修改System Picker UI的交互,越改越难用,典型的就是华为的xxxROM,把原生系统的选择按钮改成了全选,不明所以。

    enter image description here

    国产ROM有时候还有修改SAF存储框架,在华为xxx机型上居然不需要通过SAF申请任何权限,仅通过存储权限申请就是可以在外置SD卡的任意目录读写,而有时在某系机型上会突然性的出现在外置SD卡的默认包名下没有写权限,只能通过SAF申请权限后才能进行正常写入。
    所以这些都是我们在适配时都需要注意的,除了做好SAF权限授权引导,还是尽量避免申请SAF权限,毕竟操作还是十分繁琐的。

Written with StackEdit.

Django博客项目迁移之从零开始

最近阿里云的服务器快到期了,打算换个国外服务器vultr,这样不仅可以自己搭一个shadowsocks,同时还可以把博客平台迁过来,一举两得。


准备

登录服务器后,第一步要做的就是安装相关必要软件。
因为服务器一般是Linux系统,python2是默认自带的,在此就忽略啦。

  1. 安装 pip
    一般地,因为我们是python 项目,所以pip是一定少不了。但pip依赖一个叫setuptools的软件包,在安装pip前先安装它。

    1. download get-pip.py
    2. python get-pip.py

    get-pip.py 在默认情况下会自动帮你安装上setuptools,这样就达到了一步到位地效果。当然你也可以分别下载setuptools和pip的安装包手动运行安装。
    ( 附上get-pip.py的下载地址 )

  2. 安装Django
    用pip安装Django非常简单,但在安装时有一点需要注意,因为是项目迁移,所以最好先查询下之前服务器上的Django版本,然后再安装与之对应的Django版本,避免因为版本升级造成额外的麻烦。

    pip install Django==1.7( old version )

    当安装完之后,运行如下命令,如没有异常则表明Django已安装成功。

    python
    import django
    django.get_version()
  3. 服务器安装
    目前采用的是Nginx+ uWSGI模式 。

    • 对于Nginx安装,一般地如果没有特殊需求的话( 如需要Nginx插件之类的 ),可以直接通过apt-get直接安装nginx。

      sudo apt-get install nginx
      sudo /etc/init.d/nginx start    # 启动 nginx
    • uWSGI的安装相对比较麻烦,依赖于C编译器,gcc和clang都可以。
      当然我们可以通过pip命令一次性把python build中所需的环境安装完成。

      #On a Debian/Ubuntu system
      apt-get install build-essential python
      apt-get install python-dev

    然后就可以直接通过pip安装uWSGI了。

    pip install uwsgi

    更多安装参考详见官方文档

  4. 数据库
    在我的博客项目中使用的是MySQL数据库,下面就简单介绍下MySql的配置方法。
    MySql的安装其实非常简单,通过apt-get可以直接进行安装。

    sudo apt-get install mysql-server 

    安装期间会提示输入密码,这里可以输入也可以选择忽略。
    然后就可以在shell中输入命令进入MySql了。

    mysql -u root -p

    但这里有两点需要注意:

    • 因为Django是一个python项目,所以在使用MySql时需要额外安装一个python-mysqldb的插件,安装方式非常简单apt-get即可。

      sudo apt-get install python-mysqldb
    • 另一点就是MySql的字符集问题(这个问题坑了我好久-,-)。
      MySql的存储的默认字符集是****,而Django的默认字符集是utf-8,所以如果直接将数据迁入的话会造成数据错乱,并且数据库由于输出的数据不是utf-8格式,还会造成Django发生异常。
      首先使用命令show variables like ‘collation_%’show variables like ‘character_%’; 查看Mysql的字符集和排序方式。

    EFB64E7D-DF6A-4DEC-A848-4BFCAE8A52A1.png

    7082E143-A2C2-48AA-A6CB-003C98C2C620.png

    可以从图中看到有些字符集的配置都是latin1,而导致乱码的罪魁祸首就是server端的character不是uf8。

    1. vim /etc/mysql/my.cnf
    2. character-set-server=utf8  #在 [mysqld] 标签下添加
    3. default-character-set=utf8  #在 [mysql] 标签下添加
    4. service mysql restart

    当修改完之后,可以再查看数据库当前字符集配置,你会发现server端的character变成utf-8了,这就说明大工告成了。

5E3E98E3-D491-418F-B94B-E0AF84813C13.png


迁移

完成上面的准备工作,基本上整个博客项目的迁移就完成了60%,剩下的主要就是Django的生产环境配置和数据库的数据迁移。

  1. 生产环境配置
    生产环境的配置其实主要是两个配置文件的迁移,分别是nginx的配置文件nginx.conf 和 uWSGI的配置文件uwsgi.ini。
    方式也非常简单,直接使用scp命令从服务器拷贝出来,然后再修改与本地路径相关的一些参数即可。
    至于nginx.conf 和 uwsgi.ini怎么配置,可以参考官方文档进行配置。

  2. 数据库内容迁移
    Django的基础框架建设非常的完善,所以数据库的内容迁移自然也是非常简单。
    Django 提供了一个命令可以直接将数据库中的内容导出成json格式的文本文件。

     1. python manage.py dumpdata > all.json # 指定导出的json名字
     2. python manage.py dumpdata [appname] > blog.json # 指定导出的json名字

    上述命令1是默认导出Django项目中所有app的数据,当然你也可以使用命令2单独指定导出一个app的数据,不过并不建议这么做,除非你没有使用Django的用户管理,后台管理等系统功能( 显然这是不常见的 )。
    在正在执行数据导入前,我们还需要先将项目对应的数据库建立起来。

     1. mysql -u root -p
     2. create database; [数据库名称];
     3. show databases;

    这里需要注意的是新创建的数据库名字最好和Django项目的settings文件中配置的数据库名字保持一致,不然将无法找到数据库。
    然后再调用Django命令将项目中的数据模型在数据库中建立起来(就是创建表)。

     python manage.py migrate

    最后再将之前导出的数据导入至新服务器的数据库中,就大功告成啦。

    python manage.py loaddata blog_dump.json
  3. 启动服务器
    nginx服务器之前已经启动,现在我们只需启动Django配置的uwsgi服务器,就可以监听相应的端口号,响应nginx服务器分发的动态请求了。

    uwsgi --ini pcms_uwsgi.ini

最后

至此一个简单的Django项目迁移流程就结束了,欢迎访问我的博客xyczero.com,如有疑问欢迎提出。
最后安利一波,如果你也想在vultr上建立自己的服务器或shadowsocks,欢迎点击优惠链接>> ,互惠互利嘛^ . ^ 。


参考资料 :
1. http://uwsgi-docs.readthedocs.io/en/latest/
2. https://pip.pypa.io/en/latest/installing/
3. http://www.ziqiangxuetang.com/django/django-data-migration.html
4. http://www.cnblogs.com/donqiang/articles/2057972.html

MagicaSakura多主题框架

如何不重启APP切换多主题?
如何只写一份drawable或layout就可以自动适配各种多主题?
如何兼容低版本的Android系统?
如何减少开发人员的学习成本,能够快速适应多主题框架?
开源多主题框架 MagicaSakura 都可以帮你做到。
(备注:此处的多主题是指轻量级的多彩主题色,而非插件化形式的多主题皮肤)


不重启APP切换主题

MagicaSakura提供了全局方法Theme.refreshUI,直接调用即可无需重启App更换应用主题(当然必须得在主线程-,-),同时该方法提供了额外的回调参数,以便满足在主题切换过程中的各种自定义需求。


自适应多主题

  • Drawable XML
    使用预先定义的颜色值,只需编写一份drawable即可自动适配多主题样式,无需再为每一种主题都编写一份drawable或style。

    • selector,item , shape, layerlist,color 等常规xml标签都已支持,能满足应用基本开发需求。
    • 额外支持在xml中直接染色 (app : drawableTint , app : drawableTintMode) 以及在shape标签中配置颜色透明度(android : alpha)。如:

      • 直接染色

        <selector
            xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto">
               <item android:drawable="@drawable/icon " android:state_pressed="true"
        app:drawableTint="@color/theme_color_primary" />
              <item android:drawable="@drawable/icon" app:drawableTint="@color/gray_dark" />
        </selector>
      • 标签配置颜色透明度

         <selector xmlns:android="http://schemas.android.com/apk/res/android">
            <item android:state_enabled="true" android:state_pressed="true">
                <shape>
                  <corners android:radius="4dp" />
                  <solid android:color="@color/theme_color_primary_dark" />
                </shape>
            </item>
            <item android:state_enabled="true">
                <shape>
                  <solid android:color="@color/theme_color_primary" />
                  <corners android:radius="4dp" />
                </shape>
            </item>
            <item android:state_enabled="false">
                <shape>
                  <solid android:alpha="0.3" android:color="@color/theme_color_primary" />
                  <corners android:radius="4dp" />
            </shape>
            </item>
        </selector>
  • Layout XML
    使用MagicaSakura中的提供的TintXXX控件可以在layout 中直接对其drawable属性进行染色,如:background, src, drawableLeft, button 等,并且包括文字颜色,超链接颜色在内都可以自动跟随多主题变化,非常快捷方便。

    // TintTextView
    //其中drawableRightTint中的selector_lock是一个ColorStateList
    <com.bilibili.magicasakura.widgets.TintTextView
             xmlns:app="http://schemas.android.com/apk/res-auto"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:drawablePadding="@dimen/padding_half"
             android:drawableRight="@drawable/selector_lock"
             android:text="@string/textview_title"
             android:textColor="@color/selector_text"
             android:textSize="19sp" 
             app:drawableRightTint="@color/selector_lock"/>     
  • 代码中适配多主题
    MagicaSakura支持在代码中直接适配多主题。

    • 对于MagicaSakura中的TintXXX控件
      在代码中可以直接对TintXXX控件的相关drawable进行染色,使用方法与Android原生控件基本一致,例子如下:

      //TextView的background是一个shape类型的selector,那么就可以直接调用setBackgroundResource()方法进行染色。
       tintTextView.setBackgroundResource(R.drawable.selector_shape_lock);
      
      //ImageView的src是一个包含png的selector,那么只需比Android原生控件多调用一个方法。
      tintImageView.setImageResource(R.drawable.selecor_png_lock);
      tintImageView.setImageTintList(R.color.selector_color_lock);
    • 对于一些特殊需求或一些自定义控件
      MagicaSakura中提供了ThemeUtils工具类,该工具类主要提供了drawable染色以及主题色自动转换的相关方法,其中主题色自动转换支持colorStateList和 color,可以非常方便的在代码中进行多主题适配。

      // R.color.selector_color.lock 通过 getThemeColorStateList转换,返回主题色相关的colorStateList。
      ThemeUtils.getThemeColorStateList(context, R.color.selector_color.lock);

兼容低版本的Android系统

MagicaSakura目前兼容的最低SDK版本为API 15,即Android 4.0.3版本,基本符合绝大多数APP开发中的最低API要求。


尽量减少学习成本

为减少学习成本,MagicaSakura基于Android原生控件封装了一套TintXXX控件,包含所有常用的控件类型,如:TextView,Button,EditText, ProgressDialog等,能基本满足常规应用开发需求。
TintXXX控件可自动适配多主题样式,支持在layout.xml,drawable.xml 和代码中配置。特别的在layout.xml中可以方便地与Android原生属性配合使用,如 app:drawableLeftTint可以直接染色android:drawableLeft,app: backgroundTint 直接染色android:background等。


关于夜间模式和多主题皮肤

MagicaSakura支持夜间模式平滑切换(即无需重启应用),只需在res中按需添加相关的xxx_night资源文件包。
特别得当app的support库版本低于23.2.0时,可以直接使用MagicaSakura内提供的ThemeUtils.updateNightMode()方法切换夜间模式,当support库版本大于等于23.2.0时可以使用support库提供的方法。其实Android是原生支持夜间模式的,更多介绍可以详见android多主题之坑这篇文章。

多主题皮肤和多彩主题是不完全相同的,支持多主题皮肤一般需要引入插件框架,而多彩主题则相对轻量一些。目前MagicaSakura暂不支持多主题皮肤,以后会考虑增加对其的支持。

开源

经过几周的筹备和优化,现正式将多主题框架MagicaSakura开源,希望能对你有所帮助。

  • 相关源码和详细文档都已在GitHub开源,欢迎大家来围观,指出不足之处,一起来完善MagicaSakura多主题框架。
    传送门地址:MagicaSakura

  • Demo下载地址: 点击直接下载


Written with StackEdit.

Android之FragmentManager add机制的变迁

起因

最近在把项目support库从23.1.1升级至23.4.0时,突然发现有些地方会莫名的找不到”已经“add的fragment,从而导致了空指针引用异常。郁闷的我就仔细对比了23.1.1和23.4.0源码,发现原来是google悄悄地把FragmentManager add()机制更改了,真是坑爹-,-。


探究

如下是一段很平常的代码:


//In Activity.java
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    getSupportFragmentManager()
        .beginTransaction()
        .add(android.R.id.content, fragmentA, fragmentA.TAG)
        .add(android.R.id.content, fragmentB, fragmentB.TAG)
        .commit();
    }

//In FragmentA.java
     @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
            mFragmentB =(FragmentB)getFragmentManager()
                   .findFragmentByTag(FragmentB.TAG);
        mFragmentB.performSomeAction();
       ... ...
    }

在23.1.1之前FragmentManager的add机制:

//STEP0: 记录已经添加和有效的fragment集合
    ArrayList<Fragment> mAdded;
    ArrayList<Fragment> mActive;
    ... ...
    public void run() {
    ... ...
//STEP1: 根据之前的add顺序,从mHead中取出fragment并依次放入lastInFragments中,为之后的transition做准备。
    calculateFragments(firstOutFragments,lastInFragments);

//STEP2: 再根据lastInFragments集合统计出fragments的过渡状态(因为仅add操作,所以firstOutFragments为空)
    state=beginTransition(firstOutFragments,lastInFragments, false);
    ... ...
//STEP3:依次将mHead链表中的fragment加入至mAdded和mActive中,但不会更改fragment的状态(即不会触发fragment的初始化操作)。
    while (op != null) {
            int enterAnim = state != null ? 0 : op.enterAnim;
            int exitAnim = state != null ? 0 : op.exitAnim;
            switch (op.cmd) {
                case OP_ADD: {
                    Fragment f = op.fragment;
                    f.mNextAnim = enterAnim;
                    mManager.addFragment(f, false);
                } break;
                ... ...
            }
    }

//STEP4: 最后更新mActive集合中所有fragment的状态(对于add操作而言即是触发了fragment的初始化操作)
    mManager.moveToState(mManager.mCurState
                            , transition, transitionStyle, true);
    ... ...
}

而在23.4.0版本的support库中,它初始化fragment的时机发生了变化,主要变化是在上述的STEP1中。
在23.4.0的STEP1中,除了根据之前的add顺序,从mHead中取出fragment并依次放入lastInFragments中,为之后的transition做准备。并且将新add的fragment放入mActive中,同时更新fragment的状态(即触发了fragment的初始化操作)。

// 23.1.1版本:
    private void setLastIn(SparseArray<Fragment> fragments, Fragment fragment) {
        if (fragment != null) {
            int containerId = fragment.mContainerId;
            if (containerId != 0) {
                fragments.put(containerId, fragment);
            }
        }
    }

// 23.4.0版本:
    private void setLastIn(... , Fragment fragment) {
        if (fragment != null) {
             ... ...
            if (fragment.mState < Fragment.CREATED 
                    && mManager.mCurState >= Fragment.CREATED) {
                mManager.makeActive(fragment);
                mManager.moveToState(fragment, Fragment.CREATED, 0, 0, false);
            }
        }
    }

因为触发fragment初始化时机发生了改变,所以当FragmentB在FragmentA之后add,此时在FragmentA的onCreate方法中通过findFragmentByTag找FragmentB时,必然找到的为空(即mAdded和mActive两个集合中都无法找到),因为此时FragmentB根本还未来得及被添加至mAdded或mActive集合中。

    public Fragment findFragmentByTag(String tag) {
        if (mAdded != null && tag != null) {
            // First look through added fragments.
            for (int i=mAdded.size()-1; i>=0; i--) {
                Fragment f = mAdded.get(i);
                if (f != null && tag.equals(f.mTag)) {
                    return f;
                }
            }
        }
        if (mActive != null && tag != null) {
            // Now for any known fragment.
            for (int i=mActive.size()-1; i>=0; i--) {
                Fragment f = mActive.get(i);
                if (f != null && tag.equals(f.mTag)) {
                    return f;
                }
            }
        }
        return null;
     }

结语

所以谨记,如果两个fragment之间有调用关系的话,添加他们时一定要优先add被调用的fragment(即例子中的FragmentB),以方support库升级后引起空指针异常。


Written with StackEdit.

android多主题之坑

声明:本文已授权微信公众号Android程序员 (Android Trending) 在微信公众号平台原创首发。

年后重构了一版多主题框架,在重构过程中遇到了不少的坑,特此记录下与君共勉。(Tips: 多主题框架也将于6月初开源啦^.^)

  • 多彩主题和夜间主题
    在写多主题框架时,首先一个概念要分清就是多彩主题和夜间模式。

    • 多彩主题其实是白天模式的衍生,与夜间模式是对立的。
    • 虽然夜间和多彩是对立,但还是建议多彩主题应该与夜间模式解偶,因为有时夜间模式的颜色变化并不是简单的颜色取反,受产品设计的影响较大,有时甚至一个tag在夜间和多彩中的取色完全不一样的,这时如果还在强求通过一次编码“通吃“多彩和夜间,这样的做法完全是不明智的,同时也会导致框架易用性变差。
      当然如果某些控件在夜间模式下的需求只是简单的颜色取反,对于这种情况,框架是应当给予适配支持的(不能一棒子打死嘛),因为这种特性支持很简单,所以可以在基本不增加框架学习使用成本的前提下,大大减少程序员的重复编码,提高了开发效率。
    • 关于夜间模式的具体实现方式有很多,在这里推荐一篇文章 Android夜间模式最佳实践,文中一共概述了三种实现方式,其中第三种通过修改uiMode来切换夜间模式 其实就是Google在support库23.2.0版本(新增支持夜间模式,其实早就支持了0,0)中采用的方式,只不过在AppcompatDeleglate中进行了封装,使用起来更加简单了。
  • 关于ColorDrawable
    API21以下是不支持染色的,所以从兼容性上考虑,一般地对ColorDrawable直接new而不是染色。
    源码如下(API19):

    /**
     * Setting a color filter on a ColorDrawable has no effect.
     *
     * @param colorFilter Ignore.
     */
    public void setColorFilter(ColorFilter colorFilter) {
    }
    
  • 关于GradientDrawable
    比较特殊,API22以下是不支持直接tint的,这点在support库中有很清楚的说明(DrawableCompatLollipop.java$setTintList):

    public static void setTintList(Drawable drawable, ColorStateList tint) {
        if (drawable instanceof DrawableWrapperLollipop) {
            // GradientDrawable on Lollipop does not support tinting, so we'll use our compatible
            // functionality instead
            DrawableCompatBase.setTintList(drawable, tint);
        } else {
            // Else, we'll use the framework API
            drawable.setTintList(tint);
        }
    }

    另外值得注意的是,GradientDrawable不支持tint的原因有两点,1).在API21以下它并没有实现onStateChange方法,而onStateChange在view中的默认实现是直接返回false,所以它就不会随着状态的变化刷新UI了。2). 在API21的GradientDrawable源码中并没有支持setTint,这有点奇怪,因为其他Drawable基本都支持了,有时间要仔细对比下源码。

       protected boolean onStateChange(int[] state) { 
           return false; 
       }
    
  • 关于setPressed(boolean)
    setPressed 方法不同于setSelected方法,虽然它在执行过程中会更新Drawable的state状态,但是不会调用invalidate函数(备注:并不是针对所有Drawable,stateDrawableList会在setPressed执行过程中调用invalidate())。
    附上API23部分源码:

    //View.java
    public void setPressed(boolean pressed) {
        ... ...
        if (needsRefresh) {
            refreshDrawableState();
        }
         ... ...
     }
    protected void drawableStateChanged() {
        ... ...
        final Drawable bg = mBackground;
        if (bg != null && bg.isStateful()) {
            bg.setState(state);
        }
        ... ...
    }
    
    //BitmapDrawable
    @Override
    protected boolean onStateChange(int[] stateSet) {
        ... ...
            mTintFilter = updateTintFilter(mTintFilter, state.mTint, state.mTintMode);
        ... ...
    }
    
    //Drawable
    /**
     * Ensures the tint filter is consistent with the current tint color and
     * mode.
     */
    PorterDuffColorFilter updateTintFilter(PorterDuffColorFilter tintFilter, ColorStateList tint,
            PorterDuff.Mode tintMode) {
            ... ...
        if (tintFilter == null) {
            return new PorterDuffColorFilter(color, tintMode);
        }
        tintFilter.setColor(color);
        tintFilter.setMode(tintMode);
        return tintFilter;
    }

    特别地,当一个textview设置了一张png为background并对该background设置了normaltint和pressed tint,然后你会发现background在按下时背景色并没有tint。
    解决的方法:1). 在view的drawstateChanged()中手动调用invalidate方法。2). 在view的drawstateChanged()中apply新的drawable state。3). 等待你来补充。

  • 关于.9png
    .9png在绘制时如果.9png内含有padding值,则5.0以下时view的padding会消失。如果想要view的padding保留,目前比较好的做法就是在set前先将view的padding值保存下来,然后等set之后再重新setPadding回去(首先要明确的一点是drawable和view的padding是有区别的)。

  • 关于StateListDrawable对child tint 无效
    这是一个5.0以下的bug,现在比较好的解决方案就是继承StateListDrawable,重写它的selectDrawable方法,每次在状态切换获取对应的drawable时,手动进行setColorFilter设置。附上链接

  • 关于setButtonDrawable方法
    setButtonDrawable方法在API21以下存在一个 非常隐蔽的bug。
    在API21以下,如果CompoundButton已设置了一个buttonDrawable(非空),然后在调用setButtonDrawable(null),你会发现之前设置的buttonDrawable仍然存在!根本没有被置空。
    至于原因非常简单,对比一下源码就一目了然了。下面附上API23 和API19的相关源码

    //API19
    /**
     * Set the background to a given Drawable
     *
     * @param d The Drawable to use as the background
     */
    public void setButtonDrawable(Drawable d) {
        if (d != null) {
            if (mButtonDrawable != null) {
                mButtonDrawable.setCallback(null);
                unscheduleDrawable(mButtonDrawable);
            }
            ... ...
    }
    //API21
    /**
     * Sets a drawable as the compound button image.
     *
     * @param drawable the drawable to set
     * @attr ref android.R.styleable#CompoundButton_button
     */
    @Nullable
    public void setButtonDrawable(@Nullable Drawable drawable) {
        if (mButtonDrawable != drawable) {
            if (mButtonDrawable != null) {
                mButtonDrawable.setCallback(null);
                unscheduleDrawable(mButtonDrawable);
            }
    
            mButtonDrawable = drawable;
    
            if (drawable != null) {
                drawable.setCallback(this);
                ... ...
            }
        }
    }
  • 关于obtain属性
    好吧,这个obtain属性非常怪,有时候会出些莫名其妙的bug。

    • 在API21以下,如果在int [] ATTRS数组中将android属性放在自定义属性之后读取,则你会发现android属性的值将无法取到,-,-是不是很奇葩。
    • 在API19上,如果将drawableLeft之类的android属性放在一个int [] ATTRS中通过TypeArray读取时,除了第一个android属性能取到resourceId,之后的drawableXxx的resourceId解析的值都为0。
    • 目前的解决方案是针对每个attr都单独obtain一次,如果有更好的解决方案,欢迎支持。

拖沓了两个月终于踩着五月份的尾巴把文章发了,唏嘘…(拖延症害死人--|||)


Written with StackEdit.