深入分析PMS服务(一)

在前面两篇自己动手编译最新Android源码自己动手调试Android源码中,我们掌握了Android源码的编译以及调试,现在呢,我们在这基础上分析一些源码的实现.首先从PMS服务开始.

PMS服务即PackageManagerService,主要用来进行APK的管理任务.但是今天,我们并不直接分析PMS的源码,而是从一个工具类PackageParse说起.

首先来认识PackageParser类,它主要用来解析手机上的apk文件(支持Single APK和Multiple APK),主要包含两个过程

  • APK->Package:解析APK文件为Package对象的过程
  • Package->PackageInfo:由Package对象生成PackageInfo的过程

介于不少童鞋不了解Single APK和Multiple APK,在这里做个简单解释:

Single APK是我们通常所开发的APK,即一个应用只有一个apk文件.而Google Play还允许你为一个应用中发布不同的apk文件,这些apk文件适用于不同设备.举例说明,假设你现在开发了一款APP叫做Monkey,但是目前该APP由于体积太大或者其他因素导致不能同时适用于手机和平板,此时你就可将原先的Monkey.apk拆分为了Monkey-Phone.apk和Monkey-Tablet,分别用于运行在Android手机和Android平板,只要保存两者拥有相同的包名,并用相同key进行签名就可以在发布Monkey应用的时候,一同发布Monkey-Phone.apk和Moneky-Tablet.apk,那么这种一个应用拥有多个APK文件的程序就称之为Multiple APK.

更多信息查看官网:multiple-apks


解析APK文件为Package对象

该过程目的是通过解析磁盘上的APK文件来生成与之相关的Package对象.而Pakcage对象是APK经过完整解析之后的结果.

该过程主要涉及到一系列parseXXX()格式的方法,起始方法是public Package parsePackage(File packageFile, int flags),那么我们就从该方法开始分析其流程:

public Package parsePackage(File packageFile, int flags) throws PackageParserException {
        if (packageFile.isDirectory()) {
            //多个apk文件的目录
            return parseClusterPackage(packageFile, flags);
        } else {
            //单一APK文件
            return parseMonolithicPackage(packageFile, flags);
        }
    }

该方法接受两个参数packageFile和flags.并根据packageFile是否是文件目录来确定具体的解析流程.通常我们都是Single APK,因此我们重点关注Single APK的解析,不难发现其具体解析过程给委托给parseMonolithicPackage(packageFile, flags)方法:

 public Package parseMonolithicPackage(File apkFile, int flags) throws PackageParserException {
        //如果是核心应用则以更轻量级的方式进行解析后,判断是否是核心应用,非核心应用不执行解析过程
        if (mOnlyCoreApps) {
            final PackageLite lite = parseMonolithicPackageLite(apkFile, flags);
            if (!lite.coreApp) {
                throw new PackageParserException(INSTALL_PARSE_FAILED_MANIFEST_MALFORMED,
                        "Not a coreApp: " + apkFile);
            }
        }

        final AssetManager assets = new AssetManager();
        try {
            //调用parseBaseAPK()继续解析操作
            final Package pkg = parseBaseApk(apkFile, assets, flags);
            pkg.codePath = apkFile.getAbsolutePath();
            return pkg;
        } finally {
            IoUtils.closeQuietly(assets);
        }
    }

在该方法中首先通过mOnlyCoreApps属性判断当前系统是不是只解析核心APK,默认是全部解析.至于什么是核心APK后面再说.现在我们继续关注其解析过程.

这里其解析操作继续由parseBaseApk(apkFile, assets, flags)完成:

 private Package parseBaseApk(File apkFile, AssetManager assets, int flags)
            throws PackageParserException {
                ...
        final int cookie = loadApkIntoAssetManager(assets, apkPath, flags);
        Resources res = null;
        XmlResourceParser parser = null;
        try {
            res = new Resources(assets, mMetrics, null);
            assets.setConfiguration(0, 0, null, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                    Build.VERSION.RESOURCES_SDK_INT);
            //为AndroidManifest.xml生成xml文件解析器
            parser = assets.openXmlResourceParser(cookie, ANDROID_MANIFEST_FILENAME);

            final String[] outError = new String[1];
            final Package pkg = parseBaseApk(res, parser, flags, outError);
                ...
            pkg.baseCodePath = apkPath;
            pkg.mSignatures = null;

            return pkg;

        }
                ...
    }

而真正的解析又是通过该方法的同名函数:parseBaseApk(Resources res, XmlResourceParser parser, int flags,String[] outError)完成的,为了突出重点,我对其方法进行了简化:

    private Package parseBaseApk(Resources res, XmlResourceParser parser, int flags,
            String[] outError) throws XmlPullParserException, IOException {

             //....省略多行代码....

       //循环解析AndroidManifest.xml中的元素
       while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
                && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
                if (tagName.equals("application")) {
                    //....省略多行代码,重点关注其中调用的parseBaseApplication()方法
                }else if (tagName.equals("overlay")) {
                    //....省略多行代码....
                }else if (tagName.equals("key-sets")){
                    //paseKeySets()
                }else if (tagName.equals("permission-group")) {
                    parsePermissionGroup(pkg, flags, res, parser, attrs,
                }else if (tagName.equals("permission")) {
                    //parsePermission
                }else if (tagName.equals("uses-configuration")) {
                    //....省略多行代码....
                }else if (tagName.equals("uses-feature")) {
                    //parseUsesFeature()
                }else if (tagName.equals("feature-group")) {
                    //....省略多行代码....
                }else if (tagName.equals("uses-sdk")) {
                    //....省略多行代码....
                }else if (tagName.equals("supports-screens")) {
                    //....省略多行代码....
                }else if (tagName.equals("protected-broadcast")) {
                    //....省略多行代码....
                }else if (tagName.equals("instrumentation")) {
                    //....省略多行代码....
                }else if (tagName.equals("original-package")) {
                    //....省略多行代码....
                }else if (tagName.equals("adopt-permissions")) {
                    //....省略多行代码....
                }
              //....省略多行代码....
            }
        //....省略多汗代码....
    }

不难发现这里通过很多parseXXX()方法解析相应的数据,比如:parseBaseApplication(),parseKeySets(),parsePermissionGroup(),parseUsesPermission()等等.

下面,我们重点关注Application标签的解析,即:parseBaseApplication()方法:

 private boolean parseBaseApplication(Package owner, Resources res,
            XmlPullParser parser, AttributeSet attrs, int flags, String[] outError)
        throws XmlPullParserException, IOException {

        //....省略对Application元素属性解析多行代码....

    //解析Application下的子元素结点,如activity,receiver,service等
    while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
                && (type != XmlPullParser.END_TAG || parser.getDepth() > innerDepth)) {
             if (tagName.equals("activity")) {
                //....省略多行代码,主要关注parseActivity()....
             } else if (tagName.equals("receiver")) {
                //....省略多行代码,主要关注parseActivity()....
             }else if (tagName.equals("service")) {
                //....省略多行代码,主要关注parseService()....
             }else if (tagName.equals("provider")) {
                //....省略多行代码,主要关注parseProvider()....
             }else if (tagName.equals("activity-alias")) {
                //....省略多行代码,主要关注parseActivityAlias()...
             }else if (parser.getName().equals("meta-data")) {
                //....省略多行代码,重点关注parseMetaData()....
             }else if (tagName.equals("library")) {
                //....省略多行代码....
             }else if (tagName.equals("uses-library")) {
                //....省略多行代码....
             }else if (tagName.equals("uses-package")) {
                //....省略多行代码....
             }else{
                //....省略多行代码....
             }

         }

       return true;      

}

在解析Application下子元素结点时,同样也是通过很多parseXXX()方法来完成的.比如在解析activity结点时是通过parseActivity()来完成的,其余自行查阅代码.

另外你可能已经注意到对receiver的解析也是通过parseActivity()实现的.

到此为止,整个为止,解析的整个流程完成,并返回一个Package对象.

附:PackageParser中所有相关解析方法


由Package对象生成PackageInfo

该过程的目的是从Package中提取相关属性,并封装成PackageInfo类型的对象.

该过程主要涉及到一系列generateXXXInfo()格式的方法,起始方法是generatePackageInfo(),那么我们就从该方法开始分析其流程:

 public static PackageInfo generatePackageInfo(PackageParser.Package p,
            int gids[], int flags, long firstInstallTime, long lastUpdateTime,
            ArraySet<String> grantedPermissions, PackageUserState state) {

        return generatePackageInfo(p, gids, flags, firstInstallTime, lastUpdateTime,
                grantedPermissions, state, UserHandle.getCallingUserId());
    }

不难看出这里由调用了其同名方法generatePackageInfo(PackageParser.Package p,

int gids[], int flags, long firstInstallTime, long lastUpdateTime,

ArraySet<String> grantedPermissions, PackageUserState state, int userId)来进行继续解析工作:

public static PackageInfo generatePackageInfo(PackageParser.Package p,
            int gids[], int flags, long firstInstallTime, long lastUpdateTime,
            ArraySet<String> grantedPermissions, PackageUserState state, int userId)

        if (!checkUseInstalledOrHidden(flags, state)) {
            return null;
        }
        //从Package对象p中取出一系列的属性值用来初始化pi
        PackageInfo pi = new PackageInfo();
        pi.packageName = p.packageName;
        pi.splitNames = p.splitNames;
        pi.versionCode = p.mVersionCode;
        pi.baseRevisionCode = p.baseRevisionCode;
        pi.splitRevisionCodes = p.splitRevisionCodes;
        pi.versionName = p.mVersionName;
        pi.sharedUserId = p.mSharedUserId;
        pi.sharedUserLabel = p.mSharedUserLabel;
        pi.applicationInfo = generateApplicationInfo(p, flags, state, userId);
        pi.installLocation = p.installLocation;
        pi.coreApp = p.coreApp;
        if ((pi.applicationInfo.flags&ApplicationInfo.FLAG_SYSTEM) != 0
                || (pi.applicationInfo.flags&ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0) {
            pi.requiredForAllUsers = p.mRequiredForAllUsers;
        }
        pi.restrictedAccountType = p.mRestrictedAccountType;
        pi.requiredAccountType = p.mRequiredAccountType;
        pi.overlayTarget = p.mOverlayTarget;
        pi.firstInstallTime = firstInstallTime;
        pi.lastUpdateTime = lastUpdateTime;
        if ((flags&PackageManager.GET_GIDS) != 0) {
            pi.gids = gids;
        }

        if ((flags&PackageManager.GET_CONFIGURATIONS) != 0) {
            //....省略多行代码....
        }

        if ((flags&PackageManager.GET_ACTIVITIES) != 0) {
            //....省略多行代码,关注generateActivityInfo()....
        }

        if ((flags&PackageManager.GET_RECEIVERS) != 0) {
           //....省略多行代码,关注generateActivityInfo()....
        }

        if ((flags&PackageManager.GET_SERVICES) != 0) {
            //....省略多行代码,关注generateServiceInfo()....
        }

        if ((flags&PackageManager.GET_PROVIDERS) != 0) {
            //....省略多行代码,关注generateProviderInfo()....
        }

        if ((flags&PackageManager.GET_INSTRUMENTATION) != 0) {
           //....省略多行代码,关注generateInstrumentationInfo()....
        }

        if ((flags&PackageManager.GET_PERMISSIONS) != 0) {
            //....省略多行代码,generatePermissionInfo....
        }

        if ((flags&PackageManager.GET_SIGNATURES) != 0) {
           //....省略多行代码....
        }
        return pi;
    }

上面的过程主要从Package对象取出一系列的属性用来初始化PackageInfo对象,该过程不再涉及磁盘文件的解析操作.

和解析过程相对,该过程借助了很多generateXXXInfo()方法来实现.在解析过程中对于Application元素的解析提供了parseApplication(),而在该过程中也提供了generateApplicationInfo()来实现Application的取值操作

附:PackageParser中所有相关的generate方法

中途小结

到现在为止,我们已经了解Package的生成和PackageInfo生成,不难发现Package的生成是以磁盘APK文件作为输入,而PackageInfo是以Package对象作为输入.得益于Google工程师良好的设计,PackageParse具有非常好的对称性,非常容易理解.在这里,我只是简单的介绍了该类,对于具体的操作并没有深入的说明,其原因在于,其核心就是通过使用Pull Parser对xml文件进行解析的操作.

附:PackageParser所有内部类:

细心的同学已经发现在上面所示的内部类中也存在Activity,Service等类,要注意这些并不是我们平常使用的Activity组件.PackageParser中的内部类,如Activity,Service,Provider,Permission,皆对应于AndroidManifest.xml文件中的某个标签,用于存储解析出来相关的信息.

这里我们同样用类图来简单的描述期间的关系:


相关实体类

接下来,我们来介绍与上述过程相关的几个实体类,以便你有一个宏观的认识,从而为理解后面的PMS打下基础.

对于这几个实体类,我们值做简单的说明,其具体的点还是需要我们自己进行深究.

Package

PackageParser的静态内部类,代表磁盘上APK文件完整解析后的对象,相当于在内存中Package的对象是对磁盘APK的描述.这里我们只需要关注其属性即可,大部分属性对你而来都是很熟悉的:

public final static class Package {

        public String packageName;

        /** Names of any split APKs, ordered by parsed splitName */
        public String[] splitNames;

        //apk文件在磁盘的路径.可能是一个apk的路径,也可能是包含多个apk文件的目录
        public String codePath;

        /** Path of base APK */
        public String baseCodePath;
        /** Paths of any split APKs, ordered by parsed splitName */
        public String[] splitCodePaths;

        /** Revision code of base APK */
        public int baseRevisionCode;
        /** Revision codes of any split APKs, ordered by parsed splitName */
        public int[] splitRevisionCodes;

        /** Flags of any split APKs; ordered by parsed splitName */
        public int[] splitFlags;

        public boolean baseHardwareAccelerated;

        public final ApplicationInfo applicationInfo = new ApplicationInfo();

        //权限
        public final ArrayList<Permission> permissions = new ArrayList<Permission>(0);
        public final ArrayList<PermissionGroup> permissionGroups = new ArrayList<PermissionGroup>(0);

        //四大组件Activity,Receiver,Service,Provider
        public final ArrayList<Activity> activities = new ArrayList<Activity>(0);
        public final ArrayList<Activity> receivers = new ArrayList<Activity>(0);
        public final ArrayList<Provider> providers = new ArrayList<Provider>(0);
        public final ArrayList<Service> services = new ArrayList<Service>(0);

        public final ArrayList<Instrumentation> instrumentation = new ArrayList<Instrumentation>(0);

        public final ArrayList<String> requestedPermissions = new ArrayList<String>();
        public final ArrayList<Boolean> requestedPermissionsRequired = new ArrayList<Boolean>();

        public ArrayList<String> protectedBroadcasts;

        public ArrayList<String> libraryNames = null;
        public ArrayList<String> usesLibraries = null;
        public ArrayList<String> usesOptionalLibraries = null;
        public String[] usesLibraryFiles = null;

        public ArrayList<ActivityIntentInfo> preferredActivityFilters = null;

        public ArrayList<String> mOriginalPackages = null;
        public String mRealPackage = null;
        public ArrayList<String> mAdoptPermissions = null;

        // We store the application meta-data independently to avoid multiple unwanted references
        public Bundle mAppMetaData = null;

        // The version code declared for this package.
        public int mVersionCode;

        // The version name declared for this package.
        public String mVersionName;

        // The shared user id that this package wants to use.
        public String mSharedUserId;

        // The shared user label that this package wants to use.
        public int mSharedUserLabel;

        // Signatures that were read from the package.
        public Signature[] mSignatures;
        public Certificate[][] mCertificates;

        // For use by package manager service for quick lookup of
        // preferred up order.
        public int mPreferredOrder = 0;

        // For use by package manager to keep track of where it needs to do dexopt.
        public final ArraySet<String> mDexOptPerformed = new ArraySet<>(4);

        // For use by package manager to keep track of when a package was last used.
        public long mLastPackageUsageTimeInMills;

        // // User set enabled state.
        // public int mSetEnabled = PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
        //
        // // Whether the package has been stopped.
        // public boolean mSetStopped = false;

        // Additional data supplied by callers.
        public Object mExtras;

        // Whether an operation is currently pending on this package
        public boolean mOperationPending;

        // Applications hardware preferences
        public ArrayList<ConfigurationInfo> configPreferences = null;

        // Applications requested features
        public ArrayList<FeatureInfo> reqFeatures = null;

        // Applications requested feature groups
        public ArrayList<FeatureGroupInfo> featureGroups = null;

        public int installLocation;

        public boolean coreApp;

        /* An app that‘s required for all users and cannot be uninstalled for a user */
        public boolean mRequiredForAllUsers;

        /* The restricted account authenticator type that is used by this application */
        public String mRestrictedAccountType;

        /* The required account type without which this application will not function */
        public String mRequiredAccountType;

        /**
         * 代表一个包文件的摘要,用于确定两个package是否不一致
         */
        public ManifestDigest manifestDigest;

        public String mOverlayTarget;
        public int mOverlayPriority;
        public boolean mTrustedOverlay;

        /**
         * Data used to feed the KeySetManagerService
         */
        public ArraySet<PublicKey> mSigningKeys;
        public ArraySet<String> mUpgradeKeySets;
        public ArrayMap<String, ArraySet<PublicKey>> mKeySetMapping;

        public String cpuAbiOverride;

    }

PackageInfo

该类代表包的整体描述信息,即AndroidManifest.xml中的信息.如果说Package在内存中代表完整的APK描述,那么PackageInfo则是其子集,来简单的看一下其代码:

public class PackageInfo implements Parcelable {
    public String packageName;
    public String[] splitNames;
    public int versionCode;
    public String versionName;
    public int baseRevisionCode;
    public int[] splitRevisionCodes;
    public String sharedUserId;
    public int sharedUserLabel;

    public ApplicationInfo applicationInfo;
    public long firstInstallTime;
    public long lastUpdateTime;
    public int[] gids;
    public ActivityInfo[] activities;
    public ActivityInfo[] receivers;
    public ServiceInfo[] services;
    public ProviderInfo[] providers;
    public InstrumentationInfo[] instrumentation;
    public PermissionInfo[] permissions;
    public String[] requestedPermissions;
    public int[] requestedPermissionsFlags;

    public Signature[] signatures;
    public ConfigurationInfo[] configPreferences;
    public FeatureInfo[] reqFeatures;
    public FeatureGroupInfo[] featureGroups;

}

对比Package和PackageInfo很容易发现期间的关系,接下来顺便介绍PackageInfo中涉及到的实体类:

类名 描述
ActivityInfo 该实体类代表AndroidManiest.xml中的<activity><recevier>元素的信息
ServiceInfo 该实体类代表AndroidManiest.xml中的<service>元素中的信息
ProviderInfo 该实体类代表AndroidManiest.xml中的<provider>元素的信息
InstrumentationInfo 该实体类代表AndroidManiest.xml中的<instrumentation>元素的信息
PermissionInfo 该实体类代表AndroidManiest.xml中的<permission>元素的信息
ConfigurationInfo 关于程序要求的硬件信息,该实体类代表AndroidManiest.xml中<uses-configuration><uses-feature>元素的信息.
FeatureInfo 该实体类代表AndroidManiest.xml中的<uses-feature>元素的信息
FeatureGroupInfo 该实体类代表AndroidManiest.xml中的<feature-group>元素的信息
ManifestDigest 代表一个包文件的摘要信息

这里我们用一张类图来描述其类间的关系:


总结

到现在PackageParser的基本解释已经完成,之所以在分析PMS之前先来谈PackageParser的原因在于,该工具类可以脱离上下文,单独进行理解,而无关你目前的状态,这也就避免我们面对一大堆源码,在阅读过程找不到侧重点的问题.接下来,是对PackageManager的分析.

时间: 2024-11-06 12:11:06

深入分析PMS服务(一)的相关文章

Android系统篇之----Hook系统的AMS服务实现应用启动的拦截功能

技术概念来源:[ 360开源插件框架,项目地址:https://github.com/DroidPluginTeam/DroidPlugin ] 一.Hook系统剪切板服务流程回顾 在之前的一篇文章中已经介绍了 Android中的应用启动流程,这个流程一定要理解透彻,这样我们才可以进行后续的Hook操作,在之前还介绍了Android中如何Hook系统的剪切板服务实现方法的拦截效果,实现原理就是: 1.先找到Hook点,这个一般是分析源码来得到,而一般的Hook点都是静态变量或者是单例方法. 2.

Android中带你开发一款自动爆破签名校验工具kstools

一.技术回顾 为了安全起见,一些应用会利用自身的签名信息对应用做一层防护,为了防止应用被二次打包操作,在之前已经介绍了很多关于应用签名校验爆破的方法,一条基本原则不能忘:全局搜索"signature"字符串,这里可以在Jadx打开apk搜索,也可以在IDA中打开so搜索都可以.找到这信息之后可以手动的修改校验逻辑,但是这个法则有个问题,就是如果一个应用在代码中很多地方都做了签名校验,比如以前介绍的一篇爆破游戏文章:Android中爆破应用签名信息案例分析,那时候就会发现,应用在很多地方

Android应用程序窗体设计框架介绍

在Android系统中,一个Activity相应一个应用程序窗体.不论什么一个Activity的启动都是由AMS服务和应用程序进程相互配合来完毕的.AMS服务统一调度系统中全部进程的Activity启动,而每一个Activity的启动过程则由其所属进程来完毕.AMS服务通过realStartActivityLocked函数来通知应用程序进程启动某个Activity: frameworks\base\services\java\com\android\server\am\ ActivityStac

Android 中带你开发一款自动爆破签名校验工具 kstools

"-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> Android中带你开发一款自动爆破签名校验工具kstools - 生死看淡,不服就干! - 博客频道 - CSDN.NET 生死看淡,不服就干! http://www.wjdiankong.cn 目录视图 摘要视图 订阅 [活动]2017 CSDN博客专栏评选 &n

Activity的启动流程分析

Activity是Android应用程序的四大组件之一,负责管理Android应用程序的用户界面,一般一个应用程序中包含很多个Activity,他们可能运行在一个进程中,也可能运行在不同的进程中. 我们主要通过启动在不同进程中的Activity,来分析Activity的启动流程及AMS对Activity的管理逻辑. 有两个应用程序App1和App2,在App1的Activity A中点击button 启动 App2中的Activity B. 通过分析以上ActivityB的启动过程来了解AMS对

Android系统篇之----免root实现Hook系统服务拦截方法

技术概念来源:[ 360开源插件框架,项目地址:https://github.com/DroidPluginTeam/DroidPlugin ] 一.Binder机制回顾 在之前一篇文章中介绍了 Android中的Binder机制和系统远程服务调用机制,本文将继续借助上一篇的内容来实现Hook系统服务拦截指定方法的逻辑,了解了上一篇文章之后,知道系统的服务其实都是一个远程Binder对象,而这个对象都是由ServiceManager大管家管理的,用户在使用系统服务的时候,会通过指定服务的Stub

Android逆向之旅---爆破应用签名的一种全新高效方式(Native+服务器验证)

一.知识回顾 关于Android中的签名校验是一种很普遍的安全防护策略了,很多应用也都做了这部分的工作,在之前我也介绍了几篇关于如何爆破应用的签名校验问题的文章,不了解的同学可以去查看:Android中爆破应用签名校验功能,当时介绍完这篇文章之后,其实总结了现在爆破签名校验的几种方式,其中最方便快捷的就是:全局搜索字符串内容:"signature",因为只要有签名校验功能,一定会调用系统的一个方法,而这个方法中就是包含了这个字符串内容. 之前的这篇文章中介绍的签名校验处理方式也是如此,

以前编写的inno setup脚本,涵盖了自定义安装界面,调用dll等等应用

原文:以前编写的inno setup脚本,涵盖了自定义安装界面,调用dll等等应用 ; Script generated by the Inno Setup 脚本向导. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! ; 为1的时候表示定义成试用版本 #define VERSION_TYPE ReadIni('Setup.ini', 'SetupType', 'type', '0') #if VER

CSDN日报20170522 ——《从个人习惯到真正的好方法》

"-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> CSDN日报20170522 --<从个人习惯到真正的好方法> - CSDN 官方博客 - 博客频道 - CSDN.NET CSDN 官方博客 欢迎加入博客 QQ 群:631873947,加群请注明 CSDN 博客地址. 目录视图 摘要视图 订阅 [活动]2017