注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

为什么我推荐你用ViewBinding 替换findViewById?

为什么推荐你使用ViewBinding 替换findViewById 和 ButterKnife ? 因为太爽了,太上头了。 用过一次就爱上了,再也不想回去。真心的。不信你看下文! 定义 ViewBinding 是google推出Jetpack库的一个...
继续阅读 »

为什么推荐你使用ViewBinding 替换findViewById 和 ButterKnife ? 因为太爽了,太上头了。 用过一次就爱上了,再也不想回去。真心的。不信你看下文!



定义


ViewBinding 是google推出Jetpack库的一个组件,主要用于视图绑定,替代 findViewById操作.Viewbinding会根据xml文件生成一个对应的绑定类, 比如我们xml文件是: activity_login_layout.xml 生成的绑定类就是ActivityLoginLayoutBinding 这么一个类.在使用的时候直接通过生成的绑定类调用我们xml中的视图组件, 不用findViewById,也不用声明组件. 接下来看下我们集成和项目就能很快的理解.


集成


首先在我们工程build.gradld中引入viewBind


    //引入ViewBinding
viewBinding {
viewBinding = true
}

然后我们就可以在代码中使用ViewBinding了


代码


创建了一个登录页面, activity_login_layout.xml



<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
tools:viewBindingIgnore="false"
android:padding="16dp">


<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_login_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="登录"
android:textColor="@color/design_default_color_primary_variant"
android:textSize="19sp" />


<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_login_account"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:hint="用户名"
android:paddingStart="10dp"
android:paddingEnd="10dp" />


<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_login_pwd"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:hint="密码"
android:paddingStart="10dp"
android:paddingEnd="10dp" />


<Button
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="20dp"
android:background="@color/design_default_color_secondary"
android:text="登录"
android:textSize="20dp" />


<TextView
android:id="@+id/tv_find_pwd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:text="找回密码"
android:textColor="@android:color/holo_red_dark"
android:textSize="16sp" />

</LinearLayout>

大忽悠登录账号.png 接下来我们看下用findViewById方式获取id并且设置点击事件等




class LoginActivity2 : AppCompatActivity(R.layout.activity_login_layout) {
//先声明控件
lateinit var etAccount: EditText
lateinit var etPwd: EditText
lateinit var btnLogin: Button

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initView()
}

private fun initView() {
//挨个findViewById 初始化控件
etAccount = findViewById(R.id.et_login_account)
etPwd = findViewById(R.id.et_login_pwd)
btnLogin = findViewById(R.id.btn_login)
btnLogin.setOnClickListener {
var accountInfo = etAccount.text
var accountPwd = etPwd.text
if (!TextUtils.isEmpty(accountInfo)) {
if (!TextUtils.isEmpty(accountPwd)) {
Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
} else {
Log.e("ping", "请输入密码")
}
} else {
Log.e("ping", "请输入用户名")
}
}
}
}

在看下用ViewBinding的代码



class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginLayoutBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginLayoutBinding.inflate(layoutInflater)
val view = binding.root;
setContentView(view)
initView()
}

private fun initView() {
binding.btnLogin.setOnClickListener {
var accountInfo = binding.etLoginAccount.text
var accountPwd = binding.etLoginPwd.text
if (!TextUtils.isEmpty(accountInfo)) {
if (!TextUtils.isEmpty(accountPwd)) {
Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
} else {
Log.e("ping", "请输入密码")
}
} else {
Log.e("ping", "请输入用户名")
}
}
}
}

是不是很直观, 不需要声明控件,也不需要findViewById 直接用 ActivityLoginLayoutBinding绑定类 . 操作就可以, 是不是很Nice. 这个时候你又说了,这样我不知道那个是那个控件怎么办? 我们来看下ActivityLoginLayoutBinding类的代码:



public final class ActivityLoginLayoutBinding implements ViewBinding {
@NonNull
private final LinearLayout rootView;
@NonNull //android:id="@+id/btn_login"
public final Button btnLogin; //根据我们在xml中定义的id 驼峰命名发声明控件
@NonNull //android:id="@+id/et_login_account"
public final AppCompatEditText etLoginAccount;
@NonNull //android:id="@+id/et_login_pwd"
public final AppCompatEditText etLoginPwd;
@NonNull // android:id="@+id/tv_find_pwd"
public final TextView tvFindPwd;
@NonNull
public final AppCompatTextView tvLoginTitle;

private ActivityLoginLayoutBinding(@NonNull LinearLayout rootView, @NonNull Button btnLogin, @NonNull AppCompatEditText etLoginAccount, @NonNull AppCompatEditText etLoginPwd, @NonNull TextView tvFindPwd, @NonNull AppCompatTextView tvLoginTitle) {
this.rootView = rootView;
this.btnLogin = btnLogin;
this.etLoginAccount = etLoginAccount;
this.etLoginPwd = etLoginPwd;
this.tvFindPwd = tvFindPwd;
this.tvLoginTitle = tvLoginTitle;
}

@NonNull
public LinearLayout getRoot() {
return this.rootView;
}

@NonNull
public static ActivityLoginLayoutBinding inflate(@NonNull LayoutInflater inflater) {
return inflate(inflater, (ViewGroup)null, false);
}

@NonNull
public static ActivityLoginLayoutBinding inflate(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent, boolean attachToParent) {
View root = inflater.inflate(2131427356, parent, false);
if (attachToParent) {
parent.addView(root);
}

return bind(root);
}

@NonNull
public static ActivityLoginLayoutBinding bind(@NonNull View rootView) {
int id = 2131230807;
Button btnLogin = (Button)ViewBindings.findChildViewById(rootView, id);
if (btnLogin != null) {
id = 2131230876;
AppCompatEditText etLoginAccount = (AppCompatEditText)ViewBindings.findChildViewById(rootView, id);
if (etLoginAccount != null) {
id = 2131230877;
AppCompatEditText etLoginPwd = (AppCompatEditText)ViewBindings.findChildViewById(rootView, id);
if (etLoginPwd != null) {
id = 2131231121;
TextView tvFindPwd = (TextView)ViewBindings.findChildViewById(rootView, id);
if (tvFindPwd != null) {
id = 2131231122;
AppCompatTextView tvLoginTitle = (AppCompatTextView)ViewBindings.findChildViewById(rootView, id);
if (tvLoginTitle != null) {
return new ActivityLoginLayoutBinding((LinearLayout)rootView, btnLogin, etLoginAccount, etLoginPwd, tvFindPwd, tvLoginTitle);
}
}
}
}
}

String missingId = rootView.getResources().getResourceName(id);
throw new NullPointerException("Missing required view with ID: ".concat(missingId));
}
}

其实ViewBinding自动为我们声明控件,并且执行fingViewById,我们只需要在用的时候直接用 binding.btnLogin 等等


在Activity中如何视图绑定


Activity中使用视图绑定的话需要在 的 onCreate() 方法中执行以下步骤:



  • 调用生成的绑定类中包含的静态 inflate() 方法。此操作会创建该绑定类的实例以供 Activity 使用。

  • 通过调用 getRoot() 方法或使用 Kotlin 属性语法获取对根视图的引用。

  • 将根视图传递到 setContentView(),使其成为屏幕上的活动视图。


 
private lateinit var binding: ActivityLoginLayoutBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginLayoutBinding.inflate(layoutInflater)
val view = binding.root;
setContentView(view)
}

然后就可以使用视图上任何定义的View了,例如:



binding.btnLogin.setOnClickListener {
var accountInfo = binding.etLoginAccount.text
var accountPwd = binding.etLoginPwd.text
if (!TextUtils.isEmpty(accountInfo)) {
if (!TextUtils.isEmpty(accountPwd)) {
Log.e("ping", "执行登录操作:用户名:$accountInfo 密码: $accountPwd")
} else {
Log.e("ping", "请输入密码")
}
} else {
Log.e("ping", "请输入用户名")
}
}
}

在 Fragment 中使用视图绑定


在Fragment使用视图绑定,首先需要在 onCreateView() 方法中执行以下步骤:



  • 调用生成的绑定类中包含的静态 inflate() 方法。此操作会创建该绑定类的实例以供 Fragment 使用。

  • 通过调用 getRoot() 方法或使用 Kotlin 属性语法获取对根视图的引用。

  • 从 onCreateView() 方法返回根视图,使其成为屏幕上的活动视图。



class LoginFragment : Fragment() {
private lateinit var binding: ActivityLoginLayoutBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
)
: View? {
binding = ActivityLoginLayoutBinding.inflate(inflater, container, false)
return binding.root
}

override fun onDestroyView() {
super.onDestroyView()
null.also { it -> binding = it }
}
}

然后就能愉快的使用了我们视图上定义的view啦


 binding.btnLogin.setOnClickListener {
//执行登录操作
}

总结


与findViewById相比,很明显的优点是,代码量减少,使用更加简单,减少了很多无用的操作. 还有就是



  • Null 安全:由于视图绑定会创建对视图的直接引用,因此不存在因视图 ID 无效而引发 Null 指针异常的风险。此外,如果视图仅出现在布局的某些配置中,则绑定类中包含其引用的字段会使用 @Nullable 标记。

  • 类型安全:每个绑定类中的字段均具有与它们在 XML 文件中引用的视图相匹配的类型。这意味着不存在发生类转换异常的风险。


优点



  • 更快的编译速度:视图绑定不需要处理注释,因此编译时间更短

  • 易于使用:视图绑定不需要特别标记的 XML 布局文件,因此在应用中采用速度更快。在模块中启用视图绑定后,它会自动应用于该模块的所有布局。


缺点



  • 视图绑定不支持布局变量或布局表达式,因此不能用于直接在 XML 布局文件中声明动态界面内容。

  • 视图绑定不支持双向数据绑定。


以上就是ViewBinding的全部内容啦, 如果觉得不错,不妨点个赞.谢谢.



作者:丁大忽悠
链接:https://juejin.cn/post/6981471420769370126
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


收起阅读 »

安卓进阶二: 这次我把ARouter源码搞清楚啦!

四. ARouter 注解处理器:arouter-compilerARouter 生成路由信息代码利用了注解处理器的特性。 arouter-compiler 就是注解处理代码模块,先看看该模块的依赖库//定义的注解类,以及相关数据实体类 i...
继续阅读 »

四. ARouter 注解处理器:arouter-compiler

ARouter 生成路由信息代码利用了注解处理器的特性。 arouter-compiler 就是注解处理代码模块,先看看该模块的依赖库

//定义的注解类,以及相关数据实体类
implementation 'com.alibaba:arouter-annotation:1.0.6'

annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7'
compileOnly 'com.google.auto.service:auto-service-annotations:1.0-rc7'

implementation 'com.squareup:javapoet:1.8.0'

implementation 'org.apache.commons:commons-lang3:3.5'
implementation 'org.apache.commons:commons-collections4:4.1'

implementation 'com.alibaba:fastjson:1.2.69'

依赖库中注解处理相关依赖库说明:

  • Auto-service官方文档 针对被@AutoService注解的类,生成对应元数据,在javac 编译的时候,会自动加载,并放在注释处理环境中。
  • javapoet :square推出的开源java代码生成框架,我们可以很方便的使用它根据注解、数据库模式、协议格式等来对应生成代码。
  • arouter-annotation :arouter 的注解类们和路由信息实体类们
  • 其他,工具类库

RouteProcessor注解处理器处理流程说明

我们先看看路由处理器 RouteProcessor

@AutoService(Processor.class)
@SupportedAnnotationTypes({ANNOTATION_TYPE_ROUTE, ANNOTATION_TYPE_AUTOWIRED})
public class RouteProcessor extends BaseProcessor {
@Override
//在该方法中可以获取到processingEnvironment对象,
//借由该对象可以获取到生成代码的文件对象, debug输出对象,以及一些相关工具类
public synchronized void init(ProcessingEnvironment processingEnv) {
//...
super.init(processingEnv);
}
@Override
//返回所支持的java版本,一般返回当前所支持的最新java版本即可
public SourceVersion getSupportedSourceVersion() {
//...
return super.getSupportedSourceVersion();
}

@Override
//必须实现 扫描所有被注解的元素,并作处理,最后生成文件。该方法的返回值为boolean类型,若返回true,
//则代表本次处理的注解已经都被处理,不希望下一个注解处理器继续处理,
//否则下一个注解处理器会继续处理。
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
//...
return false;
}

}

可以看到处理注解主要是在process方法。 RouteProcessor 继承 BaseProcessor 间接继承了AbstractProcessor,在BaseProcessor#init 方法中,获取到processingEnv 中的各种实用工具,以供处理注解使用。 值得一提的是,init 中获取了moduleName 和 generateDoc 参数代码如下:

if (MapUtils.isNotEmpty(options)) {
///AROUTER_MODULE_NAME
moduleName = options.get(KEY_MODULE_NAME);
///AROUTER_GENERATE_DOC
generateDoc = VALUE_ENABLE.equals(options.get(KEY_GENERATE_DOC_NAME));
}

这一块就是我们常常需要在gradle中配置的arguments 的由来:

android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments = [AROUTER_MODULE_NAME: project.getName(), AROUTER_GENERATE_DOC: "enable"]
}
}
}
}
//或者kotlin
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}

接下来看RouteProcessor#process方法的具体实现:

Set<? extends Element> routeElements = roundEnv.getElementsAnnotatedWith(Route.class);
this.parseRoutes(routeElements);

代码中拿到了所有标注@Route注解的相关类元素。 然后在parseRoutes方法中进行处理: 省略了一大把代码后,代码还是很长,可以直接到下面看结论:

private void parseRoutes(Set<? extends Element> routeElements) throws IOException {
if (CollectionUtils.isNotEmpty(routeElements)) {
// prepare the type an so on.
logger.info(">>> Found routes, size is " + routeElements.size() + " <<<");

rootMap.clear();
///省略类型获取代码

/*...省略构建'loadInto'方法描述,通过定义变量名,
定义类型最后得出 MethodSpec.Builder
void loadInto(Map<String, Class<? extends IRouteGroup>> atlas);
*/

MethodSpec.Builder loadIntoMethodOfRootBuilder;

// Follow a sequence, find out metas of group first, generate java file, then statistics them as root.
for (Element element : routeElements) {
//..省略相关代码,根据element类型,创建出对应的RouteMate实例,得到路由信息,
//并且通过injectParamCollector 方法将Activity和Fragmentr内部的所有@AutoWired
//注解 的信息放到MetaData的 paramsType 和injectConfig 中
v
//对 routeMate进行分类,在groupMap中填充对应数据
categories(routeMeta);
}

/*...省略构建'loadInto'方法描述,通过定义变量名,
定义类型最后得出 MethodSpec.Builder,主要用来构建providers索引。
void loadInto(Map<String, RouteMeta> providers);
*/

MethodSpec.Builder loadIntoMethodOfProviderBuilder;

Map<String, List<RouteDoc>> docSource = new HashMap<>();

//...
if (MapUtils.isNotEmpty(rootMap)) {
// Generate root meta by group name, it must be generated before root, then I can find out the class of group.
for (Map.Entry<String, String> entry : rootMap.entrySet()) {
loadIntoMethodOfRootBuilder.addStatement("routes.put($S, $T.class)", entry.getKey(), ClassName.get(PACKAGE_OF_GENERATE_FILE, entry.getValue()));
}
}

// 2.Output route doc 写入json到doc文档中
if (generateDoc) {
docWriter.append(JSON.toJSONString(docSource, SerializerFeature.PrettyFormat));
docWriter.flush();
docWriter.close();
}

// Write provider into disk
//3.生成对应的IProviderGroup 类代码文件 ARouter$$Providers$$[moduleName]
String providerMapFileName = NAME_OF_PROVIDER + SEPARATOR + moduleName;
JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
TypeSpec.classBuilder(providerMapFileName)
.addJavadoc(WARNING_TIPS)
.addSuperinterface(ClassName.get(type_IProviderGroup))
.addModifiers(PUBLIC)
.addMethod(loadIntoMethodOfProviderBuilder.build())
.build()
).build().writeTo(mFiler);

logger.info(">>> Generated provider map, name is " + providerMapFileName + " <<<");

// Write root meta into disk.
//4. 生成对应的IRouteRoot 类代码文件 ARouter$$Root$$[moduleName]
String rootFileName = NAME_OF_ROOT + SEPARATOR + moduleName;
JavaFile.builder(PACKAGE_OF_GENERATE_FILE,
TypeSpec.classBuilder(rootFileName)
.addJavadoc(WARNING_TIPS)
.addSuperinterface(ClassName.get(elementUtils.getTypeElement(ITROUTE_ROOT)))
.addModifiers(PUBLIC)
.addMethod(loadIntoMethodOfRootBuilder.build())
.build()
).build().writeTo(mFiler);

logger.info(">>> Generated root, name is " + rootFileName + " <<<");
}
}

代码很长,关键结果就是三点,

  1. com.alibaba.android.arouter.routes 包名下生成ARouter$$Group$$[GroupName] 类,包含路由组的所有路由信息。
  2. 在该包名下生成ARouter$$Root$$[moduleName]类,包含所有组的信息。
  3. 在该包名下生成ARouter$$Providers$$[moduleName] 类,包含所有Providers索引
  4. 在docs下,生成文件名为 "arouter-map-" + moduleName + ".json" 的文档。

其他注解处理器说明

剩下还有两个注解处理器 InterceptorProcessor 和 AutowiredProcessor。 生成代码逻辑大同小异,只是逻辑复杂度的区别,

  • AutowiredProcessor :处理@Autowired注解的参数,以参数所在对应的类分类,生成[classSimpleName]$$ARouter$$Autowired 代码文件,以在Activity或者Fragment跳转的时候自动从intent中获取数据,并对activity 和 fragment 对象赋值。
  • InterceptorProcessor: 处理@Interceptor注解。生成对应的ARouter$$Interceptors$$[modulename]代码文件,提供拦截器功能。

值得一提的是,对于自定义类型的@AutoWiredARouter提供了 SerializationService进行自定义,用户只需要实现该解析类就行。

小结

这个模块完成了之前ARouter初始化所需要的所有代码的生成。 ARouter 源码和源码的分析到这里,已经成功走到了闭环,主要功能都已经清楚了。 之前没有写过AnotationProcessor相关的代码生成库。这次算是学习到了整个注解处理代码生成框架的使用方式。也了解了ARouter 代码生成的原理和方式。 业余时间自己也尝试写一个简单的代码生成功能试试看吧。下面一小节,再看看ARouter初始化注册的可选方案,arouter-register的源码。

五. ARouter 自动注册插件:arouter-register

代码在arouter-gradle-plugin 文件夹下面,

刚开始查看这个模块的源码,部分代码老是飘红,找不到部分类,于是我修改了该模块build.gradle 中的gradle依赖版本号。从2.1.3 改成了 4.1.3。代码果然就正常了。 gradle插件调试可以更好地理解代码,参考网上的博客启动插件调试。

注册转换器

ARouter-register 插件通过 registerTransform api。添加了一个自定义Transform,对dex进行自定义处理。 直接看 该源码中的入口代码 PluginLaunch#apply

def isApp = project.plugins.hasPlugin(AppPlugin)
//only application module needs this plugin to generate register code
if (isApp) {
def android = project.extensions.getByType(AppExtension)
def transformImpl = new RegisterTransform(project)
android.registerTransform(transformImpl)
}

代码中调用了AppExtension.registerTransform方法注册了 RegisterTransform。查阅api文档可知,该方法的功能是:允许第三方方插件在将编译的类文件转换为 dex 文件之前对其进行操作。 那就知道了,该方法就是类文件转换中间的一道工序。

扫描class文件和jar文件,保存路由类信息

那工序做了什么呢?看看代码RegisterTransform#transform

@Override
void transform(Context context, Collection<TransformInput> inputs
, Collection<TransformInput> referencedInputs
, TransformOutputProvider outputProvider
, boolean isIncremental) throws IOException, TransformException, InterruptedException {

Logger.i('Start scan register info in jar file.')

long startTime = System.currentTimeMillis()
boolean leftSlash = File.separator == '/'

inputs.each { TransformInput input ->

//通过AMS 的 ClassVisistor 扫描所有的jar 文件,将所有扫描到的IRouteRoot IInterceptorGroup IInterceptorGroup类
//都加到ScanSetting 的 classList中
//详情可以看看 ScanClassVisitor
//如果jar包是 LogisticsCenter.class,标记该类文件到 fileContainsInitClass

input.jarInputs.each { JarInput jarInput ->
//排除对于support库,以及m2repository 内第三方库的扫描。scan jar file to find classes
if (ScanUtil.shouldProcessPreDexJar(src.absolutePath)) {
//扫描
ScanUtil.scanJar(src, dest)
}
//..省略重命名扫描过的jar包相关代码
}
// scan class files
//..省略扫描class文件相关代码,方式类似扫描jar包
}

Logger.i('Scan finish, current cost time ' + (System.currentTimeMillis() - startTime) + "ms")
//如果存在 LogisticsCenter.class 类文件
//插入注册代码到 LogisticsCenter.class 中
if (fileContainsInitClass) {
registerList.each { ext ->
//...省略一些判空和日志代码
///插入初始化代码
RegisterCodeGenerator.insertInitCodeTo(ext)
}
}
Logger.i("Generate code finish, current cost time: " + (System.currentTimeMillis() - startTime) + "ms")
}

从代码中可知,这一块代码有四个关键点。

  1. 通过ASM扫描了对应的jar 文件和class文件,并将扫描到的对应routes包下的类加入到ScanSetting 的classList属性 中
  2. 如果扫描到包含LogisticsCenter.class 类文件,将该文件记录到fileContainsInitClass 字段中。
  3. 扫描完成的文件重命名。
  4. 最后通过RegisterCodeGenerator.``*insertInitCodeTo*``(ext) 方法插入初始化代码到LogisticsCenter.class中。

明白了扫描流程,我们再看看代码是怎么插入的.

遍历包含入口class的jar文件,准备插入代码

RegisterCodeGenerator.``*insertInitCodeTo*``(ext)代码中,先判断ScanSetting#classList是否为空,再判断文件是否是jar文件。如果判断都过了,最后走到 RegisterCodeGenerator#insertInitCodeIntoJarFile代码:

private File insertInitCodeIntoJarFile(File jarFile) {
//将包含 LogisticsCenter.class 的 jar文件,插入初始化代码
//操作在 ***.jar.opt 临时文件做
if (jarFile) {
def optJar = new File(jarFile.getParent(), jarFile.name + ".opt")
if (optJar.exists())
optJar.delete()
///通过JarFile 和JarEntry
def file = new JarFile(jarFile)
Enumeration enumeration = file.entries()
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar))
//遍历jar中的所有class,查询修改
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement()
String entryName = jarEntry.getName()
ZipEntry zipEntry = new ZipEntry(entryName)
InputStream inputStream = file.getInputStream(jarEntry)
jarOutputStream.putNextEntry(zipEntry)
///如果是LogisticsCenter.class文件,调用referHackWhenInit 插入代码
///如果不是,不改变数据直接写入
if (ScanSetting.GENERATE_TO_CLASS_FILE_NAME == entryName) {
Logger.i('Insert init code to class >> ' + entryName)
//!!!!重点代码,插入初始化代码
def bytes = referHackWhenInit(inputStream)
jarOutputStream.write(bytes)
} else {
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
inputStream.close()
jarOutputStream.closeEntry()
}
jarOutputStream.close()
file.close()

if (jarFile.exists()) {
jarFile.delete()
}
optJar.renameTo(jarFile)
}
return jarFile
}

从代码中可知,按步骤梳理:

  1. 创建临时文件,***.jar.opt
  2. 通过输入输出流,遍历jar文件下面的所有class,判断是否LogisticCenter.class
  3. LogisticCenter.class 调用 referHackWhenInit 方法插入初始化代码,写入到opt临时文件
  4. 对其他class 原封不动写入opt临时文件
  5. 删除原来的jar文件,将临时文件改名为原来的jar文件名

这一步完成了对于jar文件的修改。插入了ARouter的自动注册初始化代码。

插入初始化代码

插入操作主要是找到 LogisticCenter 关键的插入代码在于RegisterCodeGenerator#referHackWhenInit

private byte[] referHackWhenInit(InputStream inputStream) {
ClassReader cr = new ClassReader(inputStream)
ClassWriter cw = new ClassWriter(cr, 0)
ClassVisitor cv = new MyClassVisitor(Opcodes.ASM5, cw)
cr.accept(cv, ClassReader.EXPAND_FRAMES)
return cw.toByteArray()
}

可以看到代码中利用了ams 框架的 ClassVisitor 来访问入口类。 再看MyClassVisistor 的visitMethod 实现:

@Override
MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions)
//generate code into this method
//针对loadRouterMap 方法进行处理
if (name == ScanSetting.GENERATE_TO_METHOD_NAME) {
mv = new RouteMethodVisitor(Opcodes.ASM5, mv)
}
return mv
}

可以看到,当asm访问的方法名为loadRouterMap时候,就通过RouteMethodVisitor 对齐进行操作,具体代码如下:

class RouteMethodVisitor extends MethodVisitor {
RouteMethodVisitor(int api, MethodVisitor mv) {
super(api, mv)
}
@Override
void visitInsn(int opcode) {
//generate code before return
if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)) {
extension.classList.each { name ->
name = name.replaceAll("/", ".")
mv.visitLdcInsn(name)//类名
// generate invoke register method into LogisticsCenter.loadRouterMap()
mv.visitMethodInsn(Opcodes.INVOKESTATIC
, ScanSetting.GENERATE_TO_CLASS_NAME
, ScanSetting.REGISTER_METHOD_NAME
, "(Ljava/lang/String;)V"
, false)
}
}
super.visitInsn(opcode)
}
@Override
void visitMaxs(int maxStack, int maxLocals) {
super.visitMaxs(maxStack + 4, maxLocals)
}
}

代码中涉及到asm MethodVisistor的不少api,我查询,简单了解下,博客链接在此 解释一下用的的几个方法

  • visitLdcInsn:访问ldc指令,向栈中压入参数
  • visitMethodInsn:调用方法的指令,上面代码中,用来调用LogisticsCenter.register(String className)方法
  • visitMaxs: 用以确定类方法在执行时候的堆栈大小。

小结

对这里我们就十分清晰了插入初始化代码的路径。

  1. 首先是扫描所有的jarclass,找到对应的routes包名的类文件和 包含 LogisticsCenter.class 类的jar文件。类文件名依据类别存放在ScanSetting中。
  2. 找到LogisticsCenter.class ,对他进行字节码操作,插入初始化代码。

整个register插件的流程就完成了

六. ARouter idea 插件:arouter helper

该插件源码在 arouter-idea-plugin 文件夹下面

刚开始的时候编译老不成功,于是我修改了源码模块中 id "org.jetbrains.intellij" 插件的版本号,从0.3.12 改成了 0.7.3,果然就可以成功运行编译了。命令./gradlew :arouter-idea-plugin:buildPlugin 可以编译插件。

插件效果

先看看这个用法效果。 安装很简单,只需要在插件市场搜索ARouter Helper 安装就行。 在安装了该插件之后,相关ARouter.build()路由的java代码那一行,行号右侧会出现一个定位图标,如下图所示。 img 点击定位图标,就能自动跳转到路由的定义类。 img

看完效果,我们直接看源码。 插件模块代码就一个类 com.alibaba.android.arouter.idea.extensions.NavigationLineMarker

判断是否是ARouter.build

NavigationLineMarker` 继承了`LineMarkerProviderDescriptor`,实现了`GutterIconNavigationHandler<PsiElement>
  • LineMarkerProviderDescriptor:将小图标(16x16 或更小)显示为行标记。也就是该插件识别到navigation方法之后,显示在行号右侧的标准图标。
  • GutterIconNavigationHandler 图标点击处理器,处理图标点击事件。

看看行图标的获取代码:

override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
return if (isNavigationCall(element)) {
LineMarkerInfo<PsiElement>(element, element.textRange, navigationOnIcon,
Pass.UPDATE_ALL, null, this,
GutterIconRenderer.Alignment.LEFT)
} else {
null
}
}
  1. 先是过isNavigationCall判断是否是ARouter.build() 方法。
  2. 然后配置LineMarkerInfo ,将this 配置为点击处理者

所以我们先看isNavigationCall

private fun isNavigationCall(psiElement: PsiElement): Boolean {
if (psiElement is PsiCallExpression) {
///resolveMethod:解析对被调用方法的引用并返回该方法。如果解析失败,则为 null。
val method = psiElement.resolveMethod() ?: return false
val parent = method.parent

if (method.name == "build" && parent is PsiClass) {
if (isClassOfARouter(parent)) {
return true
}
}
}
return false
}

该方法判断是否调用的是ARouter.build方法,如果是就返回true。展示行标记图标。

点击定位图标跳转源码

接下来再看点击图标的相关跳转 navigate方法:

override fun navigate(e: MouseEvent?, psiElement: PsiElement?) {
if (psiElement is PsiMethodCallExpression) {
///build方法参数列表
val psiExpressionList = (psiElement as PsiMethodCallExpressionImpl).argumentList
if (psiExpressionList.expressions.size == 1) {
// Support `build(path)` only now.
///搜索所有带 @Route 注解的类,匹配注解的path路径有没有包含路径参数,包含的话就跳转
val targetPath = psiExpressionList.expressions[0].text.replace("\"", "")
val fullScope = GlobalSearchScope.allScope(psiElement.project)
val routeAnnotationWrapper = AnnotatedMembersSearch.search(getAnnotationWrapper(psiElement, fullScope)
?: return, fullScope).findAll()
val target = routeAnnotationWrapper.find {
it.modifierList?.annotations?.map { it.findAttributeValue("path")?.text?.replace("\"", "") }?.contains(targetPath)
?: false
}

if (null != target) {
// Redirect to target.
NavigationItem::class.java.cast(target).navigate(true)
return
}
}
}

notifyNotFound()
}
  1. 获取build方法的参数,作为目标路径
  2. 搜索所有带 @Route 注解的类,匹配注解的path路径有没有包含目标路径参数
  3. 找到的目标文件直接跳转 NavigationItem::class.``*java*``.cast(target).navigate(true)
收起阅读 »

安卓进阶二: 这次我把ARouter源码搞清楚啦!

随着面试和工作中多次遇到ARouter的使用问题,我决定把ARouter的源码从头到尾理一遍。 让我瞧瞧你到底有几斤几两,为啥大家在项目组件化中都用你做路由框架。前言在开发一个项目的时候,我们总是希望架构出的代码能够自由复用,**自由组装。**实现业务模块的范...
继续阅读 »

随着面试和工作中多次遇到ARouter的使用问题,我决定把ARouter的源码从头到尾理一遍。 让我瞧瞧你到底有几斤几两,为啥大家在项目组件化中都用你做路由框架。

前言

在开发一个项目的时候,我们总是希望架构出的代码能够自由复用,**自由组装。**实现业务模块的范围的单一职责。并且抽离出各种各样可重复使用的组件。 而在组件化过程中,路由是个绕不过去的坎。 当模块可以自由拼装拆除的时候,类的强引用方式变得不可取。因为有些类很可能在编译期间就找不到了。所以就需要有种方式能通直接过序列化的字符串来拉起对应的功能或者页面。也就是通常的路由功能。 ARouter也是接受度比较高的一个开源路由方案。 于是我写了这篇文章,对ARouter的源码原理进行一个全面的分析梳理。 看完文章过后,将能学习到Arouter 的使用原理,注解处理器的开发方式,gradle插件如何对于和class文件转dex进行中间处理。

名词介绍

apt:APT(Annotation Processing Tool)即注解处理器,是一种处理注解的工具,确切的说它是javac的一个工具,它用来在编译时扫描和处理注解。注解处理器以Java代码(或者编译过的字节码)作为输入,生成**.java文件**作为输出。ARouter中通过处理注解生成相关路由信息类 asm:ASM 库是一款基于 Java 字节码层面的代码分析和修改工具。ASM 可以直接生产二进制的 class 文件,也可以在类被加载入 JVM 之前动态修改类行为。在ARouter中用于arouter_register插件插入初始化代码。官方链接

目录

  1. 项目模块结构
  2. ARouter路由使用分析
  3. ARouter初始化分析
  4. ARouter注解处理代码生成:arouter-compiler
  5. ARouter自动注册插件:arouter-register
  6. ARouter idea 插件:arouter helper
  7. 自动代码生成
  8. gradle插件

一. 项目模块结构

官方仓库 我们克隆github的ARouter源码,打开项目就是如下的项目结构图。

模块说明
app示例app模块
module-javajava示例组件模块
module-java-exportjava实例模块的服务数据模块,定义了一个示例的IProvider 服务接口和一些实体类
module-kotlinkotlin示例组件模块
arouter-annotation注解类以及相关数据实体类
arouter-api主要api模块,提供ARouter类等路由方法
arouter-compiler处理注解,生成相关代码
arouter-gradle-plugingradle插件,jar包中添加自动注册代码,减少扫描dex文件开销
arouter-idea-pluginidea插件,
重点类简介
  • ARouter :api入口类
  • LogisticsCenter :路由逻辑中心维护所有路由图谱
  • Warehouse :保存所有路由映射,通过他找到所以字符串对应的路由信息。这些信息都是从解析注解标记自动生成。//todo 可能不正确
  • RouteType :路由类型,现在有: *ACTIVITY,SERVICE,PROVIDER,CONTENT_PROVIDER,BOARDCAST,METHOD,FRAGMENT,UNKNOWN*
  • RouteMeta:路由信息类,包含路由类型,路由目标类class,路由组group名等。

二. ARouter 路由使用分析

ARouter的接入和使用参考官方说明就可以了。

接下来从常用Activity 跳转入手来了解路由导航处理

从最常用的api入手,我们就能知道ARouter 最主要的运转原理,了解他是怎么支撑实现跳转这个我们最常用的功能的。 跳转Activity代码如下:

ARouter.getInstance().build("/test/activity").navigation();

这一句代码就完成了activity跳转

要点步骤如下:

  1. 通过PathReplaceService 预处理路径,并从path:"/test/activity" 中抽取出 group: "test"
  2. path 和group 作为参数创建 Postcard 实例
  3. 调用 postcard#navigation ,最终导航到_ARouter#navigation
  4. 通过 group 和 path 从Warehouse.``*routes*获取具体路径信息RouteMeta,完善postcard。

详细说明

前面提到的一些用户自定义的 Service

第一步抽取 group

而不管是跳转Activity,获取Fragment还是获取Provider。 ARouter.getInstance().build("/test/activity")是 ARouter最核心的路由api。而这个build出来的,是Postcard类。 我们先看build代码的执行路径:

protected Postcard build(String path) {
if (TextUtils.isEmpty(path)) {
throw new HandlerException(Consts.TAG + "Parameter is invalid!");
} else {
/// 用户自定义路径处理类。默认为空。 ARouter.getInstance().navigation 直接获取Provider后文分析
PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
if (null != pService) {
path = pService.forString(path);
}
///获取path中包含的 group 作为参数二
return build(path, extractGroup(path), true);
}
}

代码中可以看到,其中通过ARouter.``*getInstance*``().navigation(PathReplaceService.class)获取路径替换类,对路径进行了预处理操作(默认没有自定义实现类)。通过extractGroup方法从 path中获取了 group信息。

private String extractGroup(String path) {
if (TextUtils.isEmpty(path) || !path.startsWith("/")) {
throw new HandlerException(Consts.TAG + "Extract the default group failed, the path must be start with '/' and contain more than 2 '/'!");
}

try {
String defaultGroup = path.substring(1, path.indexOf("/", 1));
if (TextUtils.isEmpty(defaultGroup)) {
throw new HandlerException(Consts.TAG + "Extract the default group failed! There's nothing between 2 '/'!");
} else {
return defaultGroup;
}
} catch (Exception e) {
logger.warning(Consts.TAG, "Failed to extract default group! " + e.getMessage());
return null;
}
}

extractGroup 源码可知,抽取group的时候对路由路径 "/test/activity" 做了校验:

  1. 一定要"/" 开头
  2. 至少要有两个"/"
  3. 第一个反斜杠后面的就是group

所以path路径一定要是类似 的格式,或者多来几个"/"。

第二步:创建Postcard实例

很简单,直接new出来了

return new Postcard(path, group);

第三步:调用_ARouter#navigation

这块代码是路由的安卓核心跳转代码 很长一大串:

protected Object navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
///1.自定义预处理代码
PretreatmentService pretreatmentService = ARouter.getInstance().navigation(PretreatmentService.class);
if (null != pretreatmentService && !pretreatmentService.onPretreatment(context, postcard)) {
// 预处理拦截了 返回
return null;
}

// 设置context
postcard.setContext(null == context ? mContext : context);

try {
///2.通过路由信息,找到对应的路由信息 RouteMeta ,根据路由类型 RouteType
///完善posrcard
LogisticsCenter.completion(postcard);
} catch (NoRouteFoundException ex) {
///... 省略异常日志和弹窗展示。以及相关回调方法
///值得一提的是走了 DegradeService 的自定义丢失回调
}

if (null != callback) {
callback.onFound(postcard);
}

///3.如果不是绿色通道,需要走拦截器:InterceptorServiceImpl
if (!postcard.isGreenChannel()) { // It must be run in async thread, maybe interceptor cost too mush time made ANR.
interceptorService.doInterceptions(postcard, new InterceptorCallback() {
@Override
public void onContinue(Postcard postcard) {
///4.继续导航方法
_navigation(postcard, requestCode, callback);
}
@Override
public void onInterrupt(Throwable exception) {
///省略拦截后的一些代码
}
});
} else {
///4.继续导航方法
return _navigation(postcard, requestCode, callback);
}

return null;
}

简单总结一下主要代码步骤:

  1. 如有自定义预处理导航逻辑,执行并检查拦截
  2. 通过path路径找到对应的routemeta路由信息,用该信息完善postcard对象(LogisticsCenter.completion方法中完成,细节后文分析)
  3. 如果不是绿色通道,需要走拦截器:InterceptorServiceImpl 。该拦截器服务类中完成拦截器一一执行。(2的源码细节可知,PROVIDERFRAGMENT类型是绿色通道)
  4. 继续导航方法,调用_navigation。

看代码:

private Object _navigation(final Postcard postcard, final int requestCode, final NavigationCallback callback) {
final Context currentContext = postcard.getContext();

switch (postcard.getType()) {
case ACTIVITY:
// Build intent
final Intent intent = new Intent(currentContext, postcard.getDestination());
//...省略完善intent代码
// Navigation in main looper.
runInMainThread(new Runnable() {
@Override
public void run() {
startActivity(requestCode, currentContext, intent, postcard, callback);
}
});

break;
case PROVIDER:
return postcard.getProvider();
case BOARDCAST:
case CONTENT_PROVIDER:
case FRAGMENT:
Class<?> fragmentMeta = postcard.getDestination();
try {
Object instance = fragmentMeta.getConstructor().newInstance();
if (instance instanceof Fragment) {
((Fragment) instance).setArguments(postcard.getExtras());
} else if (instance instanceof android.support.v4.app.Fragment) {
((android.support.v4.app.Fragment) instance).setArguments(postcard.getExtras());
}

return instance;
} catch (Exception ex) {
logger.error(Consts.TAG, "Fetch fragment instance error, " + TextUtils.formatStackTrace(ex.getStackTrace()));
}
case METHOD:
case SERVICE:
default:
return null;
}

return null;
}

很明显,代码中注意对各种类型的路由做了处理。

  • *ACTIVITY:*新建Intent ,通过postcard信息,完善intentcontext.startActivity 或者 context.startActivityForResult
  • PROVIDER:postcard.getProvider() 获取provider实例(实例化代码在LogisticsCenter.completion
  • FRAGMENT,BOARDCAST,CONTENT_PROVIDER:routeMeta.getConstructor().newInstance() 通过路由信息实例化出实例,如果是Fragment的话,则另外再设置extras信息。
  • METHOD,SERVICE:返回空,啥也不做。说明该类型路由调用*navigation*没啥意义。

看到这里,对于Activity 的路由跳转就很直观了,就是调用了startActivity 或者 startActivityForResult 方法,其他provider fragment等实例的获取也十分得清晰明了了,接下来讲讲上面提到的补全postcard关键代码。

关键代码:LogisticsCenter.completion 分析

完善postcard信息代码是通过LogisticsCenter.completion 方法完成的。现在来梳理一下这一块代码:

/**
* 通过RouteMate 完善 postcard
* @param postcard Incomplete postcard, should complete by this method.
*/

public synchronized static void completion(Postcard postcard) {
//省略空判断
RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
if (null == routeMeta) {
// 如果路由的组group没有找到,直接抛异常
if (!Warehouse.groupsIndex.containsKey(postcard.getGroup())) {
throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
} else {
//...省略一些日志代码
// 1.动态添加组元素(从groupsIndex 中找到对应 IRouteGroup的生成类,再对组元素进行加载)
addRouteGroupDynamic(postcard.getGroup(), null);
completion(postcard); // Reload
}
} else {
postcard.setDestination(routeMeta.getDestination());
postcard.setType(routeMeta.getType());
postcard.setPriority(routeMeta.getPriority());
postcard.setExtra(routeMeta.getExtra());

Uri rawUri = postcard.getUri();
///2.如果有uri 信息,解析uri相关参数。解析出AutoWired的参数的值
if (null != rawUri) { // Try to set params into bundle.
Map<String, String> resultMap = TextUtils.splitQueryParameters(rawUri);
Map<String, Integer> paramsType = routeMeta.getParamsType();

if (MapUtils.isNotEmpty(paramsType)) {
// Set value by its type, just for params which annotation by @Param
for (Map.Entry<String, Integer> params : paramsType.entrySet()) {
setValue(postcard,
params.getValue(),
params.getKey(),
resultMap.get(params.getKey()));
}
// Save params name which need auto inject.
postcard.getExtras().putStringArray(ARouter.AUTO_INJECT, paramsType.keySet().toArray(new String[]{}));
}
// Save raw uri
postcard.withString(ARouter.RAW_URI, rawUri.toString());
}
///3.获取provider实例,如果初始获取,初始化该provider, 最后赋值给postcard
switch (routeMeta.getType()) {
case PROVIDER: // if the route is provider, should find its instance
// Its provider, so it must implement IProvider
Class<? extends IProvider> providerMeta = (Class<? extends IProvider>) routeMeta.getDestination();
IProvider instance = Warehouse.providers.get(providerMeta);
if (null == instance) { // There's no instance of this provider
IProvider provider;
try {
provider = providerMeta.getConstructor().newInstance();
provider.init(mContext);
Warehouse.providers.put(providerMeta, provider);
instance = provider;
} catch (Exception e) {
logger.error(TAG, "Init provider failed!", e);
throw new HandlerException("Init provider failed!");
}
}
postcard.setProvider(instance);
postcard.greenChannel(); // Provider should skip all of interceptors
break;
case FRAGMENT:
postcard.greenChannel(); // Fragment needn't interceptors
default:
break;
}
}
}

梳理一下这一块的代码,这一部分代码完善了postcard信息,总共分成了三个要点

  1. **获取路由信息:**如果路由信息找不到,通过组信息,重新动态添加组group内所有路由 ,调用*addRouteGroupDynamic* 。
  2. **获取uri内的参数:**如果postcard创建的时候有传递uri。解析uri里面所有需要AutoInject的参数。放置到postcard中。
  3. **获取Provider实例,配置是否不走拦截器的绿色通道:**不存在的Provider通过路由信息的 getDestination 反射创建实例并初始化,存在的直接获取。

分析到这里。各种RouteType的跳转,实例获取都已经明了了。 现在剩下的问题是,WareHouse里面的路由信息数据是哪里来的?前面提到了动态添加组内路由的方法*addRouteGroupDynamic*。我们来看看:

public synchronized static void addRouteGroupDynamic(String groupName, IRouteGroup group) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
if (Warehouse.groupsIndex.containsKey(groupName)){
// If this group is included, but it has not been loaded
// load this group first, because dynamic route has high priority.
Warehouse.groupsIndex.get(groupName).getConstructor().newInstance().loadInto(Warehouse.routes);
Warehouse.groupsIndex.remove(groupName);
}

// cover old group.
if (null != group) {
group.loadInto(Warehouse.routes);
}
}

可以看到Warehouse.routes 里面的所有路由信息,都是从 IRouteGroup.loadInto 加载出来的。而IRouteGroup 都存在Warehouse.``*groupsIndex* 中。 这时候新的问题出现了,Warehouse.``*groupsIndex*的数据是哪里来的呢? 下一节 ARouter 初始化分析就有答案。

tips:提到的对外可自定义配置:

简单罗列下源码中提到的可自定义配置的IProvider。便于使用的时候自定义。

  • PathReplaceService ///路由自定义处理替换
  • DegradeService //没有找到路由的通用回调
  • PretreatmentService ///navigation 预处理拦截

通过Class 获取IProvider实例

前面提到的PathReplaceService 等用户自定义类,都是通过ARouter.getInstance().navigation(clazz) 方式获取的。 这一块代码又是怎么从路由信息中获取到实例的呢?看看具体的navigation代码:

protected <T> T navigation(Class<? extends T> service) {
try {
//1.通过类名从Provider路由信息索引中,获取路由信息,组建postcart
Postcard postcard = LogisticsCenter.buildProvider(service.getName());

// Compatible 1.0.5 compiler sdk.
// Earlier versions did not use the fully qualified name to get the service
if (null == postcard) {
// No service, or this service in old version.
//1.通过类名从Provider路由信息索引中,获取路由信息,组建postcart
postcard = LogisticsCenter.buildProvider(service.getSimpleName());
}

if (null == postcard) {
return null;
}

// Set application to postcard.
postcard.setContext(mContext);
//2.完善postcard ,该方法里面创建provider
LogisticsCenter.completion(postcard);
return (T) postcard.getProvider();
} catch (NoRouteFoundException ex) {
logger.warning(Consts.TAG, ex.getMessage());
return null;
}
}

很明显,主要代码就是 LogisticsCenter.``*buildProvider*``(service.getName()) ,获取到了postcard。后面完善postcard 和 获取provider实例的代码都已经在上文讲过。 所以我们就看*buildProvider* 方法:

public static Postcard buildProvider(String serviceName) {
RouteMeta meta = Warehouse.providersIndex.get(serviceName);

if (null == meta) {
return null;
} else {
return new Postcard(meta.getPath(), meta.getGroup());
}
}

和路由组信息获取类似,Provider的路由信息从 Warehouse.``*providersIndex* 维护的映射表中获取。 所以*providersIndex*是专门用来给没有@Route 路由信息的Provider创建实例用的。这就是维护*providersIndex*的用途。 接下来的问题就转为了 *providersIndex* 里面的数据是哪里来的。

小结

路由跳转以及获取Provider等实例的原理可以简单总结下:

  1. 先是获取postcard,可能是直接通过路由路径和uri构建, 如"/test/activity1",也可能是通过Provider 类名从索引获取,如PathReplaceService.class.getName()
  2. 然后通过RouteMate完善 postcard。获取诸如类名信息,路由类型,provider实例等信息。
  3. 最后导航,根据路由类型作出跳转或者返回对应实例。

关键点在于WareHouse 维护的路由图谱。

三. ARouter初始化分析

我们看下对用户提供的ARouter#init方法:

public static void init(Application application) {
if (!hasInit) {
logger = _ARouter.logger;
_ARouter.logger.info(Consts.TAG, "ARouter init start.");
///调用初始化代码
hasInit = _ARouter.init(application);
///初始化完成后,加载拦截器服务,并初始化所有拦截器
if (hasInit) {
_ARouter.afterInit();
}
_ARouter.logger.info(Consts.TAG, "ARouter init over.");
}
}

代码关键就两步,

  1. 初始化ARouter
  2. 获取拦截器服务实例初始化所有拦截器

初始化ARouter

init代码最终调用到了LogisticsCenter#init

通过代码我们了解到了这么几个过程:

  1. 方式一 : ARouter-auto-register 插件加载路由表(如果有该插件),该方式详细分析见第五节。
  2. 方式二 :
  3. 在需要的时候扫描所有 dex文件,找到所有包名为com.alibaba.android.ARouter.routes的类,类名放到routerMap 集里面。
  4. 实例化上面找到的所有类,并通过这些集类加载对应的集映射索引到WareHouse中。

很显然,com.alibaba.android.ARouter.routes 包名下面的类都是自动生成的路由表类。 通过搜索我们能找到样例代码中生成的该包名对象们: img module_java 生成的IRouteRoot 代码如下所示

public class ARouter$$Root$$modulejava implements IRouteRoot {
@Override
public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
routes.put("m2", ARouter$$Group$$m2.class);
routes.put("module", ARouter$$Group$$module.class);
routes.put("test", ARouter$$Group$$test.class);
routes.put("yourservicegroupname", ARouter$$Group$$yourservicegroupname.class);
}
}

这样我们就完全清楚了 WareHouse里面维护的所有路由信息是哪里来的了。追本溯源。接下来我们只需要知道 ARouter$$Root$$modulejava 等类,是啥时候怎么生成的。我们在下面一小节进行分析。初始化ARouter的过程,其实就是填充Warehouse *providersIndex**groupsIndex**interceptorsIndex*

初始化后续:初始化所有拦截器

初始化完成,看看初始化完成后的操作afterInit

static void afterInit() {
// Trigger interceptor init, use byName.
interceptorService = (InterceptorService) ARouter.getInstance().build("/ARouter/service/interceptor").navigation();
}

这一块代码就是navigation 获取到了InterceptorService。上面也讲过,在执行navigation的时候,会调用IProviderinit方法。所以我们需要找到InterceptorService 的实现类,并看看他的init做了什么。项目中其实现类是InterceptorServiceImpl,找到init代码如下:

@Override
public void init(final Context context) {
LogisticsCenter.executor.execute(new Runnable() {
@Override
public void run() {
if (MapUtils.isNotEmpty(Warehouse.interceptorsIndex)) {
for (Map.Entry<Integer, Class<? extends IInterceptor>> entry : Warehouse.interceptorsIndex.entrySet()) {
Class<? extends IInterceptor> interceptorClass = entry.getValue();
try {
IInterceptor iInterceptor = interceptorClass.getConstructor().newInstance();
iInterceptor.init(context);
Warehouse.interceptors.add(iInterceptor);
} catch (Exception ex) {
throw new HandlerException(TAG + "ARouter init interceptor error! name = [" + interceptorClass.getName() + "], reason = [" + ex.getMessage() + "]");
}
}

interceptorHasInit = true;
logger.info(TAG, "ARouter interceptors init over.");
synchronized (interceptorInitLock) {
interceptorInitLock.notifyAll();
}
}
}
});
}

代码很明白的告诉我们,该初始化代码从拦截器路由信息索引里面加载并实例化了所有拦截器。然后通知等待的拦截器开始拦截。

小结

看完初始化代码之后,明白了WareHouse的数据来源,现在问题变成了com.alibaba.android.ARouter.routes 包名的代码何时生成。我们且看下回分解。

收起阅读 »

【带着问题学】协程到底是怎么切换线程的?

前言之前对协程做了一个简单的介绍,回答了协程到底是什么的问题,感兴趣的同学可以了解下:【带着问题学】协程到底是什么?通过上文,我们了解了以下内容1.kotlin协程本质上对线程池的封装2.kotlin协程可以用同步方式写异步代码,自动实现对线程切换的管理这就引...
继续阅读 »

前言

之前对协程做了一个简单的介绍,回答了协程到底是什么的问题,感兴趣的同学可以了解下:【带着问题学】协程到底是什么?
通过上文,我们了解了以下内容
1.kotlin协程本质上对线程池的封装
2.kotlin协程可以用同步方式写异步代码,自动实现对线程切换的管理

这就引出了本文的主要内容,kotlin协程到底是怎么切换线程的?
具体内容如下:

1. 前置知识

1.1 CoroutineScope到底是什么?

CoroutineScope即协程运行的作用域,它的源码很简单

public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}

可以看出CoroutineScope的代码很简单,主要作用是提供CoroutineContext,协程运行的上下文
我们常见的实现有GlobalScope,LifecycleScope,ViewModelScope

1.2 GlobalScopeViewModelScope有什么区别?

public object GlobalScope : CoroutineScope {
/**
* 返回 [EmptyCoroutineContext].
*/

override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}

两者的代码都挺简单,从上面可以看出
1.GlobalScope返回的为CoroutineContext的空实现
2.ViewModelScope则往CoroutineContext中添加了JobDispatcher

我们先来看一段简单的代码

	fun testOne(){
GlobalScope.launch {
print("1:" + Thread.currentThread().name)
delay(1000)
print("2:" + Thread.currentThread().name)
}
}
//打印结果为:DefaultDispatcher-worker-1
fun testTwo(){
viewModelScope.launch {
print("1:" + Thread.currentThread().name)
delay(1000)
print("2:" + Thread.currentThread().name)
}
}
//打印结果为: main

上面两种Scope启动协程后,打印当前线程名是不同的,一个是线程池中的一个线程,一个则是主线程
这是因为ViewModelScopeCoroutineContext中添加了Dispatchers.Main.immediate的原因

我们可以得出结论:协程就是通过Dispatchers调度器来控制线程切换的

1.3 什么是调度器?

从使用上来讲,调度器就是我们使用的Dispatchers.Main,Dispatchers.DefaultDispatcher.IO
从作用上来讲,调度器的作用是控制协程运行的线程
从结构上来讲,Dispatchers的父类是ContinuationInterceptor,然后再继承于CoroutineContext
它们的类结构关系如下:

这也是为什么Dispatchers能加入到CoroutineContext中的原因,并且支持+操作符来完成增加

1.4 什么是拦截器

从命名上很容易看出,ContinuationInterceptor即协程拦截器,先看一下接口

interface ContinuationInterceptor : CoroutineContext.Element {
// ContinuationInterceptor 在 CoroutineContext 中的 Key
companion object Key : CoroutineContext.Key<ContinuationInterceptor>
/**
* 拦截 continuation
*/

fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>

//...
}

从上面可以提炼出两个信息
1.拦截器的Key是单例的,因此当你添加多个拦截器时,生效的只会有一个
2.我们都知道,Continuation在调用其Continuation#resumeWith()方法,会执行其suspend修饰的函数的代码块,如果我们提前拦截到,是不是可以做点其他事情?这就是调度器切换线程的原理

上面我们已经介绍了是通过Dispatchers指定协程运行的线程,通过interceptContinuation在协程恢复前进行拦截,从而切换线程
带着这些前置知识,我们一起来看下协程启动的具体流程,明确下协程切换线程源码具体实现

2. 协程线程切换源码分析

2.1 launch方法解析

我们首先看一下协程是怎样启动的,传入了什么参数

public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
)
: Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}

总共有3个参数:
1.传入的协程上下文
2.CoroutinStart启动器,是个枚举类,定义了不同的启动方法,默认是CoroutineStart.DEFAULT
3.block就是我们传入的协程体,真正要执行的代码

这段代码主要做了两件事:
1.组合新的CoroutineContext
2.再创建一个 Continuation

2.1.1 组合新的CoroutineContext

public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
val combined = coroutineContext + context
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}

从上面可以提炼出以下信息:
1.会将launch方法传入的contextCoroutineScope中的context组合起来
2.如果combined中没有拦截器,会传入一个默认的拦截器,即Dispatchers.Default,这也解释了为什么我们没有传入拦截器时会有一个默认切换线程的效果

2.1.2 创建一个Continuation

val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)

默认情况下,我们会创建一个StandloneCoroutine
值得注意的是,这个coroutine其实是我们协程体的complete,即成功后的回调,而不是协程体本身
然后调用coroutine.start,这表明协程开始启动了

2.2 协程的启动

public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
initParentJob()
start(block, receiver, this)
}

接着调用CoroutineStartstart来启动协程,默认情况下调用的是CoroutineStart.Default

经过层层调用,最后到达了:

internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(receiver: R, completion: Continuation<T>) =
runSafely(completion) {
// 外面再包一层 Coroutine
createCoroutineUnintercepted(receiver, completion)
// 如果需要,做拦截处理
.intercepted()
// 调用 resumeWith 方法
.resumeCancellableWith(Result.success(Unit))
}

这里就是协程启动的核心代码,虽然比较短,却包括3个步骤:
1.创建协程体Continuation
2.创建拦截 Continuation,即DispatchedContinuation
3.执行DispatchedContinuation.resumeWith方法

2.3 创建协程体Continuation

调用createCoroutineUnintercepted,会把我们的协程体即suspend block转换成Continuation,它是SuspendLambda,继承自ContinuationImpl
createCoroutineUnintercepted方法在源码中找不到具体实现,不过如果你把协程体代码反编译后就可以看到真正的实现
详情可见:字节码反编译

2.4 创建DispatchedContinuation

public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
(this as? ContinuationImpl)?.intercepted() ?: this

//ContinuationImpl
public fun intercepted(): Continuation<Any?> =
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }

//CoroutineDispatcher
public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
DispatchedContinuation(this, continuation)

从上可以提炼出以下信息
1.interepted是个扩展方法,最后会调用到ContinuationImpl.intercepted方法
2.在intercepted会利用CoroutineContext,获取当前的拦截器
3.因为当前的拦截器是CoroutineDispatcher,因此最终会返回一个DispatchedContinuation,我们其实也是利用它实现线程切换的
4.我们将协程体的Continuation传入DispatchedContinuation,这里其实用到了装饰器模式,实现功能的增强

这里其实很明显了,通过DispatchedContinuation装饰原有协程,在DispatchedContinuation里通过调度器处理线程切换,不影响原有逻辑,实现功能的增强

2.5 拦截处理

    //DispatchedContinuation
inline fun resumeCancellableWith(
result: Result<T>,
noinline onCancellation: ((cause: Throwable) -> Unit)?
) {
val state = result.toState(onCancellation)
if (dispatcher.isDispatchNeeded(context)) {
_state = state
resumeMode = MODE_CANCELLABLE
dispatcher.dispatch(context, this)
} else {
executeUnconfined(state, MODE_CANCELLABLE) {
if (!resumeCancelled(state)) {
resumeUndispatchedWith(result)
}
}
}
}

上面说到了启动时会调用DispatchedContinuationresumeCancellableWith方法
这里面做的事也很简单:
1.如果需要切换线程,调用dispatcher.dispatcher方法,这里的dispatcher是通过CoroutineConext取出来的
2.如果不需要切换线程,直接运行原有线程即可

2.5.2 调度器的具体实现

我们首先明确下,CoroutineDispatcher是通过CoroutineContext取出来的,这也是协程上下文作用的体现
CoroutineDispater官方提供了四种实现:Dispatchers.Main,Dispatchers.IO,Dispatchers.Default,Dispatchers.Unconfined
我们一起简单看下Dispatchers.Main的实现

internal class HandlerContext private constructor(
private val handler: Handler,
private val name: String?,
private val invokeImmediately: Boolean
) : HandlerDispatcher(), Delay {
public constructor(
handler: Handler,
name: String? = null
) : this(handler, name, false)

//...

override fun dispatch(context: CoroutineContext, block: Runnable) {
// 利用主线程的 Handler 执行任务
handler.post(block)
}
}

可以看到,其实就是用handler切换到了主线程
如果用Dispatcers.IO也是一样的,只不过换成线程池切换了

如上所示,其实就是一个装饰模式
1.调用CoroutinDispatcher.dispatch方法切换线程
2.切换完成后调用DispatchedTask.run方法,执行真正的协程体

delay是怎样切换线程的?

上面我们介绍了协程线程调度的基本原理与实现,下面我们来回答几个小问题
我们知道delay函数会挂起,然后等待一段时间再恢复。
可以想象,这里面应该也涉及到线程的切换,具体是怎么实现的呢?

public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

Dealy的代码也很简单,从上面可以提炼出以下信息
delay的切换也是通过拦截器来实现的,内置的拦截器同时也实现了Delay接口
我们来看一个具体实现

internal class HandlerContext private constructor(
private val handler: Handler,
private val name: String?,
private val invokeImmediately: Boolean
) : HandlerDispatcher(), Delay {
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
// 利用主线程的 Handler 延迟执行任务,将完成的 continuation 放在任务中执行
val block = Runnable {
with(continuation) { resumeUndispatched(Unit) }
}
handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))
continuation.invokeOnCancellation { handler.removeCallbacks(block) }
}

//..
}

1.可以看出,其实也是通过handler.postDelayed实现延时效果的
2.时间到了之后,再通过resumeUndispatched方法恢复协程
3.如果我们用的是Dispatcher.IO,效果也是一样的,不同的就是延时效果是通过切换线程实现的

4. withContext是怎样切换线程的?

我们在协程体内,可能通过withContext方法简单便捷的切换线程,用同步的方式写异步代码,这也是kotin协程的主要优势之一

    fun test(){
viewModelScope.launch(Dispatchers.Main) {
print("1:" + Thread.currentThread().name)
withContext(Dispatchers.IO){
delay(1000)
print("2:" + Thread.currentThread().name)
}
print("3:" + Thread.currentThread().name)
}
}
//1,2,3处分别输出main,DefaultDispatcher-worker-1,main

可以看出这段代码做了一个切换线程然后再切换回来的操作,我们可以提出两个问题
1.withContext是怎样切换线程的?
2.withContext内的协程体结束后,线程怎样切换回到Dispatchers.Main?

public suspend fun <T> withContext(
context: CoroutineContext,
block: suspend CoroutineScope.() -> T
)
: T {
return suspendCoroutineUninterceptedOrReturn sc@ { uCont ->
// 创建新的context
val oldContext = uCont.context
val newContext = oldContext + context
....
//使用新的Dispatcher,覆盖外层
val coroutine = DispatchedCoroutine(newContext, uCont)
coroutine.initParentJob()
//DispatchedCoroutine作为了complete传入
block.startCoroutineCancellable(coroutine, coroutine)
coroutine.getResult()
}
}

private class DispatchedCoroutine<in T>(
context: CoroutineContext,
uCont: Continuation<T>
) : ScopeCoroutine<T>(context, uCont) {
//在complete时会会回调
override fun afterCompletion(state: Any?) {
afterResume(state)
}

override fun afterResume(state: Any?) {
//uCont就是父协程,context仍是老版context,因此可以切换回原来的线程上
uCont.intercepted().resumeCancellableWith(recoverResult(state, uCont))
}
}

这段代码其实也很简单,可以提炼出以下信息
1.withContext其实就是一层Api封装,最后调用到了startCoroutineCancellable,这就跟launch后面的流程一样了,我们就不继续跟了
2.传入的context会覆盖外层的拦截器并生成一个newContext,因此可以实现线程的切换
3.DispatchedCoroutine作为complete传入协程体的创建函数中,因此协程体执行完成后会回调到afterCompletion
4.DispatchedCoroutine中传入的uCont是父协程,它的拦截器仍是外层的拦截器,因此会切换回原来的线程中

总结

本文主要回答了kotlin协程到底是怎么切换线程的这个问题,并对源码进行了分析
简单来讲主要包括以下步骤:
1.向CoroutineContext添加Dispatcher,指定运行的协程
2.在启动时将suspend block创建成Continuation,并调用intercepted生成DispatchedContinuation
3.DispatchedContinuation就是对原有协程的装饰,在这里调用Dispatcher完成线程切换任务后,resume被装饰的协程,就会执行协程体内的代码了

其实kotlin协程就是用装饰器模式实现线程切换的
看起来似乎有不少代码,但是真正的思路其实还是挺简单的,这大概就是设计模式的作用吧
如果本文对你有所帮助,欢迎点赞收藏~

收起阅读 »

你真的懂android通知消息吗?

概览通知是 android 系统存在至今为止被变更最为频繁的 api 之一,android 4.1、4.4、5.0、7.0、8.0 都对通知做过比较大的改动。到了 8.0 通知功能趋于稳定,至今没有做过更大的改动。对一个 api 进行如此大的照顾那么这必然是个...
继续阅读 »

概览

通知是 android 系统存在至今为止被变更最为频繁的 api 之一,android 4.1、4.4、5.0、7.0、8.0 都对通知做过比较大的改动。到了 8.0 通知功能趋于稳定,至今没有做过更大的改动。

对一个 api 进行如此大的照顾那么这必然是个非常重要的 api 了。那么就跟随我一起揭开通知一点都不神秘的面纱吧。

注:本文主要讲应用

通知使用

创建简单通知

我们使用 NotificationCompat 来创建通知,使用 NotificationCompat 可以兼容所有的系统版本,不需要我们去手动兼容版本。

创建通知分为两个步骤:

  • 创建渠道
  • 创建通知

关于渠道

创建渠道
notificationManager.createNotificationChannel(channel)

安卓 8.0 系统要求必须创建渠道才能展示通知,所以我们在 8.0 的系统版本中,必须添加创建渠道的方法。

创建渠道不一定非要在展示通知的时候做,同一个渠道只需要被创建一次即可(多次亦可)。我们可以在我们即将展示通知的时候创建,可以再应用启动的时候创建,也可以在 activity 中创建。总之渠道创建非常灵活

如果渠道已经存在我们仍然调用了创建渠道方法,那么什么也不会做,很安全

下面代码是我们创建渠道的完整代码:

private val channelName = "安安安安卓"
private val channelId = "channelId"
fun createNotificationChannel(context: Context): NotificationChannel? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val descriptionText = "渠道描述"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, channelName, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
return channel
}
return null
}
渠道重要性设置

需要注意,渠道的优先级和通知的优先级是不同的,注意区分

val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, channelName, importance)

上面的代码创建了通知的重要程度,我们需要说明一下 NotificationChannel 的第三个参数,也就是渠道的重要程度,这个设置不同的值,用户收到通知后手机的展示包括声音、震动、是否弹出都会不同,下面看一下参数的四种设置(四个参数在不同手机的渠道展示不同):

  • IMPORTANCE_HIGH 收到通知发出提示语,并且会浮动提示用户(小米手机表示紧急)
  • IMPORTANCE_DEFAULT 收到通知发出提示语,不会浮动提示(小米手机表示高)
  • IMPORTANCE_LOW 收到通知不会发出声音,状态栏有小图标展示(小米手机表示中)
  • IMPORTANCE_MIN 根本看不到通知(所以你压根就别用就 ok 了),不过似乎可以用于禁用通知的场景(小米手机表示低)
禁用某个渠道的通知方法

我们使用创建渠道的方式实现禁用通知,如下:

比如我们第一次创建渠道的时候代码如下:

val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(channelId, channelName, importance)

这行代码会创建一个有声音提示、横幅展示(google 文档管这个叫偷窥模式 😄)的渠道。

如果此时用户通过我们 app 内部的设置想不在收到我们这个渠道的通知,我们需要如下代码这样做:

val importance = NotificationManager.IMPORTANCE_MIN
val channel = NotificationChannel(channelId, channelName, importance)

与上一处的代码的区别是把 IMPORTANCE_HIGH 改成了 IMPORTANCE_MIN,因此我们的渠道就变成了 低级别通知渠道,收到通知也无法展示,因此用户根本看不到通知,从而实现了通知禁用。

还有一点需要注意,我们可以通过代码将一个高优先级的渠道设置为低优先级渠道,但是无法将低优先级渠道设置为高优先级渠道。

关于通知

创建通知

通知大家都太熟悉,直接上代码,记得看注释

private val channelName = "安安安安卓"
private val channelId = "channelId"
fun showNotification(context: Context) {
val notification = NotificationCompat.Builder(context, channelName)//这里的渠道名就是你自己想展示通知对应的渠道分组
.setSmallIcon(R.drawable.apple)//设置状态栏展示的通知样式
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))//设置通知中的图标样式
.setContentTitle("公众号")//设置通知标题
.setContentText("安安安安卓")//设置通知正文
.setChannelId(channelId)//设置通知渠道,这个渠道id必须是和我们创建渠道时候的id对应
.setPriority(NotificationCompat.PRIORITY_DEFAULT).build()//设置通知优先级
NotificationManagerCompat.from(context).notify(13, notification)
}

强调一下:展示通知之前一定要先创建渠道

通知中的优先级

设置方法:NotificationCompat.Builder.setPriority 通知优先级极容易跟渠道优先级混淆,一定要注意区分 通知优先级有以下几种:

  • PRIORITY_DEFAULT = 0;默认优先级
  • PRIORITY_LOW = -1; 低优先级
  • PRIORITY_MIN = -2;最低优先级
  • PRIORITY_HIGH = 1;高优先级
  • PRIORITY_MAX = 2;最高优先级

这个参数主要是给我们的通知进行排序,重要的通知放在前面展示。这可以帮助我们第一时间找到最重要的通知进行处理,这很实用不是

创建代码

我们可以再创建 NotificationCompat.Builder 的时候加上如下调用就可以展示展开式通知:

 .setStyle(
NotificationCompat.BigTextStyle()
.bigText("本文由 公众号 \"安安安安卓\"作者原创,禁抄袭\n 北国风光," +
"千里冰封,万里雪飘,望长城内外,惟余莽莽,大河上下,顿失滔滔,山舞银蛇,原驰蜡象,欲与天公试比高。" +
"须晴日,看银装素裹,分外妖娆")
)

通知默认是展开式的,长按通知可以在短文本和长文本之间来回切换

设置通知的点击事件

如下代码实现一个可点击的通知栏

  fun showNotification(context: Context) {
val intent = Intent(context,OnlyShowActivity::class.java).apply {
flags=Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent=PendingIntent.getActivity(context,0,intent,0)
val notification = NotificationCompat.Builder(context, channelId)
.setContentText("点击通知跳转的一个页面中")
.setContentTitle("可点击通知")
.setSmallIcon(R.drawable.apple)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))
.setAutoCancel(true)//设置点击了通知,则通知自动消失
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context).notify(++count, notification)
}

给通知栏设置按钮

我们可以通过 addAction 给通知设置 action,同时可以指定一个 PendingIntent。

fun showBtnNotification(context: Context) {
val intent = Intent(context, OnlyShowActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.apple)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.apple))
.setContentText("安安安安卓,北国风光,千里冰封,万里雪飘")
.setContentTitle("按钮通知")
.addAction(R.drawable.person, "李白", pendingIntent)
.addAction(R.drawable.apple, "杜甫", pendingIntent)
.addAction(R.drawable.apple, "王维", pendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(++count, notification)
}

设置进度条

 private val countdown = object : CountDownTimer(15 * 1000, 1000) {
private val perdegree = 100 / 15
var count = 0
override fun onTick(millisUntilFinished: Long) {
count++
showNotification(count * perdegree)//更新进度
}

override fun onFinish() {
showNotification(100)
count = 0
}
}

/**
* 启动一个可动的进度条
*/

fun start() {
countdown.start()
}

private fun showNotification(progress: Int) {
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.apple)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.person))
.setColor(Color.GREEN)
.setContentTitle("这是个进度标题")

NotificationManagerCompat.from(context).apply {
builder.setProgress(100, progress, false)
builder.setContentText("下载进度 $progress%")
notify(count, builder.build())
}
}

设置自定义通知

我们可以通过 RemoteViews 指定一个布局,通过 setCustomContentView 设置我们的自定义布局 代码:

 fun showNotification(context: Context){
val remoteViews = RemoteViews(context.packageName, R.layout.item_notification)
val notification = NotificationCompat.Builder(context, channelId)
.setContentTitle("这个通知的布局是自定义的")
.setContentText("安安安安卓")
.setSmallIcon(R.drawable.apple)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.person))
.setCustomContentView(remoteViews)
.build()
NotificationManagerCompat.from(context).notify(count,notification)
}

我们的 xml 代码预览图:

最终效果图:

其它的知识点

  1. 从 android8.1 开始,应用一秒钟最多只能发出一次通知提示音,如果出现多条通知只有一条通知可以出发提示音
  2. 创建通知的几种样式:NotificationCompat.BigPictureStyle、NotificationCompat.BigTextStyle、NotificationCompat.DecoratedCustomViewStyle
  3. NotificationCompat.Builder.setGroup 方法可以创建一组通知
  4. NotificationManager.getNotificationChannel()或 NotificationManager.getNotificationChannels()两个方法可以获取通知的渠道,通过获取到的渠道可以获取此渠道是否开启声音、渠道通知的重要级别。我们可以据此提示用户打开相应的设置,下面代码展示了打开通知渠道的方法:
  Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
intent.putExtra(Settings.EXTRA_CHANNEL_ID, myNotificationChannel.getId());
startActivity(intent);

  1. 删除渠道的方法 deleteNotificationChannel()
  2. 我们可以调用渠道的 NotificationChannel.setShowBadge(false)方法关闭桌面图标圆点。这个其实很有用,比如当你用通知展示下载进度条的时候这条通知明显是不需要展示圆点的,还有大部分的本地提醒类通知都不会希望显示圆点的,用这个方法正好
  3. NotificationCompat.Builder.setNumber 方法可以设置桌面图标的红点数量
  4. 通过 NotificationCompat.DecoratedCustomViewStyle 样式可以给内容区域创建自定义布局。样式就是通知展示图标在左,我们自定义的布局在右,不过这个感觉就没啥用了。
  5. 自定义布局的通知也可以给内部的 view 添加点击跳转事件,实现方法如下代码:
 val remoteViews = RemoteViews(context.packageName, R.layout.item_notification)
val intent = Intent(context,OnlyShowActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context,0,intent,0)
remoteViews.setOnClickPendingIntent(R.id.iv_pendingintent_click,pendingIntent)
收起阅读 »

如何规范的进行 Android 组件化开发?

正文进行组件化开发有一段时间了,不久后就要开始一个新项目了,为此整理了目前项目中使用的组件化开发规范,方便在下一个项目上使用。本文的重点是介绍规范和项目架构,仅提供示例代码举例,目前不打算提供示例Demo。如果你还不了解什么是组件化以及如何进行组件化开发的话,...
继续阅读 »
正文

进行组件化开发有一段时间了,不久后就要开始一个新项目了,为此整理了目前项目中使用的组件化开发规范,方便在下一个项目上使用。本文的重点是介绍规范和项目架构,仅提供示例代码举例,目前不打算提供示例Demo。如果你还不了解什么是组件化以及如何进行组件化开发的话,建议请先看下面这个文章。

定义

组件是 Android 项目中一个相对独立的功能模块,是一个抽象的概念,module 是 Android 项目中一个相对独立的代码模块。

在组件化开发的早期,一个组件就只有一个 module,导致很多代码和资源都会下沉到 common 中,导致 common 会变得很臃肿。有的文章说,专门建立一个 module 来存放通用资源,我感觉这样是治标不治本,直到后面看到微信Android模块化架构重构实践这篇文章,里面的"模块的一般组织方式"一节提到一个模块应该有多个工程,然后开始在项目对 module 进行拆分。

一般情况下,一个组件有两个 module,一个轻量级的 module 提供外部组件需要和本组件进行交互的接口方法及一些外部组件需要的资源,另一个重量级的 module 完成组件实际的功能和实现轻量级 module 定义的接口方法。

module 的命名规范请参考module名,在下文中使用 module-api 代表轻量级的 module,使用 module-impl 代表重量级的 module

common组件

common 是一个特殊的组件,不区分轻量级和重量级,它是项目中最底层的组件,基本上所有的其他组件都会依赖 common 组件,common 中放项目中所有弱业务逻辑的代码和解决循环依赖的代码和资源。

一个完整的项目的架构如下:

弱业务逻辑代码

何为弱业务逻辑代码?简单来说,就是有一定的业务逻辑,但是这个业务逻辑对于项目中其他组件来说通用的。

比如在 common 组件集成网络请求库,创建一个 HttpTool 工具类,负责初始化网络请求框架,定义网络请求方法,实现组装通用请求参数以及处理全局通用错误等,对于其他组件直接通过这个工具类进行网络请求就可以了。

比如定义界面基类,处理一些通用业务逻辑,比如接入统计分析框架。

解决循环依赖的代码和资源

何为解决循环依赖的代码和资源?比如说 module-a-api 有一个类 Cmodule-b-api 中有一个类 D,在 module-a-api 中需要使用 D,在 module-b-api 中需要使用 C,这样就会造成 module-a-api 需要依赖 module-b-api,而 module-b-api 也会依赖 module-a-api,这就造成了循环依赖,在 Android Studio 中会编译失败。

解决循环依赖的方案就是将 C 和 D 其中的一个,或者两个都下沉到 common 组件中,因为 module-a-api 和 module-b-api 都依赖了 common 组件,至于具体下沉几个,这个根据具体的情况而定,但是原则是下沉到 common 组件的东西越少越好。

上面的举的例子是代码,资源文件同样也可能会有这个问题。

module代码结构

一个组件通常含有一个或多个功能点,比如对于用户组件,它有关于界面、意见反馈、修改账户密码等功能点,在 module 中为每一个功能点创建一个路径,里面放实现该功能的代码,比如 ActivityDialog 、Adapter 等。除此之外,为了集中管理组件内部资源和统一编码习惯,特地将一部分的通用功能路径固定下来。这些路径包括 apiprovidertool 等。

一般情况下 module 的代码架构如下图:

api

该路径下放 module 内部使用到的所有网络请求路径和方法,一般使用一个类就够了,比如:UserApi

object UserApi {

/**
* 获取个人中心数据
*/
fun getPersonCenterData(): GetRequest {
return HttpTool.get(ApiVersion.v1_0_0 + "authUser/myCenter")
}
}

复制代码

ApiVersion 全局管理目前项目中使用的所有 api 版本,应当定义在 common 组件的 api 路径下:

object ApiVersion {
const val v1_0_0 = "v1/"
const val v1_1_0 = "v1_1/"
const val v1_2_2 = "v1_2_2/"
}

复制代码

entity

该路径下放 module 内部使用到的所有实体类(网络请求返回的数据类)。

对于所有从服务器获取的字段,全部定义在构造函数中,且实体类应当实现 Parcelable ,并使用 @Parcelize 注解。对于客户端使用而自己定义的字段,基本上定义为普通成员字段,并使用 @IgnoredOnParcel 注解,如果需要在界面间传递客户端定义的字段,可以将该字段定义在构造函数中,但是必须注明是客户端定义的字段。

示例如下:

@Parcelize
class ProductEntity(
// 产品名称
var name: String = "",

// 产品图标
var icon: String = "",

// 产品数量(客户端定义字段)
var count: Int = 0
) : Parcelable {
// 用户是否选择本产品
@IgnoredOnParcel
var isSelected = false
}

复制代码

其中 name 和 icon 是从服务器获取的字段,而 count 和 isSelected 是客户端自己定义的字段。

event

该路径下放 module 内部使用的事件相关类。对于使用了 EventBus 及类似框架的项目,放事件类,对于使用了 LiveEventBus 的项目,里面只需要放一个类就好,比如:UserEvent

object UserEvent {

/**
* 更新用户信息成功事件
*/
val updateUserInfoSuccessEvent: LiveEventBus.Event<Unit>
get() = LiveEventBus.get("user_update_user_info_success")
}

复制代码

注意:对于使用 LiveEventBus 的项目,事件的命名必须用组件名作为前缀,防止事件名重复。

route

该路径下放 module 内部所使用到的界面路径和跳转方法,一般使用一个类就够了,比如:UserRoute

object UserRoute {
// 关于界面
const val ABOUT = "/user/about"
// 常见问题(H5)
private const val FAQ = "FAQ/"

/**
* 跳转至关于界面
*/
fun toAbout(): RouteNavigation {
return RouteNavigation(ABOUT)
}

/**
* 跳转至常见问题(H5)
*/
fun toFAQ(): RouteNavigation? {
return RouteUtil.getServiceProvider(IH5Service::class.java)
?.toH5Activity(FAQ)
}
}

复制代码

注意:对于组件内部会跳转的H5界面链接也应当写在路由类中。

provider

该路径下放对外部 module 提供的服务,一般使用一个类就够了。在 module-api 中是一个接口类,在 module-impl 中是该接口类的实现类。

目前采用 ARouter 作为组件化的框架,为了解耦,对其进行了封装,封装示例代码如下:

typealias Route = com.alibaba.android.arouter.facade.annotation.Route

object RouteUtil {

fun <T> getServiceProvider(service: Class<out T>): T? {
return ARouter.getInstance().navigation(service)
}
}

class RouteNavigation(path: String) {

private val postcard = ARouter.getInstance().build(path)

fun param(key: String, value: Int): RouteNavigation {
postcard.withInt(key, value)
return this
}
...
}

复制代码

示例

这里介绍如何在外部 module 和 user-impl 跳转至用户组件中的关于界面。

准备工作

在 user-impl 中创建路由类,编写关于界面的路由和服务路由及跳转至关于界面方法:

object UserRoute {
// 关于界面
const val ABOUT = "/user/about"
// 用户组件服务
const val USER_SERVICE = "/user/service"

/**
* 跳转至关于界面
*/
fun toAbout(): RouteNavigation {
return RouteNavigation(ABOUT)
}
}

复制代码

在关于界面使用路由:

@Route(path = UserRoute.ABOUT)
class AboutActivity : MyBaseActivity() {
...
}

复制代码

在 user-api 中定义跳转界面方法:

interface IUserService : IServiceProvider {

/**
* 跳转至关于界面
*/
fun toAbout(): RouteNavigation
}

复制代码

在 user-impl 中实现跳转界面方法:

@Route(path = UserRoute.USER_SERVICE)
class UserServiceImpl : IUserService {

override fun toAbout(): RouteNavigation {
return UserRoute.toAbout()
}
}

复制代码
界面跳转

在 user-impl 中可以直接跳转到关于界面:

UserRoute.toAbout().navigation(this)

复制代码

假设 module-a 需要跳转到关于界面,那么先在 module-a 中配置依赖:

dependencies {
...
implementation project(':user-api')
}

复制代码

在 module-a 中使用 provider 跳转到关于界面:

RouteUtil.getServiceProvider(IUserService::class.java)
?.toAbout()
?.navigation(this)

复制代码
module依赖关系

此时各个 module 的依赖关系如下:

common:基础库、第三方库
user-api:common
user-impl:common、user-api
module-a:common、user-api
App壳:common、user-api、user-impl、module-a、...

复制代码

tool

该路径下放 module 内部使用的工具方法,一般一个类就够了,比如:UserTool

object UserTool {

/**
* 该用户是否是会员
* @param gradeId 会员等级id
*/
fun isMembership(gradeId: Int): Boolean {
return gradeId > 0
}
}

复制代码

cache

该路径下放 module 使用的缓存方法,一般一个类就够了,比如:UserCache

object UserCache {

// 搜索历史记录列表
var searchHistoryList: ArrayList<String>
get() {
val cacheStr = CacheTool.userCache.getString(SEARCH_HISTORY_LIST)
return if (cacheStr == null) {
ArrayList()
} else {
JsonUtil.parseArray(cacheStr, String::class.java) ?: ArrayList()
}
}
set(value) {
CacheTool.userCache.put(SEARCH_HISTORY_LIST, JsonUtil.toJson(value))
}

// 搜索历史记录列表
private const val SEARCH_HISTORY_LIST = "user_search_history_list"
}

复制代码

注意:

  1. 缓存Key的命名必须用组件名作为前缀,防止缓存Key重复。
  2. CacheTool.userCache 并不是指用户组件的缓存,而是用户的缓存,即当前登录账号的缓存,每个账号会单独存一份数据,相互之间没有干扰。与之对应的是 CacheTool.globalCache,全局缓存,所有的账号会共用一份数据。

两种module的区别

module-api 中放的都是外部组件需要的,或者说外部组件和 module-impl 都需要的,其他的都应当放在 module-impl 中,对于外部组件需要的但是能通过 provider 方式提供的,都应当把具体的实现放在 module-impl 中,module-api 中只是放一个接口方法。

下表列举项目开发中哪些东西能否放 module-api 中:

类型能否放 module-api备注
功能界面(Activity、Fragment、Dialog)不能通过 provider 方式提供使用
基类界面部分能外部 module 需要使用的可以,其他的放 module-impl 中
adapter部分能外部 module 需要使用的可以,其他的放 module-impl 中
provider部分能只能放接口类,实现类放 module-impl 中
tool部分能外部 module 需要使用的可以,其他的放 module-impl 中
api、route、cache不能通过 provider 方式提供使用
entity部分能外部 module 需要使用的可以,其他的放 module-impl 中
event部分能对使用 EventBus 及类似框架的项目,外部组件需要的可以,其他还是放 module-impl 中
对于使用了 LiveEventBus 的项目不能,通过 provider 方式提供使用
资源文件和资源变量部分能需要在 xml 文件中使用的可以, 其他的通过 provider 方式提供使用

注意:如果仅在 module-impl 中存在工具类,则该工具类命名为 xxTool。如果 module-api 和 module-impl 都存在工具类,则 module-api 中的命名为 xxToolmodule-impl 中的命名为 xxTool2

组件单独调试

在开发过程中,为了查看运行效果,需要运行整个App,比较麻烦,而且可能依赖的其他组件也在开发中,App可能运行不到当前开发的组件。为此可以采用组件单独调试的模式进行开发,减少其他组件的干扰,等开发完成后再切换回 library 的模式。

在组件单独调试模式下,可以增加一些额外的代码来方便开发和调试,比如新增一个入口 Actvity,作为组件单独运行时的第一个界面。

示例

这里介绍在 user-impl 中进行组件单独调试。

在项目根目录下的 gradle.properties 文件中新增变量 isDebugModule,通过该变量控制是否进行组件单独调试:

# 组件单独调试开关,为ture时进行组件单独调试
isDebugModule = false

复制代码

在 user-impl 的 build.gradle 的顶部增加以下代码来控制 user-impl 在 Applicaton 和 Library 之间进行切换:

if (isDebugModule.toBoolean()) {
apply plugin: 'com.android.application'
} else {
apply plugin: 'com.android.library'
}

复制代码

在 user-impl 的 src/main 的目录下创建两个文件夹 release 和 debugrelease 中放 library 模式下的 AndroidManifest.xmldebug 放 application 模式下的 AndroidManifest.xml、代码和资源,如下图所示:

在 user-impl 的 build.gradle 中配置上面的创建的代码和资源路径:

android {
...
sourceSets {
if (isDebugModule.toBoolean()) {
main.manifest.srcFile 'src/main/debug/AndroidManifest.xml'
main.java.srcDirs += 'src/main/debug'
main.res.srcDirs += 'src/main/debug'
} else {
main.manifest.srcFile 'src/main/release/AndroidManifest.xml'
}
}
}

复制代码

注意:完成上述配置后,在 library 模式下,debug 中的代码和资源不会合并到项目中。

最后在 user-impl 的 build.gradle 中配置 applicationId

android {
defaultConfig {
if (isDebugModule.toBoolean()) {
applicationId "cc.tarylorzhang.demo"
}
...
}
}

复制代码

注意:如果碰到65536的问题,在 user-impl 的 build.gradle 中新增以下配置:

android {
defaultConfig {
...
if (isDebugModule.toBoolean()) {
multiDexEnabled true
}
}
}

复制代码

以上工作都完成后,将 isDebugModule 的值改为 true,则可以开始单独调试用户组件。

命名规范

module名

组件名如果是单个单词的,直接使用该单词 + api 或 impl 的后缀作为 module 名,如果是多个单词的,多个单词小写使用 - 字符作为连接符,然后在其基础上加 api 或 impl 的后缀作为 module 名。

示例

用户组件(User),它的 module 名为 user-api 和 user-impl;会员卡组件(MembershipCard),它的 module 名为 membership-card-api 和 membership-card-impl

包名

在应用的 applicationId 的基础上增加组件名后缀作为组件基础包名。

在代码中的包名 module-api 和 module-impl 都直接使用基础包名即可,但是在 Android 中项目 AndroidManifest.xml 文件中的 package 不能重复,否则编译不通过。所以 module-impl 中的 package 使用基础包名,而 module-impl 中的 package 使用基础包名 + api 后缀。

package 重复的时候,会报 Type package.BuildConfig is defined multiple times 的错误。

示例

应用的 applicationId 为 cc.taylorzhang.demo,对于用户组件(user),组件基础包名为 cc.taylorzhang.demo.user,则实际包名如下表:

代码中的包名AndroidManifest.xml中的包名
user-apicc.taylorzhang.demo.usercc.taylorzhang.demo.userapi
user-implcc.taylorzhang.demo.usercc.taylorzhang.demo.user

对于多单词的会员卡组件(MembershipCard),其组件基础包名为 cc.taylorzhang.demo.membershipcard

资源文件和资源变量

所有的资源文件:布局文件、图片等全部要增加组件名作为前缀,所有的资源变量:字符串、颜色等也全部要增加组件名作为前缀,防止资源名重复。

示例

  • 用户组件(User),关于界面布局文件命名为:user_activity_about.xml
  • 用户组件(User),关于界面标题字符串命名为:user_about_title
  • 会员卡组件(MembershipCard),会员卡详情界面布局文件,文件名为:membership_card_activity_detail
  • 会员卡组件(MembershipCard),会员卡详情界面标题字符串,文件名为:membership_card_detail_title

类名

对于类名没必要增加前缀,比如 UserAboutActivity,因为对资源文件和资源变量增加前缀主要是为了避免重复定义资源导致资源被覆盖的问题,而上面的包名命名规范已经避免了类重复的问题,直接命名 AboutActivity 即可。

全局管理App环境

App 环境一般分为开发、测试和生产环境,不同环境下使用的网络请求地址大概率是不一样的,甚至一些UI都不一样,在打包的时候手动修改很容易有遗漏,产生不必要的 BUG。应当使用 buildConfigField 在打包的时候将当前环境写入 App 中,在代码中根据读取环境变量,根据不同的环境执行不同的操作。

示例

准备工作

在 App 壳 的 build.gradle 中给每个buildType 都配置 APP_ENV

android {
...
buildTypes {
debug {
buildConfigField "String", "APP_ENV", '\"dev\"'
...
}
release {
buildConfigField "String", "APP_ENV", '\"release\"'
...
}
ctest {
initWith release

buildConfigField "String", "APP_ENV", '\"test\"'
matchingFallbacks = ['release']
}
}
}

复制代码

注意:测试环境的 buildType 不能使用 test 作为名字,Android Studio 会报 ERROR: BuildType names cannot start with 'test',这里在 test 前增加了一个 c

在 common 的 tool 路径下创建一个App环境工具类:

object AppEnvTool {

/** 开发环境 */
const val APP_ENV_DEV = "dev"
/** 测试环境 */
const val APP_ENV_TEST = "test"
/** 生产环境 */
const val APP_ENV_RELEASE = "release"

/** 当前App环境,默认为开发环境 */
private var curAppEnv = APP_ENV_DEV

fun init(env: String) {
curAppEnv = env
}

/** 当前是否处于开发环境 */
val isDev: Boolean
get() = curAppEnv == APP_ENV_DEV

/** 当前是否处于测试环境 */
val isTest: Boolean
get() = curAppEnv == APP_ENV_TEST

/** 当前是否处于生产环境 */
val isRelease: Boolean
get() = curAppEnv == APP_ENV_RELEASE

}

复制代码

在 Application 中初始化App环境工具类:

class DemoApplication : Application() {

override fun onCreate() {
super.onCreate()

// 初始化App环境工具类
AppEnvTool.init(BuildConfig.APP_ENV)
...
}
}

复制代码

使用App环境工具类

这里介绍根据App环境使用不同的网络请求地址:

object CommonApi {

// api开发环境地址
private const val API_DEV_URL = "https://demodev.taylorzhang.cc/api/"
// api测试环境地址
private const val API_TEST_URL = "https://demotest.taylorzhang.cc/api/"
// api生产环境地址
private const val API_RELEASE_URL = "https://demo.taylorzhang.cc/api/"
// api地址
val API_URL = getUrlByEnv(API_DEV_URL, API_TEST_URL, API_RELEASE_URL)

// H5开发环境地址
private const val H5_DEV_URL = "https://demodev.taylorzhang.cc/m/"
// H5测试环境地址
private const val H5_TEST_URL = "https://demotest.taylorzhang.cc/m/"
// H5生产环境地址
private const val H5_RELEASE_URL = "https://demo.taylorzhang.cc/m/"
// H5地址
val H5_URL = getUrlByEnv(H5_DEV_URL, H5_TEST_URL, H5_RELEASE_URL)

private fun getUrlByEnv(devUrl: String, testUrl: String, releaseUrl: String): String {
return when {
AppEnvTool.isDev -> devUrl
AppEnvTool.isTest -> testUrl
else -> releaseUrl
}
}
}

复制代码

打包

通过不同的命令打包,打出对应的App环境包:

# 打开发环境包
./gradlew clean assembleDebug

# 打测试环境包
./gradlew clean assembleCtest

# 打生产环境包
./gradlew clean assembleRelease

复制代码

全局管理版本信息

项目中的 module 变多之后,如果要修改第三方库和App使用的SDK版本是一件很蛋疼的事情。应当建立一个配置文件进行管理,其他地方使用配置文件中设置的版本。

示例

在项目根目录下创建一个配置文件 config.gradle,里面放版本信息:

ext {
compile_sdk_version = 28
min_sdk_version = 17
target_sdk_version = 28

arouter_compiler_version = '1.2.2'
}

复制代码

在项目根目录下的 build.gradle 文件中的最上方使用以下代码引入配置文件:

apply from: "config.gradle"

复制代码

创建 module 后,修改该 module 中的 build.gradle 文件,将 SDK 版本默认值换成配置文件中的变量,按需添加第三方依赖,并使用 $ + 配置文件中的变量作为第三方库的版本:

android {
...
compileSdkVersion compile_sdk_version

defaultConfig {
...
minSdkVersion min_sdk_version
targetSdkVersion target_sdk_version
}
}

dependencies {
...
kapt "com.alibaba:arouter-compiler:$arouter_compiler_version"
}

复制代码

混淆

混淆文件不应该在 App 壳中集中定义,应当在每个 module 中各自定义自己的混淆。

示例

这里介绍配置 user-impl 的混淆,先在 user-impl 的 build.gradle 中配置消费者混淆文件:

android {
defaultConfig {
...
consumerProguardFiles 'proguard-rules.pro'
}
}

复制代码

在 proguard-rules.pro 文件中写入该 module 的混淆:

# 实体类
-keepclassmembers class cc.taylorzhang.demo.user.entity.** { *; }

复制代码

总结

组件化开发应当遵守"高内聚,低耦合"的原则,尽量少的对外暴露细节。如果用一句话来总结的话,就是代码和资源能放 module-impl 里面的就都放在 module-impl,因为代码隔离问题实在不能放 module-impl 里面的才放 module-api,最后因为涉及到循环依赖问题的才往 common 中放。

收起阅读 »

okhttp文件上传失败,居然是Android Studio背锅?太难了~

1、前言本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封...
继续阅读 »

1、前言

本案例是我本人遇到的真实案例,因查找原因的过程一度让我崩溃,我相信不少人也遇到过相同的问题,故将其记录下来,希望对大家有帮助,本案例使用RxHttp 2.6.4 + OkHttp 4.9.1版本,当然,如果你使用Retrofit等其它基于OkHttp封装的框架,且用到监听上传进度功能,那么很大概率你也会遇到这个问题,请耐心看完,如果你想直接看到结果,划到文章末尾即可。

2、问题描述

事情是这样的,有一段文件上传的代码,如下:

fun uploadFiles(fileList: List<File>) {
RxHttp.postForm("/server/...")
.add("key", "value")
.addFiles("files", fileList)
.upload {
//上传进度回调
}
.asString()
.subscribe({
//成功回调
}, {
//失败回调
})
}

这段代码在写完后很长一段时间内都是ok的,突然有一天,执行这段代码居然报错了,日志如下:

image.png 这个异常是100%出现的,很熟悉的异常,具体原因就是,数据流被关闭了,但依然往里面写数据,来看看最后抛异常的地方,如下:

image.png 可以看到,方法里面第一行代码就判断数据流是否已关闭,是的话,抛出异常。

注:如果你是RxHttp使用者,正在尝试这段代码,发现没问题,也不要惊讶,因为这需要在Android Studio特定场景下执行才会出现,而且是相对高频使用的场景,请待我一步步揭晓答案

3、一探究竟

本着出现问题,先定位到自己代码的原则,打开ProgressRequestBody类76行看看,如下:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
private BufferedSink bufferedSink;
@Override
public void writeTo(BufferedSink sink) throws IOException {
if (bufferedSink == null) {
bufferedSink = Okio.buffer(sink(sink));
}
requestBody.writeTo(bufferedSink); //这里是76行
bufferedSink.flush();
}
}

ProgressRequestBody继承了okhttp3.RequestBody类,作用是监听上传进度;显然最后执行到这里时,数据流已经被关闭了,从日志里可以看到,最后一次调用ProgressRequestBody#writeTo(BufferedSink)方法的地方在CallServerInterceptor拦截器的59行,打开看看

class CallServerInterceptor(private val forWebSocket: Boolean) : Interceptor {

//省略相关代码
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
//省略相关代码
if (responseBuilder == null) {
if (requestBody.isDuplex()) {
exchange.flushRequest()
val bufferedRequestBody = exchange.createRequestBody(request, true).buffer()
requestBody.writeTo(bufferedRequestBody)
} else {
val bufferedRequestBody = exchange.createRequestBody(request, false).buffer()
requestBody.writeTo(bufferedRequestBody) //这里是59行
bufferedRequestBody.close() //数据写完,将数据流关闭
}
}
}
}

熟悉OkHttp原理的同学应该知道,CallServerInterceptor拦截器是okhttp拦截器链的最后一个拦截器,将客户端数据写出到服务端,就是在这里实现的,也就是59行,那问题就来了,数据都还没写出去,数据流怎么就关闭了呢?这令我百思不得其解,毫无头绪。

于是乎,我做了很多无用功,如:重新检查代码,看看是否有手动关闭数据流的地方,显然没有找到;接着,实在没有办法,代码回滚,回滚到最初写这段代码的版本,我满怀期待的以为,这下应该没问题了,可尝试过后,依旧报java.lang.IllegalStateException: closed,成年人的崩溃就在这一瞬间,我陷入了绝境,已经消耗5个小时在这个问题上,此时已晚上23:30,看来又是一个不眠夜。

question1.jpeg

习惯告诉我,一个问题很久没查出来,可以先放弃,好吧,拔手机关电脑,洗澡睡觉。

半小时后,我躺在床上,很难受,于是我拿出手机,打开app,再试了试上传功能,惊奇的发现,可以了,上传成功了,这。。。。一脸懵逼,我找谁说理去,虽然没问题了,但问题没找到,作为一名初级程序员,这我无法接受。

精神的力量把我从床上扶了起来,再次打开电脑,连上手机,这次,果然有了新的收获,也一下子刷新了我的世界观;当我再次打开app,尝试上传文件时,一样的错误出现在我眼前,What??? 刚才还好好的,连上电脑就不行了?

question2.jpeg

ok,我彻底没脾气了,拔掉手机,重启app,再试,没问题了,再次连上电脑,再试,问题又出来了。。

此时,我的心态有了些许的好转,毕竟有了新的调查方向,我再次查看错误日志,发现了一个很奇怪的地方,如下:

image.png

com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor是从哪冒出来的?在我的认知里,OkHttp3是没有这个拦截器的,为了验证我的认知,再次查看okhttp3源码,如下:

image.png

确定是没有添加这个拦截器的,仔细看日志发现,OkHttp3InterceptorCallServerInterceptor、ConnectInterceptor之间执行的,那就只有一个解释,OkHttp3Interceptor是通过addNetworkInterceptor方法添加,现在就好办了,全局搜索addNetworkInterceptor就知道是谁添加的,哪里添加的,很可惜,未找到调用此方法的源码,似乎又陷入了绝境。

question.jpeg

那就只能开启调试,看看OkHttp3Interceptor是否在OkHttpClient对象的networkInterceptors网络拦截器列表里,一调试,果然有发现,如下:

image.png 调试点击下一步,神奇的事情就发生了,如下:

image.png

这怎么解释?networkInterceptors.size始终是0,interceptors.size是如何加1变为5的?再来看看,加的1是什么,如下:

image.png

很熟悉,就是我们之前提到的OkHttp3Interceptor,这是如何做到的?只有一个解释,OkHttpClient#networkInterceptors()方法被字节码插桩技术插入了新的代码,为了验证我的想法,我做了以下实验:

image.png

image.png

可以看到,我直接new了一个OkHttpClient对象,啥也没配置,调用networkInterceptors()方法,就获取了OkHttp3Interceptor拦截器,但OkHttpClient对象里的networkInterceptors列表中是没有这个拦截器的,这就证实了我的想法。

那现在的问题就是,OkHttp3Interceptor是谁注入的?跟文件上传失败是否有直接的关系?

OkHttp3Interceptor是谁注入的?

先来探索第一个问题,通过OkHttp3Interceptor类的包名class com.android.tools.profiler.agent.okhttp,我有以下3点猜测

  • 包名有com.android.tools,应该跟 Android 官方有关系

  • 包名有agent,又是拦截器,应该跟网络代理,也就是网络监控有关

  • 最后一点,也是最重要的,包名有profiler,这让我联想到了Android Studio(以下简称AS)里Profiler网络分析器

果然,在Google的源码中,真找到了OkHttp3Interceptor类,看看相关代码:

public final class OkHttp3Interceptor implements Interceptor {

//省略相关代码
@Override
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
HttpConnectionTracker tracker = null;
try {
tracker = trackRequest(request); //1、追踪请求体
} catch (Exception ex) {
StudioLog.e("Could not track an OkHttp3 request", ex);
}
Response response;
try {
response = chain.proceed(request);
} catch (IOException ex) {

}
try {
if (tracker != null) {
response = trackResponse(tracker, response); //2、追踪响应体
}
} catch (Exception ex) {
StudioLog.e("Could not track an OkHttp3 response", ex);
}
return response;
}

可以确定它就是一个网络监控器,但它是不是AS的网络监听器,我却还持怀疑态度,因为我这个项目没开启Profiler分析器,但我最近在开发room数据库相关功能,开启了数据分析器Database Inspector,难道跟这个有关?我尝试关掉Database Inspector,并且重启app,再次尝试文件上传,居然成功了,是真的成功了,你能信?我也不信,于是,再次开启Database Inspector,再次尝试文件上传,失败了,异常跟之前的一模一样;接着,我关闭Database Inspector,并且打开Profiler分析器,再次尝试文件上传,一样失败了。

我想到这里,基本可以认定OkHttp3Interceptor就是Profiler里面的网络监控器,但也好像缺乏直接证据,于是,我尝试改了下ProgressRequestBody类,如下:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
private BufferedSink bufferedSink;

@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,不写请求体,直接返回
if (sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker"))
return;
if (bufferedSink == null) {
bufferedSink = Okio.buffer(sink(sink));
}
requestBody.writeTo(bufferedSink);
bufferedSink.flush();
}
}

以上代码,仅仅加了一句if语句,这条语句可以判断当前调用方是不是OkHttp3Interceptor,是的话,不写请求体,直接返回;如果OkHttp3Interceptor就是Profiler里的网络监控器,那么此时Profiler里应该是看不到请求体的,也就是看不到请求参数,如下:

image.png

可以看到,Profiler里的网络监控器,没有监控到请求参数。

这就证实了OkHttp3Interceptor的确是Profiler里的网络监控器,也就是AS动态注入的。

OkHttp3Interceptor 与文件上传是否有直接的关系?

通过上面的案例分析,显然是有直接关系的,当你未打开Database InspectorProfiler时,文件上传一切正常。

OkHttp3Interceptor是如何影响文件上传的?

回到正题,OkHttp3Interceptor是如何影响文件上传的?这个就需要继续分析OkHttp3Interceptor的源码,来看看追踪请求体的代码:

public final class OkHttp3Interceptor implements Interceptor {

private HttpConnectionTracker trackRequest(Request request) throws IOException {
StackTraceElement[] callstack =
OkHttpUtils.getCallstack(request.getClass().getPackage().getName());
HttpConnectionTracker tracker =
HttpTracker.trackConnection(request.url().toString(), callstack);
tracker.trackRequest(request.method(), toMultimap(request.headers()));
if (request.body() != null) {
OutputStream outputStream =
tracker.trackRequestBody(OkHttpUtils.createNullOutputStream());
BufferedSink bufferedSink = Okio.buffer(Okio.sink(outputStream));
request.body().writeTo(bufferedSink); // 1、将请求体写入到BufferedSink中
bufferedSink.close(); // 2、关闭BufferedSink
}
return tracker;
}

}

想到这里问题就很清楚了,上面备注的第一代码中request.body(),拿到的就是ProgressRequestBody对象,随后调用其writeTo(BufferedSink)方法,传入BufferedSink对象,方法执行完,就将BufferedSink对象关闭了,然而,ProgressRequestBody里却将BufferedSink声明为成员变量,并且为空时才会赋值,这就导致后续CallServerInterceptor调用其writeTo(BufferedSink)方法时,使用的还是上一个已关闭的BufferedSink对象,此时再往里面写数据,自然就java.lang.IllegalStateException: closed异常了。

4、如何解决

知道了具体的原因,就好解决,将ProgressRequestBody里面的BufferedSink对象改为局部变量即可,如下:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}

改完后,开启Profiler里的网络监控器,再次尝试文件上传,ok成功了,但又有一个新的问题,ProgressRequestBody是用于监听上传进度的,OkHttp3InterceptorCallServerInterceptor先后调用了其writeTo(BufferedSink)方法,这就会导致请求体写两次,也就是进度监听会收到两遍,而我们真正需要的是CallServerInterceptor调用的那次,咋整?好办,我们前面就判断过调用方是否OkHttp3Interceptor

于是,做出如下更改:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,直接写请求体,不再通过包装类来处理请求进度
if (sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
requestBody.writeTo(bufferedSink);
} else {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}
}

你以为这样就完了?相信很多人都会用到com.squareup.okhttp3:logging-interceptor日志拦截器,当你添加该日志拦截器后,再次上传文件,会发现,进度回调又执行了两遍,为啥?因为该日志拦截器,也会调用ProgressRequestBody#writeTo(BufferedSink)方法,看看代码:

//省略部分代码
class HttpLoggingInterceptor @JvmOverloads constructor(
private val logger: Logger = Logger.DEFAULT
) : Interceptor {

@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val requestBody = request.body

if (logHeaders) {
if (!logBody || requestBody == null) {
logger.log("--> END ${request.method}")
} else if (bodyHasUnknownEncoding(request.headers)) {
logger.log("--> END ${request.method} (encoded body omitted)")
} else if (requestBody.isDuplex()) {
logger.log("--> END ${request.method} (duplex request body omitted)")
} else if (requestBody.isOneShot()) {
logger.log("--> END ${request.method} (one-shot body omitted)")
} else {
val buffer = Buffer()
//1、这里调用了RequestBody的writeTo方法,并传入了Buffer对象
requestBody.writeTo(buffer)
}
}

val response: Response
try {
response = chain.proceed(request)
} catch (e: Exception) {
throw e
}
return response
}

}

可以看到,HttpLoggingInterceptor内部也会调用RequestBody#writeTo方法,并传入Buffer对象,到这,我们就好办了,在ProgressRequestBody类增加一个Buffer的判断逻辑即可,如下:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是OkHttp3Interceptor,或者传入的是Buffer对象,直接写请求体,不再通过包装类来处理请求进度
if (sink instanceof Buffer
|| sink.toString().contains(
"com.android.tools.profiler.support.network.HttpTracker$OutputStreamTracker")) {
requestBody.writeTo(bufferedSink);
} else {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
}
}
}

这样就完了?也不见得,如果后续又遇到什么拦截器调用其writeTo方法,还是会出现进度回调执行两遍的情况,只能在遇到这种情况时,加入对应的判断逻辑

到这,也许有人会问,为啥不直接判断调用方是不是CallServerInterceptor,是的话监听进度回调,否则,直接写入请求体。想法很好,也是可行的,如下:

public class ProgressRequestBody extends RequestBody {

//省略相关代码
@Override
public void writeTo(BufferedSink sink) throws IOException {
//如果调用方是CallServerInterceptor,监听上传进度
if (sink.toString().contains("RequestBodySink(okhttp3.internal")) {
BufferedSink bufferedSink = Okio.buffer(sink(sink));
requestBody.writeTo(bufferedSink);
bufferedSink.colse();
} else {
requestBody.writeTo(bufferedSink);
}
}
}

但是该方案有个致命的缺陷,如果okhttp未来版本更改了目录结构,ProgressRequestBody类就完全失效。

两个方案就由大家自己去选择,这里给出ProgressRequestBody完整源码,需要自取

5、小结

本案例上传失败的直接原因就是在AS开启了Database Inspector数据库分析器或Profiler网络监控器时,AS就会通过字节码插桩技术,对OkHttpClient#networkInterceptors()方法注入新的字节码,使其多返回一个com.android.tools.profiler.agent.okhttp.OkHttp3Interceptor拦截器(用于监听网络),该拦截器会调用ProgressRequestBody#writeTo(BufferedSink)方法,并传入BufferedSink对象,writeTo方法执行完毕后,立即将BufferedSink对象关闭,在随后的CallServerInterceptor拦截又调用ProgressRequestBody#writeTo(BufferedSink)方法往已关闭的BufferedSink对象写数据,最终导致java.lang.IllegalStateException: closed异常。

收起阅读 »

iOS逆向必须了解的logos语法

一、概述Logos语法其实是CydiaSubstruct框架提供的一组宏定义。便于开发者使用宏进行HOOK操作。语法简单,功能强大且稳定,它是跨平台的。[logos] http://iphonedevwiki.net/index.php/Logos二...
继续阅读 »

一、概述

Logos语法其实是CydiaSubstruct框架提供的一组宏定义。便于开发者使用宏进行HOOK操作。语法简单,功能强大且稳定,它是跨平台的。[logos] 

http://iphonedevwiki.net/index.php/Logos

二、logos语法

logos语法分为3类。

2.1、Block level

这一类型的指令会开辟一个代码块,以%end结束。

%group

用来将代码分组。开发中hook代码会很多,这样方便管理Logos代码。所有的group都必须初始化,否则编译报错。


#import <UIKit/UIKit.h>

%group group1

%hook RichTextView

- (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
//hook后要处理的方式1
return %orig;
}

%end

%end


%group group2

%hook RichTextView

- (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
//hook后要处理的方式2
return %orig;
}

%end

%end

%group group3

%hook RichTextView

- (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
//hook后要处理的方式3
return %orig;
}

%end

%end

//使用group要配合ctor
%ctor {
//[[UIDevice currentDevice] systemVersion].doubleValue 可以用来判断版本或其它逻辑。
if ([[UIDevice currentDevice] systemVersion].doubleValue >= 11.0) {
//这里group3会覆盖group1,不会执行group1逻辑。
%init(group1)%init(group3);
} else {
%init(group2);
}
}

  • group初始化在%ctor中,需要%init初始化。
  • 所有group必须初始化,否则编译报错。
  • 在一个逻辑中同时初始化多个group,后面的会覆盖前面的。
  • 在不添加group的情况下,默认有个_ungrouped组,会自动初始化。

  • Begin a hook group with the name Groupname. Groups cannot be inside another [%group](https://iphonedev.wiki/index.php/Logos#.25group "Logos") block. All ungrouped hooks are in the implicit "_ungrouped" group. The _ungrouped group is initialized for you if there are no other groups. You can use the %initdirective to initialize it manually. Other groups must be initialized with the %init(Groupname) directive

    %hook

    HOOK某个类里面的某个方法。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //hook后要处理的方式1
    return %orig;
    }

    %end

    %hook后面需要跟需要hook的类名。

    %new
    为某个类添加新方法,在%hook 和 %end 中使用。

    %hook RichTextView

    %new
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    }

    %end

    %subclass

    %subclass Classname: Superclass <Protocol list>

    运行时创建子类,只能包含方法或者关联属性,不能包含属性。可以通过%c创建类实例。

    #import <UIKit/UIKit.h>

    @interface MyObject

    - (void)setSomeValue:(id)value;

    @end

    %subclass MyObject : NSObject

    - (id)init {
    self = %orig;
    [self setSomeValue:@"value"];
    return self;
    }

    %new
    - (id)someValue {
    return objc_getAssociatedObject(self, @selector(someValue));
    }

    %new
    - (void)setSomeValue:(id)value {
    objc_setAssociatedObject(self, @selector(someValue), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    %end

    %property

    %property (nonatomic|assign|retain|copy|weak|strong|getter|setter) Type name;

    subclass或者hook的类添加属性。必须在 %subclass 或%hook中。

    %property(nonatomic,assign) NSInteger age;

    %end

    与其它命令配对出现。

    2.2、Top level

    TopLevel指令不放在BlockLevel中。

    %config

    %config(Key=Value);

    logos设置标记。

    Configuration Flags

    keyvaluesnotes
    generatorMobileSubstrate生成的代码使用MobileSubstrate hook
    generatorinternal生成的代码只使用OC runtime方法hook
    warningsnone忽略所有警告
    warningsdefault没有致命的警告
    warningserror使所有警告报错
    dumpyamlYAML格式转储内部解析树

    %config(generator=internal);
    %config(warnings=error);
    %config(dump=yaml);

    %hookf

    hook函数,类似fishhook
    语法

    %hookf(rtype, symbolName, args...) { … }
    • rtype:返回值。
    • symbolName:原函数地址。
    • args...:参数。
      示例
    FILE *fopen(const char *path, const char *mode);
    %hookf(FILE *, fopen, const char *path, const char *mode) {
    NSLog(@"Hey, we're hooking fopen to deny relative paths!");
    if (path[0] != '/') {
    return NULL;
    }
    return %orig;
    }

    %ctor

    构造函数,用于确定加载那个组。和%init结合用。

    %dtor

    析构,做一些收尾工作。比如应用挂起的时候。

    2.3、Function level

    这一块的指令就放在方法中

    %init

    用来初始化某个组。

    %class

    %class Class;

    %class已经废弃了,不建议使用。

    %c

    类似getClass函数,获得一个类对象。一般用于调用类方法。

    //只是为了声明编译通过
    @interface MainViewController

    + (void)HP_classMethod;

    @end


    %hook MainViewController

    %new
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //方式一
    // [self.class HP_classMethod];
    //方式二
    // [NSClassFromString(@"MainViewController") HP_classMethod];
    //方式三
    [%c(MainViewController) HP_classMethod];
    }

    %new
    + (void)HP_classMethod {
    NSLog(@"HP_classMethod");
    }

    %end
    • %c 中没有引号。

    %orig

    保持原有的方法实现,如果原来的方法有返回值和参数,那么可以传递参数和接收返回值。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    //传递参数&接收返回值。
    BOOL result1 = %orig(arg1,arg2,arg3,arg4);
    BOOL result2 = %orig;
    return %orig;
    }

    %end


    • %orig可以接收返回值。
    • 可以传递参数,不传就是传递该方法的默认参数。

    %log

    能够输出日志,输出方法调用的详细信息 。

    %hook RichTextView

    - (_Bool)setPrefixContent:(id)arg1 TargetContent:(NSString *)arg2 TargetParserString:(id)arg3 SuffixContent:(id)arg4 {
    %log;
    return %orig;
    }

    %end
    输出:

     WeChat[11309:6708938] -[<RichTextView: 0x15c4c9720> setPrefixContent:(null) TargetContent:钱已经借给你了。 TargetParserString:<contentMD5>0399062cd62208dad884224feae2aa30</contentMD5><fontsize>20.287109</fontsize><fwidth>240.000000</fwidth><parser><type>1</type><range>{0, 8}</range><info><![CDATA[<style><range>{0, 8}</range><rect>{{0, 0}, {135, 21}}</rect></style>]]></info></parser> SuffixContent:(null)]

    能够输出详细的日志信息,包含类、方法、参数、以及控件信息等详细信息。


    总结

    • logos语法其实是CydiaSubstruct框架提供的一组宏定义。
    • 语法
      • %hook%end勾住某个类,在一个代码块中直接写需要勾住的方法。
      • %group%end用于分组。
        • 每一组都需要%ctor()函数构造。
        • 通过%init(组名称)进行初始化。
      • %log输出方法的详细信息(调用者、方法名、方法参数)
      • %orig调用原始方法。可以传递参数,接收返回值。
      • %c类似getClass函数,获取一个类对象。
      • %new添加某个方法。
    • .xm文件代表该文件支持OCC/C++语法。
    • 编译该文件时需要导入头文件以便编译通过。.xm文件不参与代码的执行,编译后生成的.mm文件参与代码的执行。


    作者:HotPotCat
    链接:https://www.jianshu.com/p/70151c602886






    收起阅读 »

    iOS逆向需要了解的OpenSSH

    这两个源比较有名,推荐添加。然后在搜索中搜索apt.bingner.com。当然直接添加这个源也可以。电脑(客户端)请求连接手机(ip:22)。手机(服务端)将公钥发送给mac电脑通过收到的公钥加密登录密码。手机利用私钥解密登录密码,返回是否登录成功。上面的登...
    继续阅读 »

    一、OpenSSH概述


    1.1 SSH

    SSH是一种网络协议,用于计算机之间的加密登录。
    1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。

    1.2 OpenSSH

    OpenSSH 是 SSH (Secure SHell) 协议的免费开源实现。它是一款软件,应用非常广泛。SSH协议可以用来进行远程控制, 或在计算机之间传送文件。

    1.2.1 OpenSSH插件安装

    通过OpenSSH插件可以连接手机,进行远程控制, 或者传送文件。以cydia为例,需要在软件源中添加源:


    //蜜蜂源
    apt.cydiami.com
    //雷锋源
    apt.abcydia.com

    这两个源比较有名,推荐添加。





    • 软件源可以理解为服务器,存放了插件安装包。

    然后在搜索中搜索OpenSSH,认准来自apt.bingner.com。当然直接添加这个源也可以。




    1.3 SSH登录过程



    1. 电脑(客户端)请求连接手机(ip:22)。
    2. 手机(服务端)将公钥发送给mac电脑。


    1. mac电脑通过收到的公钥加密登录密码。
    2. 手机利用私钥解密登录密码,返回是否登录成功。

    1.4 中间人攻击(Man-in-the-middle attack)

    上面的登录方式存在一种隐患。如果有人 冒充服务器 将生成的 虚假公钥 发给客户端,那么它将获得客户端连接服务器的 密码


    1. 中间人模拟电脑给手机发送登录请求获取手机端公钥(I)
    2. 然后自己生成公私钥(M)将自己生成的公钥(M)发送给电脑
    3. 电脑端密码使用公钥(M)加密后发送给中间人,中间人使用私钥(M)解密拿到密码。
    4. 中间人将密码通过公钥(I)加密从而实现登录。

    那么怎么解决呢?
    这个也就是通过登录的时候返回的hash值来验证公钥的。一般服务器都会在自己的官网上公布自己公钥的hash值。这样就有效避免中间人攻击了。


    二、连接手机

    通过OpenSSH插件使用Wifi连接手机:ssh 用户名@手机IP地址

    • 在这里手机是服务端,电脑是客户端。OpenSSH是让手机开启SSH登录服务。
    • 登录:ssh 用户名@手机IP地址
    • 默认密码:alpine

    首次连接会出现保存提示,需要输入yes继续

      ~ ssh root@172.20.10.11
    The authenticity of host '172.20.10.11 (172.20.10.11)' can't be established.
    RSA key fingerprint is SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes

    这里其实是提示公钥key 的hash值让验证有没有被篡改的。

    确认后需要输入密码alpine(默认),输入密码后就登录成功了。

    Warning: Permanently added '172.20.10.11' (RSA) to the list of known hosts.
    root@172.20.10.11's password:
    zaizai:~ root#

    2.1 查看文件目录


    在 root用户目录下:

    zaizai:~ root# ls
    Application\ Support/ Library/ Media/

    cd /进入根目录下:

    zaizai:~ root# cd /
    zaizai:/ root# ls
    Applications/ Library/ User@ boot/ dev/ lib/ private/ tmp@ var@
    Developer/ System/ bin/ cores/ etc@ mnt/ sbin/ usr/

    查看安装应用列表:

    zaizai:/ root# cd Applications/
    zaizai:/Applications root# ls
    AXUIViewService.app/
    AccountAuthenticationDialog.app/
    ActivityMessagesApp.app/
    AnimojiStickers.app/
    AppSSOUIService.app/
    AppStore.app/
    Apple\ TV\ Remote.app/
    AskPermissionUI.app/
    AuthKitUIService.app/
    BarcodeScanner.app/
    BusinessChatViewService.app/
    BusinessExtensionsWrapper.app/
    CTCarrierSpaceAuth.app/
    CTKUIService.app/
    CTNotifyUIService.app/
    Camera.app/
    CarPlaySettings.app/
    CarPlaySplashScreen.app/

    ps -A查看当前进程:

    zaizai:/Applications root# ps -A
    PID TTY TIME CMD
    1 ?? 13:45.28 /sbin/launchd
    295 ?? 3:17.53 /usr/libexec/substituted
    296 ?? 0:00.00 (amfid)
    1585 ?? 0:00.00 /usr/libexec/amfid
    12460 ?? 0:00.13 /System/Library/Frameworks/WebKit.framework/XPCService
    12461 ?? 0:00.10 /System/Library/Frameworks/WebKit.framework/XPCService
    12489 ?? 0:00.06 /usr/libexec/tzd
    12522 ?? 0:00.05 /System/Library/PrivateFrameworks/FontServices.framewo
    12524 ?? 0:01.04 /System/Library/PrivateFrameworks/CoreSuggestions.fram
    12528 ?? 0:00.44 /System/Library/PrivateFrameworks/DeviceCheckInternal.
    12538 ?? 0:00.03 /usr/libexec/OTATaskingAgent server-init
    12539 ?? 0:00.05 /usr/libexec/tailspind
    12542 ?? 0:00.58 /usr/libexec/ptpd -t usb
    12545 ?? 0:00.50 /usr/libexec/adprivacyd
    12908 ?? 0:01.20 /System/Library/PrivateFrameworks/AppleMediaServicesUI
    13275 ?? 0:01.73 /usr/libexec/remindd
    13280 ?? 0:00.04 /usr/libexec/microstackshot
    13283 ?? 0:00.24 /System/Library/PrivateFrameworks/DifferentialPrivacy.
    13286 ?? 0:00.14 /System/Library/Frameworks/FileProvider.framework/Plug
    13289 ?? 0:19.42 /System/Library/PrivateFrameworks/AssistantServices.fr
    13294 ?? 0:00.07 /usr/libexec/proactiveeventtrackerd
    13298 ?? 0:00.32 /usr/libexec/gamecontrollerd
    13357 ?? 0:00.17 sshd: root@ttys i
    13359 ttys000 0:00.08 -sh
    13365 ttys000 0:00.04 ps -A

    查看微信进程ps -A | grep WeChat

    zaizai:/Applications root# ps -A | grep WeChat
    12459 ?? 0:18.22 /var/containers/Bundle/Application/295AC27A-5F06-4099-85AC-32EBA9FC9373/MonkeyDemo.app/WeChat
    13373 ttys000 0:00.02 grep WeChat

    这个时候MachO文件路径就找到了。


    exit可以退出登录:


    zaizai:~/Media root# exit
    logout
    Connection to 172.20.10.11 closed.

    2.2 用户

    iOS系统下有两个用户:rootmobile




    • root:最高权限用户,可以访问任意文件。
    • mobile:普通用户,只能访问改用户目录下文件/var/Mobile

    mobile用户在自己的目录下可以创建文件,在根目录下没有权限:


    2.3 修改用户密码

    • root用户可以修改所有用户的密码。
    • passwd命令修改密码:
      • passwd 用户名
      • 输入两次新密码,确认修改。因为是登录状态所以不用输入原始密码。

    root用户修改mobile用户密码:


      ~ ssh root@172.20.10.11
    zaizai:~ root# passwd mobile
    Changing password for mobile.
    New password:
    Retype new password:
    zaizai:~ root#
    一般不推荐修改密码,直接配置免密登录就好了。如果修改密码后忘记了那么重新安装就好了。


    2.4 密钥保存验证

    通过1.3 SSH登录过程我们知道在首次登录的时候会提示验证公钥hash值,并且保存公钥~/.ssh目录下的known_hosts中,那么公私钥手机中也应该是有的。
    进入手机cd /etc/ssh目录:


    可以看到ssh_host_rsa_key的公私钥。这也就验证了上面的登录过程。




    如果下次ip地址变了再登录就访问不了了,出提示中间人攻击。


    2.5 免密登录(公钥登录)

    2.5.1 免密登录原理

    免密码登录也称公钥登录,原理就是用户将自己的公钥储存在远程主机上。登录的时候,远程主机会向用户发送一段随机字符串,用户用自己的私钥加密后再发回来。远程主机用事先储存的公钥进行解密,如果成功,就证明用户是可信的直接允许登录不再要求密码。



    1. mac将自己的公钥(mac)存储在手机上。
    2. 登录的时候手机发送一个随机字符串给mac
    3. mac通过私钥加密字符串发送回给手机。
    4. 手机利用保存的mac公钥进行解密验证。

    这样就完成了免密登录。

    2.5.2 免密登录配置

    1.客户端在~/.ssh/目录下生成公私钥ssh-keygen


      .ssh ssh-keygen
    Generating public/private rsa key pair.
    Enter file in which to save the key (/Users/zaizai/.ssh/id_rsa):
    Enter passphrase (empty for no passphrase):
    Enter same passphrase again:
    Your identification has been saved in /Users/zaizai/.ssh/id_rsa.
    Your public key has been saved in /Users/zaizai/.ssh/id_rsa.pub.
    The key fingerprint is:
    SHA256:dJFdigu6cijJlQf9AaNVBGZPTcLO9itHE/RDT/QiCQk cozhang@zaizai
    The key's randomart image is:
    +---[RSA 3072]----+
    | B=E+++ oo |
    | * =..=+oo.. |
    | o .o=.oo+o. .|
    | o +++..o... |
    | o o.S... . |
    | . o o . + |
    | + o o . o |
    | . o . o |
    | o |
    +----[SHA256]-----+

    一路回车不设置密码(如果设置密码虽然免密登录了,但是每次都要输rsa的密码)。

    2.拷贝公钥SSH服务器ssh-copy-id 用户名@服务器IP

    ➜  .ssh ssh-copy-id root@172.20.10.11
    /usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/Users/zaizai/.ssh/id_rsa.pub"
    /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
    /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
    root@172.20.10.11's password:

    Number of key(s) added: 1

    Now try logging into the machine, with: "ssh '
    root@172.20.10.11'"
    and check to
    make sure that only the key(s) you wanted were added.

    拷贝的时候需要输入root账户的密码。这个时候再登录就不需要输入密码了:

    ➜  ~ ssh root@172.20.10.11
    zaizai:~ root#
    ssh-copy-id可以通过-i指定文件。某些系统通过指定-i会无效。(虽然拷贝成功,但是验证的是ssh-copy-id自己生成的key)。

    3.拷贝的公钥在服务器~/.ssh/authorized_keys中:



    在某些版本中ssh-copy-id不需要我们生成公钥,该命令会自己生成公私钥进行拷贝。如果遇见自己生成的公钥key和和拷贝到authorized_keys中的对不上那么很可能是这个问题。

    2.6 配置快捷登录


    加入我们有多台手机,或者并不想输入ip那么麻烦的去登录。在~/.ssh下创建一个config文件,对ssh登录配置别名:

    Host iPhone7
    Hostname 172.20.10.11
    User root
    Port 22

    使用:

    ➜  ~ ssh iPhone7
    zaizai:~ root#

    这样就配置好了别名,可以登录了。


    2.7 SSH其它操作

    • 删除保存的服务器地址的key:ssh-keygen –R 服务器IP地址(当SSH登录手机,手机就是服务器)`

    • know_hosts文件:用于保存SSH登录服务器所接受的key,在系统~/.ssh 目录

    • ssh_host_rsa_key.pub文件:作为SSH服务器发送给连接者的key,在系统/etc/ssh 目录中

    • config文件:在~/.ssh 目录下创建一个config文件。内部可以配置ssh登录的别名。


    Host 别名
    Hostname IP地址
    User 用户名
    Port 端口号

    三、USB登录(推荐)

    上面我们都是通过wifi连接的,由于通过wifi链接存在不稳定性,有时候会断开链接,并且有速度限制。所以推荐使用usb链接。苹果有一个服务,叫usbmuxd,这个服务主要用于在USB协议上实现多路TCP连接。
    usbmuxd目录:

    /System/Library/PrivateFrameworks/MobileDevice.framework/Resources



    3.1 USB 连接

    3.1.1 python脚本映射端口

    ssh root@172.20.10.11其实也就是ssh -p 22 root@172.20.10.11,默认22端口省略了,我们可以通过ssh -p 12345 root@localhost连接,只要将本地的12345端口映射到usb端口,只要usb端口连接哪个设备就相当于给哪个设备发送请求。
    有个python-client工具可以映射端口:



    python tcprelay.py -t 要映射端口:本地端口

      python-client python tcprelay.py -t 22:12345
    Forwarding local port 12345 to remote port 22

    将本地的12345端口映射到设备的TCP端口22。这样就可以通过本地的12345端口建立连接了。


    3.1.2 通过USB进行SSH连接

    映射成功后想要登录直接:

    //也可以 ssh -p 12345 root@127.0.0.1
    ssh -p 12345 root@localhost
    这里有个注意点是映射端口成功后不能关闭窗口,否则映射就没有了。
    ssh连接本地的12345,由于做了端口映射所以会通过usb连接对面设备的22端口。

      ~ ssh -p 12345 root@localhost
    The authenticity of host '[localhost]:12345 ([127.0.0.1]:12345)' can't be established.
    RSA key fingerprint is SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
    Warning: Permanently added '[localhost]:12345' (RSA) to the list of known hosts.
    zaizai:~ root#

    这里会重新进行rsa本地记录(ip变了),免密登录仍然有效。



    ip变了,相当于登录一个新的服务器。所以保存rsa


    3.1.3 验证中间人攻击


    这个时候换一台设备进行ssh -p 12345 root@localhost登录就会提示中间人攻击了,由于本地localhost对应的rsa和新手机返回的hash值对应不上。如果只有一台手机可以通过修改know_hosts对应的localhostrsa公钥模拟:


      ~ ssh -p 12345 root@localhost
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
    Someone could be eavesdropping on you right now (man-in-the-middle attack)!
    It is also possible that a host key has just been changed.
    The fingerprint for the RSA key sent by the remote host is
    SHA256:pIPlaWYd9wT2MfpRqvP/WOe1wVXfVVKiCKttyPHK3f0.
    Please contact your system administrator.
    Add correct host key in /Users/zaizai/.ssh/known_hosts to get rid of this message.
    Offending RSA key in /Users/zaizai/.ssh/known_hosts:4
    RSA host key for [localhost]:12345 has changed and you have requested strict checking.
    Host key verification failed.
    所以如果有两台手机可以分别通过ssh -p 12345 root@localhostssh -p 12345 root@127.0.0.1登录,就能区分开了。

    3.2 配置USB快捷登录


    这个时候在 config中取别名就不行了,因为有端口的映射,并且地址也不是真实的地址。
    在自己的脚本目录创建一个iPhone7.sh文件(最好给这个目录配置环境变量),脚本内容如下:

    ssh -p 12345 root@localhost

    那么这个时候还需要端口映射的脚本usbConnect.sh,内容如下:

    python /Users/zaizai/HPShell/python-client/tcprelay.py -t 22:12345

    端口映射脚本和连接脚本分开是为了方便多个设备切换,由于映射只需要一次。


    使用:

    //映射端口
    ~ usbConnect.sh
    //链接
    ~ iPhone7.sh

    这样就连接上手机了。




    需要两个窗口执行,映射完窗口一直存在的。
    脚本目录文件:




    3.3 Iproxy端口映射

    Iproxy也是一个映射工具。

    3.3.1 libimobiledevice 安装

    brew install libimobiledevice

    3.3.2 映射端口

    iproxy 本地端口 要映射端口

    iproxy 12345 22 

    这个映射和python脚本是反过来的。左边是本地端口,右边是要映射端口。其它的使用方式相同。

    映射终端:

      ~ iproxy 12345 22
    Creating listening port 12345 for device port 22
    waiting for connection
    New connection for 12345->22, fd = 5
    waiting for connection
    Requesting connecion to USB device handle 3 (serial: 5d38c0a07ffa912050c2cbc05da5436e10a2d5d7), port 22

    连接终端:

    ➜  ~ iPhone7.sh
    zaizai:~ root#


    总结

    • SSH是一种网络协议。OpenSSH是一款软件。
    • SSH登录过程:
      • 远程主机(服务器)收到用户登录请求,将自己的公钥发送给用户端
      • 用户端使用公钥将自己登录的密码加密发送
      • 远程主机(服务端)使用私钥解密登录密码。密码正确则通过登录。
    • 中间人攻击:冒充服务端将虚拟公钥发送给客户端 。截获用户连接服务器的密码。
    • 服务器防护
      • 服务器在第一次登录时会让客户端保存IP-公钥这个KEY
      • KEY存放在~/.ssh/know_hosts文件中
      • 一般SSH服务器会将自己KEYHASH值公布在网站上
    • 免密登录(公钥登录)
      • 生成公私钥$ssh-keygen
      • ssh-copy-id将公钥拷贝到SSH服务器
      • 原理:
        • 用户将自己的公钥存储在远程服务器上
        • 登录的时候,远程服务器会向用户发送一串随机字符串
        • 用户用自己的私钥加密后再发送给服务器
        • 服务器用事先存储的公钥进行解密。如果成功就证明是真实用户登录,直接允许登录。
    • 取别名
      • ~/.ssh目录中有一个config用来配置SSH
      • 通过Host(别名)Hostname(IP)User(用户名)Port(端口)配置登录的别名
    • 端口映射(USB连接)
      • iproxy
      • python脚本



    作者:HotPotCat
    链接:https://www.jianshu.com/p/51f989c373da





    收起阅读 »

    iOS砸壳

    一、砸壳软件脱壳,顾名思义,就是对软件加壳的逆操作,把软件上存在的壳去掉(解密)。1.1 砸壳原理1.1.1 应用加壳(加密)提交给Appstore发布的App,都经过官方保护而加密,这样可以保证机器上跑的应用是苹果审核过的,也可以管理软件授权(企业包默认情况...
    继续阅读 »

    一、砸壳

    软件脱壳,顾名思义,就是对软件加壳的逆操作,把软件上存在的壳去掉(解密)。

    1.1 砸壳原理

    1.1.1 应用加壳(加密)

    提交给Appstore发布的App,都经过官方保护而加密,这样可以保证机器上跑的应用是苹果审核过的,也可以管理软件授权(企业包默认情况下也是没有加密的,TF是加壳的。)。经过App Store加密的应用,我们无法通过Hopper等反编译静态分析,也无法Class-Dump,在逆向分析过程中需要对加密的二进制文件进行解密才可以进行静态分析,这一过程就是大家熟知的砸壳(脱壳)。

    App Store是通过对称加密(AES)加壳的,为了速度和效率。

    1.1.2 应用砸壳(解密)

    静态砸壳
    静态砸壳就是在已经掌握和了解到了壳应用的加密算法和逻辑后在不运行壳应用程序的前提下将壳应用程序进行解密处理。静态脱壳的方法难度大,而且加密方发现应用被破解后就可能会改用更加高级和复杂的加密技术。

    动态砸壳  
    动态砸壳就是从运行在进程内存空间中的可执行程序映像(image)入手,来将内存中的内容进行转储(dump)处理来实现脱壳处理。这种方法实现起来相对简单,且不必关心使用的是何种加密技术。在iOS中都是用的动态砸壳。

    1.2 iOS应用运行原理


  • 加了壳的程序CPU是读不懂的,只有解密后才能载入内存。
  • iOS系统内核会对MachO进行脱壳。
  • 所以我们只需要将解密后的MachO拷贝出来。
  • 非越狱手机做不到跨进程访问,越狱后拿到root权限就可以访问了。这就是砸壳的原理。(按页加解密-代码段)


  • 二、Clutch

    Clutch是由KJCracks开发的一款开源砸壳工具。工具支持iPhoneiPod TouchiPad。该工具需要使用iOS8.0以上的越狱手机应用。

    2.1安装

    Clutch官网找到发布的版本下载:




    查看这个文件可以看到支持arm_v7arm_v7sarm64设备:

    file Clutch-2.0.4
    Clutch-2.0.4: Mach-O universal binary with 3 architectures: [arm_v7:Mach-O executable arm_v7] [arm_v7s:Mach-O executable arm_v7s] [arm64:Mach-O 64-bit executable arm64]
    Clutch-2.0.4 (for architecture armv7): Mach-O executable arm_v7
    Clutch-2.0.4 (for architecture armv7s): Mach-O executable arm_v7s
    Clutch-2.0.4 (for architecture arm64): Mach-O 64-bit executable arm64

    2.2 使用


    映射端口,python或者iproxy都可以

    usbConnect.sh
    1. 拷贝Clutch到手机(注意加可执行权限)
      scp -P 端口 文件 用户@地址:目录/别名
    ➜ scp -P 12345 ./Clutch-2.0.4  root@localhost:/var/root/Clutch
    Clutch-2.0.4 100% 1204KB 32.1MB/s 00:00

    手机端查看:

    zaizai:~ root# ls
    Application\ Support/ Clutch Library/ Media/

    zaizai:~ root# ls -l
    total 1204
    drwxr-xr-x 3 root wheel 96 Mar 17 2018 Application\ Support/
    -rw-r--r-- 1 root wheel 1232832 May 25 16:59 Clutch
    drwxr-xr-x 11 root wheel 352 Oct 23 2019 Library/
    drwxr-xr-x 2 root wheel 64 Feb 27 2008 Media/
    加可执行权限:

    zaizai:~ root# chmod +x Clutch
    zaizai:~ root# ls -l
    total 1204
    drwxr-xr-x 3 root wheel 96 Mar 17 2018 Application\ Support/
    -rwxr-xr-x 1 root wheel 1232832 May 25 16:59 Clutch*
    drwxr-xr-x 11 root wheel 352 Oct 23 2019 Library/
    drwxr-xr-x 2 root wheel 64 Feb 27 2008 Media/
    1. 列出可以砸壳的应用列表 Clutch -i
    root# ./Clutch -i
    1. 砸壳 Clutch –d 应用ID
    root# Clutch –d  4
    砸壳成功后的应用在Device->private->var->mobileDocuments->Dumped目录下。

    自己拷贝的应用是加壳的。

    1. 在手机端通过ps -A找到进程:
    14837 ??         0:03.93 /var/containers/Bundle/Application/8F382114-BBA7-4D81-AA3E-3CD02E03E23E/WeChat.app/WeChat
    16560 ttys000 0:00.02 grep WeChat
    1. 然后拷贝:
    scp -P 12345 root@localhost://var/containers/Bundle/Application/8F382114-BBA7-4D81-AA3E-3CD02E03E23E/WeChat.app/WeChat
     otool -l WeChat  | grep crypt
    cryptoff 28672
    cryptsize 4096
    cryptid 1

    三、插入动态库

    是通过DYLD_INSERT_LIBRARIES来实现的。

    1. 创建一个HPHook动态库,创建一个类实现一个+load方法:
    + (void)load {
    NSLog(@"\n\n\nInject SUCCESS 🍉🍉🍉\n\n\n");
    }

    编译拷贝出HPHook.framework

    1. 拷贝HPHook.framework到越狱手机
     scp -r -P 12345 HPHook.framework root@localhost:/var/root
    CodeResources 100% 2258 301.0KB/s 00:00
    HPHook 100% 85KB 9.0MB/s 00:00
    HPHook.h 100% 422 91.2KB/s 00:00
    module.modulemap 100% 93 25.4KB/s 00:00
    Info.plist 100% 744 187.8KB/s 00:00

    • -r:代表循环拷贝文件夹。
    1. 查看手机App进程(任意一个)
    zaizai:/var/root mobile$ ps -A | grep InsertDemo
    16708 ?? 0:00.13 /var/containers/Bundle/Application/5AC46FE0-EB40-4FE2-BEA5-1AED9C95E7E9/InsertDemo.app/InsertDemo
    16710 ttys000 0:00.01 grep InsertDemo

    1. HPHook.framework插入步骤3中的App

    zaizai:/var/root mobile$ DYLD_INSERT_LIBRARIES=HPHook.framework/HPHook  /var/containers/Bundle/Application/5AC46FE0-EB40-4FE2-BEA5-1AED9C95E7E9/InsertDemo.app/InsertDemo
    2021-05-25 18:32:07.606 InsertDemo[16797:7420505]


    Inject SUCCESS 🍉🍉🍉

    这个时候就插入成功了。


    iOS9.1以后root用户不能用DYLD_INSERT_LIBRARIES(会报错kill 9),需要切换到mobile用户(su mobile)。
    主流App会有防护,可以自己创建一个App插入。
    高版本的iOS系统可能会遇到错误。

    四、dumpdecrypted

    dumpdecryptedGithub开源工具。这个工具就是通过建立一个名为dumpdecrypted.dylib的动态库,插入目标应用实现脱壳。


    4.1 安装

    1. dumpdecrypted官网直接git clone

    2. 通过make编译生成动态库:

    ➜  dumpdecrypted-master make
    `xcrun --sdk iphoneos --find gcc` -Os -Wimplicit -isysroot `xcrun --sdk iphoneos --show-sdk-path` -F`xcrun --sdk iphoneos --show-sdk-path`/System/Library/Frameworks -F`xcrun --sdk iphoneos --show-sdk-path`/System/Library/PrivateFrameworks -arch armv7 -arch armv7s -arch arm64 -dynamiclib -o dumpdecrypted.dylib dumpdecrypted.o
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.4.sdk/System/Library/PrivateFrameworks'
    直接在clone的目录make,最后会生成dumpdecrypted.dylib

    1. 拷贝到手机


    ➜  dumpdecrypted-master scp  -P 12345  dumpdecrypted.dylib  mobile@localhost:/var/mobile/
    mobile@localhost's password:
    dumpdecrypted.dylib
    100% 209KB 24.6MB/s 00:00
    1. 通过DYLD_INSERT_LIBRARIES 环境变量插入动态库执行
    DYLD_INSERT_LIBRARIES=dumpdecrypted.dylib /var/containers/Bundle/Application/36B02DC8-B625-4633-A2C7-45079855BFAC/Aweme.app/Aweme
    需要将dumpdecrypted.dylib拷贝到mobile路径中,为了导出有写的权限。dumpdecrypted.dylib会导出和自己同一目录。

    五、frida-iOS-dump

    该工具基于frida提供的强大功能通过注入js实现内存dump然后通过python自动拷贝到电脑生成ipa文件。

    5.1 安装

    5.1.1 Mac安装

    1. 查看python版本(Mac自带)


      ~ python --version
    Python 2.7.16

    如果是python3这里需要改成python3。根据自己的版本进行配置。

    2.查看pip版本

      ~ pip --version
    pip 19.0.1 from /Library/Python/2.7/site-packages/pip-19.0.1-py2.7.egg/pip (python 2.7)
    如果没有安装,执行:

    sudo easy_install pip
    卸载pip:python -m pip uninstall pip,如果是python3就安装pip3

    3.安装frida

    sudo pip install frida-tools



    出现这个提示表明目录不归当前用户所有。请检查该目录的权限和所有者。需要sudo-H标志。

    sudo -H pip install frida-tools

    • sudo -H:  set-home 将 HOME 变量设为目标用户的主目录

    5.1.2 iOS安装

    1. 添加源(需要科学上网)
    https://build.frida.re
    安装Frida




    5.2 Mac配置ios-dump

    1. 下载脚本
    sudo git clone https://github.com/AloneMonkey/frida-ios-dump
    或者直接去github下载。然后拷贝到/opt目录。

    当然如果电脑上安装了Monkey那直接在monkey目录中安装frida就好了。不然的话导出环境变量可能会有冲突。(monkey中已经导出dump.py了)。直接在Monkey/bin目录进行安装,将安装需要的内容直接从下载的frida-ios-dump中拷贝到Monkey/bin目录(其实只需要requirements.txtdump.jsdump.py。然后在该目录下安装依赖。

    1. 安装依赖
    //sudo pip install -r /opt/frida-ios-dump/requirements.txt –upgrade
    sudo pip install -r requirements.txt --ignore-installed six
    在这个过程中有可能报错:

    *frida-tools 1.2.2 has requirement prompt-toolkit<2.0.0,>=0.57, but you'll have >prompt-toolkit 2.0.7 which is incompatible.
    需要降低 prompt-toolkit 版本:

    //卸载
    $sudo pip uninstall prompt-toolkit
    //安装指定版本
    $sudo pip install prompt-toolkit==1.0.6
    1. 修改dump.py
    User = 'root'
    Password = 'alpine'
    Host = 'localhost'
    Port = 12345
    一般只需要修改Port就好了,和自己映射的本地端口一致。

    5.3 frida 命令

    • frida-ps:列出电脑上的进程
      ~ frida-ps
    PID Name
    ----- --------------------------------------------------------------------------------
    514 AirPlayUIAgent
    573 AppSSOAgent
    65527 Backup and Sync from Google
    533 Backup and Sync from Google
    636 CoreLocationAgent
    73560 CoreServicesUIAgent

    • frida-ps -U:列出手机进程
      ~ frida-ps -U
    PID Name
    ----- -----------------------------------------------
    15758 AlipayWallet
    16643 BTFuwa
    18079 CAReportingService
    11127 CMFSyncAgent
    1644 CommCenter
    17367 ContainerMetadataExtractor
    6691 EscrowSecurityAlert
    16196 HeuristicInterpreter
    11204 IDSRemoteURLConnectionAgent
    16218 MQQSecure
    11119 MobileGestaltHelper
    17611 PhotosReliveWidget
    10051 PinCleaner

    • frida -U 微信:进入微信进程,调试微信
    ➜  ~ frida -U AlipayWallet
    ____
    / _ | Frida 14.2.18 - A world-class dynamic instrumentation toolkit
    | (_| |
    > _ | Commands:
    /_/ |_| help -> Displays the help system
    . . . . object? -> Display information about 'object'
    . . . . exit/quit -> Exit
    . . . .
    . . . . More info at https://frida.re/docs/home/

    5.4 砸壳

    5.4.1 查看安装的应用

    dump.py -l可以查看已经安装的应用

    ~ dump.py -l
    /Library/Python/2.7/site-packages/paramiko/transport.py:33: CryptographyDeprecationWarning: Python 2 is no longer supported by the Python core team. Support for it is now deprecated in cryptography, and will be removed in the next release.
    from cryptography.hazmat.backends import default_backend
    PID Name Identifier
    ----- ------------ -------------------------------
    13582 微信 com.tencent.xin
    10769 支付宝 com.alipay.iphoneclient
    9912 相机 com.apple.camera
    11265 腾讯手机管家 com.tencent.mqqsecure
    - Acrobat com.adobe.Adobe-Reader
    - App Store com.apple.AppStore
    - Cydia com.saurik.Cydia
    - Enframe me.sspai.Enframe
    - Excel com.microsoft.Office.Excel

    这个时候是不需要映射的。

    5.4.2 导出ipa

    dump.py bundleId/displayName:

    //dump.py 微信
    dump.py com.tencent.xin
    • 可以通过bundleId或者displayName导出应用,推荐使用bundleIddisplayName可能会有同名。如果有同名哪个排在前面导出哪个。
    • 导出ipa包时需要app在运行状态(正常情况下会自动打开App),最好在前台不锁屏。
    • 导出的ipa包一般在你执行导出命令的目录。(如果配置了环境变量,任何目录都可以执行)目录没有权限会报错。
    • 导出包的时候是需要打开端口映射的。

    验证(需要解压拿到.app):


    ➜ otool -l WeChat.app/WeChat | grep crypt
    cryptoff 16384
    cryptsize 101646336
    cryptid 0
    • cryptid0表示没有加密,否则是加密的包。

    错误信息

    1. ImportError: No module named typing
      pip安装后报错:
    ~ sudo pip install frida-tools
    Traceback (most recent call last):
    File "/usr/local/bin/pip", line 11, in <module>
    load_entry_point('pip==21.1.1', 'console_scripts', 'pip')()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 489, in load_entry_point
    return get_distribution(dist).load_entry_point(group, name)
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2843, in load_entry_point
    return ep.load()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2434, in load
    return self.resolve()
    File "/System/Library/Frameworks/Python.framework/Versions/2.7/Extras/lib/python/pkg_resources/__init__.py", line 2440, in resolve
    module = __import__(self.module_name, fromlist=['__name__'], level=0)
    File "/Library/Python/2.7/site-packages/pip-21.1.1-py2.7.egg/pip/__init__.py", line 1, in <module>
    from typing import List, Optional
    ImportError: No module named typing
    解决方案:

    sudo easy_install pip==19.0.1

    直接指定版本安装。

    2.Invalid requirement: '–upgrade'
    直接替换掉–upgrade命令:

    //sudo pip install -r /opt/frida-ios-dump/requirements.txt –upgrade
    sudo pip install -r requirements.txt --ignore-installed six

    3.如果遇见命令输入没有反映,电脑端frida没有问题。那么需要重新安装手机端frida

    4.Start the target app QQMusic unable to launch iOS app: The operation couldn’t be completed. Application info provider (FBSApplicationLibrary) returned nil for ""
    如果App启动后还是无法dump,直接通过BundleId dump就好了。



    作者:HotPotCat

    总结

    • 应用砸壳:一般应用为了防止反编译分析会对应用进行加密(加壳)。砸壳就是解密的过程。
      • 静态砸壳:已经知道了解密方式,不需要运行应用的情况下直接解密。
      • 动态砸壳:在应用启动之后从内存中找到应用的位置,dump(内存中)数据。
    • Clutch( 命令行工具)
      • Clutch -i:列出可以砸壳的应用列表。
      • Clutch –d 应用ID:砸壳
    • dumpdecrypted(动态库)
      • 通过DYLD_INSERT_LIBRARIES环境变量插入动态库载入某个进程。
      • 配置DYLD_INSERT_LIBRARIES=dumpdecrypted路径 Macho路径
    • frida-iOS-dump(利用frida加载脚本砸壳)
      • 安装frida(MaciPhone都需要)
      • 下载frida-iOS-dump脚本工具
      • 执行dump.py displayName /BundleId


    作者:HotPotCat
    链接:https://www.jianshu.com/p/0d89bbff8140




    收起阅读 »

    android(6大布局)

    LinearLayout(线性布局) RelativeLayout(相对布局) TableLayout(表格布局) FrameLayout(帧布局) FrameLayout的属性很少就两个,但是在说之前我们先介绍一个东西: 前景图像:永远处于帧布局最上面...
    继续阅读 »


    LinearLayout(线性布局)
    在这里插入图片描述
    RelativeLayout(相对布局)
    在这里插入图片描述
    TableLayout(表格布局)
    在这里插入图片描述
    FrameLayout(帧布局)
    FrameLayout的属性很少就两个,但是在说之前我们先介绍一个东西:
    前景图像:永远处于帧布局最上面,直接面对用户的图像,就是不会被覆盖的图片。
    两个属性:
    android:foreground:*设置改帧布局容器的前景图像
    android:foregroundGravity:设置前景图像显示的位置


    GridLayout(网格布局)
    在这里插入图片描述


    AbsoluteLayout(绝对布局)
    1.四大控制属性(单位都是dp):
    ①控制大小: android:layout_width:组件宽度 android:layout_height:组件高度 ②控制位置: android:layout_x:设置组件的X坐标 android:layout_y:设置组件的Y坐标


    收起阅读 »

    Android四大组件的启动分析与整理(二):Service的启动过程

    前言 换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。 service的启动过程分两种,一种是直接start,另一种是bind;我们先来分析第一种,直接start过程要简单的多。一样,先分析源码,然后一幅图总结: st...
    继续阅读 »


    前言


    换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。
    service的启动过程分两种,一种是直接start,另一种是bind;我们先来分析第一种,直接start过程要简单的多。一样,先分析源码,然后一幅图总结:


    startService()


    	startService(new Intent());
    public ComponentName startService(Intent service) {
    return mBase.startService(service);
    }

    startService();其实是调用了ContextWrapper中的startService方法,ContextWrapper我的理解是一个外观模式,他基本没有什么自己的东西,而是都去间接调用mBase中的方法,mBase,其实就是Context的实现类ContextImpl类;在 Activity的启动过程 的最后已经介绍了,这个ContextImpl是怎么来的了,这里不多将,继续。


        public ComponentName startService(Intent service) {
    warnIfCallingFromSystemProcess();
    return startServiceCommon(service, false, mUser);
    }
    private ComponentName startServiceCommon(Intent service, boolean requireForeground,
    UserHandle user) {
    try {
    validateServiceIntent(service);
    service.prepareToLeaveProcess(this);
    ComponentName cn = ActivityManager.getService().startService(
    mMainThread.getApplicationThread(), service, service.resolveTypeIfNeeded(
    getContentResolver()), requireForeground,
    getOpPackageName(), user.getIdentifier());
    ..................
    return cn;
    } catch (RemoteException e) {
    throw e.rethrowFromSystemServer();
    }
    }

    这个地方非常熟悉了,调用了AMS的startService方法;


    public ComponentName startService(IApplicationThread caller, Intent service,
    String resolvedType, boolean requireForeground, String callingPackage, int userId)
    throws TransactionTooLargeException {
    enforceNotIsolatedCaller("startService");
    ...............
    synchronized(this) {
    final int callingPid = Binder.getCallingPid();
    final int callingUid = Binder.getCallingUid();
    final long origId = Binder.clearCallingIdentity();
    ComponentName res;
    try {
    res = mServices.startServiceLocked(caller, service,
    resolvedType, callingPid, callingUid,
    requireForeground, callingPackage, userId);
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    return res;
    }
    }

    这里将启动工作委托给了ActiveService,就像Activity启动的时候将委托工作交给ActivityStarter一样;


    ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
    int callingPid, int callingUid, boolean fgRequired, String callingPackage, final int userId)
    throws TransactionTooLargeException {
    final boolean callerFg;
    if (caller != null) {
    final ProcessRecord callerApp = mAm.getRecordForAppLocked(caller);(1)
    ..................
    } else {
    callerFg = true;
    }
    ServiceLookupResult res =
    retrieveServiceLocked(service, resolvedType, callingPackage,
    callingPid, callingUid, userId, true, callerFg, false);(2)
    ..................
    ServiceRecord r = res.record;(3)
    if (!mAm.mUserController.exists(r.userId)) {
    return null;
    }
    ..................
    ComponentName cmp = startServiceInnerLocked(smap, service, r, callerFg, addToStarting);(4)
    return cmp;
    }

    这个方法很长,主要是为了获取ProcessRecorder和ServiceRecorder,就跟Activity启动需要ProcessRecorder和ActivityRecorder一样。
    (2)处先从缓存中查找,没有的话直接new一个对象
    (4)处继续调用startServiceInnerLocked方法,这个方法调用了bringUpServiceLocked()方法。


        private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
    boolean whileRestarting, boolean permissionsReviewRequired)
    throws TransactionTooLargeException {
    if (r.app != null && r.app.thread != null) {
    sendServiceArgsLocked(r, execInFg, false);1
    return null;
    }
    final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
    final String procName = r.processName;
    String hostingType = "service";
    ProcessRecord app;
    if (!isolated) {
    app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
    if (app != null && app.thread != null) {
    try {
    app.addPackage(r.appInfo.packageName, r.appInfo.versionCode, mAm.mProcessStats);
    realStartServiceLocked(r, app, execInFg);(2)
    return null;
    }
    }
    } else {
    .......................
    }
    if (app == null && !permissionsReviewRequired) {(1)
    if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
    hostingType, r.name, false, isolated, false)) == null) {
    bringDownServiceLocked(r);
    return msg;
    }
    if (isolated) {
    r.isolatedProc = app;
    }
    }
    .......................
    return null;
    }

    (1)处是发送service的入参,就是走的onStartCommand()方法,这里第一次进来,app为null,因为ServiceRecorder是新new出来的
    (2)从AMS中获取ProcessRecorder,获取到成功之后,调用realStartServiceLocked()方法去启动service
    (3)如果上一步没有获取到ProcessRecorder,那么就创建一个,这个过程跟Activity创建进程是一样,都是通过Zygote去执行Process.start方法创建新的进程


        private final void realStartServiceLocked(ServiceRecord r,
    ProcessRecord app, boolean execInFg) throws RemoteException {
    .................
    try {
    .................
    app.thread.scheduleCreateService(r, r.serviceInfo,mAm.compatibilityInfoForPackageLocked(r.serviceInfo.applicationInfo),
    app.repProcState);1
    r.postNotification();
    created = true;
    }
    .................
    sendServiceArgsLocked(r, execInFg, true);2
    .................
    }

    (1)通知ApplicationThread去执行scheduleCreateService方法,
    (2)创建完了之后,发送入参,也就是调用哦那onStartCommand()方法。


            public final void scheduleCreateService(IBinder token,
    ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
    updateProcessState(processState, false);
    CreateServiceData s = new CreateServiceData();
    s.token = token;
    s.info = info;
    s.compatInfo = compatInfo;
    sendMessage(H.CREATE_SERVICE, s);
    }

    然后就是非常熟悉的地方了,发送handler:CREATE_SERVICE消息


                    case CREATE_SERVICE:
    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, ("serviceCreate: " + String.valueOf(msg.obj)));
    handleCreateService((CreateServiceData)msg.obj);
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    break;

    private void handleCreateService(CreateServiceData data) {
    unscheduleGcIdler();
    LoadedApk packageInfo = getPackageInfoNoCheck(
    data.info.applicationInfo, data.compatInfo);
    Service service = null;
    try {
    java.lang.ClassLoader cl = packageInfo.getClassLoader();
    service = (Service) cl.loadClass(data.info.name).newInstance();1
    }
    .................
    try {
    ContextImpl context = ContextImpl.createAppContext(this, packageInfo);2
    context.setOuterContext(service);
    Application app = packageInfo.makeApplication(false, mInstrumentation);3
    service.attach(context, this, data.info.name, data.token, app,
    ActivityManager.getService());4
    service.onCreate();5
    mServices.put(data.token, service);
    .................
    }
    .................
    }

    跟启动Activity一样,需要两个必备因素,Context和Application
    (1)处跟Activity一样通过反射创建Service
    (2)处new一个上下文,跟Activity的区别就是不需要传入AMS和classloader
    (3)处跟Activity一样通过反射创建Application
    (4)处attach上去,将context、app、AMS、binder等都封装进去
    (5)处执行onCreate()方法,区别是Activity通过Instrumentation去创建,这里直接调用


    bindService()


    这里追加一下bindService的过程:
    从调用bindService(new Intent(), mConnection, Context.BIND_AUTO_CREATE);开始,跟startService一样,走的context中的方法,然后调用了bindServiceCommon()


    private boolean bindServiceCommon(Intent service, ServiceConnection conn, int flags, Handler
    handler, UserHandle user) {
    IServiceConnection sd;
    if (conn == null) {
    throw new IllegalArgumentException("connection is null");
    }
    if (mPackageInfo != null) {
    sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), handler, flags);(1)
    } else {
    throw new RuntimeException("Not supported in system context");
    }
    validateServiceIntent(service);
    try {
    .............
    int res = ActivityManager.getService().bindService(
    mMainThread.getApplicationThread(), getActivityToken(), service,
    service.resolveTypeIfNeeded(getContentResolver()),
    sd, flags, getOpPackageName(), user.getIdentifier());(2)
    .............
    } catch (RemoteException e) {
    throw e.rethrowFromSystemServer();
    }
    }

    (1)跟startService不一样的是,需要先获取IServiceConnection,从名字可以看出实现了binder,那么service就可以跨进程绑定了,IServiceConnection内部new了一个ServiceDispatcher对象,ServiceDispatcher的内部类InnerConnection就是继承了IServiceConnection.stub,实现binder的。
    (2)走AMS的bindService方法,AMS委托给了ActiveService去执行bindServiceLocked()


        int bindServiceLocked(IApplicationThread caller, IBinder token, Intent service,
    String resolvedType, final IServiceConnection connection, int flags,
    String callingPackage, final int userId) throws TransactionTooLargeException {
    final ProcessRecord callerApp = mAm.getRecordForAppLocked(caller);1
    ................
    ServiceLookupResult res =2
    retrieveServiceLocked(service, resolvedType, callingPackage, Binder.getCallingPid(),
    Binder.getCallingUid(), userId, true, callerFg, isBindExternal);
    ................
    ServiceRecord s = res.record;
    try {
    ................
    AppBindRecord b = s.retrieveAppBindingLocked(service, callerApp);3
    ConnectionRecord c = new ConnectionRecord(b, activity,
    connection, flags, clientLabel, clientIntent);4
    ................
    if ((flags&Context.BIND_AUTO_CREATE) != 0) {
    s.lastActivity = SystemClock.uptimeMillis();
    if (bringUpServiceLocked(s, service.getFlags(), callerFg, false,
    permissionsReviewRequired) != null) {5
    return 0;
    }
    }
    if (s.app != null && b.intent.received) {
    try {
    c.conn.connected(s.name, b.intent.binder, false);6
    } catch (Exception e) {
    }
    if (b.intent.apps.size() == 1 && b.intent.doRebind) {
    requestServiceBindingLocked(s, b.intent, callerFg, true);7
    }
    } else if (!b.intent.requested) {
    requestServiceBindingLocked(s, b.intent, callerFg, false);8
    }
    getServiceMapLocked(s.userId).ensureNotStartingBackgroundLocked(s);
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    return 1;
    }

    (1)处拿到请求者的进程
    (2)处创建必备的条件:ServiceRecord
    (3、4)处bind要比start多两个对象,AppBindRecord和ConnectionRecord,AppBindRecord对象是
    (5)处因为flag是BIND_AUTO_CREATE,因此走bringUpServiceLocked方法去创建Service
    (6)创建成功后,如果b.intent.received表示已经接受到了绑定的bind就会执行c.conn.connected,这个c.conn就是IServiceConnection,前面bindServiceCommon就讲了,ServiceConnection被封到了LoaderApk中的内部类ServiceDispatcher中,ServiceDispatcher的内部类innerConnection继承了IServiceConnection.stub类,并调用ServiceDispatcher的connect方法,并向mActivityThread 的handler发送一个runnable方法执行mConnection.onServiceConnected回调,到此绑定成功。
    (7)如果第一次bind且还没有rebind过,requestServiceBindingLocked第三个参数为true表重新绑定
    (8)如果创建成功还没有绑定,就执行requestServiceBindingLocked第三个参数为false
    这里第一次bind应该是创建了但还没有发送请求,走的8;


    private final boolean requestServiceBindingLocked(ServiceRecord r, IntentBindRecord i,
    boolean execInFg, boolean rebind) throws TransactionTooLargeException {
    if ((!i.requested || rebind) && i.apps.size() > 0) {(1)
    try {
    bumpServiceExecutingLocked(r, execInFg, "bind");
    r.app.forceProcessStateUpTo(ActivityManager.PROCESS_STATE_SERVICE);
    r.app.thread.scheduleBindService(r, i.intent.getIntent(), rebind,
    r.app.repProcState);2
    if (!rebind) {
    i.requested = true;3
    }
    i.hasBound = true;
    i.doRebind = false;4
    } catch (TransactionTooLargeException e) {
    .............
    } catch (RemoteException e) {
    .............
    }
    }
    return true;
    }

    (1)第一次进来,i.requested没有发送过请求,因此为false,不是重新rebind,在创建AppBinderRecord的时候,i.apps.size() > 0;
    (2)熟悉的一幕,发送scheduleBindService方法,然后发送BIND_SERVICE,然后执行handleBindService方法
    (3、4)设置标志位,请求过了,非重绑


    private void handleBindService(BindServiceData data) {
    Service s = mServices.get(data.token);
    if (s != null) {
    try {
    data.intent.setExtrasClassLoader(s.getClassLoader());
    data.intent.prepareToEnterProcess();
    try {
    if (!data.rebind) {
    IBinder binder = s.onBind(data.intent);
    ActivityManager.getService().publishService(
    data.token, data.intent, binder);
    } else {
    s.onRebind(data.intent);
    ActivityManager.getService().serviceDoneExecuting(
    data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
    }
    ensureJitEnabled();
    } catch (RemoteException ex) {
    }
    } catch (Exception e) {
    }
    }
    }


    没有rebind过的话,通知AMS去执行publishService方法,如果是rebind操作,那么就直接s.onRebind方法,然后通知AMS绑定结束。这里第一次进来,通知AMS去publishService,然后委托ActiveService去执行publishServiceLocked方法;


    void publishServiceLocked(ServiceRecord r, Intent intent, IBinder service) {
    final long origId = Binder.clearCallingIdentity();
    try {
    if (r != null) {
    Intent.FilterComparison filter
    = new Intent.FilterComparison(intent);
    IntentBindRecord b = r.bindings.get(filter);
    if (b != null && !b.received) {
    b.binder = service;
    b.requested = true;(1)
    b.received = true;(2)
    for (int conni=r.connections.size()-1; conni>=0; conni--) {
    ArrayList<ConnectionRecord> clist = r.connections.valueAt(conni);
    for (int i=0; i<clist.size(); i++) {
    ConnectionRecord c = clist.get(i);(3)
    .....................
    try {
    c.conn.connected(r.name, service, false);3
    } catch (Exception e) {
    .....................
    }
    }
    }
    }
    serviceDoneExecutingLocked(r, mDestroyingServices.contains(r), false);
    }
    } finally {
    Binder.restoreCallingIdentity(origId);
    }
    }

    (1)处设置已请求,(2)处设置已绑定;(3)处就是调用IServiceConnection的connect方法。


    		private static class InnerConnection extends IServiceConnection.Stub {
    final WeakReference<LoadedApk.ServiceDispatcher> mDispatcher;
    InnerConnection(LoadedApk.ServiceDispatcher sd) {
    mDispatcher = new WeakReference<LoadedApk.ServiceDispatcher>(sd);
    }
    public void connected(ComponentName name, IBinder service, boolean dead)
    throws RemoteException {
    LoadedApk.ServiceDispatcher sd = mDispatcher.get();
    if (sd != null) {
    sd.connected(name, service, dead);1
    }
    }
    }
    public void connected(ComponentName name, IBinder service, boolean dead) {
    if (mActivityThread != null) {2
    mActivityThread.post(new RunConnection(name, service, 0, dead));
    } else {
    doConnected(name, service, dead);
    }
    }
    private final class RunConnection implements Runnable {
    .........
    public void run() {
    if (mCommand == 0) {
    doConnected(mName, mService, mDead);
    } else if (mCommand == 1) {
    doDeath(mName, mService);
    }
    }
    .........
    }
    ----------------doConnected-----------------
    mConnection.onServiceConnected(name, service);

    在bind的第一步,其实就将ServiceConnection封装到了ServiceDispatcher中了,其内部类InnerConnection 继承了IServiceConnection.Stub,那么就可以通过binder进行跨进程的通信了,很方便。
    上一步骤的(3)其实就是调用了innerConnection的connect方法(1)处
    (2)处mActivityThread其实就是ActivityThread的handler方法执行run方法,简介调用了doConnected,然后调用mConnection的onServiceConnected()方法,这个mConnection其实就是我们自定义的ServiceConnection类,就此结束;


    startService图解:


    在这里插入图片描述


    bindService图解:


    在这里插入图片描述


    ————————————————
    版权声明:本文为CSDN博主「蒋八九」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/massonJ/article/details/117914349

    收起阅读 »

    Android四大组件的启动分析与整理(一):Activity的启动过程

    前言 换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。 首先Activity的启动分两种,一种是根Activity的启动,另一种是普通Activity的启动,根Activity的启动,从LauncherActivity...
    继续阅读 »


    前言


    换工作后,一直忙,没时间整理,逼自己一把吧,目标一周整理出来,理顺思路,这里先起个头。


    首先Activity的启动分两种,一种是根Activity的启动,另一种是普通Activity的启动,根Activity的启动,从LauncherActivity开始,启动方式跟我们平时的startActivity是基本一样的。


    public boolean startActivitySafely(View v, Intent intent, ItemInfo item) {
    。。。。。。。。。。。
    //设置flag为new task
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    。。。。。。。。。。。
    if (Utilities.ATLEAST_MARSHMALLOW
    && (item instanceof ShortcutInfo)
    && (item.itemType == Favorites.ITEM_TYPE_SHORTCUT
    || item.itemType == Favorites.ITEM_TYPE_DEEP_SHORTCUT)
    && !((ShortcutInfo) item).isPromise()) {
    // Shortcuts need some special checks due to legacy reasons.
    startShortcutIntentSafely(intent, optsBundle, item);
    } else if (user == null || user.equals(Process.myUserHandle())) {
    // Could be launching some bookkeeping activity
    //通过startActivity开启
    startActivity(intent, optsBundle);
    } else {
    LauncherAppsCompat.getInstance(this).startActivityForProfile(
    intent.getComponent(), user, intent.getSourceBounds(), optsBundle);
    }
    return true;
    } catch (ActivityNotFoundException|SecurityException e) {
    Toast.makeText(this, R.string.activity_not_found, Toast.LENGTH_SHORT).show();
    Log.e(TAG, "Unable to launch. tag=" + item + " intent=" + intent, e);
    }
    return false;
    }

    当点击桌面的图标的时候,调用startActivitySafely(),然后先设置它的flag是new task,然后调用Activity的startActivity()方法,然后调用继续调用startActivityForResult(intent, -1, options);


    	public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
    @Nullable Bundle options) {
    if (mParent == null) {
    options = transferSpringboardActivityOptions(options);
    Instrumentation.ActivityResult ar =
    mInstrumentation.execStartActivity(
    this, mMainThread.getApplicationThread(), mToken, this,
    intent, requestCode, options);
    。。。。。。。。。。。。
    } else {
    。。。。。。。。。。。。
    }
    }

    因为根Activity,mParent肯定为null,Activity最终都会通过Instrumentation工具类去执行,这里就调用了execStartActivity()方法。


    	public ActivityResult execStartActivity(
    Context who, IBinder contextThread, IBinder token, String target,
    Intent intent, int requestCode, Bundle options) {
    IApplicationThread whoThread = (IApplicationThread) contextThread;
    。。。。。。。。。。
    try {
    intent.migrateExtraStreamToClipData();
    intent.prepareToLeaveProcess(who);
    int result = ActivityManager.getService()
    .startActivity(whoThread, who.getBasePackageName(), intent,
    intent.resolveTypeIfNeeded(who.getContentResolver()),
    token, target, requestCode, 0, null, options);
    checkStartActivityResult(result, intent);
    } catch (RemoteException e) {
    throw new RuntimeException("Failure from system", e);
    }
    return null;
    }

    instrumentation中通过调用ActivityManager.getService()方法,得到AMS,然后调用AMS中的startActivity方法继续执行。


        public static IActivityManager getService() {
    return IActivityManagerSingleton.get();
    }

    private static final Singleton<IActivityManager> IActivityManagerSingleton =
    new Singleton<IActivityManager>() {
    @Override
    protected IActivityManager create() {
    final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
    final IActivityManager am = IActivityManager.Stub.asInterface(b);
    return am;
    }
    };

    这里需要注意一下,在Android8.0以前,都是通过ActivityManagerNative.getDefalt()方法,然后通过IActiv tyManager am= asinterface (b) ; 去获取AMS的代理类ActivityManagerProxy对象的。在asinterface 中直接new ActivityManagerProxy(b)并返回,8.0之后通过通过IActivityManager.Stub.asInterface(b)去获得,典型的AIDL写法AMS中也继承了IActivityManager.Stub。


    @Override
    public final int startActivity(.....) {
    return startActivityAsUser(.....,UserHandle.getCallingUserId());
    }
    public final int startActivityAsUser(.....) {
    enforceNotIsolatedCaller("startActivity");1
    userId = mUserController.handleIncomingUser(Binder.getCallingPid(), Binder.getCallingUid(),2
    userId, false, ALLOW_FULL_ONLY, "startActivity", null);
    return mActivityStarter.startActivityMayWait(caller, -1, callingPackage, intent,
    resolvedType, null, null, resultTo, resultWho, requestCode, startFlags,
    profilerInfo, null, null, bOptions, false, userId, null, null,
    "startActivityAsUser");
    }

    (1)处判断调用者的进程是否被隔离,如果是就抛出SecurityException异常。
    (2)处检查调用者是否有权限,如果没有也会抛出SecurityException异常。
    然后继续调用ActivityStarter中的startActivityMayWait()方法,ActivityStarter是Activity的一个控制类,主要将flag和intent转为Activity,然后将Activity和task以及stack关联起来。


    int startActivityLocked(...., String reason) {
    if (TextUtils.isEmpty(reason)) {1
    throw new IllegalArgumentException("Need to specify a reason.");
    }
    mLastStartReason = reason;
    mLastStartActivityTimeMs = System.currentTimeMillis();
    mLastStartActivityRecord[0] = null;
    mLastStartActivityResult = startActivity(....);
    if (outActivity != null) {
    outActivity[0] = mLastStartActivityRecord[0];
    }
    return mLastStartActivityResult;
    }

    startActivityMayWait()方法很长,其中调用了startActivityLocked方法,(1)处就是之前传入的“startActivityAsUser”参数,用来说明调用原因,如果没有原因,抛出IllegalArgument异常,然后继续调用startActivity方法。


    private int startActivity(IApplicationThread caller,.....ActivityRecord[] outActivity,.....) {
    int err = ActivityManager.START_SUCCESS;
    final Bundle verificationBundle
    = options != null ? options.popAppVerificationBundle() : null;
    ProcessRecord callerApp = null;
    if (caller != null) {1
    //获取Launcher进程
    callerApp = mService.getRecordForAppLocked(caller);//2
    if (callerApp != null) {
    //获取Launcher进程的pid和uid并赋值
    callingPid = callerApp.pid;
    callingUid = callerApp.info.uid;
    } else {
    Slog.w(TAG,.....);
    err = ActivityManager.START_PERMISSION_DENIED;
    }
    }
    ...
    //创建即将要启动的Activity的描述类ActivityRecord
    ActivityRecord r = new ActivityRecord(mService, callerApp, callingPid, callingUid,
    callingPackage, intent, resolvedType, aInfo, mService.getGlobalConfiguration(),
    resultRecord, resultWho, requestCode, componentSpecified, voiceSession != null,
    mSupervisor, container, options, sourceRecord);2
    if (outActivity != null) {
    outActivity[0] = r;3
    }
    ...
    doPendingActivityLaunchesLocked(false);
    return startActivity(r, sourceRecord, voiceSession, voiceInteractor, startFlags, true,
    options, inTask, outActivity);//4
    }

    这里首先在(1)处通过AMS的getRecordForAppLocked方法获取请求进程对象callerApp ,因为是从launcher启动,所以这里是launcher所在的进程,他是一个ProcessRecord对象,然后拿到pid和uid。
    (2)处创建Activity信息,这样ProcessRecord和ActivityRecord就齐全了。继续startActivity;


        private int startActivityUnchecked(....) {
    ........
    int result = START_SUCCESS;
    if (mStartActivity.resultTo == null && mInTask == null && !mAddingToTask
    && (mLaunchFlags & FLAG_ACTIVITY_NEW_TASK) != 0) {
    newTask = true;
    result = setTaskFromReuseOrCreateNewTask(
    taskToAffiliate, preferredLaunchStackId, topStack);—————(1
    } else if (mSourceRecord != null) {
    result = setTaskFromSourceRecord();
    } else if (mInTask != null) {
    result = setTaskFromInTask();
    } else {
    .........
    setTaskToCurrentTopOrCreateNewTask();
    }
    ........
    if (mDoResume) {
    final ActivityRecord topTaskActivity =
    mStartActivity.getTask().topRunningActivityLocked();
    if (!mTargetStack.isFocusable()
    || (topTaskActivity != null && topTaskActivity.mTaskOverlay
    && mStartActivity != topTaskActivity)) {
    mTargetStack.ensureActivitiesVisibleLocked(null, 0, !PRESERVE_WINDOWS);
    mWindowManager.executeAppTransition();
    } else {
    if (mTargetStack.isFocusable() && !mSupervisor.isFocusedStack(mTargetStack)) {
    mTargetStack.moveToFront("startActivityUnchecked");
    }
    mSupervisor.resumeFocusedStackTopActivityLocked(mTargetStack, mStartActivity,
    mOptions);————(2
    }
    } else {
    mTargetStack.addRecentActivityLocked(mStartActivity);
    }
    .............
    return START_SUCCESS;
    }

    (1)处通过setTaskFromReuseOrCreateNewTask()方法,创建TaskRecorder,这样一来,ProcessRecorder、ActivityRecorder以及TaskRecorder都齐全了,
    (2)处调用resumeFocusedStackTopActivityLocked方法


        boolean resumeFocusedStackTopActivityLocked(
    ActivityStack targetStack, ActivityRecord target, ActivityOptions targetOptions) {
    if (targetStack != null && isFocusedStack(targetStack)) {
    return targetStack.resumeTopActivityUncheckedLocked(target, targetOptions);
    }
    final ActivityRecord r = mFocusedStack.topRunningActivityLocked();
    if (r == null || r.state != RESUMED) {
    mFocusedStack.resumeTopActivityUncheckedLocked(null, null);
    } else if (r.state == RESUMED) {
    mFocusedStack.executeAppTransition(targetOptions);
    }
    return false;
    }
    boolean resumeTopActivityUncheckedLocked(ActivityRecord prev, ActivityOptions options) {
    if (mStackSupervisor.inResumeTopActivity) {
    return false;
    }
    boolean result = false;
    try {
    mStackSupervisor.inResumeTopActivity = true;
    result = resumeTopActivityInnerLocked(prev, options);
    } finally {
    mStackSupervisor.inResumeTopActivity = false;
    }
    mStackSupervisor.checkReadyForSleepLocked();
    return result;
    }

    这里因为我们启动的是根Activity,那么topActivity肯定是为没有在running状态的,走的resumeTopActivityUncheckedLocked方法,然后执行resumeTopActivityInnerLocked方法。


        private boolean resumeTopActivityInnerLocked(ActivityRecord prev, ActivityOptions options) {
    ........
    ActivityStack lastStack = mStackSupervisor.getLastStack();
    if (next.app != null && next.app.thread != null) {
    final boolean lastActivityTranslucent = lastStack != null
    && (!lastStack.mFullscreen
    || (lastStack.mLastPausedActivity != null
    && !lastStack.mLastPausedActivity.fullscreen));
    ..........
    } else {
    ..........
    mStackSupervisor.startSpecificActivityLocked(next, true, true);
    }
    if (DEBUG_STACK) mStackSupervisor.validateTopActivitiesLocked();
    return true;
    }

    这里代码很多,最终执行的是ActivityStackSupervisor类中的startSpecificActivityLocked方法。


        void startSpecificActivityLocked(ActivityRecord r,
    boolean andResume, boolean checkConfig) {
    ProcessRecord app = mService.getProcessRecordLocked(r.processName,
    r.info.applicationInfo.uid, true);
    r.getStack().setLaunchTime(r);
    if (app != null && app.thread != null) {
    try {
    if ((r.info.flags&ActivityInfo.FLAG_MULTIPROCESS) == 0
    || !"android".equals(r.info.packageName)) {
    app.addPackage(r.info.packageName, r.info.applicationInfo.versionCode,
    mService.mProcessStats);
    }
    realStartActivityLocked(r, app, andResume, checkConfig);(1)
    return;
    } catch (RemoteException e) {
    }
    }
    mService.startProcessLocked(r.processName, r.info.applicationInfo, true, 0,
    "activity", r.intent.getComponent(), false, false, true);(2)
    }

    这里先获取启动进程ProcessRecord 然后调用realStartActivityLocked()方法;
    如果ProcessRecorder进程为null那么就通过AMS的startProcessLocked去执行Process.start创建。


        final boolean realStartActivityLocked(ActivityRecord r, ProcessRecord app,
    boolean andResume, boolean checkConfig) throws RemoteException {
    ..........
    app.thread.scheduleLaunchActivity(new Intent(r.intent), r.appToken,
    System.identityHashCode(r), r.info,
    mergedConfiguration.getGlobalConfiguration(),
    mergedConfiguration.getOverrideConfiguration(), r.compat,
    r.launchedFromPackage, task.voiceInteractor, app.repProcState, r.icicle,
    r.persistentState, results, newIntents, !andResume,
    mService.isNextTransitionForward(), profilerInfo);
    ..........
    return true;
    }

    其中app.thread其实就是ProcessRecorder的IApplicationManager,也就是ActivityThread的内部类ApplicationThread。
    Activity启动过程其实就目标应用程序进程启动Activity的过程,这里的app就代表目标应用程序进程,那ApplicationThread继承了IApplicationThread.Stub就是目标应用程序与AMS进行binder通信的桥梁。
    最后通知ApplicationThread调用scheduleLaunchActivity去启动Activity;


    public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,
    ActivityInfo info, Configuration curConfig, Configuration overrideConfig,
    CompatibilityInfo compatInfo, String referrer, IVoiceInteractor voiceInteractor,
    int procState, Bundle state, PersistableBundle persistentState,
    List<ResultInfo> pendingResults, List<ReferrerIntent> pendingNewIntents,
    boolean notResumed, boolean isForward, ProfilerInfo profilerInfo) {
    updateProcessState(procState, false);
    ActivityClientRecord r = new ActivityClientRecord();
    r.token = token;
    r.ident = ident;
    r.intent = intent;
    r.referrer = referrer;
    r.voiceInteractor = voiceInteractor;
    r.activityInfo = info;
    r.compatInfo = compatInfo;
    r.state = state;
    r.persistentState = persistentState;
    r.pendingResults = pendingResults;
    r.pendingIntents = pendingNewIntents;
    r.startsNotResumed = notResumed;
    r.isForward = isForward;
    r.profilerInfo = profilerInfo;
    r.overrideConfig = overrideConfig;
    updatePendingConfiguration(curConfig);
    sendMessage(H.LAUNCH_ACTIVITY, r);
    }

    这里就是设置一堆属性,然后通过Activity的内部类H,其实就是handler类,发送LAUNCH_ACTIVITY消息,去执行


    case LAUNCH_ACTIVITY: {
    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
    final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
    r.packageInfo = getPackageInfoNoCheck(
    r.activityInfo.applicationInfo, r.compatInfo);
    handleLaunchActivity(r, null, "LAUNCH_ACTIVITY");
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
    } break;
    ----------------handleLaunchActivity------------------
    Activity a = performLaunchActivity(r, customIntent);

    然后调用handleLaunchActivity,然后继续调用performLaunchActivity,要执行一个Activity首先需要两个必备因素,一个是Context上下文,一个是Application。


    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    .......................
    ContextImpl appContext = createBaseContextForActivity(r);1
    Activity activity = null;
    try {
    java.lang.ClassLoader cl = appContext.getClassLoader();
    activity = mInstrumentation.newActivity(
    cl, component.getClassName(), r.intent);2
    .......................
    } catch (Exception e) {
    .......................
    }
    try {
    Application app = r.packageInfo.makeApplication(false, mInstrumentation);3
    if (activity != null) {
    .......................
    appContext.setOuterContext(activity);
    activity.attach(appContext, this, getInstrumentation(), r.token,
    r.ident, app, r.intent, r.activityInfo, title, r.parent,
    r.embeddedID, r.lastNonConfigurationInstances, config,
    r.referrer, r.voiceInteractor, window, r.configCallback);4
    .......................
    if (r.isPersistable()) {
    mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);5
    } else {
    mInstrumentation.callActivityOnCreate(activity, r.state);
    }
    .......................
    return activity;
    }

    (1)处就是通过new ContextImpl的方式,创建Actviity的上下文。
    (2)处就是通过反射的方式,创建Actviity类。
    (3)处通过反射的方式,创建Application类,并把ContextImpl(这个上下文跟Activity的不一样,上面那个多了token和classloader)上下文attach到父类ContextWrapper中去,也就是mBase。
    (4)有了ContextImpl和Application,然后将Actviity做attach操作,就是将ContextImpl给父类ContextWrapper中的mBase,同时创建PhoneWindow。
    (5)处就是通过Instrumentation去调用oncreate方法
    罗里吧嗦讲那么多,先去上个厕所,回来用一张图总结一下:


    在这里插入图片描述



    ————————————————
    版权声明:本文为CSDN博主「蒋八九」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/massonJ/article/details/117914286


    收起阅读 »

    【面试官爸爸】来给我讲讲View绘制?

    前言 迎面走来的一位中年男子,他一手拿着保温杯,一手抱着笔记本电脑,顶着惺忪的睡眼,不紧不慢地走着,不多的几根头发在他头顶自由飞翔。过了一会,他面对着我坐下,放下电脑和保温杯,边揉眉头边对我说 “来面试的?” “对对对” 我赶紧答应 ...
    继续阅读 »

    前言


    迎面走来的一位中年男子,他一手拿着保温杯,一手抱着笔记本电脑,顶着惺忪的睡眼,不紧不慢地走着,不多的几根头发在他头顶自由飞翔。过了一会,他面对着我坐下,放下电脑和保温杯,边揉眉头边对我说



    “来面试的?”




    “对对对” 我赶紧答应




    “行吧,那你讲讲 View 的绘制流程吧”




    起一个好头


    View 的绘制流程应该是每个初高级 Android 攻城狮必知必会的东西,也是面试必考的内容,每个人都有不同的回答方式。


    简单点譬如 measure,layout,draw 分别对应测量,布局,绘制三个过程,高明一点的会引申出 Handler,同步屏障,View 的事件传递,甚至 activity 的启动过程。掌握哪些东西,如何回答,能够给面试官一种清晰,了然于胸的感觉,同时又不会被追问三连一问三不知。各位老爷听我慢慢道来。



    “噢噢,View 的绘制啊。这个可以分为顶级 View 的绘制,Viewgroup 的绘制和 View 的绘制三个方面。顶级 View 就是 ViewrootImpl”



    将回答的内容分类是体现自己思考能力和知识结构的重要表现。


    什么是 ViewRootImpl


    相比 Viewgroup 和 View,ViewRootImpl 可能更为陌生,实际开发中我们基本用不到它。那么



    什么是 ViewRootImpl 呢?



    从结构上来看,ViewRootImpl 和 ViewGroup 其实是一种东西


    图 9


    它们都继承了 ViewParent。ViewParent 是一个接口,定义了一些父 View 的基本行为,比如 requestlayout,getparent 等。不同的是,ViewRootImpl 并不会像 ViewGroup 一样被真正绘制在屏幕上。在 activity 中,它是专门用来绘制 DecorView 的,核心方法是 setView


    回答的好好的偏要问我其他问题


    提到 DecorView,就不得不说一下 window 了。面试中常常我们提到一个点,或者一个词,面试官会马上引申出这个知识点相关的问题。如果我们只是死记硬背,自顾自背一堆绘制相关的东西而回答不上来,会大大减分。所以储备与必问内容相关的东西对面试和自己的知识体系很有帮助。不少老爷被面试的时候都会被问到一个问题



    “activity,window,View 三者之间的关系是什么?”



    我们可以通过一张图来说明。


    图 1


    如图所示,window 是 activity 里的一个实例变量,本质是一个接口,唯一的实现类是 PhoneWindow。


    activity 的 setContentView 方法实际上是就是交给 phonewindow 去做的。window 和 View 的关系可以类比为显示器显示的内容


    每个 activity 都有一个“显示器” window,“显示的内容”就是 DecorView。这个“显示器”定义了一些方法来决定如何显示内容。比如 setTitleColor setTitle 是设置导航栏的颜色和 title , setAllowReturnTransitionOverlap 设置进/出场动画等等。


    所以 window 是 activity 的一个成员变量,window 和 View 是“显示器”和“显示内容”的关系。


    这就是他们的关系


    View 是怎么绘制的



    “呦呵,不错嘛,这个比喻不错,看来平时还挺爱思考的。行,你继续说说 View 是怎么绘制的”



    在整个 activity 的生命周期中,setContentView 是在 onCreate 中调用的,它实现了对资源文件的解析,完成了 xml 文件到 View 的转化。那么 View 真正开始绘制是在哪个生命周期呢?



    答案是 onResume 结束后



    他们的关系在源码中一目了然。


    图 4


    从源码中可以看到,onResume 之后,ActivityThread 通过调用 activity 中 windowmanager 的 addView 方法,将 decorView 传入到 ViewRootImpl 的 setView 方法中,通过 setView 来完成 View 的绘制。


    问题又来了,setView 到底有什么魔法,为什么他就能完成 View 的绘制工作呢?


    ViewRootImpl 是如何绘制 View 的


    我们再来看一下 setView 方法


    图 5


    简单来说 setView 做了三件事


    ① 检查绘制的线程是不是创建 View 的线程。这里可以引申出一个问题,View 的绘制必须在主线程吗?


    ② 通过内存屏障保证绘制 View 的任务是最优先的


    ③ 调用 performTraversals 完成 measure,layout,draw 的绘制


    看到这里,ViewRootImpl 的绘制基本就完成了。其实这也是面试官希望听到的内容。考察的是面试者对 View 绘制体系的理解。


    后续 ViewGroup 和 View 的绘制其实是 performTraversals 对整个 ViewTree 的绘制。他们的关系可以用下面这张图表示


    图 2


    考考你对知识的运用



    “不错不错,看来你对 Viewrootimpl 的绘制过程掌握的不错嘛,你刚才提到 View 的绘制是在 onResume 之后才开始的,那为什么我在 onCreate 中调用 View.post 方法可以得到 View 的宽高呢”



    这个问题乍看挺唬人的。其实看一眼源码大概就明白了


    图 6


    View.post 会判断当前 View 是否已经被添加到 window 上。如果添加了则立即执行 runnable,如果没有被添加则先放到一个队列中存储起来,等添加到 window 上时再执行。


    而 View 被测量完成后才会 attachToWindow。所以当 post 的 runnable 执行时,View 已经绘制完成了。


    MeasureSpec 的理解



    “可以可以。看来这个小细节你注意到了。再问你个简单的问题,你刚才说到 measure 方法吧,那你说说什么是 MeasureSpec?为什么测量宽高要用它作为参数呢?”



    这个问题看似很简单死板,其实是想考察对 View 测量的理解。


    View 的大小不仅仅取决于自身的宽高,还取决于父 View 的大小和测量模式。一个 200200 的父 View 是不可能容纳一个 300300 的子 View 的,父 View 的 wrap_content 和 match_content 也会影响子 View 的大小。


    所以 View 的 measure 函数其实应该有 4 个参数:父 View 的宽父 View 的高宽的测量模式高的测量模式


    Android 这里用了一个巧妙的设计,用一个 Int 值来表示宽/高的测量模式和大小。一个 int 有 32 位,前 2 位表示测量 MODE,后 30 位表示 SIZE。


    为什么要用 2 位表示 MODE 呢?因为 MODE 只有 3 种呀,UNSPECIFIED,EXACTLY,AT_MOST,小傻瓜。




    “不错啊小伙子,那我自定义一个 View 的时候,如果不对 MeasureSpec 做处理。使用这个 View 时宽高传入 wrap_content,结果会怎么样?”



    这个考察的就是 View 绘制的实际运用了。当我们自定义一个 View 时,如果继承的是 View,measure 方法走的就是 View 默认的逻辑


    图 7


    所以当我们自定义 View 时,如果没有对 MODE 做处理,设置 wrap_content 和 match_content 结果其实是一样的,View 的宽高都是取父 View 的宽高。


    再来点细节



    “呦呵,那你说说 invaliate 和 requestlayout 方法的区别”



    前面我们说到,ViewRootImpl 作为顶级 View 负责 View 的绘制。所以简单来说,requestlayout 和 invaliate 最终都会向上回溯调用到 ViewRootImpl 的 postTranversals 方法来绘制 View。


    不同的是 requestlayout 会绘制 View 的 measure,layout 和 draw 过程。invaliate 因为只添加了绘制 draw 的标志位,只会绘制 draw 过程。


    这也能考算法



    “可以可以,看来 View 绘制这块你理解的不错嘛。来考你个小算法,实现一下 findViewbyid 的过程”



    一般对开发而言,算法的考察都不会太深,主要是常见算法的简单使用。目的是对业务中遇到的一些问题有更好的解决思路。像这个问题其实是想考察一下递归算法的简单使用。


    图 8



    “小伙子准备的不错嘛,好了,View 绘制这块我没有什么问题了,我们来聊聊 View 事件处理吧....”



    View 绘制相关的问题到这里就结束啦。如果大家觉得还不错的话,欢迎各位点赞,收藏,关注三连~


    后续我还会继续更新【面试官爸爸】这个系列,包括事件处理HandlerActivity 启动流程编译打包优化Context 等面试最常问的问题。如果不想错过,欢迎点赞,收藏,关注我!


    也可以关注我的公众号 @方木Rudy 里面不仅有技术,还有故事和感悟。你的支持,是我不断创作的动力!


    哦对了,是不是看完一遍觉得不够爽?杂七杂八说一大堆复习的时候一点也不轻松! 嘿嘿,我把上面提到的所有问题整理成了思维导图,方便各位观众老爷复习 ~


    图 1


    作者:方木Rudy
    链接:https://juejin.cn/post/6979395482946633758
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android平台debug完全解析

    一:Java程序调试原理:java这种上层语言编译结果是字节码,字节码需要jvm解释执行,那么调试java具体就是和jvm通信的问题,一般IDE中对Java程序的调试功能都是对jdb的包装,关于jvm调试体系网上有很多文章,比如:juejin.cn/post/...
    继续阅读 »

    一:Java程序调试原理:

    java这种上层语言编译结果是字节码,字节码需要jvm解释执行,那么调试java具体就是和jvm通信的问题,一般IDE中对Java程序的调试功能都是对jdb的包装,关于jvm调试体系网上有很多文章,比如:juejin.cn/post/688739… ,不赘述了

    二:Native 程序调试原理:

    native代码包含的是对应平台的cpu指令,是直接cpu跑的,对native代码调试需要cpu的支持(比如int3软中断指令),以及操作系统的协助(比如Linux的ptrace系统调用),lldb,gdb,IDA的android_server等调试器都是基于上面的功能实现的,具体网上有资料,比如:zhuanlan.zhihu.com/p/336922639 ,不赘述了

    三:Class,Dex,Elf三种文件指令和源码对应关系描述结构:

    1:class字节码和源码行号对应关系描述结构:

    image.png

    2:dex字节码和源码行号对应关系描述结构:

    image.png

    3:elf指令和源码行号对应关系描述结构:

    image.png

    4:小结

    当class没有了行号,那只能反编译调试class指令

    当dex没有了行号,那只能反编译调试smali指令

    当elf没有了行号,那只能反汇编调试汇编指令

    四:调试Android Studio

    AS本质上是个Java程序,调试AS就是调试个Java程序

    1:配置AS以Debug模式启动

    dmg安装包安装后,可执行程序路径: /Applications/Android Studio.app/Contents/MacOS/studio 由于这是个mac下的可执行文件,此程序内部又启动java程序,并传入andorid studio相关的jar路径和参数,直接通过这个没法传递java参数,不过AS提供了个VM配置文件,启动时候会读取此文件的内容加入到java参数中 VM配置文件路径: Applications/Android Studio.app/Contents/bin/studio.vmoptions 加上:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=6006 这里为方便观察,直接双击/Applications/Android Studio.app/Contents/MacOS/studio 程序启动AS,可观察到终端中输出: image.png

    说明jvm已经准备好被调试器附加了

    2:调试配置:

    实现了JDWP协议的程序都可以作为调试器来用,当然没道理自己搞一个,用jdb则命令行操作太繁琐,手动管理源码也很费劲,不如使用包装完善的IDE,这里使用Idea,新建一个Remote JVM Debug 类型的configuration。配置如下图: image.png

    3:导入代码

    源码从哪来,你可以去下载下来,甚至可以自己编译个AS,参考:tools.android.com/build/studi… 但我们大多数时候只是想调试下,不想这么麻烦,可以直接导入AS的jar包到Idea中,利用Idea的反编译和Debug功能完成我们的目标,(AS的程序包的各个目录中有很多jar,需要哪个导入哪个,如下图):

    image.png

    导入Idea方式:Idea中新建个java项目,随便建个文件夹,把需要的jar复制过去,在jar上右键->Add as Library

    image.png image.png

    4:开始调试

    首先要找到要调试的功能所涉及的类或者方法,寻找的方式可以通过在jar中搜索字符串,或者尝试在感觉相关名称的Class中断点,在 IDEA Plugin 框架体系中,大多数插件的功能入口都依赖 Action,那就可以在Action的一些方法中断点 image.png

    五:调试Gradle

    Gradle本质上是个Java程序,调试Gradle就是调试个Java程序

    1:配置Gradle以Debug模式启动

    gradle.properties中添加 org.gradle.jvmargs=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005

    启动gradle,比如执行下assemble task,执行后下图所示,由于上面设置的suspend=y,启动后会等待调试器链接后再继续运行

    image.png

    2:调试配置

    这里都使用Idea调试,配置和AS调试一样:

    image.png

    3:导入代码

    源码从哪来,你可以去下载下来,甚至可以自己编译个Gradle,参考:github.com/gradle/grad… 但我们大多数时候只是想调试下,不想这么麻烦,可以直接导入Gradle的jar包到Idea中,利用Idea的反编译和Debug功能完成我们的目标,Gradle以及Gradle plugin的jar位置,如下图):

    gradle程序位置

    image.png

    图中lib目录是编译后的jar,如果现在的是gradle-{version}-all类型的,则src目录中会对应的源码,可以导入源码调试

    gradle plugin位置

    在下图所示的文件夹中搜索目标插件:

    image.png

    比如我要搜索android gradle plugin:

    image.png

    导入Idea方式:Idea中新建个java项目,随便建个文件夹,把需要的jar复制过去,在jar上右键->Add as Library,和AS一样,不赘述了

    4:开始调试

    首先要找到要调试的功能所涉及的类或者方法,寻找的方式不多说了,自己摸索着来吧

    image.png

    六:调试任意App Java层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    如果是第三方App,需要反编译dex为java源码导入AS调试,如果行号对不上老是调飞,说明行号信息被混淆了或去掉了,这时候可以考虑反编译成smali,使用AS+smalidea插件调试smali代码,网上有很多资料。比如:blog.csdn.net/YJJYXM/arti… 如果遇到AS无法对smali类型的文件下断点,就参考 blog.csdn.net/qq_43278826…

    七:调试任意App Native层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    由于第三方app中的so都是去除debug信息的,以及我们并没有对应源码,所以只能反汇编调试,我一般都是习惯使用IDA,网上有很多资料,比如: blog.csdn.net/Breeze_CAT/… IDA调试时候注意下这个坑: bbs.pediy.com/thread-2654…

    八:调试Android系统Java层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    导入编译的代码到AS中(可以参考http://www.jianshu.com/p/2ba5d6bd4… ),或者也可以按需要把android sdk中的源码替换为编译系统用的源码(我就是这样),注意targetSdk版本要和编译的系统版本一致

    九:调试Android系统Native层

    自己编个aosp刷机,类型选择userDebug或者Eng,这样的系统有root权限且全局可以调试,如何编译网上很多资料,中间若遇到问题也可以参考我写的aosp编译的坑点,不赘述

    收起阅读 »

    Android常见图形绘制方式

    图形绘制概述Android平台提供丰富的官方控件给开发者实现界面UI开发,但在实际业务中经常会遇到各种各样的定制化需求,这必须由开发者通过自绘控件的方式来实现。通常Android提供了Canvas和OpenGL ES两种方式来实现,其中Canvas借助于And...
    继续阅读 »

    图形绘制概述

    Android平台提供丰富的官方控件给开发者实现界面UI开发,但在实际业务中经常会遇到各种各样的定制化需求,这必须由开发者通过自绘控件的方式来实现。通常Android提供了Canvas和OpenGL ES两种方式来实现,其中Canvas借助于Android底层的Skia 2D向量图形处理函数库来实现的。具体如何通过Canvas和OpenGL来绘制图形呢?这必须依赖于Android提供的View类来具体实现,下面组合几种常见的应用方式,如下所示:

    Canvas

    • View + Canvas
    • SurfaceView + Canvas
    • TextureView + Canvas

    OpenGL ES

    • SurfaceView + OpenGL ES
    • GLSurfaceView + OpenGL ES
    • TextureView + OpenGL ES

    View + Canvas

    这是一种通常使用的自绘控件方式,通过重写View类的onDraw(Canvas canvas)方法实现。当需要刷新绘制图形时,调用invalidate()方法让View对象自身进行刷新。该方案比较简单,涉及自定义逻辑较少,缺点是绘制逻辑在UI线程中进行,刷新效率不高,且不支持3D渲染。

    public class CustomView extends View {
    public CustomView(Context context) {
    super(context);
    }

    public CustomView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    }

    public CustomView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
    // draw whatever.
    }
    }
    复制代码

    SurfaceView + Canvas

    这种方式相对于View + Canvas方式在于使用SurfaceView,因此会在Android的WMS系统上创建一块自己的Surface进行渲染绘制,其绘制逻辑可以在独立的线程中进行,因此性能相对于View + Canvas方式更高效。但通常情况下需要创建一个绘制线程,以及实现SurfaceHolder.Callback接口来管理SurfaceView的生命周期,其实现逻辑相比View + Canvas略复杂。另外它依然不支持3D渲染,且Surface因不在View hierachy中,它的显示也不受View的属性控制,所以不能进行平移,缩放等变换,也不能放在其它ViewGroup中,SurfaceView 不能嵌套使用。

    public class CustomSurfaceView extends SurfaceView implements SurfaceHolder.Callback, Runnable {

    private boolean mRunning = false;
    private SurfaceHolder mSurfaceHolder;

    public CustomSurfaceView(Context context) {
    super(context);
    initView();
    }

    public CustomSurfaceView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initView();
    }

    public CustomSurfaceView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initView();
    }

    private void initView() {
    getHolder().addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
    mSurfaceHolder = holder;
    new Thread(this).start();
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
    mSurfaceHolder = holder;
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
    mRunning = false;
    }

    @Override
    public void run() {
    mRunning = true;
    while (mRunning) {
    SystemClock.sleep(333);
    Canvas canvas = mSurfaceHolder.lockCanvas();
    if (canvas != null) {
    try {
    synchronized (mSurfaceHolder) {
    onRender(canvas);
    }
    } finally {
    mSurfaceHolder.unlockCanvasAndPost(canvas);
    }
    }
    }
    }

    private void onRender(Canvas canvas) {
    // draw whatever.
    }
    }
    复制代码

    TextureView + Canvas

    该方式同SurfaceView + Canvas方式有些类似,但由于它是通过TextureView来实现的,所以可以摒弃Surface不在View hierachy中缺陷,TextureView不会在WMS中单独创建窗口,而是作为View hierachy中的一个普通View,因此可以和其它普通View一样进行移动,旋转,缩放,动画等变化。这种方式也有自身缺点,它必须在硬件加速的窗口中才能使用,占用内存比SurfaceView要高,在5.0以前在主UI线程渲染,5.0以后有单独的渲染线程。

    public class CustomTextureView extends TextureView implements TextureView.SurfaceTextureListener, Runnable {

    private boolean mRunning = false;
    private SurfaceTexture mSurfaceTexture;
    private Surface mSurface;
    private Rect mRect;

    public CustomTextureView(Context context) {
    super(context);
    initView();
    }

    public CustomTextureView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initView();
    }

    public CustomTextureView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initView();
    }

    private void initView() {
    setSurfaceTextureListener(this);
    }

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
    mSurfaceTexture = surface;
    mRect = new Rect(0, 0, width, height);
    mSurface = new Surface(mSurfaceTexture);
    new Thread(this).start();
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
    mSurfaceTexture = surface;
    mRect = new Rect(0, 0, width, height);
    mSurface = new Surface(mSurfaceTexture);
    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
    mRunning = false;
    return false;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {

    }

    @Override
    public void run() {
    mRunning = true;
    while (mRunning) {
    SystemClock.sleep(333);
    Canvas canvas = mSurface.lockCanvas(mRect);
    if (canvas != null) {
    try {
    synchronized (mSurface) {
    onRender(canvas);
    }
    } finally {
    mSurface.unlockCanvasAndPost(canvas);
    }
    }
    }
    }

    private void onRender(Canvas canvas) {
    canvas.drawColor(Color.RED);
    // draw whatever.
    }
    }
    复制代码

    以上都是2D图形渲染常见的方式,如果想要进行3D图形渲染或者是高级图像处理(比如滤镜、AR等效果),就必须得引入OpenGL ES来实现了。OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三维图形 API 的子集,针对手机、PDA和游戏主机等嵌入式设备而设计,是一种图形渲染API的设计标准,不同的软硬件开发商在OpenGL API内部可能会有不同的实现方式。下面介绍一下在Android平台上,如何进行OpenGL ES渲染绘制,通常有以下三种方式:

    SurfaceView + OpenGL ES

    EGL是OpenGL API和原生窗口系统之间的接口,OpenGL ES 的平台无关性正是借助 EGL 实现的,EGL 屏蔽了不同平台的差异。如果使用OpenGL API来绘制图形就必须先构建EGL环境。

    通常使用 EGL 渲染的一般步骤:

    - 获取 EGLDisplay对象,建立与本地窗口系统的连接调用eglGetDisplay方法得到EGLDisplay。

    - 初始化EGL方法,打开连接之后,调用eglInitialize方法初始化。

    - 获取EGLConfig对象,确定渲染表面的配置信息调用eglChooseConfig方法得到 EGLConfig。

    - 创建渲染表面EGLSurface通过EGLDisplay和EGLConfig,调用eglCreateWindowSurface或eglCreatePbufferSurface方法创建渲染表面得到EGLSurface。

    - 创建渲染上下文EGLContext通过EGLDisplay和EGLConfig,调用eglCreateContext方法创建渲染上下文,得到EGLContext。

    - 绑定上下文通过eglMakeCurrent 方法将 EGLSurface、EGLContext、EGLDisplay 三者绑定,绑定成功之后OpenGLES环境就创建好了,接下来便可以进行渲染。

    - 交换缓冲OpenGLES 绘制结束后,使用eglSwapBuffers方法交换前后缓冲,将绘制内容显示到屏幕上,而屏幕外的渲染不需要调用此方法。

    - 释放EGL环境绘制结束后,不再需要使用EGL时,需要取消eglMakeCurrent的绑定,销毁 EGLDisplay、EGLSurface、EGLContext三个对象。

    以上EGL环境构建比较复杂,这里先不做过多解释,下面可以通过代码参考其具体实现:

    public class OpenGLSurfaceView extends SurfaceView implements SurfaceHolder.Callback, Runnable {
    private boolean mRunning = false;
    private SurfaceHolder mSurfaceHolder;

    public OpenGLSurfaceView(Context context) {
    super(context);
    initView();
    }

    public OpenGLSurfaceView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initView();
    }

    public OpenGLSurfaceView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initView();
    }

    private void initView() {
    getHolder().addCallback(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
    mSurfaceHolder = holder;
    new Thread(this).start();
    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
    mSurfaceHolder = holder;
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
    mRunning = false;
    }

    @Override
    public void run() {
    //创建一个EGL实例
    EGL10 egl = (EGL10) EGLContext.getEGL();
    //
    EGLDisplay dpy = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
    //初始化EGLDisplay
    int[] version = new int[2];
    egl.eglInitialize(dpy, version);

    int[] configSpec = {
    EGL10.EGL_RED_SIZE, 5,
    EGL10.EGL_GREEN_SIZE, 6,
    EGL10.EGL_BLUE_SIZE, 5,
    EGL10.EGL_DEPTH_SIZE, 16,
    EGL10.EGL_NONE
    };

    EGLConfig[] configs = new EGLConfig[1];
    int[] num_config = new int[1];
    //选择config创建opengl运行环境
    egl.eglChooseConfig(dpy, configSpec, configs, 1, num_config);
    EGLConfig config = configs[0];

    EGLContext context = egl.eglCreateContext(dpy, config,
    EGL10.EGL_NO_CONTEXT, null);
    //创建新的surface
    EGLSurface surface = egl.eglCreateWindowSurface(dpy, config, mSurfaceHolder, null);
    //将opengles环境设置为当前
    egl.eglMakeCurrent(dpy, surface, surface, context);
    //获取当前opengles画布
    GL10 gl = (GL10)context.getGL();

    mRunning = true;
    while (mRunning) {
    SystemClock.sleep(333);
    synchronized (mSurfaceHolder) {
    onRender(gl);

    //显示绘制结果到屏幕上
    egl.eglSwapBuffers(dpy, surface);
    }
    }

    egl.eglMakeCurrent(dpy, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT);
    egl.eglDestroySurface(dpy, surface);
    egl.eglDestroyContext(dpy, context);
    egl.eglTerminate(dpy);
    }

    private void onRender(GL10 gl) {
    gl.glClearColor(1.0F, 0.0F, 0.0F, 1.0F);
    // Clears the screen and depth buffer.
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT
    | GL10.GL_DEPTH_BUFFER_BIT);
    }
    }
    复制代码

    从上面的代码可以看到,相对于SurfaceView + Canvas的绘制方式,主要有以下两点变化:

    • 在while(true)循环前后增加了EGL环境构造的代码
    • onRender()方法内参数用的是GL10而不是Canvas

    GLSurfaceView + OpenGL ES

    由于构建EGL环境比较繁琐,以及还需要健壮地维护一个线程,直接使用SurfaceView进行OpenGL绘制并不方便。幸好Android平台提供GLSurfaceView类,很好地封装了这些逻辑,使开发者能够快速地进行OpenGL的渲染开发。要使用GLSurfaceView类进行图形渲染,需要实现GLSurfaceView.Renderer接口,该接口提供一个onDrawFrame(GL10 gl)方法,在该方法内实现具体的渲染逻辑。

    public class OpenGLGLSurfaceView extends GLSurfaceView implements GLSurfaceView.Renderer {
    public OpenGLGLSurfaceView(Context context) {
    super(context);
    setRenderer(this);
    }

    public OpenGLGLSurfaceView(Context context, AttributeSet attrs) {
    super(context, attrs);
    setRenderer(this);
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
    // pass through
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
    gl.glViewport(0, 0, width, height);
    }

    @Override
    public void onDrawFrame(GL10 gl) {
    gl.glClearColor(1.0F, 0.0F, 0.0F, 1.0F);
    // Clears the screen and depth buffer.
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT
    | GL10.GL_DEPTH_BUFFER_BIT);
    }
    }
    复制代码

    TextureView + OpenGL ES

    该方式跟SurfaceView + OpenGL ES使用方法比较类似,使用该方法有个好处是它是通过TextureView来实现的,所以可以摒弃Surface不在View hierachy中缺陷,TextureView不会在WMS中单独创建窗口,而是作为View hierachy中的一个普通View,因此可以和其它普通View一样进行移动,旋转,缩放,动画等变化。这里使用TextureView类在构建EGL环境时需要注意,传入eglCreateWindowSurface()的参数是SurfaceTexture实例。

    public class OpenGLTextureView extends TextureView implements TextureView.SurfaceTextureListener, Runnable {
    private boolean mRunning = false;
    private SurfaceTexture mSurfaceTexture;

    public OpenGLTextureView(Context context) {
    super(context);
    initView();
    }

    public OpenGLTextureView(Context context, AttributeSet attrs) {
    super(context, attrs);
    initView();
    }

    public OpenGLTextureView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initView();
    }

    private void initView() {
    setSurfaceTextureListener(this);
    }

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
    mSurfaceTexture = surface;
    new Thread(this).start();
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
    mSurfaceTexture = surface;
    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
    mRunning = false;
    return false;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {

    }

    @Override
    public void run() {
    //创建一个EGL实例
    EGL10 egl = (EGL10) EGLContext.getEGL();
    //
    EGLDisplay dpy = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY);
    //初始化EGLDisplay
    int[] version = new int[2];
    egl.eglInitialize(dpy, version);

    int[] configSpec = {
    EGL10.EGL_RED_SIZE, 5,
    EGL10.EGL_GREEN_SIZE, 6,
    EGL10.EGL_BLUE_SIZE, 5,
    EGL10.EGL_DEPTH_SIZE, 16,
    EGL10.EGL_NONE
    };

    EGLConfig[] configs = new EGLConfig[1];
    int[] num_config = new int[1];
    //选择config创建opengl运行环境
    egl.eglChooseConfig(dpy, configSpec, configs, 1, num_config);
    EGLConfig config = configs[0];

    EGLContext context = egl.eglCreateContext(dpy, config,
    EGL10.EGL_NO_CONTEXT, null);
    //创建新的surface
    EGLSurface surface = egl.eglCreateWindowSurface(dpy, config, mSurfaceTexture, null);
    //将opengles环境设置为当前
    egl.eglMakeCurrent(dpy, surface, surface, context);
    //获取当前opengles画布
    GL10 gl = (GL10)context.getGL();

    mRunning = true;
    while (mRunning) {
    SystemClock.sleep(333);
    synchronized (mSurfaceTexture) {
    onRender(gl);

    //显示绘制结果到屏幕上
    egl.eglSwapBuffers(dpy, surface);
    }
    }

    egl.eglMakeCurrent(dpy, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT);
    egl.eglDestroySurface(dpy, surface);
    egl.eglDestroyContext(dpy, context);
    egl.eglTerminate(dpy);
    }

    private void onRender(GL10 gl) {
    gl.glClearColor(1.0F, 0.0F, 1.0F, 1.0F);
    // Clears the screen and depth buffer.
    gl.glClear(GL10.GL_COLOR_BUFFER_BIT
    | GL10.GL_DEPTH_BUFFER_BIT);
    }
    }
    收起阅读 »

    使用Jetpack Compose完成自定义手势处理

    概述Jetpack Compose 为我们提供了许多手势处理 Modifier,对于常见业务需求来说已足够我们使用了,然而如果说我们对手势有定制需求,就需要具备自定义手势处理的能力了。通过使用官方所提供的基础 API 来完成各类手势交互需求,触摸反馈基础 AP...
    继续阅读 »

    概述

    Jetpack Compose 为我们提供了许多手势处理 Modifier,对于常见业务需求来说已足够我们使用了,然而如果说我们对手势有定制需求,就需要具备自定义手势处理的能力了。通过使用官方所提供的基础 API 来完成各类手势交互需求,触摸反馈基础 API 类似传统 View 系统的 onTouchEvent()。 当然 Compose 中也支持类似传统 ViewGroup 通过 onInterceptTouchEvent()定制手势事件分发流程。通过对自定义手势处理的学习将帮助大家掌握处理绝大多数场景下手势需求的能力。

    使用 PointerInput Modifier

    对于所有手势操作的处理都需要封装在这个 Modifier 中,我们知道 Modifier 是用来修饰 UI 组件的,所以将手势操作的处理封装在 Modifier 符合开发者设计直觉,这同时也做到了手势处理逻辑与 UI 视图的解耦,从而提高复用性。

    通过翻阅 Swipeable Modifier 、Draggable Modifier 以及 Transformer Modifier,我们都能看到 PointerInput Modifier 的身影。因为这类上层的手势处理 Modifier 其实都是基于这个基础 Modifier 实现的。所以既然要自定义手势处理流程,自定义逻辑也必然要在这个 Modifier 中进行实现。

    通过 PointerInput Modifier 实现我们可以看出,我们所定义的自定义手势处理流程均发生在 PointerInputScope 中,suspend 关键字也告知我们自定义手势处理流程是发生在协程中。这其实是无可厚非的,在探索重组工作原理的过程中我们也经常能够看到协程的身影。伴随着越来越多的主流开发技术拥抱协程,这也就意味着协程成了 Android 开发者未来必须掌握的技能。推广协程同时其实也是在推广 Kotlin,即使官方一直强调不会放弃 Java,然而谁又会在 Java 中使用 Kotlin 协程呢?

    fun Modifier.pointerInput(
    vararg keys: Any?,
    block: suspend PointerInputScope.() -> Unit
    ): Modifier = composed(
    ...
    ) {
    ...
    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
    LaunchedEffect(this, *keys) {
    block()
    }
    }
    }

    接下来我们就看看 PointerInputScope 作用域中,为我们可以使用哪些 API 来处理手势交互。本文将会根据手势能力分类进行解释说明。

    拖动类型基础 API

    API 介绍

    API名称作用
    detectDragGestures监听拖动手势
    detectDragGesturesAfterLongPress监听长按后的拖动手势
    detectHorizontalDragGestures监听水平拖动手势
    detectVerticalDragGestures监听垂直拖动手势

    谈及拖动,许多人第一个反应就是 Draggable Modifier,因为 Draggable Modifier 为我们提供了监听 UI 组件拖动能力。然而 Draggable Modifier 在提供了监听 UI 组件拖动能力的同时也拓展增加其他功能,我们通过 Draggable Modifier 参数列表即可看出。例如通过使用 DraggableState 允许开发者根据需求使 UI 组件自动被拖动。

    fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
    reverseDirection: Boolean = false
    )

    我们上面所罗列的这些拖动 API 只提供了监听 UI 组件拖动的能力,我们可以根据需求为其拓展功能,这也是这些API所存在的意义。我们从字面上就可以看出每个 API 所对应的含义,由于这些API的功能与参数相近,这里我们仅以 detectDragGestures 作为举例说明。

    举例说明

    接下来我们将完成一个绿色方块的手势拖动。在 Draggabel Modifier 中我们还只能监听垂直或水平中某一个方向的手势拖动,而使用 detectDragGestures 所有手势信息都是可以拿到的。如果我们还是只希望拿到某一个方向的手势拖动,使用 detectHorizontalDragGestures 或 detectVerticalDragGestures 即可,当然我们也可以使用 detectDragGestures 并且忽略掉某个方向的手势信息。如果我们希望在长按后才能拿到手势信息可以使用 detectDragGesturesAfterLongPress

    detectDragGestures 提供了四个参数。

    onDragStart (可选):拖动开始时回调

    onDragEnd (可选):拖动结束时回调

    onDragCancel (可选):拖动取消时回调

    onDrag (必须):拖动时回调

    decectDragGestures 的源码分析在 awaitTouchSlopOrCancellation 小节会有讲解。

    suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = { },
    onDragEnd: () -> Unit = { },
    onDragCancel: () -> Unit = { },
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
    )

    💡 Tips

    有些同学可能困惑 onDragCancel 触发时机。在一些场景中,当组件拖动时会根据事件分发顺序进行事件分发,当前面先处理事件的组件满足了设置的消费条件,导致手势事件被消费,导致本组件拿到的是被消费的手势事件,从而会执行 onDragCancel 回调。如何定制事件分发顺序并消费事件后续会进行详细的描述。

    示例如下所示

    @Preview
    @Composable
    fun DragGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(contentAlignment = Alignment.Center,
    modifier = Modifier.fillMaxSize()
    ) {
    Box(Modifier
    .size(boxSize)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    detectDragGestures(
    onDragStart = { offset ->
    // 拖动开始
    },
    onDragEnd = {
    // 拖动结束
    },
    onDragCancel = {
    // 拖动取消
    },
    onDrag = { change: PointerInputChange, dragAmount: Offset ->
    // 拖动中
    offset += dragAmount
    }
    )
    }
    )
    }
    }

    drag.gif

    点击类型基础 API

    API 介绍

    API名称作用
    detectTapGestures监听点击手势

    与 Clickable Modifier 不同的是,detectTapGestures 可以监听更多的点击事件。作为手机监听的基础 API,必然不会存在 Clickable Modifier 所拓展的涟漪效果。

    举例说明

    接下来我们将为一个绿色方块添加点击手势处理逻辑。detectTapGestures 提供了四个可选参数,用来监听不同点击事件。

    onDoubleTap (可选):双击时回调

    onLongPress (可选):长按时回调

    onPress (可选):按下时回调

    onTap (可选):轻触时回调

    suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((Offset) -> Unit)? = null,
    onLongPress: ((Offset) -> Unit)? = null,
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null
    )

    💡 Tips

    onPress 普通按下事件

    onDoubleTap 前必定会先回调 2 次 Press

    onLongPress 前必定会先回调 1 次 Press(时间长)

    onTap 前必定会先回调 1 次 Press(时间短)

    示例如下所示

    @Preview
    @Composable
    fun TapGestureDemo() {
    var boxSize = 100.dp
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(Modifier
    .size(boxSize)
    .background(Color.Green)
    .pointerInput(Unit) {
    detectTapGestures(
    onDoubleTap = { offset: Offset ->
    // 双击
    },
    onLongPress = { offset: Offset ->
    // 长按
    },
    onPress = { offset: Offset ->
    // 按下
    },
    onTap = { offset: Offset ->
    // 轻触
    }
    )
    }
    )
    }
    }

    变换类型基础 API

    API 介绍

    API名称作用
    detectTransformGestures监听拖动、缩放与旋转手势

    与 Transfomer Modifier 不同的是,通过这个 API 可以监听单指的拖动手势,和拖动类型基础 API所提供的功能一样,除此之外还支持监听双指缩放与旋转手势。反观Transfomer Modifier 只能监听到双指拖动手势,不知设计成这样的行为不一致是否是 Google 有意而为之。

    举例说明

    接下来我们为这个绿色方块添加变化手势处理逻辑。detectTransformGestures 方法提供了两个参数。

    panZoomLock(可选): 当拖动或缩放手势发生时是否支持旋转

    onGesture(必须):当拖动、缩放或旋转手势发生时回调

    suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock: Boolean = false,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
    )

    💡 Tips

    关于偏移、缩放与旋转,我们建议的调用顺序是 rotate -> scale -> offset

    1. 若offset发生在rotate之前时,rotate会对offset造成影响。具体表现为当出现拖动手势时,组件会以当前角度为坐标轴进行偏移。

    2. 若offset发生在scale之前是,scale也会对offset造成影响。具体表现为UI组件在拖动时不跟手

    @Preview
    @Composable
    fun TransformGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    var ratationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(Modifier
    .size(boxSize)
    .rotate(ratationAngle) // 需要注意offset与rotate的调用先后顺序
    .scale(scale)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    detectTransformGestures(
    panZoomLock = true, // 平移或放大时是否可以旋转
    onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
    offset += pan
    scale *= zoom
    ratationAngle += rotation
    }
    )
    }
    )
    }
    }

    forEachGesture

    在传统 View 系统中,一次手指按下、移动到抬起过程中的所有手势事件可以共同构成一个手势事件序列。我们可以通过自定义手势处理来对于每一个手势事件序列进行定制处理。Compose 提供了 forEachGesture 以允许用户可以对每一个手势事件序列进行相同的定制处理。如果我们忘记使用 forEachGesture ,那么只会处理第一次手势事件序列。有些同学可能会问,为什么我不能在手势处理逻辑最外层套一层 while(true) 呢,通过 forEachGesture 的实现我们可以看到 forEachGesture 其实内部也是由while 实现的,除此之外他保证了协程只有存活时才能监听手势事件,同时也保证了每次交互结束时所有手指都是离开屏幕的。有些同学看到 while 可能新生疑问,难道这样不会阻塞主线程嘛?其实我们在介绍 PointerInput Modifier 时就提到过,我们的手势操作处理均发生在协程中。其实前面我们所提到的绝大多数 API 其内部实现均使用了 forEachGesture 。有些特殊场景下我们仅使用前面所提出的 API 可能仍然无法满足我们的需求,当然如果可以满足的话我们直接使用其分别对应的 Modifier 即可,前面所提出的 API 存在的意义是为了方便开发者为其进行功能拓展。既然要掌握自定义手势处理,我们就要从更底层角度来看这些上层 API 是如何实现的,了解原理我们就可以轻松自定义了。

    suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    while (currentContext.isActive) {
    try {
    block()
    // 挂起等待所有手指抬起
    awaitAllPointersUp()
    } catch (e: CancellationException) {
    ...
    }
    }
    }

    手势事件作用域 awaitPointerEventScope

    在 PointerInputScope 中我们可以找到一个名为 awaitPointerEventScope 的 API 方法。

    通过翻阅方法声明可以发现这是个挂起方法,其尾部 lambda 在 AwaitPointerEventScope 作用域中。 通过这个 AwaitPointerEventScope 作用域我们可以获取到更加底层的 API 手势事件,这也为自定义手势处理提供了可能。

    suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
    ): R

    我们在 AwaitPointerEventScope 中发现了以下这些基础手势方法,可以发现这些 API 均是挂起函数,接下来我们会对每个 API 进行描述说明。

    API名称作用
    awaitPointerEvent手势事件
    awaitFirstDown第一根手指的按下事件
    drag拖动事件
    horizontalDrag水平拖动事件
    verticalDrag垂直拖动事件
    awaitDragOrCancellation单次拖动事件
    awaitHorizontalDragOrCancellation单次水平拖动事件
    awaitVerticalDragOrCancellation单次垂直拖动事件
    awaitTouchSlopOrCancellation有效拖动事件
    awaitHorizontalTouchSlopOrCancellation有效水平拖动事件
    awaitVerticalTouchSlopOrCancellation有效垂直拖动事件

    万物之源 awaitPointerEvent

    awaitPointerEvent 类似于传统 View 系统的 onTouchEvent() 。无论用户是按下、移动或抬起都将视作一次手势事件,当手势事件发生时 awaitPointerEvent 会恢复执行并将手势事件返回。

    suspend fun awaitPointerEvent(
    pass: PointerEventPass = PointerEventPass.Main
    ): PointerEvent

    通过 API 声明可以看到 awaitPointerEvent 有个可选参数 PointerEventPass

    我们知道手势事件的分发是由父组件到子组件的单链结构。这个参数目的是用以设置父组件与子组件的事件分发顺序,PointerEventPass 有 3 个枚举值可供选择,每个枚举值的具体含义如下

    枚举值含义
    PointerEventPass.Initial本组件优先处理手势,处理后交给子组件
    PointerEventPass.Main若子组件为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。
    PointerEventPass.Final若子组件也为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。

    大家可能觉得 Main 与 Final 是等价的。但其实两者在作为子组件时分发顺序会完全不同,举个例子。

    当父组件为Final,子组件为Main时,事件分发顺序: 子组件 -> 父组件

    当父组件为Final,子组件为Final时,事件分发顺序: 父组件 -> 子组件

    文字描述可能并不直观,接下来进行举例说明。

    事件分发流程

    接下来,我将通过一个嵌套了三层 Box 的示例来直观表现事件分发过程。我们为这嵌套的三层Box 中的每一层都进行手势获取。

    如果我们点击中间的绿色方块时,便会触发手势事件。

    当三层 Box 均使用默认 Main 模式时,事件分发顺序为:第三层 -> 第二层 -> 第一层

    当第一层Box使用 Inital 模式,第二层使用 Final 模式,第三层使用 Main 模式时,事件分发顺序为:第一层 -> 第三层 -> 第二层

    @Preview
    @Composable
    fun NestedBoxDemo() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent(PointerEventPass.Initial)
    Log.d("compose_study", "first layer")
    }
    }
    ) {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .size(400.dp)
    .background(Color.Blue)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent(PointerEventPass.Final)
    Log.d("compose_study", "second layer")
    }
    }
    ) {
    Box(
    Modifier
    .size(200.dp)
    .background(Color.Green)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent()
    Log.d("compose_study", "third layer")
    }
    }
    )
    }
    }
    }

    // Output:
    // first layer
    // third layer
    // second layer

    能够自定义事件分发顺序之后,我们就可以决定手势事件由事件分发流程中哪个组件进行消费。那么如何进行消费呢,这就需要我们看看 awaitPointerEvent 返回的手势事件了。通过 awaintPointerEvent 声明,我们可以看到返回的手势事件是个 PointerEvent 实例。

    通过 PointerEvent 类声明,我们可以看到两个成员属性 changes 与 motionEvent。

    motionEvent 我们再熟悉不过了,就是传统 View 系统中的手势事件,然而却被声明了 internal 关键字,看来是不希望我们使用。

    changes 是一个 List,其中包含了每次发生手势事件时,屏幕上所有手指的状态信息。

    当只有一根手指时,这个 List 的大小为 1。在多指操作时,我们通过这个 List 获取其他手指的状态信息就可以轻松定制多指自定义手势处理了。

    actual data class PointerEvent internal constructor(
    actual val changes: List<PointerInputChange>,
    internal val motionEvent: MotionEvent?
    )

    PointerInputChange

    class PointerInputChange(
    val id: PointerId, // 手指Id
    val uptimeMillis: Long, // 当前手势事件的时间戳
    val position: Offset, // 当前手势事件相对组件左上角的位置
    val pressed: Boolean, // 当前手势是否按下
    val previousUptimeMillis: Long, // 上一次手势事件的时间戳
    val previousPosition: Offset, // 上一次手势事件相对组件左上角的位置
    val previousPressed: Boolean, // 上一次手势是否按下
    val consumed: ConsumedData, // 当前手势是否已被消费
    val type: PointerType = PointerType.Touch // 手势类型(鼠标、手指、手写笔、橡皮)
    )
    API名称作用
    changedToDown是否已经按下(按下手势已消费则返回false)
    changedToDownIgnoreConsumed是否已经按下(忽略按下手势已消费标记)
    changedToUp是否已经抬起(按下手势已消费则返回false)
    changedToUpIgnoreConsumed是否已经抬起(忽略按下手势已消费标记)
    positionChanged是否位置发生了改变(移动手势已消费则返回false)
    positionChangedIgnoreConsumed是否位置发生了改变(忽略已消费标记)
    positionChange位置改变量(移动手势已消费则返回Offset.Zero)
    positionChangeIgnoreConsumed位置改变量(忽略移动手势已消费标记)
    positionChangeConsumed当前移动手势是否已被消费
    anyChangeConsumed当前按下手势或移动手势是否有被消费
    consumeDownChange消费按下手势
    consumePositionChange消费移动手势
    consumeAllChanges消费按下与移动手势
    isOutOfBounds当前手势是否在固定范围内

    这些 API 会在我们自定义手势处理时会被用到。可以发现的是,Compose 通过 PointerEventPass 来定制事件分发流程,在事件分发流程中即使前一个组件先获取了手势信息并进行了消费,后面的组件仍然可以通过带有 IgnoreConsumed 系列 API 来获取到手势信息。这也极大增加了手势操作的可定制性。就好像父组件先把事件消费,希望子组件不要处理这个手势了,但子组件完全可以不用听从父组件的话。

    我们通过一个实例来看看该如何进行手势消费,处于方便我们的示例不涉及移动,只消费按下手势事件来进行举例。和之前的样式一样,我们将手势消费放在了第三层 Box,根据事件分发规则我们知道第三层Box是第2个处理手势事件的,所以输出结果如下。

    @Preview
    @Composable
    fun Demo() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Initial)
    Log.d("compose_study", "first layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    }
    ) {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .size(400.dp)
    .background(Color.Blue)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Final)
    Log.d("compose_study", "second layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    }
    ) {
    Box(
    Modifier
    .size(200.dp)
    .background(Color.Green)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent()
    event.changes[0].consumeDownChange()
    Log.d("compose_study", "third layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    }
    )
    }
    }
    }

    // Output:
    // first layer, downChange: false
    // third layer, downChange: true
    // second layer, downChange: true

    ⚠️ 注意事项

    如果我们是在定制事件分发流程,那么需要注意以下两种写法

    // 正确写法
    awaitPointerEventScope {
    var event = awaitPointerEvent()
    event.changes[0].consumeDownChange()
    }

    // 错误写法
    var event = awaitPointerEventScope {
    awaitPointerEvent()
    }
    event.changes[0].consumeDownChange()

    他们的区别在于 awaitPointerEventScope 会在其内部所有手势在事件分发流程结束后返回,当所有组件都已经完成手势处理再进行消费已经没有什么意义了。我们仍然用刚才的例子来直观说明这个问题。我们在每一层Box awaitPointerEventScope 后面添加了日志信息。

    通过输出结果可以发现,这三层执行的相对顺序没有发生变化,然而却是在事件分发流程结束后才进行输出的。

    @Preview
    @Composable
    fun Demo() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Initial)
    Log.d("compose_study", "first layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "first layer Outside")
    }
    ) {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .size(400.dp)
    .background(Color.Blue)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Final)
    Log.d("compose_study", "second layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "second layer Outside")
    }
    ) {
    Box(
    Modifier
    .size(200.dp)
    .background(Color.Green)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent()
    event.changes[0].consumeDownChange()
    Log.d("compose_study", "third layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "third layer Outside")
    }
    )
    }
    }
    }

    // Output:
    // first layer, downChange: false
    // third layer, downChange: true
    // second layer, downChange: true
    // first layer Outside
    // third layer Outside
    // second layer Outside

    awaitFirstDown

    awaitFirstDown 将等待第一根手指按下事件时恢复执行,并将手指按下事件返回。分析源码我们可以发现 awaitFirstDown 也使用的是 awaitPointerEvent 实现的,默认使用 Main 模式。

    suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed: Boolean = true
    ): PointerInputChange {
    var event: PointerEvent
    do {
    event = awaitPointerEvent()
    } while (
    !event.changes.fastAll {
    if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
    }
    )
    return event.changes[0]
    }

    drag

    看到 drag 可能很多同学疑惑为什么又是拖动。其实前面所提到的拖动类型基础API detectDragGestures 其内部就是使用 drag 而实现的。与 detectDragGestures 不同的是,drag 需要主动传入一个 PointerId 用以表示要具体获取到哪根手指的拖动事件。

    suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
    )

    翻阅源码可以发现,其实 drag 内部实现最终使用的仍然还是 awaitPointerEvent 。这里就不具体展开看了,感兴趣的可以自己去跟源码。

    收起阅读 »

    将构建配置从 Groovy 迁移到 KTS

    将构建配置从 Groovy 迁移到 KTS前言作为Android开发习惯了面向对象编程,习惯了IDEA提供的各种辅助开发快捷功能。那么带有陌生的常规语法的Groovy脚本对于我来说一向敬而远之。Kotlin DSL的出现感觉是为了我们量身定做的,因为采用 Ko...
    继续阅读 »

    将构建配置从 Groovy 迁移到 KTS

    前言

    作为Android开发习惯了面向对象编程,习惯了IDEA提供的各种辅助开发快捷功能。

    那么带有陌生的常规语法的Groovy脚本对于我来说一向敬而远之。

    Kotlin DSL的出现感觉是为了我们量身定做的,因为采用 Kotlin 编写的代码可读性更高,并且 Kotlin 提供了更好的编译时检查和 IDE 支持。


    名词概念解释

    • Gradle: 自动化构建工具. 平行产品: Maven.

    • Groovy: 语言, 编译后变为JVM byte code, 兼容Java平台.

    • DSLDomain Specific Language, 领域特定语言.

    • Groovy DSLGradle的API是Java的, Groovy DSL是在其之上的脚本语言. Groovy DS脚本文件后缀: .gradle.

    • KTS:是指 Kotlin 脚本,这是 Gradle 在构建配置文件中使用的一种 Kotlin 语言形式。Kotlin 脚本是可从命令行运行的 Kotlin 代码。

    • Kotlin DSL:主要是指 Android Gradle 插件 Kotlin DSL,有时也指底层 Gradle Kotlin DSL

    在讨论从 Groovy 迁移时,术语“KTS”和“Kotlin DSL”可以互换使用。换句话说,“将 Android 项目从 Groovy 转换为 KTS”与“将 Android 项目从 Groovy 转换为 Kotlin DSL”实际上是一个意思。

    Groovy和KTS对比

    类型KotlinGroovy
    自动代码补全支持不支持
    是否类型安全不是
    源码导航支持不支持
    重构自动关联手动修改

    优点:

    • 可以使用Kotlin, 开发者可能对这个语言更熟悉更喜欢.
    • IDE支持更好, 自动补全提示, 重构, imports等.
    • 类型安全: Kotlin是静态类型.
    • 不用一次性迁移完: 两种语言的脚本可以共存, 也可以互相调用.

    缺点和已知问题:

    • 目前,采用 KTS 的构建速度可能比采用 Groovy 慢(自测小demo耗时增加约40%(约8s))。

    • Project Structure 编辑器不会展开在 buildSrc 文件夹中定义的用于库名称或版本的常量。

    • KTS 文件目前在项目视图中不提供文本提示

    Android构建配置从Groovy迁移KTS

    准备工作

    1. Groovy 字符串可以用单引号 'string' 或双引号 "string" 引用,而 Kotlin 需要双引号 "string"

    2. Groovy 允许在调用函数时省略括号,而 Kotlin 总是需要括号。

    3. Gradle Groovy DSL 允许在分配属性时省略 = 赋值运算符,而 Kotlin 始终需要赋值运算符。

    所以在KTS中需要统一做到:

    • 使用双引号统一引号.

    groovy-kts-diff1.png

    • 消除函数调用和属性赋值的歧义(分别使用括号和赋值运算符)。

    groovy-kts-diff2.png

    脚本文件名

    Groovy DSL 脚本文件使用 .gradle 文件扩展名。

    Kotlin DSL 脚本文件使用 .gradle.kts 文件扩展名。

    一次迁移一个文件

    由于您可以在项目中结合使用 Groovy build 文件和 KTS build 文件,因此将项目转换为 KTS 的一个简单方法是先选择一个简单的 build 文件(例如 settings.gradle),将其重命名为 settings.gradle.kts,然后将其内容转换为 KTS。之后,确保您的项目在迁移每个 build 文件之后仍然可以编译。

    自定义Task

    由于Koltin 是静态类型语言,Groovy是动态语言,前者是类型安全的,他们的性质区别很明显的体现在了 task 的创建和配置上。详情可以参考Gradle官方迁移教程

    // groovy
    task clean(type: Delete) {
    delete rootProject.buildDir
    }
    // kotiln-dsl
    tasks.register("clean", Delete::class) {
    delete(rootProject.buildDir)
    }
    val clean by tasks.creating(Delete::class) {
    delete(rootProject.buildDir)
    }
    复制代码
    open class GreetingTask : DefaultTask() {
    var msg: String? = null
    @TaskAction
    fun greet() {
    println("GreetingTask:$msg")
    }
    }
    val msg by tasks.creating(GreetingTask::class) {}
    val testTask: Task by tasks.creating {
    doLast {
    println("testTask:Run")
    }
    }
    val testTask2: Task = task("test2") {
    doLast {
    println("Hello, World!")
    }
    }
    val testTask3: Task = tasks.create("test3") {
    doLast {
    println("testTask:Run")
    }
    }
    复制代码

    使用 plugins 代码块

    如果您在 build 文件中使用 plugins 代码块,IDE 将能够获知相关上下文信息,即使在构建失败时也是如此。IDE 可使用这些信息执行代码补全并提供其他实用建议,从而帮助您解决 KTS 文件中存在的问题。

    在您的代码中,将命令式 apply plugin 替换为声明式 plugins 代码块。Groovy 中的以下代码…

    apply plugin: 'com.android.application'
    apply plugin: 'kotlin-android'
    apply plugin: 'kotlin-kapt'
    apply plugin: 'androidx.navigation.safeargs.kotlin'
    复制代码

    在 KTS 中变为以下代码:

    plugins {
    id("com.android.application")
    id("kotlin-android")
    id("kotlin-kapt")
    id("androidx.navigation.safeargs.kotlin")
    }
    复制代码

    如需详细了解 plugins 代码块,请参阅 Gradle 的迁移指南

    注意plugins 代码块仅解析 Gradle 插件门户中提供的插件或使用 pluginManagement 代码块指定的自定义存储库中提供的插件。如果插件来自插件门户中不存在的 buildScript 依赖项,那么这些插件在 Kotlin 中就必须使用 apply 才能应用。例如:

    apply(plugin = "kotlin-android")
    apply {
    from("${rootDir.path}/config.gradle")
    from("${rootDir.path}/version.gradle.kts")
    }
    复制代码

    如需了解详情,请参阅 Gradle 文档

    强烈建议您plugins {}优先使用块而不是apply()函数。

    有两个关键的最佳实践可以更轻松地在 Kotlin DSL 的静态上下文中工作:

    • 使用plugins {}
    • 将本地构建逻辑放在构建的buildSrc目录中

    plugins {}块是关于保持您的构建脚本声明性,以便充分利用 Kotlin DSL

    使用buildSrc项目是关于将您的构建逻辑组织成共享的本地插件和约定,这些插件和约定易于测试并提供良好的 IDE 支持。

    依赖管理

    常见依赖

    // groovy
    implementation project(':library')
    implementation 'com.xxxx:xxxx:8.8.1'

    // kotlin
    implementation(project(":library"))
    implementation("com.xxxx:xxx:8.8.1")
    复制代码

    freeTree

    // groovy
    implementation fileTree(include: '*.jar', dir: 'libs')

    //kotlin
    implementation(fileTree(mapOf("include" to listOf("*.jar"), "dir" to "libs")))
    复制代码

    特别类型库依赖

    //groovy
    implementation(name: 'splibrary', ext: 'aar')

    //kotlin
    implementation (group="",name="splibrary",ext = "aar")
    复制代码

    构建变体

    显式和隐式 buildTypes

    在 Kotlin DSL 中,某些 buildTypes(如 debug 和 release,)是隐式提供的。但是,其他 buildTypes 则必须手动创建。

    例如,在 Groovy 中,您可能有 debugrelease 和 staging buildTypes

    buildTypes
    debug {
    ...
    }
    release {
    ...
    }
    staging {
    ...
    }
    复制代码

    在 KTS 中,仅 debug 和 release buildTypes 是隐式提供的,而 staging 则必须由您手动创建:

    buildTypes
    getByName("debug") {
    ...
    }
    getByName("release") {
    ...
    }
    create("staging") {
    ...
    }
    复制代码

    举例说明

    Grovvy编写:

    productFlavors {
    demo {
    dimension "app"
    }
    full {
    dimension "app"
    multiDexEnabled true
    }
    }

    buildTypes {
    release {
    signingConfig signingConfigs.signConfig
    minifyEnabled true
    debuggable false
    proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }

    debug {
    minifyEnabled false
    debuggable true
    }
    }
    signingConfigs {
    release {
    storeFile file("myreleasekey.keystore")
    storePassword "password"
    keyAlias "MyReleaseKey"
    keyPassword "password"
    }
    debug {
    ...
    }
    }
    复制代码

    kotlin-KTL编写:

    productFlavors {
    create("demo") {
    dimension = "app"
    }
    create("full") {
    dimension = "app"
    multiDexEnabled = true
    }
    }

    buildTypes {
    getByName("release") {
    signingConfig = signingConfigs.getByName("release")
    isMinifyEnabled = true
    isDebuggable = false
    proguardFiles(getDefaultProguardFile("proguard-android.txtt"), "proguard-rules.pro")
    }

    getByName("debug") {
    isMinifyEnabled = false
    isDebuggable = true
    }
    }

    signingConfigs {
    create("release") {
    storeFile = file("myreleasekey.keystore")
    storePassword = "password"
    keyAlias = "MyReleaseKey"
    keyPassword = "password"
    }
    getByName("debug") {
    ...
    }
    }
    复制代码

    访问配置

    gradle.properties

    我们通常会把签名信息、版本信息等配置写在gradle.properties中,在kotlin-dsl中我们可以通过一下方式访问:

    1. rootProject.extra.properties
    2. project.extra.properties
    3. rootProject.properties
    4. properties
    5. System.getProperties()

    System.getProperties()使用的限制比较多

    • 参数名必须按照systemProp.xxx格式(例如:systemProp.kotlinVersion=1.3.72);
    • 与当前执行的task有关(> Configure project :buildSrc> Configure project :的结果不同,后者无法获取的gradle.properties中的数据);

    local.properties

    获取工程的local.properties文件

    gradleLocalProperties(rootDir)

    gradleLocalProperties(projectDir)

    获取系统环境变量的值

    val JAVA_HOME:String = System.getenv("JAVA_HOME") ?: "default_value"

    关于Ext

    Google 官方推荐的一个 Gradle 配置最佳实践是在项目最外层 build.gradle 文件的ext代码块中定义项目范围的属性,然后在所有模块间共享这些属性,比如我们通常会这样存放依赖的版本号。

    // build.gradle

    ext {
    compileSdkVersion = 28
    buildToolsVersion = "28.0.3"
    supportLibVersion = "28.0.0"
    ...
    }
    复制代码

    但是由于缺乏IDE的辅助(跳转查看、全局重构等都不支持),实际使用体验欠佳。

    KTL中用extra来代替Groovy中的ext

    // The extra object can be used for custom properties and makes them available to all
    // modules in the project.
    // The following are only a few examples of the types of properties you can define.
    extra["compileSdkVersion"] = 28
    // You can also create properties to specify versions for dependencies.
    // Having consistent versions between modules can avoid conflicts with behavior.
    extra["supportLibVersion"] = "28.0.0"
    复制代码
    android {
    // Use the following syntax to access properties you defined at the project level:
    // rootProject.extra["property_name"]
    compileSdkVersion(rootProject.extra["sdkVersion"])

    // Alternatively, you can access properties using a type safe delegate:
    val sdkVersion: Int by rootProject.extra
    ...
    compileSdkVersion(sdkVersion)
    }
    ...
    dependencies {
    implementation("com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}")
    ...
    }
    复制代码

    build.gralde中的ext数据是可以在build.gradle.kts中使用extra进行访问的。

    修改生成apk名称和BuildConfig中添加apk支持的cpu架构

    val abiCodes = mapOf("armeabi-v7a" to 1, "x86" to 2, "x86_64" to 3)
    android.applicationVariants.all {
    val buildType = this.buildType.name
    val variant = this
    outputs.all {
    val name =
    this.filters.find { it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI.name }?.identifier
    val baseAbiCode = abiCodes[name]
    if (baseAbiCode != null) {
    //写入cpu架构信息
    variant.buildConfigField("String", "CUP_ABI", "\"${name}\"")
    }
    if (this is com.android.build.gradle.internal.api.ApkVariantOutputImpl) {
    //修改apk名称
    if (buildType == "release") {
    this.outputFileName = "KotlinDSL_${name}_${buildType}.apk"
    } else if (buildType == "debug") {
    this.outputFileName = "KotlinDSL_V${variant.versionName}_${name}_${buildType}.apk"
    }
    }
    }
    }
    复制代码

    buildSrc

    我们在使用Groovy语言构建的时候,往往会抽取一个version_config.gradle来作为全局的变量控制,而ext扩展函数则是必须要使用到的,而在我们的Gradle Kotlin DSL中,如果想要使用全局控制,则需要建议使用buildSrc

    复杂的构建逻辑通常很适合作为自定义任务或二进制插件进行封装。自定义任务和插件实现不应存在于构建脚本中。buildSrc则不需要在多个独立项目之间共享代码,就可以非常方便地使用该代码了。

    buildSrc被视为构建目录。编译器发现目录后,Gradle会自动编译并测试此代码,并将其放入构建脚本的类路径中。

    1. 先创建buildSrc目录;
    2. 在该目录下创建build.gradle.kts文件;
    3. 创建一个buildSrc/src/main/koltin目录;
    4. 在该目录下创建Dependencies.kt文件作为版本管理类;

    需要注意的是buildSrcbuild.gradle.kts

    plugins {
    `kotlin-dsl`
    }
    repositories {
    jcenter()
    }
    复制代码

    或者

    apply {
    plugin("kotlin")
    }
    buildscript {
    repositories {
    gradlePluginPortal()
    }
    dependencies {
    classpath(kotlin("gradle-plugin", "1.3.72"))
    }
    }
    //dependencies {
    // implementation(gradleKotlinDsl())
    // implementation(kotlin("stdlib", "1.3.72"))
    //}
    repositories {
    gradlePluginPortal()
    }
    复制代码

    不同版本之间buildSrc下的build.gradle文件执行顺序:

    gradle-wrapper.properties:5.6.4

    com.android.tools.build:gradle:3.2.0

    1. BuildSrc:build.gradle
    2. setting.gradle
    3. Project:build.gradle
    4. Moudle:build.gradle

    gradle-wrapper.properties:6.5

    com.android.tools.build:gradle:4.1.1

    1. setting.gradle
    2. BuildSrc:build.gradle
    3. Project:build.gradle
    4. Moudle:build.gradle

    所以在非buildSrc目录下的build.gradle.kts文件中我们使用Dependencies.kt需要注意其加载顺序。

    收起阅读 »

    老生新谈,从OkHttp原理看网络请求

    OkHttp作为一个网络请求框架,地位是不言而喻的,研究它的好处就在于能够将TCP、HTTP、HTTPS等这些基础的网络知识实例化,抽象变为形象。 读完这篇文章您将了解到: OkHttp的整体请求结构; 责任链模式下各个拦截器的实现细节与职责; ...
    继续阅读 »

    OkHttp作为一个网络请求框架,地位是不言而喻的,研究它的好处就在于能够将TCP、HTTP、HTTPS等这些基础的网络知识实例化,抽象变为形象。


    读完这篇文章您将了解到:



    • OkHttp的整体请求结构;

    • 责任链模式下各个拦截器的实现细节与职责;

    • 如何找到可用且健康的连接?即连接池的复用;

    • 如何找到Http1和Http2的编/解码器?

    • NetworkInterceptor与ApplicationInterceptor拦截器的区别?

    • 如何建立TCP/TLS连接?



    本文源码为okhttp:4.9.1版本,文中没有贴大量源码,结合源码一起阅读最佳。



    OkHttp整体结构


    OkHttp的使用不是本文的主要内容,它只是作为源码解读的一个入口。


            val okHttpClient = OkHttpClient()
    val request: Request = Request.Builder()
    .url("https://cn.bing.com/")
    .build()

    okHttpClient.newCall(request).enqueue(object :Callback{
    override fun onFailure(call: Call, e: IOException) {
    }

    override fun onResponse(call: Call, response: Response) {
    }
    })

    OkHttp使用起来很简单,先创建OkHttpClient和Request对象,以Request来创建一个RealCall对象,利用它执行异步enqueue或者同步execute操作将请求发送出去,并监听请求失败或者成功的反馈Callback。


    这里有三个主要的类需要说明一下:OkHttpClient、Request以及RealCall



    • OkHttpClient: 相当于配置中?,可用于发送 HTTP 请求并读取其响应。它的配置有很多,例如connectTimeout:建?连接(TCP 或 TLS)的超时时间,readTimeout :发起请求到读到响应数据的超时时间,Dispatcher:调度器,?于调度后台发起的?络请求,等等。还有其他配置可查看源码。

    • Request: 一个主要设置网络请求Url请求方法(GET、POST......)请求头请求body的请求类。

    • RealCall: RealCall是由newCall(Request)方法返回,是OkHttp执行请求最核心的一个类之一,用作连接OkHttp的应用程序层和网络层,也就是将OkHttpClient和Request结合起来,发起异步和同步请求。


    从上面的使用步骤可以看到,OkHttp最后执行的是okHttpClient.newCall(request).enqueue,也就是RealCall的enqueue方法,这是一个异步请求,同样的,也可以执行同步请求RealCall.execute()


    RealCall的同步请求最后其实会调用RealCall.getResponseWithInterceptorChain(),而RealCall的异步请求是使用线程池先将请求放置到后台处理,但是最后还是会调用RealCall.getResponseWithInterceptorChain()来获取网络请求的返回值Response。从这里就基本能嗅到网络请求的核心其实与getResponseWithInterceptorChain()方法有关,那到底如何与服务器连接进行网络请求的?这个问题就先抛在这,后面再详细说。


    我们先从异步请求enqueue开始,来看异步请求的主要结构。


      类:Dispatcher

    private fun promoteAndExecute(): Boolean {
    ...
    val executableCalls = mutableListOf<AsyncCall>()
    synchronized(this) {
    val i = readyAsyncCalls.iterator()
    while (i.hasNext()) {
    val asyncCall = i.next()

    if (runningAsyncCalls.size >= this.maxRequests) break // Max capacity.
    if (asyncCall.callsPerHost.get() >= this.maxRequestsPerHost) continue // Host max capacity.

    i.remove()
    asyncCall.callsPerHost.incrementAndGet()
    executableCalls.add(asyncCall)
    runningAsyncCalls.add(asyncCall)
    }
    isRunning = runningCallsCount() > 0
    }

    for (i in 0 until executableCalls.size) {
    val asyncCall = executableCalls[i]
    asyncCall.executeOn(executorService)
    }

    return isRunning
    }

    异步请求首先会将AsyncCall添加到双向队列readyAsyncCalls中(即准备执行但还没有执行的队列),做请求的准备动作。接着遍历准备执行队列readyAsyncCalls,寻找符合条件的请求,并将其加入到一个保存有效请求的列表executableCalls和正在执行队列runningAsyncCalls中,而这个筛选条件主要有两条:



    • if (runningAsyncCalls.size >= this.maxRequests) break :并发执行的请求数要小于最大的请求数64。


    • if (asyncCall.callsPerHost.get() >= this.maxRequestsPerHost) continue :某个主机的并发请求数不能超过最大请求数5



    也就是说,当我们的并发请求量超过64个或者某个主机的的请求数超过5,则超过的请求暂时不能执行,需要等一等才能再加入执行队列中。


    将有效的请求筛选出后并保存,立即开始遍历请求,一一利用调度器Dispatcher里的ExecutorService进行Runnable任务,也就是遍历后加入到线程池中执行这些有效的网络请求。


     类:RealCall.AsyncCall

    override fun run() {
    threadName("OkHttp ${redactedUrl()}") {
    ...
    try {
    val response = getResponseWithInterceptorChain()
    signalledCallback = true
    responseCallback.onResponse(this@RealCall, response)
    } catch (e: IOException) {
    ...
    responseCallback.onFailure(this@RealCall, e)
    }
    }
    }

    上面的代码就是在线程池中执行的请求任务,可以看到try-catch块中有一句 val response = getResponseWithInterceptorChain() 得到网络请求结果resonse ,将返回的response或者错误,通过callback告知给用户。这个callback也就是一开始OkHttp使用时所注册监听的callback。


    另外,这个方法是不是很熟悉?因为在上面说明三个主要核心类时提到过,RealCall的同步请求或者异步请求,最后都会走到getResponseWithInterceptorChain()这一步。


    网络请求结果response就是通过这个getResponseWithInterceptorChain()方法返回的,那网络请求结果到底是如何拿到的? 与服务器又是如何交互的呢? 我们就来剖析这个方法的内部结构。


    拦截器内部实现


    从上面OkHttp的结构分析知道,所有网络请求的细节都封装在getResponseWithInterceptorChain() 这个核心方法中。那我们就来研究一下它的具体实现。


     类:RealCall

    internal fun getResponseWithInterceptorChain(): Response {
    // Build a full stack of interceptors.
    val interceptors = mutableListOf<Interceptor>()
    interceptors += client.interceptors
    interceptors += RetryAndFollowUpInterceptor(client)
    interceptors += BridgeInterceptor(client.cookieJar)
    interceptors += CacheInterceptor(client.cache)
    interceptors += ConnectInterceptor
    if (!forWebSocket) {
    interceptors += client.networkInterceptors
    }
    interceptors += CallServerInterceptor(forWebSocket)

    val chain = RealInterceptorChain(
    call = this,
    interceptors = interceptors,
    index = 0,
    exchange = null,
    request = originalRequest,
    connectTimeoutMillis = client.connectTimeoutMillis,
    readTimeoutMillis = client.readTimeoutMillis,
    writeTimeoutMillis = client.writeTimeoutMillis
    )
    ...
    try {
    val response = chain.proceed(originalRequest)
    ...
    return response
    }
    ...
    }

    getResponseWithInterceptorChain()的内部实现是通过一个责任链模式来完成,将网络请求的各个阶段封装到各个链条中(即各个拦截器Interceptor),配置好各个Interceptor后将其放在?个List?,然后作为参数,创建?个RealInterceptorChain对象,并调? chain.proceed(request)来发起请求和获取响应。


    在每一条拦截器中,会先做一些准备动作,例如对该请求进行是否可用的判断,或者将请求转换为服务器解析的格式,等等,接着就对请求执行chain.proceed(request)。上面提到getResponseWithInterceptorChain()的内部实现是一个责任链模式,而chain.proceed(request)的作用就是责任链模式的核心所在,将请求移交给下一个拦截器。


    OkHttp中连自定义拦截器包括在内,一共有7种拦截器,在这里,网络请求的细节就封装在各个拦截器中,每个拦截器也都有自己的职责,只要把每个拦截器研究清楚,整个网络请求也就明了了。下面就来一一分析这些拦截器的职责。


    7种拦截器的职责


    1、用户自定义拦截器interceptors


    用户自定义拦截器是在所有其他拦截器之前,开发者可根据业务需求进行网络拦截器的自定义,例如我们常常自定义Token处理拦截器,日志打印拦截器等。


    2、RetryAndFollowUpInterceptor


    RetryAndFollowUpInterceptor是一个请求失败和重定向时重试的拦截器。它的内部开启了一个请求循环,每次循环都会先做一个准备动作(call.enterNetworkInterceptorExchange(request, newExchangeFinder)),这个准备动作最主要的目的在于创建一个ExchangeFinder,为请求寻找可用的Tcl或者Tsl连接以及设置跟连接相关的一些参数,如连接编码解码器等。 ExchangeFinder在后面网络连接时,会详细说明。


    准备工作做好后便开始了一个网络请求(response = realChain.proceed(request)),这句代码的目的是为了将请求传递给下一个拦截器。同时,会判断当前请求是否会出错以及是否需要重定向。如果出错或者需要重定向,那么就又开始新一轮的循环,直到没有出错和需要重定向为止。


    这里出错和重定向的判断标准也简单说一下:



    • 判断出错的标准: 利用try-catch块对请求进行异常捕获,这里会捕获RouteException和IOException,并且在出错后都会先判断当前请求是否能够进行重试的操作。

    • 重定向标准: 这里判断是否需要重定向,是对Response的状态码Code进行审查,当状态码为3xx时,则表示需要重定向,而后创建一个新的request,进行重试操作。


    3、BridgeInterceptor


    BridgeInterceptor是用来连接应用程序代码和网络代码的一个拦截器。也就是说该拦截器会帮用户准备好服务器请求所需要的一些配置。可能定义太抽象,我们就先来看一下一个请求Url所对应的服务器请求头是怎么样的?



    URL: wanandroid.com/wxarticle/c…
    方法: GET



    那它所对应的请求头如下:



    GET /wxarticle/chapters/json HTTP/1.1
    Host: wanandroid.com
    Accept: application/json, text/plain, /
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9
    Connection: keep-alive
    User-Agent: Mozilla/5.0 xxx
    ......



    你可能会问,BridgeInterceptor拦截器和这个有什么关系?其实BridgeInterceptor的作用就是帮用户处理网络请求,它会帮助用户填写服务器请求所需要的配置信息,如上面所展示的User-Agent、Connection、Host、Accept-Encoding等。同时也会对请求的结果进行相应处理。


    BridgeInterceptor的内部实现主要分为以下三步:



    1. 为用户网络请求设置Content-Type、Content-Length、Host、Connection、Cookie等参数,也就是将一般请求转换为适合服务器解析的格式,以适应服务器端;


    2. 通过 chain.proceed(requestBuilder.build())方法,将转换后的请求移交给下一个拦截器CacheInterceptor,并接收返回的结果Response;


    3. 对结果Response也进行gzip、Content-Type转换,以适应应用程序端。



    所以说BridgeInterceptor是应用程序和服务器端的一个桥梁。


    4、CacheInterceptor


    CacheInterceptor是一个处理网络请求缓存的拦截器。它的内部处理和一些图片缓存的逻辑相似,首先会判断是否存在可用的缓存,如果存在,则直接返回缓存,反之,调用chain.proceed(networkRequest)方法将请求移交给下一个拦截器,有了结果后,将结果put到cache中。


    5、ConnectInterceptor


    ConnectInterceptor是建立连接去请求的拦截器。


      internal fun initExchange(chain: RealInterceptorChain): Exchange {
    ...
    val exchangeFinder = this.exchangeFinder!!
    val codec = exchangeFinder.find(client, chain)
    val result = Exchange(this, eventListener, exchangeFinder, codec)
    this.interceptorScopedExchange = result
    this.exchange = result
    ...

    if (canceled) throw IOException("Canceled")
    return result
    }

    从它的源码可以看到,它首先会通过ExchangeFinder查询到codec,这个ExchangeFinder是不是很熟悉?在上面RetryAndFollowUpInterceptor分析中,每次循环都会先做创建ExchangeFinder的准备工作。


    而这个codec是什么?它是一个编码解码器,来确定是用Http1的方式还是以Http2的方式进行请求。


    在找到合适的codec后,作为参数创建Exchange。Exchange内部涉及了很多网络连接的实现,这个后面再详细说,我们先看看是如何找到合适的codec?


    如何找到可用连接?


    找到合适的codec,就必须先找到一个可用的网络连接,再利用这个可用的连接创建一个新的codec。 为了找到可用的连接,内部使用了大概5种方式进行筛选。


    第一种:从连接池中查找


    if (connectionPool.callAcquirePooledConnection(address, call, null, false)) {
    val result = call.connection!!
    return result
    }

    尝试在连接池中查找可用的连接,在遍历连接池中的连接时,就会判断每个连接是否可用,而判断连接是否可用的条件如下:



    1. 请求数要小于该连接最大能承受的请求数,Http2以下,最大请求数为1个,并且此连接上可创建新的交换;

    2. 该连接的主机和请求的主机一致;


    如果从连接池中拿到了合格的连接connection,则直接返回。


    如果没有拿到,那就进行第二种拿可用连接的方式。


    第二种:传入Route,从连接池中查找


     if (connectionPool.callAcquirePooledConnection(address, call, routes, false)) {
    val result = call.connection!!
    return result
    }

    第二种依然是从连接池中拿,但是这次不同的是,参数里传入了routes,这个routes是包含路由Route的一个List集合,而Route其实指的是连接的IP地址、TCP端口以及代理模式。


    而这次从连接池中拿,主要是针对Http2,路由必须共用一个IP地址,此连接的服务器证书必须包含新主机且证书必须与主机匹配。


    第三种:自己创建连接


    如果前两次从连接池里都没有拿到可用连接,那么就自己创建连接。


     val newConnection = RealConnection(connectionPool, route)
    call.connectionToCancel = newConnection
    try {
    newConnection.connect(
    connectTimeout,
    readTimeout,
    writeTimeout,
    pingIntervalMillis,
    connectionRetryEnabled,
    call,
    eventListener
    )
    }

    创建连接其实是内部自己在进行socket,tls的连接,这里抛出一个问题在后面解答:TCP/TLS连接是如何实现的?


    自己创建好连接后,又做了一次从连接池中查找的操作。


    第四种:多路复用置为true,依然从连接池中查找


     if (connectionPool.callAcquirePooledConnection(address, call, routes, true)) {
    val result = call.connection!!
    newConnection.socket().closeQuietly()
    return result
    }

    这次从连接池中查找,requireMultiplexed置为了true,只查找支持多路复用的连接。并且在建立连接后,将新的连接保存到连接池中。


    如何找到Http1和Http2的编/解码器?


    上面已经分析出寻找可用且健康的连接的几种方式,那对于codec的创建则需要根据这些连接进行Http1和Http2的区分。如果http2Connection不为null,则创建Http2ExchangeCodec,反之创建Http1ExchangeCodec。


    找到编解码器后,我们就回到ConnectInterceptor的一开始,利用编解码器codec创建了一个Exchange,而这个Exchange的内部其实是利用Http1解码器或者Http2解码器,分别进行请求头的编写writeRequestHeaders,或者创建Request Body,发送给服务器。


    Exchange初始化成功后,就又将请求移交给了下一个拦截器CallServerInterceptor。


    6、CallServerInterceptor


    CallServerInterceptor是链中最后一个拦截器,主要用于向服务器发送内容,主要传输http的头部和body信息。


    其内部利用上面创建的Exchange进行请求头编写,创建Request body,发送请求,得到结果后,对结果进行解析并回传。


    7、NetworkInterceptor


    networkInterceptor也是属于用户自定义的一种拦截器,它的位置在ConnectInterceptor之后,CallServerInterceptor之前。我们知道第一个拦截器便是用户自定义,那和这个有什么区别呢?


    networkInterceptor前面已经存在有多个拦截器的使用,在请求到达该拦截器时,请求信息已经相当复杂了,其中就包括RetryAndFollowUpInterceptor重试拦截器,经过分析知道,每当重试一次,其后面的拦截器也都会被调用一次,这样就导致networkInterceptor也会被调用多次,而第一个自定义拦截器只会调用一次。当我们需要自定义拦截器时,如token、log,为了资源消耗这一点,一般都是使用第一个。


    到这里为止,7种拦截器都分析完成。在分析ConnectInterceptor时抛出了一个问题:TCP/TLS连接是如何实现的?


    如何建立TCP/TLS连接?


    TCP连接


    fun connect(
    connectTimeout: Int,
    readTimeout: Int,
    writeTimeout: Int,
    pingIntervalMillis: Int,
    connectionRetryEnabled: Boolean,
    call: Call,
    eventListener: EventListener
    )
    {
    ...

    while (true) {
    try {
    if (route.requiresTunnel()) {
    connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener)
    if (rawSocket == null) {
    // We were unable to connect the tunnel but properly closed down our resources.
    break
    }
    } else {
    connectSocket(connectTimeout, readTimeout, call, eventListener)
    }
    establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener)
    eventListener.connectEnd(call, route.socketAddress, route.proxy, protocol)
    break
    } catch (e: IOException) {
    ...
    }


    1. 在connect的内部开启了一个while循环,可以看到第一步就是route.requiresTunnel()判断,这个requiresTunnel()方法表示该请求是否使用了Proxy.Type.HTTP代理且目标是Https连接;

    2. 如果是,则创建一个代理隧道连接Tunnel(connectTunnel)。创建这个隧道的目的在于利用Http来代理请求Https;

    3. 如果不是,则直接建立一个TCP连接(connectSocket);

    4. 建立请求协议。


    代理隧道是如何创建的?它的内部会先通过Http代理创建一个TLS的请求,也就是在地址url上增加Host、Proxy-Connection、User-Agent首部。接着最多21次的尝试,利用connectSocket开启TCP连接且利用TLS请求创建一个代理隧道。


    从这里可以看见,不管是否需要代理隧道,都会开始建立一个TCP连接(connectSocket),那又是如何建立TCP连接的?


     private fun connectSocket(
    connectTimeout: Int,
    readTimeout: Int,
    call: Call,
    eventListener: EventListener
    )
    {
    val proxy = route.proxy
    val address = route.address

    val rawSocket = when (proxy.type()) {
    Proxy.Type.DIRECT, Proxy.Type.HTTP -> address.socketFactory.createSocket()!!
    else -> Socket(proxy)
    }
    this.rawSocket = rawSocket

    eventListener.connectStart(call, route.socketAddress, proxy)
    rawSocket.soTimeout = readTimeout
    try {
    Platform.get().connectSocket(rawSocket, route.socketAddress, connectTimeout)
    } catch (e: ConnectException) {
    throw ConnectException("Failed to connect to ${route.socketAddress}").apply {
    initCause(e)
    }
    }

    ...
    }

    从源码上看,如果代理类型为直连或者HTTP/FTP代理,则直接创建一个socket,反之,则指定代理类型进行创建。我们看到创建后返回了一个rawSocket,这个就代表着TCP连接。在最后 调用Platform.get().connectSocket,而这实际就是调用socket的connect方法来打开一个TCP连接。


    TLS连接


    在建立TCP连接或者创建Http代理隧道后,就会开始建立连接协议(establishProtocol)。


      private fun establishProtocol(
    connectionSpecSelector: ConnectionSpecSelector,
    pingIntervalMillis: Int,
    call: Call,
    eventListener: EventListener
    )
    {
    if (route.address.sslSocketFactory == null) {
    if (Protocol.H2_PRIOR_KNOWLEDGE in route.address.protocols) {
    socket = rawSocket
    protocol = Protocol.H2_PRIOR_KNOWLEDGE
    startHttp2(pingIntervalMillis)
    return
    }

    socket = rawSocket
    protocol = Protocol.HTTP_1_1
    return
    }

    eventListener.secureConnectStart(call)
    connectTls(connectionSpecSelector)
    eventListener.secureConnectEnd(call, handshake)

    if (protocol === Protocol.HTTP_2) {
    startHttp2(pingIntervalMillis)
    }
    }


    1. 判断当前地址是否是HTTPS;

    2. 如果不是HTTPS,则判断当前协议是否是明文HTTP2,如果是的则调用startHttp2,开始Http2的握手动作,如果是Http/1.1则直接return返回;

    3. 如果是HTTPS,就开始建立TLS安全协议连接了(connectTls);

    4. 如果是HTTPS且为HTTP2,除了建立TLS连接外,还会调用startHttp2,开始Http2的握手动作。


    在上述第3步时就提到了TLS的连接(connectTls),那我们就来看一下它的内部实现:


    private fun connectTls(connectionSpecSelector: ConnectionSpecSelector) {
    val address = route.address
    val sslSocketFactory = address.sslSocketFactory
    var success = false
    var sslSocket: SSLSocket? = null
    try {
    // Create the wrapper over the connected socket.
    sslSocket = sslSocketFactory!!.createSocket(
    rawSocket, address.url.host, address.url.port, true /* autoClose */) as SSLSocket

    // Configure the socket's ciphers, TLS versions, and extensions.
    val connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket)
    if (connectionSpec.supportsTlsExtensions) {
    Platform.get().configureTlsExtensions(sslSocket, address.url.host, address.protocols)
    }

    // Force handshake. This can throw!
    sslSocket.startHandshake()
    // block for session establishment
    val sslSocketSession = sslSocket.session
    val unverifiedHandshake = sslSocketSession.handshake()

    // Verify that the socket's certificates are acceptable for the target host.
    if (!address.hostnameVerifier!!.verify(address.url.host, sslSocketSession)) {
    val peerCertificates = unverifiedHandshake.peerCertificates
    if (peerCertificates.isNotEmpty()) {
    val cert = peerCertificates[0] as X509Certificate
    throw SSLPeerUnverifiedException("""
    |Hostname ${address.url.host} not verified:
    | certificate: ${CertificatePinner.pin(cert)}
    | DN: ${cert.subjectDN.name}
    | subjectAltNames: ${OkHostnameVerifier.allSubjectAltNames(cert)}
    "
    "".trimMargin())
    } else {
    throw SSLPeerUnverifiedException(
    "Hostname ${address.url.host} not verified (no certificates)")
    }
    }

    val certificatePinner = address.certificatePinner!!

    handshake = Handshake(unverifiedHandshake.tlsVersion, unverifiedHandshake.cipherSuite,
    unverifiedHandshake.localCertificates
    )
    {
    certificatePinner.certificateChainCleaner!!.clean(unverifiedHandshake.peerCertificates,
    address.url.host)
    }

    // Check that the certificate pinner is satisfied by the certificates presented.
    certificatePinner.check(address.url.host) {
    handshake!!.peerCertificates.map { it as X509Certificate }
    }

    // Success! Save the handshake and the ALPN protocol.
    val maybeProtocol = if (connectionSpec.supportsTlsExtensions) {
    Platform.get().getSelectedProtocol(sslSocket)
    } else {
    null
    }
    socket = sslSocket
    source = sslSocket.source().buffer()
    sink = sslSocket.sink().buffer()
    protocol = if (maybeProtocol != null) Protocol.get(maybeProtocol) else Protocol.HTTP_1_1
    success = true
    } finally {
    ...
    }
    }

    这段代码很长,具体逻辑我就源码总结了以下几点:



    1. 利用请求地址host,端口以及TCP socket共同创建sslSocket;

    2. 为Socket 配置加密算法,TLS版本等;

    3. 调用startHandshake()进行强制握手;

    4. 验证服务器证书的合法性;

    5. 利用握手记录进行证书锁定校验(Pinner);

    6. 连接成功则保存握手记录和ALPN协议。


    Tsl加密连接的源码内容其实与HTTPS所定义的客户端与服务器通信的规则一致。创建好sslSocket后就会开始进行client和server的通信操作。


    总结


    OkHttp大致的请求实现如上面解析,跟着源码走完了一个请求到处理再到返回结果的整个流程,期间OkHttp做了很多细节封装,也使用了很多设计模式,如做核心的责任链模式、建造者模式、工厂模式以及策略模式等,都值得我们学习。


    以上便是OkHttp的解析,希望这篇文章能帮到您,感谢阅读。



    参考资料


    OkHttp源码深度解析-OPPO互联网技术


    推荐阅读


    【网络篇】开发必备知识点:UDP/TCP协议



    作者:付十一
    链接:https://juejin.cn/post/6979729429228421134
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


    收起阅读 »

    性能优化一分钟定位Android启动耗时问题

    前言 Tencent Matrix默认无法监测Application冷启动的耗时方法,本文介绍了如何改造Matrix支持冷启动耗时方法监测。让你一分钟就能给App启动卡顿号脉。 1. 接入Tencent Matrix 1.1 在你项目根目录下的 grad...
    继续阅读 »

    前言


    Tencent Matrix默认无法监测Application冷启动的耗时方法,本文介绍了如何改造Matrix支持冷启动耗时方法监测。让你一分钟就能给App启动卡顿号脉。


    1. 接入Tencent Matrix


    1.1 在你项目根目录下的 gradle.properties 中配置要依赖的 Matrix 版本号,如:


    MATRIX_VERSION=1.0.0

    1.2 在你项目根目录下的 build.gradle 文件添加 Matrix 依赖,如:


    dependencies {
    classpath ("com.tencent.matrix:matrix-gradle-plugin:${MATRIX_VERSION}") { changing = true }
    }

    1.3 在 app/build.gradle 文件中添加 Matrix 各模块的依赖,如:


      dependencies {
    implementation group: "com.tencent.matrix", name: "matrix-android-lib", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-android-commons", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-trace-canary", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-resource-canary-android", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-resource-canary-common", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-io-canary", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-sqlite-lint-android-sdk", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-battery-canary", version: MATRIX_VERSION, changing: true
    implementation group: "com.tencent.matrix", name: "matrix-hooks", version: MATRIX_VERSION, changing: true
    }

    apply plugin: 'com.tencent.matrix-plugin'
    matrix {
    trace {
    enable = true //if you don't want to use trace canary, set false
    baseMethodMapFile = "${project.buildDir}/matrix_output/Debug.methodmap"
    blackListFile = "${project.projectDir}/matrixTrace/blackMethodList.txt"
    }
    }


    1.4 实现 PluginListener,接收 Matrix 处理后的数据, 如:


    class MatrixListener(context: Context?) : DefaultPluginListener(context) {
    companion object {
    const val TAG: String = "Matrix.TestPluginListener"
    }

    override fun onReportIssue(issue: Issue) {
    super.onReportIssue(issue)
    MatrixLog.e(TAG, issue.toString())

    }
    }

    1.5 实现动态配置接口, 可修改 Matrix 内部参数. 在 sample-android 中 我们有个简单的动态接口实例DynamicConfigImplDemo.java, 其中参数对应的 key 位于文件 MatrixEnum中, 摘抄部分示例如下:


      class MatrixConfig : IDynamicConfig {
    val isFPSEnable: Boolean
    get() = true
    val isTraceEnable: Boolean
    get() = true
    val isMatrixEnable: Boolean
    get() = true

    override fun get(key: String, defStr: String): String {

    // for Activity leak detect
    if (ExptEnum.clicfg_matrix_resource_detect_interval_millis.name == key || ExptEnum.clicfg_matrix_resource_detect_interval_millis_bg.name == key) {
    Log.d(
    "DynamicConfig",
    "Matrix.ActivityRefWatcher: clicfg_matrix_resource_detect_interval_millis 10s"
    )
    return TimeUnit.SECONDS.toMillis(5).toString()
    }
    if (ExptEnum.clicfg_matrix_resource_max_detect_times.name == key) {
    Log.d(
    "DynamicConfig",
    "Matrix.ActivityRefWatcher: clicfg_matrix_resource_max_detect_times 5"
    )
    return 3.toString()
    }
    return defStr
    }

    override fun get(key: String, defInt: Int): Int {
    //TODO here return default value which is inside sdk, you can change it as you wish. matrix-sdk-key in class MatrixEnum.
    if (MatrixEnum.clicfg_matrix_resource_max_detect_times.name == key) {
    MatrixLog.i(TAG, "key:$key, before change:$defInt, after change, value:2")
    return 2 //new value
    }
    if (MatrixEnum.clicfg_matrix_trace_fps_report_threshold.name == key) {
    return 10000
    }
    if (MatrixEnum.clicfg_matrix_trace_fps_time_slice.name == key) {
    return 12000
    }
    if (ExptEnum.clicfg_matrix_trace_app_start_up_threshold.name == key) {
    return 3000
    }
    return if (ExptEnum.clicfg_matrix_trace_evil_method_threshold.name == key) {
    200
    } else defInt
    }

    override fun get(key: String, defLong: Long): Long {
    //TODO here return default value which is inside sdk, you can change it as you wish. matrix-sdk-key in class MatrixEnum.
    if (MatrixEnum.clicfg_matrix_trace_fps_report_threshold.name == key) {
    return 10000L
    }
    if (MatrixEnum.clicfg_matrix_resource_detect_interval_millis.name == key) {
    MatrixLog.i(TAG, "$key, before change:$defLong, after change, value:2000")
    return 2000
    }
    return defLong
    }

    override fun get(key: String, defBool: Boolean): Boolean {
    //TODO here return default value which is inside sdk, you can change it as you wish. matrix-sdk-key in class MatrixEnum.
    return defBool
    }

    override fun get(key: String, defFloat: Float): Float {
    //TODO here return default value which is inside sdk, you can change it as you wish. matrix-sdk-key in class MatrixEnum.
    return defFloat
    }

    companion object {
    private const val TAG = "Matrix.DynamicConfigImplDemo"
    }
    }

    1.6 选择程序启动的位置对 Matrix 进行初始化,如在 Application 的继承类中, Init 核心逻辑如下:


      Matrix.Builder builder = new Matrix.Builder(application); // build matrix
    builder.patchListener(new TestPluginListener(this)); // add general pluginListener
    DynamicConfigImplDemo dynamicConfig = new DynamicConfigImplDemo(); // dynamic config

    // init plugin
    IOCanaryPlugin ioCanaryPlugin = new IOCanaryPlugin(new IOConfig.Builder()
    .dynamicConfig(dynamicConfig)
    .build());
    //add to matrix
    builder.plugin(ioCanaryPlugin);

    //init matrix
    Matrix.init(builder.build());

    // start plugin
    ioCanaryPlugin.start();


    2. 改造Application子类


    2.1 模拟Application卡顿


    private fun A() {
    B()
    H()
    L()
    SystemClock.sleep(800)
    }

    private fun B() {
    C()
    G()
    SystemClock.sleep(200)
    }

    private fun C() {
    D()
    E()
    F()
    SystemClock.sleep(100)
    }

    private fun D() {
    SystemClock.sleep(20)
    }

    private fun E() {
    SystemClock.sleep(20)
    }

    private fun F() {
    SystemClock.sleep(20)
    }

    private fun G() {
    SystemClock.sleep(20)
    }

    private fun H() {
    SystemClock.sleep(20)
    I()
    J()
    K()
    }

    private fun I() {
    SystemClock.sleep(20)
    }

    private fun J() {
    SystemClock.sleep(6)
    }

    private fun K() {
    SystemClock.sleep(10)
    }


    private fun L() {
    SystemClock.sleep(10000)
    }

    2.2 Application.onCreate()调用卡顿方法


    override fun onCreate() {
    A()
    }

    2.3 反射获取ActivityThread的mHandler


    override fun attachBaseContext(base: Context?) {
    super.attachBaseContext(base)
    println("zijiexiaozhan MyApp attachBaseContext")
    time1 = SystemClock.uptimeMillis()
    time3 = System.currentTimeMillis()

    try {
    val forName = Class.forName("android.app.ActivityThread")
    val field = forName.getDeclaredField("sCurrentActivityThread")
    field.isAccessible = true
    val activityThreadValue = field[forName]
    val mH = forName.getDeclaredField("mH")
    mH.isAccessible = true
    val handler = mH[activityThreadValue]
    mHandler = handler as Handler
    } catch (e: Exception) {
    }
    }

    2.4 将原来的onCreate的方法调用转入匿名内部类调用


    inner class ApplicationTask : Runnable {
    override fun run() {
    A()
    }
    }

    2.5 重写Application onCreate方法


    override fun onCreate() {
    super.onCreate()
    //重点
    mHandler.postAtFrontOfQueue(ApplicationTask())
    }

    3.运行,快速定位


    3.1 关键字"Trace_EvilMethod"查找日志



    tag[Trace_EvilMethod]type[0];key[null];content[{"machine":"MIDDLE","cpu_app":0,"mem":3822452736,"mem_free":1164132,"detail":"NORMAL","cost":1344,"usage":"0.37%","scene":"default","stack":"0,1048574,1,1344\n1,5471,1,1338\n2,17582,1,1338\n3,17558,1,1338\n4,17560,1,379\n5,17562,1,160\n6,17563,1,17\n6,17566,1,20\n6,17568,1,20\n5,17569,1,20\n4,17573,1,56\n5,17575,1,21\n5,17576,1,5\n5,17578,1,10\n4,17580,1,102\n","stackKey":"17558|","tag":"Trace_EvilMethod","process":"com.peter.viewgrouptutorial","time":1624837969986}]



    3.2 解析日志 打印卡顿堆栈


    android.os.Handler dispatchMessage 1344
    .com.peter.viewgrouptutorial.MyApp$ApplicationTask run 1338
    ..com.peter.viewgrouptutorial.MyApp access$A 1338
    ...com.peter.viewgrouptutorial.MyApp A 1338
    ....com.peter.viewgrouptutorial.MyApp B 379
    .....com.peter.viewgrouptutorial.MyApp C 160
    ......com.peter.viewgrouptutorial.MyApp D 17
    ......com.peter.viewgrouptutorial.MyApp E 20
    ......com.peter.viewgrouptutorial.MyApp F 20
    .....com.peter.viewgrouptutorial.MyApp G 20
    ....com.peter.viewgrouptutorial.MyApp H 56
    .....com.peter.viewgrouptutorial.MyApp I 21
    .....com.peter.viewgrouptutorial.MyApp J 5
    .....com.peter.viewgrouptutorial.MyApp K 10
    ....com.peter.viewgrouptutorial.MyApp L 102


    收起阅读 »

    Android APT 系列 (一):APT 筑基之反射

    前言很高兴遇见你~这又是一个新的系列,灵感来源于最近做的一次布局优化,我们知道:Android 中少量的系统控件是通过 new 的方式创建出来的,而大部分控件如 androidx.appcompat.widget 下的控...
    继续阅读 »

    前言

    很高兴遇见你~

    这又是一个新的系列,灵感来源于最近做的一次布局优化,我们知道:Android 中少量的系统控件是通过 new 的方式创建出来的,而大部分控件如 androidx.appcompat.widget 下的控件,自定义控件,第三方控件等等,都是通过反射创建的。大量的反射创建多多少少会带来一些性能问题,因此我们需要去解决反射创建的问题,我的解决思路是:

    1、通过编写 Android 插件获取 Xml 布局中的所有控件

    2、拿到控件后,通过 APT 生成用 new 的方式创建 View 的类

    3、最后通过反射获取当前类并在基类里面完成替换

    一个小小的布局优化,涉及的东西还挺多的,Android 插件我们后续在讲,话说 Gradle 系列目前只更了一篇😂,别急,后面都会有的。我们这个系列主要是讲 APT,而讲 APT ,我们必须先了解两个重点知识:注解和反射

    今天就重点来介绍下反射

    Github Demo 地址 , 大家可以看 Demo 跟随我的思路一起分析

    一、什么是反射?

    简单来讲,反射就是:已知一个类,可以获取这个类的所有信息

    一般情况下,根据面向对象封装原则,Java 实体类的属性都是私有的,我们不能获取类中的属性。但我们可以根据反射,获取私有变量、方法、构造方法,注解,泛型等等,非常的强大

    注意:Google 在 Android 9.0 及之后对反射做了限制,被使用 @hide 标记的属性和方法通过反射拿不到

    二、反射使用

    下面给出一段已知的代码,我们通过实践来对反射进行讲解:

    //包路径
    package com.dream.aptdemo;

    //自定义注解1
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @interface CustomAnnotation1{

    }

    //自定义注解2
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @interface CustomAnnotation2{

    }

    //自定义注解3
    @Target(ElementType.TYPE)
    @Inherited
    @Retention(RetentionPolicy.RUNTIME)
    @interface CustomAnnotation3{

    }

    //接口
    interface ICar {
    void combine();
    }

    //车
    @CustomAnnotation3
    class Car<K,V> {
    private String carDesign = "设计稿";
    public String engine = "发动机";

    public void run(long kilometer) {
    System.out.println("Car run " + kilometer + " km");
    }
    }
    //==============================上面这些都是为下面这台奔驰服务的😂===========================
    //奔驰
    @CustomAnnotation1
    @CustomAnnotation2
    class Benz extends Car<String,Integer> implements ICar {

    private String carName = "奔驰";
    public String carColor = "白色";

    public Benz() {
    }

    private Benz(String carName) {
    this.carName = carName;
    }

    public Benz(String carName, String carColor) {
    this.carName = carName;
    this.carColor = carColor;
    }

    @Override
    public void combine() {
    System.out.println("组装一台奔驰");
    }

    private void privateMethod(String params){
    System.out.println("我是私有方法: " + params);
    }
    }

    下面所讲到的都是关于反射一些常用的 Api

    三、类

    我们可以通过 3 种方式去获取类对象:

    1)、Benz.class :类获取

    2)、benz.getClass :对象获取

    3)、Class.forName :静态获取

     Benz benz = new Benz();
    Class benzClass = Benz.class;
    Class benzClass1 = benz.getClass();
    Class benzClass2 = Class.forName("com.dream.aptdemo.Benz");

    注意

    1、在一个 JVM 中,一种类,只会有一个类对象存在。所以以上三种方式取出来的类对象,都是一样的。

    2、无论哪种途径获取类对象,都会导致静态属性被初始化,而且只会执行一次。(除了直接使用 Benz.class 类获取这种方式,这种方式不会导致静态属性被初始化)

    下面的流程会经常使用到 benz 实例和 benzClass 类对象

    4)、获取类名

    String className = benzClass.getSimpleName();
    System.out.println(className);

    //打印结果
    Benz

    5)、获取类路径

    String classPath1 = benzClass.getName();
    String classPath2 = benzClass.getCanonicalName();
    System.out.println(classPath1);
    System.out.println(classPath2);
    //打印结果
    com.dream.aptdemo.Benz
    com.dream.aptdemo.Benz

    这里可能大家会有个疑问:benzClass.getName() 和 benzClass.getCanonicalName() 有啥区别吗?

    从上面打印结果来看,没啥区别,但是如果我们在 Benz 这个里面加个内部类,然后获取内部类的路径,你就会看到区别了:

    //...
    class Benz extends Car implements ICar {
    //...
    class InnerClass{

    }
    }

    Class<Benz.InnerClass> innerClass = Benz.InnerClass.class;
    System.out.println(innerClass.getName());
    System.out.println(innerClass.getCanonicalName());
    //打印结果
    com.dream.aptdemo.Benz$InnerClass
    com.dream.aptdemo.Benz.InnerClass

    看到区别了吧,因此我们可以得到结论:在正常情况下,getCanonicalName和 getName 获取到的都是包含路径的类名。但内部类有点特殊,getName 获取的是路径.类名$内部类

    6)、获取父类名

    String fatherClassName = benzClass.getSuperclass().getSimpleName();
    System.out.println(fatherClassName);
    //打印结果
    Car

    7)、获取接口

    Class[] interfaces = benzClass.getInterfaces();
    for (Class anInterface : interfaces) {
    System.out.println(anInterface.getName());
    }
    //打印结果
    com.dream.aptdemo.ICar

    8)、创建实例对象

    //获取构造方法
    Constructor constructor = benzClass.getDeclaredConstructor();
    //创建实例
    Benz myBenz = (Benz) constructor.newInstance();
    //修改属性
    myBenz.carColor = "黑色";
    myBenz.combine();
    System.out.println(myBenz.carColor);
    //打印结果
    组装一台奔驰
    黑色

    注意:下面要讲的关于带 Declare 的属性和方法和不带Declare 区别:

    1、带 Declare 的属性和方法获取的是本类所有的属性和方法,不包含继承得来的

    2、不带 Declare 的属性和方法获取的是所有 public 修饰的属性和方法,包含继承得来的

    3、访问 private 修饰的属性和方法,需调用 setAccessible 设置为 true ,表示允许我们访问私有变量

    四、属性

    1)、获取单个属性

    Field carName = benzClass.getDeclaredField("carName");

    2)、获取多个属性

    //获取本类全部属性
    Field[] declaredFields = benzClass.getDeclaredFields();
    for (Field declaredField : declaredFields) {
    System.out.println("属性: " + declaredField.getName());
    }
    //打印结果
    属性: carName
    属性: carColor

    //获取本类及父类全部 public 修饰的属性
    Field[] fields = benzClass.getFields();
    for (Field field : fields) {
    System.out.println("属性: " + field.getName());
    }
    //打印结果
    属性: carColor
    属性: engine

    3)、设置允许访问私有变量

    carName.setAccessible(true);

    4)、获取属性名

    System.out.println(carName.getName());
    //打印结果
    carName

    5)、获取变量类型

    System.out.println(carName.getType().getName());
    //打印结果
    java.lang.String

    6)、获取对象中该属性的值

    System.out.println(carName.get(benz));
    //打印结果
    奔驰

    7)、给属性设置值

    carName.set(benz,"sweetying");
    System.out.println(carName.get(benz));
    //打印结果
    sweetying

    五、方法

    1)、获取单个方法

    //获取 public 方法
    Method publicMethod = benzClass.getMethod("combine");

    //获取 private 方法
    Method privateMethod = benzClass.getDeclaredMethod("privateMethod",String.class);

    2)、获取多个方法

    //获取本类全部方法
    Method[] declaredMethods = benzClass.getDeclaredMethods();
    for (Method declaredMethod : declaredMethods) {
    System.out.println("方法名: " + declaredMethod.getName());
    }
    //打印结果
    方法名: privateMethod
    方法名: combine


    //获取本类及父类全部 public 修饰的方法
    Method[] methods = benzClass.getMethods();
    for (Method method : methods) {
    System.out.println("方法名: " + method.getName());
    }
    //打印结果 因为所有类默认继承 Object , 所以打印了 Object 的一些方法
    方法名: combine
    方法名: run
    方法名: wait
    方法名: wait
    方法名: wait
    方法名: equals
    方法名: toString
    方法名: hashCode
    方法名: getClass
    方法名: notify
    方法名: notifyAll

    3)、方法调用

    Method privateMethod = benzClass.getDeclaredMethod("privateMethod",String.class);
    privateMethod.setAccessible(true);
    privateMethod.invoke(benz,"接收传入的参数");
    //打印结果
    我是私有方法: 接收传入的参数

    六、构造方法

    1)、获取单个构造方法

    //获取本类单个构造方法
    Constructor declaredConstructor = benzClass.getDeclaredConstructor(String.class);

    //获取本类单个 public 修饰的构造方法
    Constructor singleConstructor = benzClass.getConstructor(String.class,String.class);

    2)、获取多个构造方法

    //获取本类全部构造方法
    Constructor[] declaredConstructors = benzClass.getDeclaredConstructors();
    for (Constructor declaredConstructor1 : declaredConstructors) {
    System.out.println("构造方法: " + declaredConstructor1);
    }
    //打印结果
    构造方法: public com.dream.aptdemo.Benz()
    构造方法: public com.dream.aptdemo.Benz(java.lang.String,java.lang.String)
    构造方法: private com.dream.aptdemo.Benz(java.lang.String)


    //获取全部 public 构造方法, 不包含父类的构造方法
    Constructor[] constructors = benzClass.getConstructors();
    for (Constructor constructor1 : constructors) {
    System.out.println("构造方法: " + constructor1);
    }
    //打印结果
    构造方法: public com.dream.aptdemo.Benz()
    构造方法: public com.dream.aptdemo.Benz(java.lang.String,java.lang.String)

    3)、构造方法实例化对象

    //以上面 declaredConstructor 为例
    declaredConstructor.setAccessible(true);
    Benz declareBenz = (Benz) declaredConstructor.newInstance("");
    System.out.println(declareBenz.carColor);
    //打印结果
    白色

    //以上面 singleConstructor 为例
    Benz singleBenz = (Benz) singleConstructor.newInstance("奔驰 S ","香槟金");
    System.out.println(singleBenz.carColor);
    //打印结果
    香槟金

    七、泛型

    1)、获取父类的泛型

    Type genericType = benzClass.getGenericSuperclass();
    if (genericType instanceof ParameterizedType) {
    Type[] actualType = ((ParameterizedType) genericType).getActualTypeArguments();
    for (Type type : actualType) {
    System.out.println(type.getTypeName());
    }
    }
    //打印结果
    java.lang.String
    java.lang.Integer

    八、注解

    1)、获取单个注解

    //获取单个本类或父类注解
    Annotation annotation1 = benzClass.getAnnotation(CustomAnnotation1.class);
    System.out.println(annotation1.annotationType().getSimpleName());
    Annotation annotation3 = benzClass.getAnnotation(CustomAnnotation3.class);
    System.out.println(annotation3.annotationType().getSimpleName());
    //打印结果
    CustomAnnotation1
    CustomAnnotation3

    //获取单个本类注解
    Annotation declaredAnnotation1 = benzClass.getDeclaredAnnotation(CustomAnnotation2.class);
    System.out.println(declaredAnnotation1.annotationType().getSimpleName());
    //打印结果
    CustomAnnotation2

    2)、获取全部注解

    //获取本类和父类的注解(父类的注解需用 @Inherited 表示可被继承)
    Annotation[] annotations = benzClass.getAnnotations();
    for (Annotation annotation : annotations) {
    System.out.println("注解名称: " + annotation.annotationType().getSimpleName());
    }
    //打印结果
    注解名称: CustomAnnotation3
    注解名称: CustomAnnotation1
    注解名称: CustomAnnotation2

    //获取本类的注解
    Annotation[] declaredAnnotations = benzClass.getDeclaredAnnotations();
    for (Annotation declaredAnnotation : declaredAnnotations) {
    System.out.println("注解名称: " + declaredAnnotation.annotationType().getSimpleName());
    }
    //打印结果
    注解名称: CustomAnnotation1
    注解名称: CustomAnnotation2

    通过上面的讲解,我们把反射大部分知识点都讲完了,可以说反射是非常的强大,但是学习了之后,你可能会不知道该如何使用,反而觉得还不如直接调用方法来的直接和方便,下面我们通过实践来感受一下。

    九、反射实践

    需求大概就是:通过后台配置下发,完成 App 业务功能的切换。因为只是模拟,我们这里就以通过读取本地配置文件完成 App 业务功能的切换:

    1)、首先准备两个业务类,假设他们的功能都很复杂

    //包名
    package com.dream.aptdemo;

    //业务1
    class Business1 {

    public void doBusiness1Function(){
    System.out.println("复杂业务功能1");
    }
    }

    //业务2
    class Business2 {

    public void doBusiness2Function(){
    System.out.println("复杂业务功能2");
    }
    }

    2)、非反射方式

    public class Client {

    @Test
    public void test() {
    //业务功能1
    new Business1().doBusiness1Function();
    }
    }

    假设这个时候需要从第一个业务功能切换到第二个业务功能,使用非反射方式,必须修改代码,并且重新编译运行,才可以达到效果。那么我们可以通过反射去通过读取配置从而完成功能的切换,这样我们就不需要修改代码且代码变得更加通用

    3)、反射方式

    1、首先准备一个配置文件,如下图:

    image-20210625180301557

    2、读取配置文件,反射创建实例并调用方法

    public class Client {

    @Test
    public void test() throws Exception {
    try {
    //获取文件
    File springConfigFile = new File("/Users/zhouying/AndroidStudioProjects/AptDemo/config.txt");
    //读取配置
    Properties config= new Properties();
    config.load(new FileInputStream(springConfigFile));
    //获取类路径
    String classPath = (String) config.get("class");
    //获取方法名
    String methodName = (String) config.get("method");

    //反射创建实例并调用方法
    Class aClass = Class.forName(classPath);
    Constructor declaredConstructor = aClass.getDeclaredConstructor();
    Object o = declaredConstructor.newInstance();
    Method declaredMethod = aClass.getDeclaredMethod(methodName);
    declaredMethod.invoke(o);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }

    3、完成上面两步后,后续我们就只需要修改配置文件就能完成 App 业务功能的切换了

    十、总结

    本篇文章讲的一些重点内容:

    1、反射常用 Api 的使用,注意在访问私有属性和方法时,调用 setAccessible 设置为 true ,表示允许我们访问私有变量

    2、实践通过反射完成 App 业务功能的切换

    收起阅读 »

    Android APT 系列 (二):APT 筑基之注解

    前言很高兴遇见你~在本系列的上一篇文章中,我们对反射一些常用的知识进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (一):APT 筑基之反射。接下来我们看下 Java 注解Github Demo 地址 ,...
    继续阅读 »

    前言

    很高兴遇见你~

    在本系列的上一篇文章中,我们对反射一些常用的知识进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (一):APT 筑基之反射。接下来我们看下 Java 注解

    Github Demo 地址 , 大家可以看 Demo 跟随我的思路一起分析

    一、注解介绍

    1)、什么是注解?

    要解释注解我们首先要明白什么是元数据:元数据就是为其他数据提供信息的数据

    那么还是引入官方一段对注解的解释:注解用于为代码提供元数据。作为元数据,注解不直接影响你的代码执行,但也有一些类型的注解实际上可以用于这一目的。Java 注解是从 JDK 1.5 开始添加到 Java 的。

    简单的理解:注解就是附加到代码上的一种额外补充信息

    2)、注解有哪些作用?

    源码阶段注解: 编译器可利用该阶段注解检测错误,提示警告信息,打印日志等

    编译阶段注解:利用注解信息自动生成代码、文档或者做其它相应的自动处理

    运行阶段注解: 可通过反射获取注解信息,做相应操作

    3)、如何自定义定义一个注解?

    使用 @interface + 注解名称这种语法结构就能定义一个注解,如下:

    @interface TestAnnotation{

    }

    通常我们会使用一些元注解来修饰自定义注解

    二、元注解

    了解了之前的元数据,元注解就是为注解提供注解的注解 😂,这句话可能有点绕,反正你清楚元注解是给注解用的就行了

    JDK 给我们提供的元注解有如下几个:

    1、@Target

    2、@Retention

    3、@Inherited

    4、@Documented

    5、@Repeatable

    1)、@Target

    @Target 表示这个注解能放在什么位置上,具体选择的位置列表如下:

    ElementType.ANNOTATION_TYPE //能修饰注解
    ElementType.CONSTRUCTOR //能修饰构造器
    ElementType.FIELD //能修饰成员变量
    ElementType.LOCAL_VARIABLE //能修饰局部变量
    ElementType.METHOD //能修饰方法
    ElementType.PACKAGE //能修饰包名
    ElementType.PARAMETER //能修饰参数
    ElementType.TYPE //能修饰类、接口或枚举类型
    ElementType.TYPE_PARAMETER //能修饰泛型,如泛型方法、泛型类、泛型接口 (jdk1.8加入)
    ElementType.TYPE_USE //能修饰类型 可用于任意类型除了 class (jdk1.8加入)

    @Target(ElementType.TYPE)
    @interface TestAnnotation{

    }

    注意:默认情况下无限制

    2)、@Retention

    @Retention 表示注解的的生命周期,可选的值有 3 个:

    RetentionPolicy.SOURCE //表示注解只在源码中存在,编译成 class 之后,就没了

    RetentionPolicy.CLASS //表示注解在 java 源文件编程成 .class 文件后,依然存在,但是运行起来后就没了

    RetentionPolicy.RUNTIME //表示注解在运行起来后依然存在,程序可以通过反射获取这些信息

    @Retention(RetentionPolicy.RUNTIME)
    @interface TestAnnotation{

    }

    注意:默认情况下为 RetentionPolicy.CLASS

    3)、@Inherited

    @Inherited 表示该注解可被继承,即当一个子类继承一个父类,该父类添加的注解有被 @Inherited 修饰,那么子类就可以获取到该注解,否则获取不到

    @Inherited
    @interface TestAnnotation{

    }

    注意:默认情况下为不可继承

    4)、@Documented

    @Documented 表示该注解在通过 javadoc 命令生成 Api 文档后,会出现该注解的注释说明

    @Documented
    @interface TestAnnotation{

    }

    注意:默认情况下为不出现

    5)、@Repeatable

    @Repeatable 是 JDK 1.8 新增的元注解,它表示注解在同一个位置能出现多次,这个注解有点抽象,我们通过一个实际例子理解一下

    //游戏玩家注解
    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface GamePlayer{
    Game[] value();
    }

    //游戏注解
    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @Repeatable(GamePlayer.class)
    @interface Game{
    String gameName();
    }

    @Game(gameName = "CF")
    @Game(gameName = "LOL")
    @Game(gameName = "DNF")
    class GameTest{

    }

    注意:默认情况下不可重复

    经验:通常情况下,我们会使用多个元注解组合来修饰自定义注解

    三、注解属性

    1)、注解属性类型

    注解属性类型可以为以下的一些类型:

    1、基本数据类型

    2、String

    3、枚举类型

    4、注解类型

    5、Class 类型

    6、以上类型的一维数组类型

    2)、定义注解属性

    首先我们定义一些注解属性,如下:

    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @interface TestAnnotation{
    //这就是注解属性的语法结构
    //定义一个属性并给了默认值
    String name() default "erdai";

    //定义一个属性未给默认值
    int age();
    }

    可能你会有些疑问:这难道不是在定义方法吗?还可以给默认值?

    这些疑问先留着,我们继续分析

    自定义注解默认都会继承 Annotation ,Annotation 是一个接口,源码如下:

    public interface Annotation {

    boolean equals(Object obj);

    int hashCode();

    String toString();

    Class<? extends Annotation> annotationType();
    }

    我们知道,在接口中可以定义属性和方法,那么作为自定义注解,是否也可以定义呢?

    可以,接口中的属性默认都是用public static final 修饰的,默认是一个常量,对于自定义注解来说,这点没有任何区别。而接口中的方法其实就相当于自定义注解的属性,只不过自定义注解还可以给默认值。因此我们在学习自定义注解属性时,我们应该把它当作一个新知识,加上我刚才对接口的分析对比,你上面的那些疑问便可以迎刃而解了

    3)、注解属性使用

    1、在使用注解的后面接上一对括号,括号里面使用 属性名 = value 的格式,多个属性之间中间用 ,隔开

    2、未给默认值的属性必须进行赋值,否则编译器会报红

    //单个属性
    @TestAnnotation(age = 18)
    class Test{

    }

    //多个属性
    @TestAnnotation(age = 18,name = "erdai666")
    class Test{

    }

    4)、注解属性获取

    注解属性的获取可以参考我的上一篇文章 传送门 ,上篇文章我们讲的是通过类对象获取注解,咱们补充点上篇文章没讲到的

    1、我们在获取属性的时候,可以先判断一下是否存在该注解,增强代码的健壮性,如下:

    @TestAnnotation(age = 18,name = "erdai666")
    class Test{

    }

    Class<Test> testClass = Test.class;
    //获取当前注解是否存在
    boolean annotationPresent = testClass.isAnnotationPresent(TestAnnotation.class);
    //如果存在则进入条件体
    if(annotationPresent){
    TestAnnotation declaredAnnotation = testClass.getDeclaredAnnotation(TestAnnotation.class);
    System.out.println(declaredAnnotation.name());
    System.out.println(declaredAnnotation.age());
    }

    2、获取类属性的注解属性

    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.FIELD)
    @interface TestField{
    String filed();
    }

    class Test{
    @TestField(filed = "我是属性")
    public String test;
    }

    //通过反射获取属性注解
    Class<Test> testClass1 = Test.class;
    try {
    Field field = testClass1.getDeclaredField("test");
    if(field.isAnnotationPresent(TestField.class)){
    TestField fieldAnnotation = field.getDeclaredAnnotation(TestField.class);
    System.out.println(fieldAnnotation.filed());
    }
    } catch (NoSuchFieldException e) {
    e.printStackTrace();
    }
    //打印结果
    我是属性

    3、获取类方法的注解属性

    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface TestMethod{
    String method();
    }

    class Test{
    @TestMethod(method = "我是方法")
    public void test(){

    }
    }
    //通过反射获取方法注解
    Class<Test> testClass2 = Test.class;
    try {
    Method method = testClass2.getDeclaredMethod("test");
    if(method.isAnnotationPresent(TestMethod.class)){
    TestMethod methodAnnotation = method.getDeclaredAnnotation(TestMethod.class);
    System.out.println(methodAnnotation.method());
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    //打印结果
    我是方法

    四、JDK 提供的内置注解

    JDK 给我们提供了很多内置的注解,其中常用的有:

    )1、@Override

    2、@Deprecated

    3、@SuppressWarnings

    4、@FunctionalInterface

    1)、@Override

    @Override 用在方法上,表示这个方法重写了父类的方法,例如 toString 方法

    @Override
    public String toString() {
    return super.toString();
    }

    2)、@Deprecated

    @Deprecated 表示这个方法被弃用,不建议开发者使用

    image-20210626113644915

    可以看到用 @Deprecated 注解的方法调用的时候会被划掉

    3)、@SuppressWarnings

    @SuppressWarnings 用于忽略警告信息,常见的取值如下:

    • deprecation:使用了不赞成使用的类或方法时的警告(使用 @Deprecated 使得编译器产生的警告)
    • unchecked:执行了未检查的转换时的警告,例如当使用集合时没有用泛型 (Generics) 来指定集合保存的类型; 关闭编译器警告
    • fallthrough:当 Switch 程序块直接通往下一种情况而没有 Break 时的警告
    • path:在类路径、源文件路径等中有不存在的路径时的警告
    • serial:当在可序列化的类上缺少 serialVersionUID 定义时的警告
    • finally:任何 finally 子句不能正常完成时的警告
    • rawtypes 泛型类型未指明
    • unused 引用定义了,但是没有被使用
    • all:关于以上所有情况的警告

    以泛型举个例子:

    image-20210626114048630

    当我们创建 List 未指定泛型时,编译器就会报黄提示我们未指明泛型,这个时候就可以使用这个注解了:

    image-20210626114241155

    4)、@FunctionalInterface

    @FunctionalInterface 是 JDK 1.8 新增的注解,用于约定函数式接口,函数式接口就是接口中只有一个抽象方法

    @FunctionalInterface
    interface testInterface{
    void testMethod();
    }

    而当你有两个抽象方法时,注解会报红提示你:

    image-20210626114855416

    五、注解实际应用场景

    1)、使用自定义注解代替枚举类型

    主要针对源码阶段注解

    这个在我们实际工作中也挺常用的,使用枚举类型开销大,我们一般都会使用自定义注解进行替代,如下:

    //1、使用枚举
    enum EnumFontType{
    ROBOTO_REGULAR,ROBOTO_MEDIUM,ROBOTO_BOLD
    }
    //实际调用
    EnumFontType type1 = EnumFontType.ROBOTO_BOLD;

    //================================ 完美的分割线 ==================================
    //2、使用自定义注解
    @Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE})
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({AnnotationFontType.ROBOTO_REGULAR,AnnotationFontType.ROBOTO_MEDIUM,AnnotationFontType.ROBOTO_BOLD})
    @interface AnnotationFontType{
    int ROBOTO_REGULAR = 1;
    int ROBOTO_MEDIUM = 2;
    int ROBOTO_BOLD = 3;
    }
    //实际调用
    @AnnotationFontType int type2 = AnnotationFontType.ROBOTO_MEDIUM;

    2)、注解处理器 (APT)

    主要针对编译阶段注解

    实际我们日常开发中,经常会遇到它,因为我们常用的一些开源库如 ButterKnife,Retrofit,Arouter,EventBus 等等都使用到了 APT 技术。也正是因为这些著名的开源库,才使得 APT 技术越来越火,在本系列的下一篇中,我也会讲到。

    3)、运行时注解处理

    主要针对运行阶段注解

    举个实际的例子:例如我们开车去自助加油机加油,设定的 Money 是 200,如果少于 200 则提示 加油中...,否则提示 油已加满,如果出现异常情况,提示 加油失败

    现在我们通过注解来实现一下它,如下:

    @Inherited
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @interface OilAnnotation{
    double maxOilMoney() default 0;
    }

    class GasStation{

    @OilAnnotation(maxOilMoney = 200)
    public void addOil(double money){
    String tips = processOilAnnotation(money);
    System.out.println(tips);
    }

    @SuppressWarnings("all")
    private String processOilAnnotation(double money){
    try {
    Class<GasStation> aClass = GasStation.class;
    //获取当前方法的注解
    Method addOilMethod = aClass.getDeclaredMethod("addOil", double.class);
    //获取方法注解是否存在
    boolean annotationPresent = addOilMethod.isAnnotationPresent(OilAnnotation.class);
    if(annotationPresent){
    OilAnnotation oilAnnotation = addOilMethod.getDeclaredAnnotation(OilAnnotation.class);
    if(money >= oilAnnotation.maxOilMoney()){
    return "油已加满";
    }else {
    return "加油中...";
    }
    }
    } catch (NoSuchMethodException e) {
    e.printStackTrace();
    }
    return "加油失败";
    }
    }

    new GasStation().addOil(100);
    //打印结果
    加油中...

    new GasStation().addOil(200);
    //打印结果
    油已加满

    六、总结

    本篇文章讲的一些重点内容:

    1、自定义注解时,元注解的组合使用

    2、注解属性的定义,使用和获取

    3、一些常用的 JDK 内置注解

    4、注解的实际应用及运行阶段注解的一个实践

    收起阅读 »

    Android APT 系列 (三):APT 技术探究

    前言很高兴遇见你~在本系列的上一篇文章中,我们对注解进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (二):APT 筑基之注解。至此,关于 Apt 基础部分我们都讲完了,接下来就正式进入 APT 技术的学习Github De...
    继续阅读 »

    前言

    很高兴遇见你~

    在本系列的上一篇文章中,我们对注解进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (二):APT 筑基之注解。至此,关于 Apt 基础部分我们都讲完了,接下来就正式进入 APT 技术的学习

    Github Demo 地址 , 大家可以看 Demo 跟随我的思路一起分析

    一、APT 介绍

    1)、什么是 APT ?

    APT 全称 Annotation Processing Tool,翻译过来即注解处理器。引用官方一段对 APT 的介绍:APT 是一种处理注释的工具, 它对源代码文件进行检测找出其中的注解,并使用注解进行额外的处理。

    2)、APT 有什么用?

    APT 能在编译期根据编译阶段注解,给我们自动生成代码,简化使用。很多流行框架都使用到了 APT 技术,如 ButterKnife,Retrofit,Arouter,EventBus 等等

    二、APT 工程

    1)、APT 工程创建

    一般情况下,APT 大致的的一个实现过程:

    1、创建一个 Java Module ,用来编写注解

    2、创建一个 Java Module ,用来读取注解信息,并根据指定规则,生成相应的类文件

    3、创建一个 Android Module ,通过反射获取生成的类,进行合理的封装,提供给上层调用

    如下图:

    image-20210627182425586

    这是我的 APT 工程,关于 Module 名称可以任意取,按照我上面说的规则去进行就好了

    2)、Module 依赖

    工程创建好后,我们就需要理清楚各个 Module 之间的一个依赖关系:

    1、因为 apt-processor 要读取 apt-annotation 的注解,所以 apt-processor 需要依赖 apt-annotation

    //apt-processor 的 build.gradle 文件
    dependencies {
    implementation project(path: ':apt-annotation')
    }

    2、app 作为调用层,以上 3 个 Module 都需要进行依赖

    //app 的 build.gradle 文件
    dependencies {
    //...
    implementation project(path: ':apt-api')
    implementation project(path: ':apt-annotation')
    annotationProcessor project(path: ':apt-processor')
    }

    APT 工程配置好之后,我们就可以对各个 Module 进行一个具体代码的编写了

    三、apt-annotation 注解编写

    这个 Module 的处理相对来说很简单,就是编写相应的自定义注解就好了,我编写的如下:

    @Inherited
    @Documented
    @Retention(RetentionPolicy.SOURCE)
    @Target({ElementType.TYPE,ElementType.METHOD})
    public @interface AptAnnotation {
    String desc() default "";
    }

    四、apt-processor 自动生成代码

    这个 Module 相对来说比较复杂,我们把它分为以下 3 个步骤:

    1、注解处理器声明

    2、注解处理器注册

    3、注解处理器生成类文件

    1)、注解处理器声明

    1、新建一个类,类名按照自己的喜好取,继承 javax.annotation.processing 这个包下的 AbstractProcessor 类并实现其抽象方法

    public class AptAnnotationProcessor extends AbstractProcessor {

    /**
    * 编写生成 Java 类的相关逻辑
    *
    * @param set 支持处理的注解集合
    * @param roundEnvironment 通过该对象查找指定注解下的节点信息
    * @return true: 表示注解已处理,后续注解处理器无需再处理它们;false: 表示注解未处理,可能要求后续注解处理器处理
    */

    @Override
    public boolean process(Set set, RoundEnvironment roundEnvironment) {
    return false;
    }
    }

    重点看下第一个参数中的 TypeElement ,这个就涉及到 Element 的知识,我们简单的介绍一下:

    Element 介绍

    实际上,Java 源文件是一种结构体语言,源代码的每一个部分都对应了一个特定类型的 Element ,例如包,类,字段,方法等等:

    package com.dream;         // PackageElement:包元素

    public class Main<T> { // TypeElement:类元素; 其中 属于 TypeParameterElement 泛型元素

    private int x; // VariableElement:变量、枚举、方法参数元素

    public Main() { // ExecuteableElement:构造函数、方法元素
    }
    }

    Java 的 Element 是一个接口,源码如下:

    public interface Element extends javax.lang.model.AnnotatedConstruct {
    // 获取元素的类型,实际的对象类型
    TypeMirror asType();
    // 获取Element的类型,判断是哪种Element
    ElementKind getKind();
    // 获取修饰符,如public static final等关键字
    Set getModifiers();
    // 获取类名
    Name getSimpleName();
    // 返回包含该节点的父节点,与getEnclosedElements()方法相反
    Element getEnclosingElement();
    // 返回该节点下直接包含的子节点,例如包节点下包含的类节点
    List getEnclosedElements();

    @Override
    boolean equals(Object obj);

    @Override
    int hashCode();

    @Override
    List getAnnotationMirrors();

    //获取注解
    @Override
    A getAnnotation(Class annotationType);

    R accept(ElementVisitor v, P p);
    }

    我们可以通过 Element 获取如上一些信息(写了注释的都是一些常用的)

    由 Element 衍生出来的扩展类共有 5 种:

    1、PackageElement 表示一个包程序元素

    2、TypeElement 表示一个类或者接口程序元素

    3、TypeParameterElement 表示一个泛型元素

    4、VariableElement 表示一个字段、enum 常量、方法或者构造方法的参数、局部变量或异常参数

    5、ExecuteableElement 表示某个类或者接口的方法、构造方法或初始化程序(静态或者实例)

    可以发现,Element 有时会代表多种元素,例如 TypeElement 代表类或接口,此时我们可以通过 element.getKind() 来区分:

    Set elements = roundEnvironment.getElementsAnnotatedWith(AptAnnotation.class);
    for (Element element : elements) {
    if (element.getKind() == ElementKind.CLASS) {
    // 如果元素是类

    } else if (element.getKind() == ElementKind.INTERFACE) {
    // 如果元素是接口

    }
    }

    ElementKind 是一个枚举类,它的取值有很多,如下:

    PACKAGE	//表示包
    ENUM //表示枚举
    CLASS //表示类
    ANNOTATION_TYPE //表示注解
    INTERFACE //表示接口
    ENUM_CONSTANT //表示枚举常量
    FIELD //表示字段
    PARAMETER //表示参数
    LOCAL_VARIABLE //表示本地变量
    EXCEPTION_PARAMETER //表示异常参数
    METHOD //表示方法
    CONSTRUCTOR //表示构造函数
    OTHER //表示其他

    关于 Element 就介绍到这,我们接着往下看

    2、重写方法解读

    除了必须实现的这个抽象方法,我们还可以重写其他 4 个常用的方法,如下:

    public class AptAnnotationProcessor extends AbstractProcessor {
    //...

    /**
    * 节点工具类(类、函数、属性都是节点)
    */

    private Elements mElementUtils;

    /**
    * 类信息工具类
    */

    private Types mTypeUtils;

    /**
    * 文件生成器
    */

    private Filer mFiler;

    /**
    * 日志信息打印器
    */

    private Messager mMessager;

    /**
    * 做一些初始化的工作
    *
    * @param processingEnvironment 这个参数提供了若干工具类,供编写生成 Java 类时所使用
    */

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    mElementUtils = processingEnv.getElementUtils();
    mTypeUtils = processingEnv.getTypeUtils();
    mFiler = processingEnv.getFiler();
    mMessager = processingEnv.getMessager();
    }

    /**
    * 接收外来传入的参数,最常用的形式就是在 build.gradle 脚本文件里的 javaCompileOptions 的配置
    *
    * @return 属性的 Key 集合
    */

    @Override
    public Set getSupportedOptions() {
    return super.getSupportedOptions();
    }

    /**
    * 当前注解处理器支持的注解集合,如果支持,就会调用 process 方法
    *
    * @return 支持的注解集合
    */

    @Override
    public Set getSupportedAnnotationTypes() {
    return super.getSupportedAnnotationTypes();
    }

    /**
    * 编译当前注解处理器的 JDK 版本
    *
    * @return JDK 版本
    */

    @Override
    public SourceVersion getSupportedSourceVersion() {
    return super.getSupportedSourceVersion();
    }
    }

    注意getSupportedAnnotationTypes()getSupportedSourceVersion()getSupportedOptions() 这三个方法,我们还可以采用注解的方式进行提供:

    @SupportedOptions("MODULE_NAME")
    @SupportedAnnotationTypes("com.dream.apt_annotation.AptAnnotation")
    @SupportedSourceVersion(SourceVersion.RELEASE_8)
    public class AptAnnotationProcessor extends AbstractProcessor {
    //...
    }

    2)、注解处理器注册

    注解处理器声明好了,下一步我们就要注册它,其中注册有两种方式:

    1、手动注册

    2、自动注册

    手动注册比较繁琐固定且容易出错,不推荐使用,这里就不讲了。我们主要看下自动注册

    自动注册

    1、首先我们要在 apt-processor这个 Module 下的 build.gradle 文件导入如下依赖:

    implementation 'com.google.auto.service:auto-service:1.0-rc6'
    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'

    注意:这两句必须都要加,否则注册不成功,我之前踩坑了

    2、在注解处理器上加上 @AutoService(Processor.class) 即可完成注册

    @AutoService(Processor.class)
    public class AptAnnotationProcessor extends AbstractProcessor {
    //...
    }

    3)、注解处理器生成类文件

    注册完成之后,我们就可以正式编写生成 Java 类文件的代码了,其中生成也有两种方式:

    1、常规的写文件方式

    2、通过 javapoet 框架来编写

    1 的方式比较死板,需要把每一个字母都写上,不推荐使用,这里就不讲了。我们主要看下通过 javapoet 这个框架生成 Java 类文件

    javapoet 方式

    这种方式更加符合面向对象编码的一个风格,对 javapoet 还不熟的朋友,可以去 github 上学习一波 传送门,这里我们介绍一下它常用的一些类:

    TypeSpec:用于生成类、接口、枚举对象的类

    MethodSpec:用于生成方法对象的类

    ParameterSpec:用于生成参数对象的类

    AnnotationSpec:用于生成注解对象的类

    FieldSpec:用于配置生成成员变量的类

    ClassName:通过包名和类名生成的对象,在JavaPoet中相当于为其指定 Class

    ParameterizedTypeName:通过 MainClass 和 IncludeClass 生成包含泛型的 Class

    JavaFile:控制生成的 Java 文件的输出的类

    1、导入 javapoet 框架依赖
    implementation 'com.squareup:javapoet:1.13.0'
    2、按照指定代码模版生成 Java 类文件

    例如,我在 app 的 build.gradle 下进行了如下配置:

    android {
    //...
    defaultConfig {
    //...
    javaCompileOptions {
    annotationProcessorOptions {
    arguments = [MODULE_NAME: project.getName()]
    }
    }
    }
    }

    在 MainActivity 下面进行了如下注解:

    image-20210627212604288

    我希望生成的代码如下:

    image-20210627220320906

    现在我们来实操一下:

    @AutoService(Processor.class)
    @SupportedOptions("MODULE_NAME")
    @SupportedAnnotationTypes("com.dream.apt_annotation.AptAnnotation")
    @SupportedSourceVersion(SourceVersion.RELEASE_8)
    public class AptAnnotationProcessor extends AbstractProcessor {

    //文件生成器
    Filer filer;
    //模块名
    private String mModuleName;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
    super.init(processingEnvironment);
    //初始化文件生成器
    filer = processingEnvironment.getFiler();
    //通过 key 获取 build.gradle 中对应的 value
    mModuleName = processingEnv.getOptions().get("MODULE_NAME");
    }

    @Override
    public boolean process(Set set, RoundEnvironment roundEnvironment) {
    if (set == null || set.isEmpty()) {
    return false;
    }

    //获取当前注解下的节点信息
    Set rootElements = roundEnvironment.getElementsAnnotatedWith(AptAnnotation.class);

    // 构建 test 函数
    MethodSpec.Builder builder = MethodSpec.methodBuilder("test")
    .addModifiers(Modifier.PUBLIC) // 指定方法修饰符
    .returns(void.class) // 指定返回类型
    .addParameter(String.class, "param"); // 添加参数
    builder.addStatement("$T.out.println($S)", System.class, "模块: " + mModuleName);

    if (rootElements != null && !rootElements.isEmpty()) {
    for (Element element : rootElements) {
    //当前节点名称
    String elementName = element.getSimpleName().toString();
    //当前节点下注解的属性
    String desc = element.getAnnotation(AptAnnotation.class).desc();
    // 构建方法体
    builder.addStatement("$T.out.println($S)", System.class,
    "节点: " + elementName + " " + "描述: " + desc);
    }
    }
    MethodSpec main =builder.build();

    // 构建 HelloWorld 类
    TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
    .addModifiers(Modifier.PUBLIC) // 指定类修饰符
    .addMethod(main) // 添加方法
    .build();

    // 指定包路径,构建文件体
    JavaFile javaFile = JavaFile.builder("com.dream.aptdemo", helloWorld).build();
    try {
    // 创建文件
    javaFile.writeTo(filer);
    } catch (IOException e) {
    e.printStackTrace();
    }

    return true;
    }
    }

    经过上面这些步骤,我们运行 App 就能生成上面截图的代码了,现在还差最后一步,对生成的代码进行使用

    注意:不同版本的 Gradle 生成的类文件位置可能不一样,我的 Gradle 版本是 6.7.1,生成的类文件在如下位置:

    image-20210627221836736

    一些低版本的 Gradle 生成的类文件在 /build/source 这个目录下

    五、apt-api 调用生成代码完成业务功能

    这个 Module 的操作相对来说也比较简单,就是通过反射获取到生成的类,进行相应的封装使用即可,我的编写如下:

    public class MyAptApi {

    @SuppressWarnings("all")
    public static void init() {
    try {
    Class c = Class.forName("com.dream.aptdemo.HelloWorld");
    Constructor declaredConstructor = c.getDeclaredConstructor();
    Object o = declaredConstructor.newInstance();
    Method test = c.getDeclaredMethod("test", String.class);
    test.invoke(o, "");
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }

    接着我们在 MainActivity 的 oncreate 方法里面进行调用:

    @AptAnnotation(desc = "我是 MainActivity 上面的注解")
    public class MainActivity extends AppCompatActivity {

    @AptAnnotation(desc = "我是 onCreate 上面的注解")
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    MyAptApi.init();
    }
    }
    //打印结果
    模块: app
    节点: MainActivity 描述: 我是 MainActivity 上面的注解
    节点: onCreate 描述: 我是 onCreate 上面的注解

    六、总结

    本篇文章讲的一些重点内容:

    1、APT 工程所需创建的不同种类的 Module 及 Module 之间的依赖关系

    2、Java 源文件实际上是一种结构体语言,源代码的每一个部分都对应了一个特定类型的 Element

    3、采用 auto-service 对注解处理器进行自动注册

    4、采用 javapoet 框架编写所需生成的 Java 类文件

    5、通过反射及适当的封装,将生成的类的功能提供给上层调用

    收起阅读 »

    Android APT 系列 (四):APT 实战应用

    前言很高兴遇见你~在本系列的上一篇文章中,我们对 APT 技术进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (三):APT 技术探究。接下来,我们就使用 APT 技术来进行实战应用。Github Demo 地址 , 大家可...
    继续阅读 »

    前言

    很高兴遇见你~

    在本系列的上一篇文章中,我们对 APT 技术进行了讲解,还没有看过上一篇文章的朋友,建议先去阅读 Android APT 系列 (三):APT 技术探究。接下来,我们就使用 APT 技术来进行实战应用。

    Github Demo 地址 , 大家可以看 Demo 跟随我的思路一起分析

    回顾

    在本系列的开篇,我讲了在项目实践过程中做的一个布局优化,Android 中少量的系统控件是通过 new 的方式创建出来的,而大部分控件如 androidx.appcompat.widget 下的控件,自定义控件,第三方控件等等,都是通过反射创建的。大量的反射创建多多少少会带来一些性能问题,因此我们需要去解决反射创建的问题,我的解决思路是:

    1、通过编写 Android 插件获取 Xml 布局中的所有控件

    2、拿到控件后,通过 APT 生成用 new 的方式创建 View 的类

    3、最后通过反射获取当前类并在基类里面完成替换

    一、准备 Android 插件生成的文件

    其中 1 的具体流程是:通过 Android 插件获取所有 Xml 布局中的控件名称,并写入到一个.txt文件中,因 Gradle 系列还没讲,这里我们假设所有的控件名称已经写入到.txt文件,如下:

    image-20210629191446005

    上述文件我们可以看到:

    1、一些不带 . 的系统控件,如 TextView,ImageView 。系统会默认给我们通过 new 的方式去创建,且替换为了androidx.appcompat.widget包下的控件,例如:TextView -> AppCompatTextView ,ImageView -> AppCompatImageView

    2、带 . 的控件。可能为 androidx.appcompat.widget 下的控件,自定义控件,第三方控件等等,这些控件如果我们不做处理,系统会通过反射去创建。因此我们主要是针对这些控件去做处理

    注意:我这里在根目录下创建了一个 all_view_name.txt 的文件,然后放入了一些 View 的名称,这里只是方便我们演示。实际上用 Android 插件去生成的文件我们一般会指定放在 app 的 /build目录下,这样我们在 clean 的时候就能顺带把它给干掉

    现在 1 完成了,接下来 2 和 3 就回到了我们熟悉的 APT 流程,我们需要读取该文件,通过 APT 生成相应的类,最后使用这个类的功能就 OK 了,还不熟悉 APT 的,先去学习一波 传送门

    还是基于上篇文章的工程进行实操,为了方便后续流程的讲解,我还是贴出上篇文章的工程图:

    image-20210627182425586

    二、apt-annotation 注解编写

    编写注解,如下:

    @Inherited
    @Documented
    @Retention(RetentionPolicy.CLASS)
    @Target(ElementType.TYPE)
    public @interface ViewCreator {

    }

    三、规定生成的类模版,为后续自动生成代码做准备

    在实际工作中,我们一般会这么做:

    1、将需要生成的类文件实现某个定义好的接口,通过接口代理来使用

    2、规定生成的 Java 类模版,根据模版去进行生成代码逻辑的编写

    1、将需要生成的类文件实现某个定义好的接口,通过接口代理来使用

    关于接口,我们一般会放到 apt-api 这个 Module 中

    2、规定生成的 Java 类模版,根据模版去进行生成代码逻辑的编写

    假设我们需要生成的 Java 类模版如下:

    package com.dream.aptdemo;

    public class MyViewCreatorImpl implements IMyViewCreator {
    @Override
    public View createView(String name, Context context, AttributeSet attr) {
    View view = null;
    switch(name) {
    case "androidx.core.widget.NestedScrollView":
    view = new NestedScrollView(context,attr);
    break;
    case "androidx.constraintlayout.widget.ConstraintLayout":
    view = new ConstraintLayout(context,attr);
    break;
    case "androidx.appcompat.widget.ButtonBarLayout":
    view = new ButtonBarLayout(context,attr);
    break;
    //...
    default:
    break;
    }
    return view;
    }

    根据上面这些信息,我们就可以进行自动生成代码逻辑的编写了

    四、apt-processor 自动生成代码

    这里你就对着上面给出的代码模版,通过 javapoet 框架编写相应的代码生成逻辑即可,对 javapoet 不熟的赶紧去学习一波 传送门

    @AutoService(Processor.class)
    @SupportedAnnotationTypes("com.dream.apt_annotation.ViewCreator")
    @SupportedSourceVersion(SourceVersion.RELEASE_8)
    public class MyViewCreatorProcessor extends AbstractProcessor {

    /**文件生成器*/
    private Filer mFiler;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    mFiler = processingEnv.getFiler();
    }

    @Override
    public boolean process(Set annotations, RoundEnvironment roundEnv) {
    //从文件中读取控件名称,并转换成对应的集合
    Set mViewNameSet = readViewNameFromFile();
    //如果获取的控件名称集合为空,则终止流程
    if(mViewNameSet == null || mViewNameSet.isEmpty()){
    return false;
    }

    //获取使用了注解的元素
    Set elementsAnnotatedWith = roundEnv.getElementsAnnotatedWith(ViewCreator.class);
    for (Element element : elementsAnnotatedWith) {
    System.out.println("Hello " + element.getSimpleName() + ", 欢迎使用 APT");
    startGenerateCode(mViewNameSet);
    //如果有多个地方标注了注解,我们只读取第一次的就行了
    break;
    }
    return true;
    }

    /**
    * 开始执行生成代码的逻辑
    *
    * @param mViewNameSet 控件名称集合
    */

    private void startGenerateCode(Set mViewNameSet) {
    System.out.println("开始生成 Java 类...");
    System.out.println("a few moment later...");
    //=================================== 构建方法 start ======================================
    //1、构建方法:方法名,注解,修饰符,返回值,参数

    ClassName viewType = ClassName.get("android.view","View");
    MethodSpec.Builder methodBuilder = MethodSpec
    //方法名
    .methodBuilder("createView")
    //注解
    .addAnnotation(Override.class)
    //修饰符
    .addModifiers(Modifier.PUBLIC)
    //返回值
    .returns(viewType)
    //第一个参数
    .addParameter(String.class,"name")
    //第二个参数
    .addParameter(ClassName.get("android.content","Context"),"context")
    //第三个参数
    .addParameter(ClassName.get("android.util","AttributeSet"),"attr");

    //2、构建方法体
    methodBuilder.addStatement("$T view = null",viewType);
    methodBuilder.beginControlFlow("switch(name)");
    //循环遍历控件名称集合
    for (String viewName : mViewNameSet) {
    //针对包含 . 的控件名称进行处理
    if(viewName.contains(".")){
    //分离包名和控件名,如:androidx.constraintlayout.widget.ConstraintLayout
    //packageName:androidx.constraintlayout.widget
    //simpleViewName:ConstraintLayout
    String packageName = viewName.substring(0,viewName.lastIndexOf("."));
    String simpleViewName = viewName.substring(viewName.lastIndexOf(".") + 1);
    ClassName returnType = ClassName.get(packageName, simpleViewName);

    methodBuilder.addCode("case $S:\n",viewName);
    methodBuilder.addStatement("\tview = new $T(context,attr)", returnType);
    methodBuilder.addStatement("\tbreak");
    }
    }
    methodBuilder.addCode("default:\n");
    methodBuilder.addStatement("\tbreak");
    methodBuilder.endControlFlow();
    methodBuilder.addStatement("return view");

    MethodSpec createView = methodBuilder.build();
    //=================================== 构建方法 end ======================================

    //=================================== 构建类 start ======================================
    TypeSpec myViewCreatorImpl = TypeSpec.classBuilder("MyViewCreatorImpl")
    //类修饰符
    .addModifiers(Modifier.PUBLIC)
    //实现接口
    .addSuperinterface(ClassName.get("com.dream.apt_api", "IMyViewCreator"))
    //添加方法
    .addMethod(createView)
    .build();
    //=================================== 构建类 end ========================================

    //=================================== 指定包路径,构建文件体 start =========================
    //指定类包路径
    JavaFile javaFile = JavaFile.builder("com.dream.aptdemo",myViewCreatorImpl).build();
    //生成文件
    try {
    javaFile.writeTo(mFiler);
    System.out.println("生成成功...");
    } catch (IOException e) {
    e.printStackTrace();
    System.out.println("生成失败...");
    }
    //=================================== 指定包路径,构建文件体 end ============================
    }

    /**
    * 从文件中读取控件名称,并转换成对应的集合
    */

    private Set readViewNameFromFile() {
    try {
    //获取存储控件名称的文件
    File file = new File("/Users/zhouying/AndroidStudioProjects/AptDemo/all_view_name.txt");
    Properties config = new Properties();
    config.load(new FileInputStream(file));
    //获取控件名称集合
    return config.stringPropertyNames();
    } catch (IOException e) {
    e.printStackTrace();
    }
    return null;
    }
    }

    上述生成代码的逻辑写了详细的注释,主要就是对 javapoet 框架的一个应用

    代码生成好了,接下来就需要提供给上层使用

    五、apt-api 业务封装供上层使用

    1、定义一个接口, apt-api 和 apt-processor 都会使用到

    //定义一个接口
    public interface IMyViewCreator {
    /**
    * 通过 new 的方式创建 View
    *
    * @param name 控件名称
    * @param context 上下文
    * @param attributeSet 属性
    */

    View createView(String name, Context context, AttributeSet attributeSet);
    }

    2、反射获取生成的类,提供相应的代理类供上层调用

    public class MyViewCreatorDelegate implements IMyViewCreator{

    private IMyViewCreator mIMyViewCreator;

    //================================== 单例 start =====================================
    @SuppressWarnings("all")
    private MyViewCreatorDelegate(){
    try {
    // 通过反射拿到 Apt 生成的类
    Class aClass = Class.forName("com.dream.aptdemo.MyViewCreatorImpl");
    mIMyViewCreator = (IMyViewCreator) aClass.newInstance();
    } catch (Throwable t) {
    t.printStackTrace();
    }
    }

    public static MyViewCreatorDelegate getInstance(){
    return Holder.MY_VIEW_CREATOR_DELEGATE;
    }

    private static final class Holder{
    private static final MyViewCreatorDelegate MY_VIEW_CREATOR_DELEGATE = new MyViewCreatorDelegate();
    }
    //================================== 单例 end =======================================


    /**
    * 通过生成的类创建 View
    *
    * @param name 控件名称
    * @param context 上下文
    * @param attributeSet 属性
    * @return View
    */

    @Override
    public View createView(String name, Context context, AttributeSet attributeSet) {
    if(mIMyViewCreator != null){
    return mIMyViewCreator.createView(name, context, attributeSet);
    }
    return null;
    }
    }

    到这里我们布局优化流程差不多就要结束了,接下来就是上层调用

    六、app 上层调用

    1、在创建的 MyApplication 上添加注解

    关于注解你可以添加在其他地方,因为我注解处理器里面做了逻辑判断,只会读取第一次的注解。为了对应,我选择把注解加到 MyApplication 中,如下图:

    image-20210629192519893

    2、最后在 MainActviity 中加入替换 View 的逻辑

    如下:

    //...
    public class MainActivity extends AppCompatActivity {

    //...
    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    //1、优先使用我们生成的类去进行 View 的创建
    View view = MyViewCreatorDelegate.getInstance().createView(name, context, attrs);
    if (view != null) {
    return view;
    }
    //2、一些系统的 View ,则走系统的一个创建流程
    return super.onCreateView(name, context, attrs);
    }
    }

    注意:一般我们会把替换 View 的逻辑放到基类里面

    七、效果验证

    运行项目

    1、先看下我们打印的日志,如下图:

    image-20210629195411055

    2、在看一眼我们生成的 Java 类文件,如下图:

    image-20210629194711378

    3、最后 debug 项目跟下流程,发现和我们预期的一致,如下图:

    image-20210629194101025

    至此,需求完结

    八、总结

    本篇文章讲的一些重点内容:

    1、通过 APT 读取文件获取所有的控件名称并生成 Java 类

    2、通过接口代理,合理的业务封装提供给上层调用

    3、在上层 Application 里面进行注解,在 Activity 中进行 View 控件的替换

    4、实际完成后的一个效果验证

    收起阅读 »

    使用Jetpack Compose完成自定义手势处理

    概述Jetpack Compose 为我们提供了许多手势处理 Modifier,对于常见业务需求来说已足够我们使用了,然而如果说我们对手势有定制需求,就需要具备自定义手势处理的能力了。通过使用官方所提供的基础 API 来完成各类手势交互需求,触摸反馈基础 AP...
    继续阅读 »

    概述

    Jetpack Compose 为我们提供了许多手势处理 Modifier,对于常见业务需求来说已足够我们使用了,然而如果说我们对手势有定制需求,就需要具备自定义手势处理的能力了。通过使用官方所提供的基础 API 来完成各类手势交互需求,触摸反馈基础 API 类似传统 View 系统的 onTouchEvent()。 当然 Compose 中也支持类似传统 ViewGroup 通过 onInterceptTouchEvent()定制手势事件分发流程。通过对自定义手势处理的学习将帮助大家掌握处理绝大多数场景下手势需求的能力。

    使用 PointerInput Modifier

    对于所有手势操作的处理都需要封装在这个 Modifier 中,我们知道 Modifier 是用来修饰 UI 组件的,所以将手势操作的处理封装在 Modifier 符合开发者设计直觉,这同时也做到了手势处理逻辑与 UI 视图的解耦,从而提高复用性。

    通过翻阅 Swipeable Modifier 、Draggable Modifier 以及 Transformer Modifier,我们都能看到 PointerInput Modifier 的身影。因为这类上层的手势处理 Modifier 其实都是基于这个基础 Modifier 实现的。所以既然要自定义手势处理流程,自定义逻辑也必然要在这个 Modifier 中进行实现。

    通过 PointerInput Modifier 实现我们可以看出,我们所定义的自定义手势处理流程均发生在 PointerInputScope 中,suspend 关键字也告知我们自定义手势处理流程是发生在协程中。这其实是无可厚非的,在探索重组工作原理的过程中我们也经常能够看到协程的身影。伴随着越来越多的主流开发技术拥抱协程,这也就意味着协程成了 Android 开发者未来必须掌握的技能。推广协程同时其实也是在推广 Kotlin,即使官方一直强调不会放弃 Java,然而谁又会在 Java 中使用 Kotlin 协程呢?

    fun Modifier.pointerInput(
    vararg keys: Any?,
    block:
    suspend PointerInputScope.() -> Unit
    )
    : Modifier = composed(
    ...
    ) {
    ...
    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.apply {
    LaunchedEffect(this, *keys) {
    block()
    }
    }
    }

    接下来我们就看看 PointerInputScope 作用域中,为我们可以使用哪些 API 来处理手势交互。本文将会根据手势能力分类进行解释说明。

    拖动类型基础 API

    API 介绍

    API名称作用
    detectDragGestures监听拖动手势
    detectDragGesturesAfterLongPress监听长按后的拖动手势
    detectHorizontalDragGestures监听水平拖动手势
    detectVerticalDragGestures监听垂直拖动手势

    谈及拖动,许多人第一个反应就是 Draggable Modifier,因为 Draggable Modifier 为我们提供了监听 UI 组件拖动能力。然而 Draggable Modifier 在提供了监听 UI 组件拖动能力的同时也拓展增加其他功能,我们通过 Draggable Modifier 参数列表即可看出。例如通过使用 DraggableState 允许开发者根据需求使 UI 组件自动被拖动。

    fun Modifier.draggable(
    state:
    DraggableState,
    orientation:
    Orientation,
    enabled:
    Boolean = true,
    interactionSource:
    MutableInteractionSource? = null,
    startDragImmediately:
    Boolean = false,
    onDragStarted:
    suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
    onDragStopped:
    suspend CoroutineScope.(velocity: Float) -> Unit = {},
    reverseDirection:
    Boolean = false
    )

    我们上面所罗列的这些拖动 API 只提供了监听 UI 组件拖动的能力,我们可以根据需求为其拓展功能,这也是这些API所存在的意义。我们从字面上就可以看出每个 API 所对应的含义,由于这些API的功能与参数相近,这里我们仅以 detectDragGestures 作为举例说明。

    举例说明

    接下来我们将完成一个绿色方块的手势拖动。在 Draggabel Modifier 中我们还只能监听垂直或水平中某一个方向的手势拖动,而使用 detectDragGestures 所有手势信息都是可以拿到的。如果我们还是只希望拿到某一个方向的手势拖动,使用 detectHorizontalDragGestures 或 detectVerticalDragGestures 即可,当然我们也可以使用 detectDragGestures 并且忽略掉某个方向的手势信息。如果我们希望在长按后才能拿到手势信息可以使用 detectDragGesturesAfterLongPress

    detectDragGestures 提供了四个参数。

    onDragStart (可选):拖动开始时回调

    onDragEnd (可选):拖动结束时回调

    onDragCancel (可选):拖动取消时回调

    onDrag (必须):拖动时回调

    decectDragGestures 的源码分析在 awaitTouchSlopOrCancellation 小节会有讲解。

    suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (
    Offset) -> Unit = { },
    onDragEnd: () ->
    Unit = { },
    onDragCancel: () ->
    Unit = { },
    onDrag: (
    change: PointerInputChange, dragAmount: Offset) -> Unit
    )

    💡 Tips

    有些同学可能困惑 onDragCancel 触发时机。在一些场景中,当组件拖动时会根据事件分发顺序进行事件分发,当前面先处理事件的组件满足了设置的消费条件,导致手势事件被消费,导致本组件拿到的是被消费的手势事件,从而会执行 onDragCancel 回调。如何定制事件分发顺序并消费事件后续会进行详细的描述。

    示例如下所示

    @Preview
    @Composable
    fun DragGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(contentAlignment = Alignment.Center,
    modifier = Modifier.fillMaxSize()
    ) {
    Box(Modifier
    .size(boxSize)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    detectDragGestures(
    onDragStart = { offset ->
    // 拖动开始
    },
    onDragEnd = {
    // 拖动结束
    },
    onDragCancel = {
    // 拖动取消
    },
    onDrag = { change: PointerInputChange, dragAmount: Offset ->
    // 拖动中
    offset += dragAmount
    }
    )
    }
    )
    }
    }

    drag.gif

    点击类型基础 API

    API 介绍

    API名称作用
    detectTapGestures监听点击手势

    与 Clickable Modifier 不同的是,detectTapGestures 可以监听更多的点击事件。作为手机监听的基础 API,必然不会存在 Clickable Modifier 所拓展的涟漪效果。

    举例说明

    接下来我们将为一个绿色方块添加点击手势处理逻辑。detectTapGestures 提供了四个可选参数,用来监听不同点击事件。

    onDoubleTap (可选):双击时回调

    onLongPress (可选):长按时回调

    onPress (可选):按下时回调

    onTap (可选):轻触时回调

    suspend fun PointerInputScope.detectTapGestures(
    onDoubleTap: ((
    Offset) -> Unit)? = null,
    onLongPress: ((
    Offset) -> Unit)? = null,
    onPress:
    suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((
    Offset) -> Unit)? = null
    )

    💡 Tips

    onPress 普通按下事件

    onDoubleTap 前必定会先回调 2 次 Press

    onLongPress 前必定会先回调 1 次 Press(时间长)

    onTap 前必定会先回调 1 次 Press(时间短)

    示例如下所示

    @Preview
    @Composable
    fun TapGestureDemo() {
    var boxSize = 100.dp
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(Modifier
    .size(boxSize)
    .background(Color.Green)
    .pointerInput(Unit) {
    detectTapGestures(
    onDoubleTap = { offset: Offset ->
    // 双击
    },
    onLongPress = { offset: Offset ->
    // 长按
    },
    onPress = { offset: Offset ->
    // 按下
    },
    onTap = { offset: Offset ->
    // 轻触
    }
    )
    }
    )
    }
    }

    变换类型基础 API

    API 介绍

    API名称作用
    detectTransformGestures监听拖动、缩放与旋转手势

    与 Transfomer Modifier 不同的是,通过这个 API 可以监听单指的拖动手势,和拖动类型基础 API所提供的功能一样,除此之外还支持监听双指缩放与旋转手势。反观Transfomer Modifier 只能监听到双指拖动手势,不知设计成这样的行为不一致是否是 Google 有意而为之。

    举例说明

    接下来我们为这个绿色方块添加变化手势处理逻辑。detectTransformGestures 方法提供了两个参数。

    panZoomLock(可选): 当拖动或缩放手势发生时是否支持旋转

    onGesture(必须):当拖动、缩放或旋转手势发生时回调

    suspend fun PointerInputScope.detectTransformGestures(
    panZoomLock:
    Boolean = false,
    onGesture: (
    centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
    )

    💡 Tips

    关于偏移、缩放与旋转,我们建议的调用顺序是 rotate -> scale -> offset

    1. 若offset发生在rotate之前时,rotate会对offset造成影响。具体表现为当出现拖动手势时,组件会以当前角度为坐标轴进行偏移。

    2. 若offset发生在scale之前是,scale也会对offset造成影响。具体表现为UI组件在拖动时不跟手

    @Preview
    @Composable
    fun TransformGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    var ratationAngle by remember { mutableStateOf(0f) }
    var scale by remember { mutableStateOf(1f) }
    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(Modifier
    .size(boxSize)
    .rotate(ratationAngle) // 需要注意offset与rotate的调用先后顺序
    .scale(scale)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    detectTransformGestures(
    panZoomLock = true, // 平移或放大时是否可以旋转
    onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
    offset += pan
    scale *= zoom
    ratationAngle += rotation
    }
    )
    }
    )
    }
    }

    transform.gif

    forEachGesture

    在传统 View 系统中,一次手指按下、移动到抬起过程中的所有手势事件可以共同构成一个手势事件序列。我们可以通过自定义手势处理来对于每一个手势事件序列进行定制处理。Compose 提供了 forEachGesture 以允许用户可以对每一个手势事件序列进行相同的定制处理。如果我们忘记使用 forEachGesture ,那么只会处理第一次手势事件序列。有些同学可能会问,为什么我不能在手势处理逻辑最外层套一层 while(true) 呢,通过 forEachGesture 的实现我们可以看到 forEachGesture 其实内部也是由while 实现的,除此之外他保证了协程只有存活时才能监听手势事件,同时也保证了每次交互结束时所有手指都是离开屏幕的。有些同学看到 while 可能新生疑问,难道这样不会阻塞主线程嘛?其实我们在介绍 PointerInput Modifier 时就提到过,我们的手势操作处理均发生在协程中。其实前面我们所提到的绝大多数 API 其内部实现均使用了 forEachGesture 。有些特殊场景下我们仅使用前面所提出的 API 可能仍然无法满足我们的需求,当然如果可以满足的话我们直接使用其分别对应的 Modifier 即可,前面所提出的 API 存在的意义是为了方便开发者为其进行功能拓展。既然要掌握自定义手势处理,我们就要从更底层角度来看这些上层 API 是如何实现的,了解原理我们就可以轻松自定义了。

    suspend fun PointerInputScope.forEachGesture(block: suspend PointerInputScope.() -> Unit) {
    val currentContext = currentCoroutineContext()
    while (currentContext.isActive) {
    try {
    block()
    // 挂起等待所有手指抬起
    awaitAllPointersUp()
    } catch (e: CancellationException) {
    ...
    }
    }
    }

    手势事件作用域 awaitPointerEventScope

    在 PointerInputScope 中我们可以找到一个名为 awaitPointerEventScope 的 API 方法。

    通过翻阅方法声明可以发现这是个挂起方法,其尾部 lambda 在 AwaitPointerEventScope 作用域中。 通过这个 AwaitPointerEventScope 作用域我们可以获取到更加底层的 API 手势事件,这也为自定义手势处理提供了可能。

    suspend fun  awaitPointerEventScope(
    block:
    suspend AwaitPointerEventScope.() -> R
    )
    : R

    我们在 AwaitPointerEventScope 中发现了以下这些基础手势方法,可以发现这些 API 均是挂起函数,接下来我们会对每个 API 进行描述说明。

    API名称作用
    awaitPointerEvent手势事件
    awaitFirstDown第一根手指的按下事件
    drag拖动事件
    horizontalDrag水平拖动事件
    verticalDrag垂直拖动事件
    awaitDragOrCancellation单次拖动事件
    awaitHorizontalDragOrCancellation单次水平拖动事件
    awaitVerticalDragOrCancellation单次垂直拖动事件
    awaitTouchSlopOrCancellation有效拖动事件
    awaitHorizontalTouchSlopOrCancellation有效水平拖动事件
    awaitVerticalTouchSlopOrCancellation有效垂直拖动事件

    万物之源 awaitPointerEvent

    awaitPointerEvent 类似于传统 View 系统的 onTouchEvent() 。无论用户是按下、移动或抬起都将视作一次手势事件,当手势事件发生时 awaitPointerEvent 会恢复执行并将手势事件返回。

    suspend fun awaitPointerEvent(
    pass:
    PointerEventPass = PointerEventPass.Main
    )
    : PointerEvent

    通过 API 声明可以看到 awaitPointerEvent 有个可选参数 PointerEventPass

    我们知道手势事件的分发是由父组件到子组件的单链结构。这个参数目的是用以设置父组件与子组件的事件分发顺序,PointerEventPass 有 3 个枚举值可供选择,每个枚举值的具体含义如下

    枚举值含义
    PointerEventPass.Initial本组件优先处理手势,处理后交给子组件
    PointerEventPass.Main若子组件为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。
    PointerEventPass.Final若子组件也为Final,本组件优先处理手势。否则将手势交给子组件处理,结束后本组件再处理。

    大家可能觉得 Main 与 Final 是等价的。但其实两者在作为子组件时分发顺序会完全不同,举个例子。

    当父组件为Final,子组件为Main时,事件分发顺序: 子组件 -> 父组件

    当父组件为Final,子组件为Final时,事件分发顺序: 父组件 -> 子组件

    文字描述可能并不直观,接下来进行举例说明。

    事件分发流程

    接下来,我将通过一个嵌套了三层 Box 的示例来直观表现事件分发过程。我们为这嵌套的三层Box 中的每一层都进行手势获取。

    box_nest.jpg

    如果我们点击中间的绿色方块时,便会触发手势事件。

    当三层 Box 均使用默认 Main 模式时,事件分发顺序为:第三层 -> 第二层 -> 第一层

    当第一层Box使用 Inital 模式,第二层使用 Final 模式,第三层使用 Main 模式时,事件分发顺序为:第一层 -> 第三层 -> 第二层

    @Preview
    @Composable
    fun NestedBoxDemo() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent(PointerEventPass.Initial)
    Log.d("compose_study", "first layer")
    }
    }
    ) {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .size(400.dp)
    .background(Color.Blue)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent(PointerEventPass.Final)
    Log.d("compose_study", "second layer")
    }
    }
    ) {
    Box(
    Modifier
    .size(200.dp)
    .background(Color.Green)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    awaitPointerEvent()
    Log.d("compose_study", "third layer")
    }
    }
    )
    }
    }
    }

    // Output:
    // first layer
    // third layer
    // second layer

    能够自定义事件分发顺序之后,我们就可以决定手势事件由事件分发流程中哪个组件进行消费。那么如何进行消费呢,这就需要我们看看 awaitPointerEvent 返回的手势事件了。通过 awaintPointerEvent 声明,我们可以看到返回的手势事件是个 PointerEvent 实例。

    通过 PointerEvent 类声明,我们可以看到两个成员属性 changes 与 motionEvent。

    motionEvent 我们再熟悉不过了,就是传统 View 系统中的手势事件,然而却被声明了 internal 关键字,看来是不希望我们使用。

    changes 是一个 List,其中包含了每次发生手势事件时,屏幕上所有手指的状态信息。

    当只有一根手指时,这个 List 的大小为 1。在多指操作时,我们通过这个 List 获取其他手指的状态信息就可以轻松定制多指自定义手势处理了。

    actual data class PointerEvent internal constructor(
    actual val changes: List,
    internal val motionEvent: MotionEvent?
    )

    PointerInputChange

    class PointerInputChange(
    val id: PointerId, // 手指Id
    val uptimeMillis: Long, // 当前手势事件的时间戳
    val position: Offset, // 当前手势事件相对组件左上角的位置
    val pressed: Boolean, // 当前手势是否按下
    val previousUptimeMillis: Long, // 上一次手势事件的时间戳
    val previousPosition: Offset, // 上一次手势事件相对组件左上角的位置
    val previousPressed: Boolean, // 上一次手势是否按下
    val consumed: ConsumedData, // 当前手势是否已被消费
    val type: PointerType = PointerType.Touch // 手势类型(鼠标、手指、手写笔、橡皮)
    )
    API名称作用
    changedToDown是否已经按下(按下手势已消费则返回false)
    changedToDownIgnoreConsumed是否已经按下(忽略按下手势已消费标记)
    changedToUp是否已经抬起(按下手势已消费则返回false)
    changedToUpIgnoreConsumed是否已经抬起(忽略按下手势已消费标记)
    positionChanged是否位置发生了改变(移动手势已消费则返回false)
    positionChangedIgnoreConsumed是否位置发生了改变(忽略已消费标记)
    positionChange位置改变量(移动手势已消费则返回Offset.Zero)
    positionChangeIgnoreConsumed位置改变量(忽略移动手势已消费标记)
    positionChangeConsumed当前移动手势是否已被消费
    anyChangeConsumed当前按下手势或移动手势是否有被消费
    consumeDownChange消费按下手势
    consumePositionChange消费移动手势
    consumeAllChanges消费按下与移动手势
    isOutOfBounds当前手势是否在固定范围内

    这些 API 会在我们自定义手势处理时会被用到。可以发现的是,Compose 通过 PointerEventPass 来定制事件分发流程,在事件分发流程中即使前一个组件先获取了手势信息并进行了消费,后面的组件仍然可以通过带有 IgnoreConsumed 系列 API 来获取到手势信息。这也极大增加了手势操作的可定制性。就好像父组件先把事件消费,希望子组件不要处理这个手势了,但子组件完全可以不用听从父组件的话。

    我们通过一个实例来看看该如何进行手势消费,处于方便我们的示例不涉及移动,只消费按下手势事件来进行举例。和之前的样式一样,我们将手势消费放在了第三层 Box,根据事件分发规则我们知道第三层Box是第2个处理手势事件的,所以输出结果如下。

    // Output:
    // first layer, downChange: false
    // third layer, downChange: true
    // second layer, downChange: true

    ⚠️ 注意事项

    如果我们是在定制事件分发流程,那么需要注意以下两种写法

    // 正确写法
    awaitPointerEventScope {
    var event = awaitPointerEvent()
    event.changes[0].consumeDownChange()
    }

    // 错误写法
    var event = awaitPointerEventScope {
    awaitPointerEvent()
    }
    event.changes[0].consumeDownChange()

    他们的区别在于 awaitPointerEventScope 会在其内部所有手势在事件分发流程结束后返回,当所有组件都已经完成手势处理再进行消费已经没有什么意义了。我们仍然用刚才的例子来直观说明这个问题。我们在每一层Box awaitPointerEventScope 后面添加了日志信息。

    通过输出结果可以发现,这三层执行的相对顺序没有发生变化,然而却是在事件分发流程结束后才进行输出的。

    @Preview
    @Composable
    fun Demo() {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .fillMaxSize()
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Initial)
    Log.d("compose_study", "first layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "first layer Outside")
    }
    ) {
    Box(
    contentAlignment = Alignment.Center,
    modifier = Modifier
    .size(400.dp)
    .background(Color.Blue)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent(PointerEventPass.Final)
    Log.d("compose_study", "second layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "second layer Outside")
    }
    ) {
    Box(
    Modifier
    .size(200.dp)
    .background(Color.Green)
    .pointerInput(Unit) {
    awaitPointerEventScope {
    var event = awaitPointerEvent()
    event.changes[0].consumeDownChange()
    Log.d("compose_study", "third layer, downChange: ${event.changes[0].consumed.downChange}")
    }
    Log.d("compose_study", "third layer Outside")
    }
    )
    }
    }
    }

    // Output:
    // first layer, downChange: false
    // third layer, downChange: true
    // second layer, downChange: true
    // first layer Outside
    // third layer Outside
    // second layer Outside

    awaitFirstDown

    awaitFirstDown 将等待第一根手指按下事件时恢复执行,并将手指按下事件返回。分析源码我们可以发现 awaitFirstDown 也使用的是 awaitPointerEvent 实现的,默认使用 Main 模式。

    suspend fun AwaitPointerEventScope.awaitFirstDown(
    requireUnconsumed:
    Boolean = true
    )
    : PointerInputChange {
    var event: PointerEvent
    do {
    event = awaitPointerEvent()
    } while (
    !event.changes.fastAll {
    if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
    }
    )
    return event.changes[0]
    }

    drag

    看到 drag 可能很多同学疑惑为什么又是拖动。其实前面所提到的拖动类型基础API detectDragGestures 其内部就是使用 drag 而实现的。与 detectDragGestures 不同的是,drag 需要主动传入一个 PointerId 用以表示要具体获取到哪根手指的拖动事件。

    suspend fun AwaitPointerEventScope.drag(
    pointerId:
    PointerId,
    onDrag: (
    PointerInputChange) -> Unit
    )

    翻阅源码可以发现,其实 drag 内部实现最终使用的仍然还是 awaitPointerEvent 。这里就不具体展开看了,感兴趣的可以自己去跟源码。

    举例说明

    通过结合 awaitFirstDown 与 drag 这些基础 API 我们已经可以自己实现 UI 拖动手势流程了。我们仍然以我们的绿色方块作为实例,为其添加拖动手势。

    @Preview
    @Composable
    fun BaseDragGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(contentAlignment = Alignment.Center,
    modifier = Modifier.fillMaxSize()
    ) {
    Box(Modifier
    .size(boxSize)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    forEachGesture { // 循环监听每一组事件序列
    awaitPointerEventScope {
    var downEvent = awaitFirstDown()
    drag(downEvent.id) {
    offset += it.positionChange()
    }
    }
    }
    }
    )
    }
    }

    awaitDragOrCancellation

    与 drag 不同的是,awaitDragOrCancellation 负责监听单次拖动事件。当手指已经抬起或拖动事件已经被消费时会返回 null。当然我们也可以使用 awaitDragOrCancellation 来完成 UI 拖动手势处理流程。通过翻阅源码可以发现 drag 其实内部也是使用 awaitDragOrCancellation 进行实现的。而 awaitDragOrCancellation 内部仍然是 awaitPointerEvent

    @Preview
    @Composable
    fun BaseDragGestureDemo() {
    var boxSize = 100.dp
    var offset by remember { mutableStateOf(Offset.Zero) }
    Box(contentAlignment = Alignment.Center,
    modifier = Modifier.fillMaxSize()
    ) {
    Box(Modifier
    .size(boxSize)
    .offset {
    IntOffset(offset.x.roundToInt(), offset.y.roundToInt())
    }
    .background(Color.Green)
    .pointerInput(Unit) {
    forEachGesture {
    awaitPointerEventScope {
    var downPointer = awaitFirstDown()
    while (true) {
    var event = awaitDragOrCancellation(downPointer.id)
    if (event == null) {
    break
    }
    offset += event.positionChange()
    }
    }
    }
    }
    )
    }
    }

    awaitTouchSlopOrCancellation

    awaitTouchSlopOrCancellation 用于监测当前拖动手势是否是一次有效的拖动。有效指的是当前手势滑动的欧式距离(位移)是否超过设定的阈值。若拖动手势还没有达到阈值便抬起或拖动手势事件已经被消费时将返回null,翻阅源码我们又找到了awaitPointerEvent ,所以说 awaitPointerEvent 是万物之源嘛~

    我们前面所提到的 detectDragGestures 其内部不仅使用了 drag 还使用了 awaitTouchSlopOrCancellation 来判断手势拖动操作。仅当监测为一次有效的拖动时,才会执行 onDragStart 回调。接下来就是使用 drag 来监听拖动手势,仅当 drag 返回 false (即在拖动过程中事件分发流程前面的组件达成定制条件消费了这次的拖动手势事件) 会执行 onDragCancel 回调,否则如果所有手指抬起正常结束则会执行 onDragEnd 回调。

    收起阅读 »

    Linux - 远程操作

    shotdown命令,默认表示1分钟后关机.命令格式:$shutdown [选项] <参数>参数示例一分钟以后关机$shutdown 立刻关机$shutdown now 在今天的21:30关机$shutdown 21:30 10分钟以后关机$s...
    继续阅读 »


    关机重/启命令

    shutdown命令可以安全关闭 或者 重新启动系统,直接使用 shotdown命令,默认表示1分钟后关机.
    命令格式:

    $shutdown [选项] <参数>

    选项
    功能
    [-r]重新启动
    [-c]取消之前的关机计划

    参数

    • [时间]:设置多久时间后执行shutdown指令;
    • [警告信息]:要传送给所有登入用户的信息。


    示例

    • 一分钟以后关机
    $shutdown  
    • 立刻关机
    $shutdown now
    • 在今天的21:30关机
    $shutdown 21:30
    • 10分钟以后关机
    $shutdown +10
    • 10分钟以后关机,同时发出警告信息
    $shutdown +10 "System will shutdown after 10 minutes"
    • 取消关机计划
    $shutdown -c

    reboot命令也可以用来重新启动正在运行的Linux操作系统。
    和 shutdown -r now一样

    网络配置命令

    命令功能
    ifconfigconfigure a network interface,查看/配置计算机当前的网卡信息
    ping测试目标ip地址的连接是否正常

    ifconfig命令

    ifconfig命令被用于配置和显示Linux中网卡信息。
    查看网卡信息

    $ifconfig

    快速定位IP地址

    $ifconfig | grep inet

    一台计算机中可能会有一个 物理网卡 和 多个虚拟网卡,在Linux中物理网卡名字一般是 ensXX

    • 127.0.0.1这个地址是一个比较特殊的地址,称之为本地回环地址,可以用来测试本机网卡是否正常工作。

    ping命令

    ping命令用来测试主机之间网络的连通性。执行ping指令会使用ICMP传输协议,发出要求回应的信息。一般用于检测计算机之间的网络通讯是否正常。

    由于ping命令的工作原理,服务器人员给往往将ping用作动词。经常说:“ping一下某某计算机”

    示例:

    “ping”目标主机

    $ping IP地址

    检测本地网卡是否正常

    $ping 127.0.0.1

    结束ping的执行使用Ctrl+C。在Linux中终止一个终端程序绝大多数都可以使用Ctrl+C

    SSH(Secure Shell)

    简单说,SSH是一种网络协议,用于计算机之间的加密登录。
    最早的时候,互联网通信都是明文通信,一旦被截获,内容就暴露无疑。1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。

    OpenSSH

    SSH只是一种协议,存在多种实现OpenSSH就是其中一种,它是一款软件,应用非常广泛在Mac以及Ubuntu中都自带OpenSSH

    SSH的登录过程

    • (1)远程主机收到用户的登录请求,把自己的公钥发给用户。
    • (2)用户使用这个公钥,将登录密码加密后,发送回来。
    • (3)远程主机用自己的私钥,解密登录密码,如果密码正确,就同意用户登录。

    SSH客户端命令

    ssh [-p port] user@remote

    • user 是远程端上的用户名,默认是当前用户
    • remote是远程端的地址,可以是IP/域名
    • port是远程端的端口,默认是22

    Ubuntu下开启SSH

    Ubuntu下SSH分

    • openssh-client(客户端)
    • openssh-server (服务端)
    检测是否有开启ssh服务
    hank@ubuntu:~$ ps -e | grep ssh
    4910 ? 00:00:00 sshd
    其中sshd 为server端的守护进程,如果没有出现sshd,那么很有可能你的系统中没有安装server端。或者ssh服务没有启动。

    开启ssh服务
    hank@ubuntu:~$ sudo /etc/init.d/ssh start
    [ ok ] Starting ssh (via systemctl): ssh.service.
    安装openssh-server

    如果显示上述命令找不到。那么是因为我们的Ubuntu系统默认没有服务端,所以可以通过下面命令安装。
    $ sudo apt-get install openssh-server

    可能出现错误
    $ sudo apt-get install openssh-server
    正在读取软件包列表... 完成
    正在分析软件包的依赖关系树
    正在读取状态信息... 完成
    有一些软件包无法被安装。如果您用的是 unstable 发行版,这也许是
    因为系统无法达到您要求的状态造成的。该版本中可能会有一些您需要的软件
    包尚未被创建或是它们已被从新到(Incoming)目录移出。
    下列信息可能会对解决问题有所帮助:

    下列软件包有未满足的依赖关系:
    openssh-server : 依赖: openssh-client (= 1:7.1p1-4)
    依赖: openssh-sftp-server 但是它将不会被安装
    推荐: ssh-import-id 但是它将不会被安装
    E: 无法修正错误,因为您要求某些软件包保持现状,就是它们破坏了软件包间的依赖关系。

    因为openssh-server 需要依赖openssh-client,但是很明显,我们系统自带的版本和目前要安装的server版本不同。所以我们重新安装一下client版本。


    hank@ubuntu:~$ sudo apt-get install openssh-client=1:7.1p1-4
    正在读取软件包列表... 完成
    正在分析软件包的依赖关系树
    正在读取状态信息... 完成
    建议安装:
    ssh-askpass libpam-ssh keychain monkeysphere
    下列软件包将被【降级】:
    openssh-client
    升级了 0 个软件包,新安装了 0 个软件包,降级了 1 个软件包,要卸载 0 个软件包,有 0 个软件包未被升级。
    需要下载 581 kB 的归档。
    解压缩后将会空出 36.9 kB 的空间。
    您希望继续执行吗? [Y/n] y
    获取:1 http://mirror.neu.edu.cn/ubuntu xenial/main amd64 openssh-client amd64 1:7.1p1-4 [581 kB]
    已下载 581 kB,耗时 33 (17.6 kB/s)
    dpkg:警告:即将把 openssh-client 1:7.2p2-4 降级到 1:7.1p1-4
    正在将 openssh-client (1:7.1p1-4) 解包到 (1:7.2p2-4) 上 ...
    正在处理用于 man-db (2.7.5-1) 的触发器 ...
    正在设置 openssh-client (1:7.1p1-4) ...
    正在安装新版本配置文件 /etc/ssh/ssh_config ...
    这样可以看到降级成功。然后我们再次安装openssh-server就OK了!

    hank@ubuntu:~$ sudo apt-get install openssh-server

    SCP(Secure copy)

    • scp scp是linux系统下基于ssh登陆进行安全的远程文件拷贝命令。
    • 命令格式
    scp -P port 源文件路径 目标文件路径
    # 将本地目录下的123.txt拷贝到远程桌面目录下
    $scp -P port 123.txt user@remote:Desktop/123.txt

    # 把远程桌面目录下的123.txt文件 复制到 本地当前目录下
    scp -P port user@remote:Desktop/123.txt 123.txt

    # 加上 -r 选项可以传送文件夹
    # 把当前目录下的 demo 文件夹 复制到 远程 家目录下的 Desktop
    scp -r demo user@remote:Desktop

    # 把远程 家目录下的 Desktop 复制到 当前目录下的 demo 文件夹
    scp -r user@remote:Desktop demo

    选项功能
    -r若给出的源文件是目录文件,则 scp 将递归复制该目录下的所有子目录和文件,目标文件必须为一个目录名
    -P若远程 SSH 服务器的端口不是 22,需要使用大写字母 -P 选项指定端口

    SSH常用配置

    免密登陆

    • 配置公钥
      执行 ssh-keygen 即可生成 SSH 钥匙,一路回车即可
    • 上传公钥到服务器
      执行 ssh-copy-id -p port user@remote,可以让远程服务器记住我们的公钥

    配置别名

    每次都输入ssh -p port user@remote,非常不方便,而且还不好记忆

    而 配置别名 可以让我们进一步偷懒,譬如用:ssh mac 来替代上面这么一长串,那么就在 ~/.ssh/config 里面追加以下内容:


    Host mac
    HostName ip地址
    User H
    Port 22

    保存之后,即可用 ssh mac 实现远程登录了,scp 同样可以使用。


    作者:请叫我Hank
    链接:https://www.jianshu.com/p/9b31892a572f



    收起阅读 »

    Linux简介

    Linux 内核以及发行版Linux内核(kernel)操作系统内核是指大多数操作系统的核心部分。它由操作系统中用于管理存储器、文件、外设和系统资源的那些部分组成。操作系统内核通常运行进程,并提供进程间的通信。Linux 内核版本又分为 稳定版&nb...
    继续阅读 »

    Linux 内核以及发行版

    • Linux内核(kernel)

    操作系统内核是指大多数操作系统的核心部分。它由操作系统中用于管理存储器、文件、外设和系统资源的那些部分组成。操作系统内核通常运行进程,并提供进程间的通信。
    Linux 内核版本又分为 稳定版 和 开发版,两种版本是相互关联,相互循环

    • 稳定版:具有工业级强度,可以广泛地应用和部署。

    • 开发版:由于要试验各种解决方案,所以变化很快

    • 内核源码网址:http://www.kernel.org

    • Linux发行版

    Linux 发行版:我们常说的Linux操作系统,也是由Linux内核与各种常用软件的集合产品. 类似Windows包含了桌面环境.全球大约有数百款的Linux系统版本,每个系统版本都有自己的特性和目标人群.

    Ubuntu(乌班图)

    Ubuntu是一个以桌面应用为主的开源GNU/Linux操作系统,主要依赖Canonical有限公司的支持,同时也有很多来自Linux社区的热心人士提供协助。
    作为Linux发行版之一.Canonical 的Ubuntu 胜过其他所有的 Linux 服务器发行版 ,它简单易用同时又相当稳定,而且具有庞大的社区力量,用户可以方便地从社区获得帮助.Ubuntu在服务器领域是妥妥的赢家.

    Ubuntu的目录结构



    Ubuntu的主要目录
    • /:根目录,一般根目录下只存放目录,在 linux 下有且只有一个根目录,所有的东西都是从这里开始
    • /bin、/usr/bin:可执行二进制文件的目录,如常用的命令 ls、tar、mv、cat 等
    • /boot:放置 linux 系统启动时用到的一些文件,如 linux 的内核文件:/boot/vmlinuz,系统引导管理器:/boot/grub
    • /dev:存放linux系统下的设备文件,访问该目录下某个文件,相当于访问某个设备,常用的是挂载光驱mount /dev/cdrom /mnt
    • /etc:系统配置文件存放的目录,不建议在此目录下存放可执行文件,重要的配置文件有
      • /etc/inittab
      • /etc/fstab
      • /etc/init.d
      • /etc/X11
      • /etc/sysconfig
      • /etc/xinetd.d
    • /home:系统默认的用户家目录,新增用户账号时,用户的家目录都存放在此目录下
      • ~ 表示当前用户的家目录
      • ~edu 表示用户 edu 的家目录
    • /lib、/usr/lib、/usr/local/lib:系统使用的函数库的目录,程序在执行过程中,需要调用一些额外的参数时需要函数库的协助
    • /lost+fount:系统异常产生错误时,会将一些遗失的片段放置于此目录下
    • /mnt: /media:光盘默认挂载点,通常光盘挂载于 /mnt/cdrom 下,也不一定,可以选择任意位置进行挂载
    • /opt:给主机额外安装软件所摆放的目录
    • /proc:此目录的数据都在内存中,如系统核心,外部设备,网络状态,由于数据都存放于内存中,所以不占用磁盘空间,比较重要的文件有:/proc/cpuinfo、/proc/interrupts、/proc/dma、/proc/ioports、/proc/net/* 等
    • /root:系统管理员root的家目录
    • /sbin、/usr/sbin、/usr/local/sbin:放置系统管理员使用的可执行命令,如 fdisk、shutdown、mount 等。与 /bin 不同的是,这几个目录是给系统管理员 root 使用的命令,一般用户只能"查看"而不能设置和使用
    • /tmp:一般用户或正在执行的程序临时存放文件的目录,任何人都可以访问,重要数据不可放置在此目录下
    • /srv:服务启动之后需要访问的数据目录,如 www 服务需要访问的网页数据存放在 /srv/www 内
    • /usr:应用程序存放目录
      • /usr/bin:存放应用程序
      • /usr/share:存放共享数据
      • /usr/lib:存放不能直接运行的,却是许多程序运行所必需的一些函数库文件
      • /usr/local:存放软件升级包
      • /usr/share/doc:系统说明文件存放目录
      • /usr/share/man:程序说明文件存放目录
    • /var:放置系统执行过程中经常变化的文件
      • /var/log:随时更改的日志文件
      • /var/spool/mail:邮件存放的目录
      • /var/run:程序或服务启动后,其 PID 存放在该目录下
    Ubuntu的常见快捷键

    可以在System Setting -> Keyboard -> Shortcuts中查看各种快捷键.

    • 终端: Ctrl+Alt+T
    • 终端新建标签页: Ctrl+Shift+T
    • 终端复制粘贴: Ctrl+Shift+C, Ctrl+Shift+V
    • 显示常用快捷键: 按住Super(Win)不动
    • 截活动窗口图: Alt+Print
    • 区域截图: Shift+Print
    • 源切换: Super(Win)+Space
    • 安装: sudo apt-get install
    • 卸载: sudo apt-get remove
    • 移除没用的包: sudo apt-get autoremove
    Ubuntu的常见设置
    首先语言设置
    • 通过右上角的 设置按钮 找到System Settings...
    • 然后选中Language Support 项
    • 注意Ubuntu的语言选项有多种语言.将第一语言设置为中文(因为如果中文显示不了的,会使用英文显示)




    • 设置完成后.选择Apply System-wide(应用到整个系统)这时,输入管理员密码以确认.最后点击 Close 按钮关闭对话框,重启电脑。


    注意:重启成功后,会让你选择文件夹名称显示.如果是为了学习.我建议大家保持原来的文件夹名称,这样便于后期在学习中熟悉Linux目录结构. 选择Keep Old Names





    Launcher(菜单栏)设置

    在系统设置中,找不到菜单栏的位置设置.所以只能通过终端命令进行设置

    • 菜单栏靠左(注意参数首字母大写)
    $ gsettings set com.canonical.Unity.Launcher launcher-position Left
    • 菜单栏靠下
    $ gsettings set com.canonical.Unity.Launcher launcher-position Bottom
    Ubuntu常用软件
    • 设置软件源: 默认的软件源是官方的, 速度慢的令人发指, 所以需要先设置一个速度较快的软件源, System Settings -> Software & Updates -> Ubuntu Software -> Download from选择Others, 然后自动选择一个网速比较快的服务器(多半是某个大学的)即可:
    • apt(Advanced Packaging Tool) 安装/卸载软件 (Ctrl+Alt+T 调出终端)

    安装软件

    $ sudo apt install 软件包

    卸载软件

    $ sudo apt remove 软件名

    更新已安装的包

    $ sudo apt upgrade  或者 sudo apt-get upgrade
    升级

     sudo apt-get update.

    那么由于有些Ubuntu中没有自带vim 而是 vi 这个古老的编辑器.所以我们需要安装vim

    sudo apt-get install vim
    在安装过程中有可能出现下列错误
    vim : 依赖: vim-common (= 2:7.4.826-1ubuntu1) 但是 2:7.4.1689-3ubuntu1.1 正要被安装
    E: 无法修正错误,因为您要求某些软件包保持现状,就是它们破坏了软件包间的依赖关系。

    解决方案:

    sudo apt-get remove vim-common
    sudo apt-get install vim


    作者:Hank
    链接:https://www.jianshu.com/p/2ca7f448ffa7




    收起阅读 »

    汇编-函数本质(下)

    函数的返回值一般是一个指针,不会超过8字节。寄存器就完全够用了。如果要返回一个结构体类型超过字节。下面的例子(结构体占用字节):汇编代码:str这里没有使用作为返回值,而是使用了栈空间。8字节,也会保存在栈中返回(上一个函数栈空间)struct str { ...
    继续阅读 »

    篇幅限制,分为2篇


    返回值



    函数的返回值一般是一个指针,不会超过8字节。X0寄存器就完全够用了。如果要返回一个结构体类型超过8字节。
    下面的例子(str结构体占用24字节):

    struct str {
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
    };

    struct str getStr(int a, int b, int c, int d, int e, int f) {
    struct str str1;
    str1.a = a;
    str1.b = b;
    str1.c = c;
    str1.d = d;
    str1.e = e;
    str1.f = f;
    return str1;
    }

    - (void)viewDidLoad {
    [super viewDidLoad];
    struct str str2 = getStr(1,2,3,4,5,6);
    }

    汇编代码:

    TestDemo`-[ViewController viewDidLoad]:
    0x1042b5e58 <+0>: sub sp, sp, #0x50 ; =0x50
    0x1042b5e5c <+4>: stp x29, x30, [sp, #0x40]
    0x1042b5e60 <+8>: add x29, sp, #0x40 ; =0x40
    0x1042b5e64 <+12>: stur x0, [x29, #-0x8]
    0x1042b5e68 <+16>: stur x1, [x29, #-0x10]
    0x1042b5e6c <+20>: ldur x8, [x29, #-0x8]
    0x1042b5e70 <+24>: add x9, sp, #0x20 ; =0x20
    0x1042b5e74 <+28>: str x8, [sp, #0x20]
    0x1042b5e78 <+32>: adrp x8, 4
    0x1042b5e7c <+36>: add x8, x8, #0x418 ; =0x418
    0x1042b5e80 <+40>: ldr x8, [x8]
    0x1042b5e84 <+44>: str x8, [x9, #0x8]
    0x1042b5e88 <+48>: adrp x8, 4
    0x1042b5e8c <+52>: add x8, x8, #0x3e8 ; =0x3e8
    0x1042b5e90 <+56>: ldr x1, [x8]
    0x1042b5e94 <+60>: mov x0, x9
    0x1042b5e98 <+64>: bl 0x1042b6564 ; symbol stub for: objc_msgSendSuper2
    //x8指向栈空间的区域,预留足够的空间
    0x1042b5e9c <+68>: add x8, sp, #0x8 ; =0x8
    0x1042b5ea0 <+72>: mov w0, #0x1
    0x1042b5ea4 <+76>: mov w1, #0x2
    0x1042b5ea8 <+80>: mov w2, #0x3
    0x1042b5eac <+84>: mov w3, #0x4
    0x1042b5eb0 <+88>: mov w4, #0x5
    0x1042b5eb4 <+92>: mov w5, #0x6
    0x1042b5eb8 <+96>: bl 0x1042b5e04 ; getStr at ViewController.m:59
    -> 0x1042b5ebc <+100>: ldp x29, x30, [sp, #0x40]
    0x1042b5ec0 <+104>: add sp, sp, #0x50 ; =0x50
    0x1042b5ec4 <+108>: ret
    str函数:

        TestDemo`getStr:
    -> 0x1001d1e04 <+0>: sub sp, sp, #0x20 ; =0x20
    //参数分别放入栈中
    0x1001d1e08 <+4>: str w0, [sp, #0x1c]
    0x1001d1e0c <+8>: str w1, [sp, #0x18]
    0x1001d1e10 <+12>: str w2, [sp, #0x14]
    0x1001d1e14 <+16>: str w3, [sp, #0x10]
    0x1001d1e18 <+20>: str w4, [sp, #0xc]
    0x1001d1e1c <+24>: str w5, [sp, #0x8]

    //取出来放入w9,
    0x1001d1e20 <+28>: ldr w9, [sp, #0x1c]
    //存入x8,也就是上一个栈中直到写完
    0x1001d1e24 <+32>: str w9, [x8]
    0x1001d1e28 <+36>: ldr w9, [sp, #0x18]
    0x1001d1e2c <+40>: str w9, [x8, #0x4]
    0x1001d1e30 <+44>: ldr w9, [sp, #0x14]
    0x1001d1e34 <+48>: str w9, [x8, #0x8]
    0x1001d1e38 <+52>: ldr w9, [sp, #0x10]
    0x1001d1e3c <+56>: str w9, [x8, #0xc]
    0x1001d1e40 <+60>: ldr w9, [sp, #0xc]
    0x1001d1e44 <+64>: str w9, [x8, #0x10]
    0x1001d1e48 <+68>: ldr w9, [sp, #0x8]
    0x1001d1e4c <+72>: str w9, [x8, #0x14]
    //栈平衡,这里没有以 x0 作为返回值,已经全部写入上一个函数栈x8中。
    0x1001d1e50 <+76>: add sp, sp, #0x20 ; =0x20
    0x1001d1e54 <+80>: ret
    这里没有使用X0作为返回值,而是使用了栈空间。



    如果返回值大于8字节,也会保存在栈中返回(上一个函数栈空间)

    那么结构体参数超过8个呢?
    猜测参数和返回值都存在上一个函数的栈中,参数应该在低地址。返回值在高地址。


    struct str {
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
    int g;
    int h;
    int i;
    int j;
    };

    struct str getStr(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) {
    struct str str1;
    str1.a = a;
    str1.b = b;
    str1.c = c;
    str1.d = d;
    str1.e = e;
    str1.f = f;
    str1.g = g;
    str1.h = h;
    str1.i = i;
    str1.j = j;
    return str1;
    }

    - (void)viewDidLoad {
    [super viewDidLoad];
    struct str str2 = getStr(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
    printf("%d",func(10,20));
    }

    ⚠️:有两个函数 A BA -> B,在B执行完后A传递给B的参数释放了么?
    在上面的例子中910没有释放,相当于A的局部变量。

    对应的汇编代码:

    TestDemo`-[ViewController viewDidLoad]:
    //函数开始
    0x100c31ee4 <+0>: sub sp, sp, #0x60 ; =0x60
    0x100c31ee8 <+4>: stp x29, x30, [sp, #0x50]
    0x100c31eec <+8>: add x29, sp, #0x50 ; =0x50

    //参数入栈
    0x100c31ef0 <+12>: stur x0, [x29, #-0x8]
    0x100c31ef4 <+16>: stur x1, [x29, #-0x10]
    //x8获取参数x0
    0x100c31ef8 <+20>: ldur x8, [x29, #-0x8]
    //x9指向 x29 - 0x20
    0x100c31efc <+24>: sub x9, x29, #0x20 ; =0x20
    //x8 存入 x29 - 0x20
    0x100c31f00 <+28>: stur x8, [x29, #-0x20]

    //address page 内存中取数据
    0x100c31f04 <+32>: adrp x8, 4
    0x100c31f08 <+36>: add x8, x8, #0x418 ; =0x418
    //x8 所指的内存取出来
    0x100c31f0c <+40>: ldr x8, [x8]
    0x100c31f10 <+44>: str x8, [x9, #0x8]
    0x100c31f14 <+48>: adrp x8, 4
    0x100c31f18 <+52>: add x8, x8, #0x3e8 ; =0x3e8
    0x100c31f1c <+56>: ldr x1, [x8]
    0x100c31f20 <+60>: mov x0, x9
    0x100c31f24 <+64>: bl 0x100c32584 ; symbol stub for: objc_msgSendSuper2
    //x8指向 sp + 0x8
    0x100c31f28 <+68>: add x8, sp, #0x8 ; =0x8
    0x100c31f2c <+72>: mov w0, #0x1
    0x100c31f30 <+76>: mov w1, #0x2
    0x100c31f34 <+80>: mov w2, #0x3
    0x100c31f38 <+84>: mov w3, #0x4
    0x100c31f3c <+88>: mov w4, #0x5
    0x100c31f40 <+92>: mov w5, #0x6
    0x100c31f44 <+96>: mov w6, #0x7
    0x100c31f48 <+100>: mov w7, #0x8
    //sp的值给x9
    0x100c31f4c <+104>: mov x9, sp
    //9 w10
    0x100c31f50 <+108>: mov w10, #0x9
    //w10写入 x9 所指向的地址
    0x100c31f54 <+112>: str w10, [x9]
    //10 w10
    0x100c31f58 <+116>: mov w10, #0xa
    //w10写入 x9 所指向的地址 偏移4个字节
    0x100c31f5c <+120>: str w10, [x9, #0x4]
    //跳转getStr
    0x100c31f60 <+124>: bl 0x100c31e58 ; getStr at ViewController.m:31

    //函数结束
    -> 0x100c31f64 <+128>: ldp x29, x30, [sp, #0x50]
    0x100c31f68 <+132>: add sp, sp, #0x60 ; =0x60
    0x100c31f6c <+136>: ret
    str:

    TestDemo`getStr:
    //开辟空间
    0x100c31e58 <+0>: sub sp, sp, #0x30 ; =0x30
    //从上一个栈空间 获取9 10
    0x100c31e5c <+4>: ldr w9, [sp, #0x30]
    0x100c31e60 <+8>: ldr w10, [sp, #0x34]
    //参数入栈
    0x100c31e64 <+12>: str w0, [sp, #0x2c]
    0x100c31e68 <+16>: str w1, [sp, #0x28]
    0x100c31e6c <+20>: str w2, [sp, #0x24]
    0x100c31e70 <+24>: str w3, [sp, #0x20]
    0x100c31e74 <+28>: str w4, [sp, #0x1c]
    0x100c31e78 <+32>: str w5, [sp, #0x18]
    0x100c31e7c <+36>: str w6, [sp, #0x14]
    0x100c31e80 <+40>: str w7, [sp, #0x10]
    0x100c31e84 <+44>: str w9, [sp, #0xc]
    0x100c31e88 <+48>: str w10,[sp, #0x8]

    //获取参数分别存入上一个栈x8所指向的地址中
    -> 0x100c31e8c <+52>: ldr w9, [sp, #0x2c]
    0x100c31e90 <+56>: str w9, [x8]
    0x100c31e94 <+60>: ldr w9, [sp, #0x28]
    0x100c31e98 <+64>: str w9, [x8, #0x4]
    0x100c31e9c <+68>: ldr w9, [sp, #0x24]
    0x100c31ea0 <+72>: str w9, [x8, #0x8]
    0x100c31ea4 <+76>: ldr w9, [sp, #0x20]
    0x100c31ea8 <+80>: str w9, [x8, #0xc]
    0x100c31eac <+84>: ldr w9, [sp, #0x1c]
    0x100c31eb0 <+88>: str w9, [x8, #0x10]
    0x100c31eb4 <+92>: ldr w9, [sp, #0x18]
    0x100c31eb8 <+96>: str w9, [x8, #0x14]
    0x100c31ebc <+100>: ldr w9, [sp, #0x14]
    0x100c31ec0 <+104>: str w9, [x8, #0x18]
    0x100c31ec4 <+108>: ldr w9, [sp, #0x10]
    0x100c31ec8 <+112>: str w9, [x8, #0x1c]
    0x100c31ecc <+116>: ldr w9, [sp, #0xc]
    0x100c31ed0 <+120>: str w9, [x8, #0x20]
    0x100c31ed4 <+124>: ldr w9, [sp, #0x8]
    0x100c31ed8 <+128>: str w9, [x8, #0x24]
    //恢复栈
    0x100c31edc <+132>: add sp, sp, #0x30 ; =0x30
    0x100c31ee0 <+136>: ret



    和之前的猜测相符。

    函数的局部变量


    int func1(int a, int b) {
    int c = 6;
    return a + b + c;
    }

    - (void)viewDidLoad {
    [super viewDidLoad];
    func1(10, 20);
    }

    对应的汇编指令:

    TestDemo`func1:
    -> 0x104bc5e40 <+0>: sub sp, sp, #0x10 ; =0x10
    0x104bc5e44 <+4>: str w0, [sp, #0xc]
    0x104bc5e48 <+8>: str w1, [sp, #0x8]
    //局部变量c存入自己的栈区
    0x104bc5e4c <+12>: mov w8, #0x6
    0x104bc5e50 <+16>: str w8, [sp, #0x4]
    0x104bc5e54 <+20>: ldr w8, [sp, #0xc]
    0x104bc5e58 <+24>: ldr w9, [sp, #0x8]
    0x104bc5e5c <+28>: add w8, w8, w9
    0x104bc5e60 <+32>: ldr w9, [sp, #0x4]
    0x104bc5e64 <+36>: add w0, w8, w9
    0x104bc5e68 <+40>: add sp, sp, #0x10 ; =0x10
    0x104bc5e6c <+44>: ret
    函数的局部变量放在栈里面!(自己的栈)
    那么有嵌套调用呢?

    int func1(int a, int b) {
    int c = 6;
    int d = func2(a, b, c);
    int e = func2(a, b, c);
    return d + e;
    }

    int func2(int a, int b, int c) {
    int d = a + b + c;
    printf("%d",d);
    return d;
    }

    - (void)viewDidLoad {
    [super viewDidLoad];
    func1(10, 20);
    }
    对应的汇编:

    TestDemo`func1:
    //函数的开始
    -> 0x100781d9c <+0>: sub sp, sp, #0x30 ; =0x30
    0x100781da0 <+4>: stp x29, x30, [sp, #0x20]
    0x100781da4 <+8>: add x29, sp, #0x20 ; =0x20

    //参数入栈
    0x100781da8 <+12>: stur w0, [x29, #-0x4]
    0x100781dac <+16>: stur w1, [x29, #-0x8]

    //局部变量入栈
    0x100781db0 <+20>: mov w8, #0x6
    0x100781db4 <+24>: stur w8, [x29, #-0xc]

    //读取参数和局部变量
    0x100781db8 <+28>: ldur w0, [x29, #-0x4]
    0x100781dbc <+32>: ldur w1, [x29, #-0x8]
    0x100781dc0 <+36>: ldur w2, [x29, #-0xc]

    //执行func2
    0x100781dc4 <+40>: bl 0x100781df8 ; func2 at ViewController.m:86
    //func2 返回值入栈
    0x100781dc8 <+44>: str w0, [sp, #0x10]

    //读取参数和局部变量
    0x100781dcc <+48>: ldur w0, [x29, #-0x4]
    0x100781dd0 <+52>: ldur w1, [x29, #-0x8]
    0x100781dd4 <+56>: ldur w2, [x29, #-0xc]

    //第二次执行func2
    0x100781dd8 <+60>: bl 0x100781df8 ; func2 at ViewController.m:86

    //func2 返回值入栈
    0x100781ddc <+64>: str w0, [sp, #0xc]

    //读取两次 func2 返回值
    0x100781de0 <+68>: ldr w8, [sp, #0x10]
    0x100781de4 <+72>: ldr w9, [sp, #0xc]
    //相加存入w0返回上层函数
    0x100781de8 <+76>: add w0, w8, w9

    //函数的结束
    0x100781dec <+80>: ldp x29, x30, [sp, #0x20]
    0x100781df0 <+84>: add sp, sp, #0x30 ; =0x30
    0x100781df4 <+88>: ret
    可以看到参数被保存到栈中。
    ⚠️:现场保护包含:FPLR参数返回值

    总结

      • 是一种具有特殊的访问方式的存储空间(后进先出,LIFO)
      • SP和FP寄存器
        • sp寄存器在任意时刻保存栈顶的地址
        • fp(x29)寄存器属于通用寄存器,在某些时刻利用它保存栈底的地址(嵌套调用)
      • ARM64里面栈的操作16字节对齐
      • 栈读写指令
        • 读:ldr(load register)指令LDR、LDP
        • 写:str(store register)指令STR、STP
      • 汇编练习
        • 指令:
          • sub sp, sp,#0x10 ;拉伸栈空间16个字节
          • stp x0,x1,[sp];往sp所在位置存放x0和x1
          • ldp x0,x1,[sp];读取sp存入x0和x1
          • add sp,#0x10;恢复栈空间
        • 简写:
          • stp x0, x1,[sp,#-0x10]!;前提条件是正好开辟的空间放满栈。先开辟空间,存入值,再改变sp的值。
          • ldp x0,x1,[sp],#0x10
    • bl指令
      • 跳转指令:bl标号,转到标号处执行指令并将下一条指令的地址保存到lr寄存器
      • B代表跳转
      • L代表lr(x30)寄存器
    • ret指令
      • 类似函数中的return
      • 让CPU执行lr寄存器所指向的指令
      • 有跳转需要“保护现场”
    • 函数
      • 函数调用栈
        • ARM64中栈是递减栈,向低地址延伸的栈
        • SP寄存器指向栈顶的位置
        • X29(FP)寄存器指向栈底的位置
      • 函数的参数
        • ARM64中,默认情况下参数是放在X0~X7的8个寄存器中
        • 如果是浮点数,会用浮点寄存器
        • 如果超过8个参数会用栈传递(多过8个的参数在函数调用结束后参数不会释放,相当于局部变量,属于调用方,只有调用方函数执行结束栈平衡后才释放。)
      • 函数的返回值
        • 一般情况下函数的返回值使用X0寄存器保存
        • 如果返回值大于了8个字节(放不下),就会利用内存。写入上一个调用栈内部,用X8寄存器作为参照。
      • 函数的局部变量
        • 使用栈保存局部变量
      • 函数的嵌套调用
        • 会将X29,X30寄存器入栈保护。
        • 同时现场保护的还有:FP,LR,参数,返回值。


    收起阅读 »

    iOS越狱

    一、概述越狱(jailBreak),通过iOS系统安全启动链漏洞,从而禁止掉信任链中负责验证的组件。拿到iOS系统最大权限ROOT权限。iOS系统安全启动链当启动一台iOS设备时,系统首先会从只读的ROM中读取初始化指令,也就是系统的引导程序(事实上所有的操作...
    继续阅读 »

    一、概述

    越狱(jailBreak),通过iOS系统安全启动链漏洞,从而禁止掉信任链中负责验证的组件。拿到iOS系统最大权限ROOT权限。

    iOS系统安全启动链
    当启动一台iOS设备时,系统首先会从只读的ROM中读取初始化指令,也就是系统的引导程序(事实上所有的操作系统启动时都要经过这一步,只是过程略有不同)。这个引导ROM包含苹果官方权威认证的公钥,他会验证底层启动加载器(LLB)的签名,一旦通过验证后就启动系统。LLB会做一些基础工作,然后验证第二级引导程序iBootiBoot启动后,设备就可以进入恢复模式或启动内核。在iBoot验证完内核签名的合法性之后,整个启动程序开始步入正轨:加载驱动程序、检测设备、启动系统守护进程。这个信任链会确保所有的系统组件都有苹果官方写入、签名、分发,不能来自第三方机构。


    越狱 的工作原理正是攻击这一信任链。所有的越狱工具的作者都需要找到这一信任链上的漏洞,从而禁止掉信任链中负责验证的组件。拿到iOS系统最大权限ROOT权限。

    熟悉越狱的都听说过 完美越狱 和 非完美越狱

    • 完美越狱:所谓完美越狱就是破解iOS系统漏洞之后,每次系统重启都能自动调用注入的恶意代码,达到破坏安全验证,再次获得ROOT权限。

    • 非完美越狱:所谓非完美越狱是指越狱系统后,并没有完全破解安全链,有部分信息或功能应用不佳;比如关机以后必须去连接越狱软件来引导开机;或者重启会导致越狱的失效;这样的越狱称为 不完美越狱

    目前iOS10以上没有完美越狱工具开放出来,iOS10以下有。目前比较靠谱的两个越狱工具:uncOver 和 Odyssey


    二、unc0ver越狱


    macOSunc0ver有3种越狱方式,这里使用Xcode重签名的方式越狱。其它方式参考官网方式就可以了。

    2.1 环境配置

    • Xcode
    • unc0ver
    • iOS App Signer(️:脚本/Monkey方式不需要这个)

    #1.网站下载
    https://dantheman827.github.io/ios-app-signer/
    #2.命令安装
    sudo gem install sigh
    • 设备 iPhone7 14.0(需要确保设备在自己的账号下)

    2.2 工程配置

    1.安装好Xcode并且新建一个iOS App
    确保自己的设备加入到自己的账号中(我这里使用免费账号)

    2.连接手机build新建的iOS App到设备
    在这个过程中需要手机信任证书(设置->通用->描述文件与设备管理


    2.3 方式一:iOS App Signer 重签名

    1.导出embedded.mobileprovision
    个人开发者账号有效期为7天,由于个人开发者账号苹果官网没有提供导出入口,需要build成功后在products app 中拷贝。如果有付费账号直接官网导出就可以了。



    2.Signer重签名
    Input File为要重签名的ipa包,这里是下载好的unc0ver,证书选择自己的证书(免费开发者账号也可以,有效期7天,前提是自己的设备已经加入免费账号并且导出.mobileprovision)。当然有企业证书是最好的。




    3.Xcode安装重签名后的unc0ver
    Xcode中打开Window → Devices and Simulatorscommand + shift +2),然后在Installed Apps中拖入重签名的unc0ver进行安装。


    4.打开unc0ver进行越狱
    越狱成功后桌面会出现CydiaSubstitute。没有出现的话uncover重新操作一遍。

    2.4 方式二:脚本重签名

    1.项目根目录下创建IPA文件夹并将unc0ver ipa包拷贝放到目录中




    2.根目录下创建appResign.sh重签名脚本
    脚本内容如下:

    # SRCROOT 为工程所在目录,Temp 为创建的临时存放 ipa 解压文件的文件夹。
    TEMP_PATH="${SRCROOT}/Temp"
    # APP 文件夹,存放要重签名的ipa包。
    IPA_PATH="${SRCROOT}/IPA"
    #重签名 ipa 包路径
    TARGRT_IPA_PATH="${IPA_PATH}/*.ipa"

    #清空 Temp 文件夹,重新创建目录
    rm -rf "$TEMP_PATH"
    mkdir -p "$TEMP_PATH"



    #1.解压 ipa 包到 Temp 目录下
    unzip -oqq "$TARGRT_IPA_PATH" -d "$TEMP_PATH"
    #获取解压后临时 App 路径
    TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
    echo "临时App路径:$TEMP_APP_PATH"

    #2.将解压出来的 .app 拷贝到工程目录,
    # BUILT_PRODUCTS_DIR 工程生成的App包路径
    # TARGET_NAME target 名称
    TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
    echo "app路径:$TARGET_APP_PATH"

    #删除工程自己创建的 app
    rm -rf "$TARGET_APP_PATH"
    mkdir -p "$TARGET_APP_PATH"
    #拷贝解压的临时 Temp 文件到工程目录
    cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"

    #3.删除 extension 和 WatchAPP。个人证书无法签名 Extention
    rm -rf "$TARGET_APP_PATH/PlugIns"
    rm -rf "$TARGET_APP_PATH/Watch"


    #4.更新 info.plist 文件 CFBundleIdentifier
    # 设置:"Set :KEY Value" "目标文件路径"
    /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"


    #5. macho 文件加上可执行权限。
    #获取 macho 文件路径
    APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
    #加上可执行权限
    chmod +x "$TARGET_APP_PATH/$APP_BINARY"


    #6.重签名第三方 FrameWorks
    TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
    if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
    then
    for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
    do
    #签名
    /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
    done
    fi

    3.在Xcode工程中配置重签名脚本



    4.重新build工程到手机上。

    5.打开unc0ver进行越狱
    越狱成功后桌面会出现CydiaSubstitute。没有出现的话uncover重新操作一遍。

    2.5 方式三:MonkeyDev


    通过Monkey可以帮助我们自动重签名,只需要准备好要签名的包和配置好证书运行工程就可以了

    Settings -> Restore RootFS 可以恢复到未越狱状态(越狱相关的内容会被删干净)





    越狱前最好在设置中勾选OpenSSH选项,一个连接手机的工具。



    三、Odyssey越狱

    Odysseyunc0ver越狱流程差不多,推荐使用Monkey。区别是Odyssey安装好后的应用商店是Sileounc0verCydia。更推荐使用unc0ver

    ️越狱注意事项:

    • odyssey 越狱中断开网络开始执行越狱,等需要开启网络的时候再联网。
    • 两种越狱方式都在安装好包后断开Xcode连接再进行越狱操作。(Xcode启动应用是附加的状态)
    • 在越狱的过程中遇到任何错误重新恢复手机再尝试。
    • iOS10以下设备直接用爱思助手越狱。
    • 恢复和越狱出错的情况下请删除unc0ver重新安装尝试。

    总结

    越狱:通过破解iOS的安全启启动链的漏洞,拿到iOSRoot权限。

    • 完美越狱:每次系统重新启动都会再次进入越狱状态。
    • 非完美越狱:没有完全破解,一般重启以后会失去越狱环境。

    附系统查询:






















































    作者:HotPotCat
    链接:https://www.jianshu.com/p/2ded2dc425cc










    收起阅读 »

    什么是库(Library)?

    常见库文件格式:.a,.dylib,.framework,.xcframework,.tdb什么是库(Library)?库(Library)本质上就是一段编译好的二进制代码,加上头文件就可以供别人使用。应用场景?某些代码需要给别人使用,但是不希望别人看到源码,...
    继续阅读 »

    常见库文件格式:.a.dylib.framework.xcframework.tdb

    什么是库(Library)?

    库(Library)本质上就是一段编译好的二进制代码,加上头文件就可以供别人使用。

    应用场景?

    1. 某些代码需要给别人使用,但是不希望别人看到源码,就需要以库的形式进行封装,只暴露出头文件。
    2. 对于某些不会进行大的改动的代码,我们想减少编译的时间,就可以把它打包成库,因为库是已经编译好的二进制了,编译的时候只需要 Link 一下,不会浪费编译时间。

    什么是链接(Link)?

    库在使用的时候需要链接(Link),链接 的方式有两种:

    1. 静态
    2. 动态

    静态库

    静态库即静态链接库:可以简单的看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的文件。Windows 下的 .libLinux 和 Mac 下的 .aMac独有的.framework

    缺点: 浪费内存和磁盘空间,模块更新困难。

    静态库链接

    将一份AFNetworking静态库文件(.h头文件和.a组成)和test.m放到统一目录。test.m如下:

    #import <Foundation/Foundation.h>
    #import <AFNetworking.h>

    int main(){
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    NSLog(@"test----%@", manager);
    return 0;
    }


    直接终端查看下.a静态库究竟是什么。

    ➜  AFNetworking file libAFNetworking.a
    libAFNetworking.a: current ar archive

    可以看到.a实际上是一个文档格式。也就是.o文件的合集。可以通过ar命令验证下。

    ar -- create and maintain library archives

    ➜  AFNetworking ar -t libAFNetworking.a
    __.SYMDEF
    AFAutoPurgingImageCache.o
    AFHTTPSessionManager.o
    AFImageDownloader.o
    AFNetworkActivityIndicatorManager.o
    AFNetworking-dummy.o
    AFNetworkReachabilityManager.o
    AFSecurityPolicy.o
    AFURLRequestSerialization.o
    AFURLResponseSerialization.o
    AFURLSessionManager.o
    UIActivityIndicatorView+AFNetworking.o
    UIButton+AFNetworking.o
    UIImageView+AFNetworking.o
    UIProgressView+AFNetworking.o
    UIRefreshControl+AFNetworking.o
    WKWebView+AFNetworking.o
    确认.a确实是.o文件的合集。清楚了.a后将AFNetworking链接到test.m文件。
    1.通过clangtest.m编译成目标文件.o

    clang - the Clang C, C++, and Objective-C compiler
    DESCRIPTION
    clang is a C, C++, and Objective-C compiler which encompasses prepro-
    cessing, parsing, optimization, code generation, assembly, and linking.
    Depending on which high-level mode setting is passed, Clang will stop
    before doing a full link. While Clang is highly integrated, it is
    important to understand the stages of compilation, to understand how to
    invoke it. These stages are:
    Driver The clang executable is actually a small driver which controls
    the overall execution of other tools such as the compiler,
    assembler and linker. Typically you do not need to interact
    with the driver, but you transparently use it to run the other
    tools.
    通过man命令我们看到clangCC++OC编译器,是一个集合包含了预处理解析优化代码生成汇编化链接

    clang -x objective-c \
    -target x86_64-apple-ios14-simulator \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk \
    -I ./AFNetworking \
    -c test.m -o test.o

    回车后就生成了test.o目标文件。

    \为了转译回车,让命令换行更易读。-x制定编译语言,-target指定编译平台,-fobjc-arc编译成ARC-isysroot指定用到的Foundation的路径,-I<directory>在指定目录寻找头文件 header search path

    为什么生成目标文件只需要告诉头文件的路径就可以了?
    因为在生成目标文件的时候,重定位符号表只需要记录哪个地方的符号需要重定位。在连接的时候链接器会自动重定位。(上面的例子中只需要保留AFHTTPSessionManager的符号。)
    2..o生成可执行文件

    clang -target x86_64-apple-ios14-simulator \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk \
    -L./AFNetworking \
    -lAFNetworking \
    test.o -o test

    这个时候test可执行程序就生成了。

    -L要链接的库文件(libAFNetworking.a)目录,-l要链接的库文件(libAFNetworking.a)这里只写AFNetworking是有查找规则的:先找lib+<library_name>的动态库,找不到,再去找lib+<library_name>的静态库,还找不到,就报错。会自动去找libAFNetworking

    经过上面的编译和链接清楚了其它参数都是固定的,那么链接成功一个库文件有3个要素:
    1.  -I<directory> 在指定目录寻找头文件 header search path头文件
    2.  -L<dir> 指定库文件路径(.a\.dylib库文件) library search path库文件路径
    3.  -l<library_name> 指定链接的库文件名称(.a\.dylib库文件)other link flags -lAFNetworking (库文件名称

    生成静态库

    将自己的一个工程编译成.a静态库。工程只有一个文件HPExample``.h和 .m

    #import <Foundation/Foundation.h>

    @interface HPExample : NSObject

    - (void)hp_test:(_Nullable id)e;

    @end

    #import "HPExample.h"

    @implementation HPExample

    - (void)hp_test:(_Nullable id)e {
    NSLog(@"hp_test----");
    }

    @end

    HPExample.m编译成.o文件:

    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -I./StaticLibrary \
    -c HPExample.m -o HPExample.o

    这个时候生成了HPExample.o文件,由于工程只有一个.o文件,直接将文件修改为libExample.dylib或者libHPExample.a
    然后创建一个test.m文件调用HPExample:


    #import <Foundation/Foundation.h>
    #import "HPExample.h"

    int main(){
    NSLog(@"testApp----");
    HPExample *manager = [HPExample new];
    [manager hp_test: nil];
    return 0;
    }

    test.m编译成test.o

    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -I./StaticLibrary \
    > -c test.m -o test.o

    test.o链接HPExample

    clang -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -L./StaticLibrary \
    -lHPExample \
    test.o -o test
    现在就已经生成了可执行文件test
    终端lldb执行test:

    ➜  staticLibraryCreat lldb
    (lldb) file test
    Current executable set to '/Users/binxiao/projects/library/staticLibraryCreat/test' (x86_64).
    (lldb) r
    Process 2148 launched: '/Users/binxiao/projects/library/staticLibraryCreat/test' (x86_64)
    2021-02-13 13:22:49.150091+0800 test[2148:13026772] testApp----
    2021-02-13 13:22:49.150352+0800 test[2148:13026772] hp_test----
    Process 2148 exited with status = 0 (0x00000000)
    这也从侧面印证了.a就是.o的合集。file test是创建一个targetr是运行的意思。
    接着再看下libHPExample.a文件。

    objdump --macho --private-header libHPExample.a
    Mach header
    magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
    MH_MAGIC_64 X86_64 ALL 0x00 OBJECT 4 1160 SUBSECTIONS_VIA_SYMBOLS
    确认还是一个目标文件。

    静态库的合并

    根据上面的分析,那么静态库的合并也就是将所有的.o放到一个文件中。
    有两个.a库:
    静态库的合并有两种方式:libAFNetworking.a,libSDWebImage.a
    1.ar -rc libAFNetworking.a libSDWebImage.a

    ar -rc libAFNetworking.a  libSDWebImage.a

    就相当于将后面的libSDWebImage.a合并到libAFNetworking.a

    2.libtool -static -o <OUTPUT NAME> <LIBRARY_1> <LIBRARY_2>
    libtool合并静态库。

    libtool -static \
    -o \
    libMerge.a \
    libAFNetworking.a \
    libSDWebImage.a
    //libAFNetworking.a要为目标文件路径,libMerge.a为输出文件
    这样就合并了libAFNetworking.alibSDWebImage.alibMerge.a了。在这个过程中libtool会先解压两个目标文件,然后合并。在合并的过程中有两个问题:
    1.冲突问题。
    2..h文件。

    clang提供了mudule可以预先把头文件(.h)预先编译成二进制缓存到系统目录中, 再去编译.m的时候就不需要再去编译.h了。

    LC_LINKER_OPTION链接器的特性,Auto-Link。启用这个特性后,当我们import <模块>,不需要我们再去往链接器去配置链接参数。比如import <framework>我们在代码里使用这个framework格式的库文件,那么在生成目标文件时,会自动在目标文件的Mach-O中,插入一个 load command格式是LC_LINKER_OPTION,存储这样一个链接器参数-framework <framework>

    动态库

    与静态库相反,动态库在编译时并不会被拷⻉到目标程序中,目标程序中只会存储指向动态库的引用。等到程序运行时,动态库才会被真正加载进来。格式有:.framework.dylib.tdb

    缺点:会导致一些性能损失。但是可以优化,比如延迟绑定(Lazy Binding)技术。

    .tdb

    tbd全称是text-based stub libraries本质上就是一个YAML描述的文本文件。他的作用是用于记录动态库的一些信息,包括导出的符号、动态库的架构信息、动态库的依赖信息。用于避免在真机开发过程中直接使用传统的dylib。对于真机来说,由于动态库都是在设备上,在Xcode上使用基于tbd格式的伪framework可以大大减少Xcode的大小。

    framework

    Mac OS/iOS 平台还可以使用 FrameworkFramework 实际上是一种打包方式,将库的二进制文件、头文件和有关的资源文件打包到一起方便管理和分发。

    Framework 和系统的 UIKit.Framework 还是有很大区别。系统的 Framework 不需要拷⻉到目标程序中,我们自己做出来的 Framework 哪怕是动态的,最后也还是要拷⻉到 App 中(App 和 Extension 的 Bundle 是共享的),因此苹果又把这种 Framework 称为 Embedded Framework

    Embedded Framework

    开发中使用的动态库会被放入到ipa下的framework目录下,基于沙盒运行。
    不同的App使用相同的动态库,并不会只在系统中存在一份。而是会在多个App中各自打包、签名、加载一份。


    framework即可以代表动态库也可以代表静态库。

    生成framework




    编译test.m

    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -I ./Frameworks/HPExample.framework/Headers \
    -c test.m -o test.o
    链接.o生成test可执行文件
    clang -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -F./Frameworks \
    -framework HPExample \
    test.o -o test
    那么链接一个framework也就需要三个条件:
    1.  -I<directory>:在指定目录寻找头文件 header search path(头文件)
    2. -F<directory>:在指定目录寻找framework framework search path
    3. -framework <framework_name>:指定链接的framework名称 other link flags -framework AFNetworking


    脚本执行命令

    上面都是通过命令行来进行编译连接的,每次输入都很麻烦(即使粘贴复制),我们可以将命令保存在脚本中,通过执行脚本来执行命令。
    还是以HPExample为例,整理后脚本如下(可以加一些日志观察执行问题):

    echo "test.m -> test.o"
    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -I ./StaticLibrary \
    -c test.m -o test.o

    echo "pushd -> StaticLibrary"
    #cd可以进入到一个目录不推荐使用,cd会修改目录栈上层,推荐使用 pushd,pushd是往目录栈中push一个目录。
    pushd ./StaticLibrary

    echo "HPExample.m -> HPExample.o"
    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -I./StaticLibrary \
    -c HPExample.m -o HPExample.o

    echo "HPExample.o -> libHPExample.a"
    #打包.o成静态库
    ar -rc libHPExample.a HPExample.o
    echo "popd -> StaticLibrary"
    popd

    echo "test.o -> test"
    #链接
    clang -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk \
    -L./StaticLibrary \
    -lHPExample \
    test.o -o test
    这个时候就已经自动编译链接完成了,其中路径是pushdpopd自动生成的。
    可以简单优化下脚本:

    SYSROOT=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.0.sdk
    #${SYSROOT}和$SYSROOT都行,如果要匹配比如${SYSROOT}.mm则用{}
    FILE_NAME=test
    HEADER_SEARCH_PATH=./StaticLibrary

    function MToOOrExec {
    if [[ $2 == ".m" ]]; then
    clang -x objective-c \
    -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot ${SYSROOT} \
    -I${HEADER_SEARCH_PATH} \
    -c $1.m -o $1.o
    else
    clang -target x86_64-apple-macos11.0 \
    -fobjc-arc \
    -isysroot ${SYSROOT} \
    -L${HEADER_SEARCH_PATH} \
    -l$1 \
    ${FILE_NAME}.o -o ${FILE_NAME}
    fi
    return 0
    }

    echo "test.m -> test.o"
    MToOOrExec ${FILE_NAME} ".m"
    echo "pushd -> StaticLibrary"
    #cd可以进入到一个目录不推荐使用,cd会修改目录栈上层,推荐使用 pushd,pushd是往目录栈中push一个目录。
    pushd ${HEADER_SEARCH_PATH}
    echo "HPExample.m -> HPExample.o"
    MToOOrExec HPExample ".m"
    echo "HPExample.o -> libHPExample.a"
    #打包.o成静态库
    ar -rc libHPExample.a HPExample.o
    echo "popd -> StaticLibrary"
    popd
    echo "test.o -> test"
    #链接
    MToOOrExec HPExample ".o"

    dead code strip

    对于上面的例子,如果我们在test.m中不使用HPExample只是导入。

    #import <Foundation/Foundation.h>
    #import "HPExample.h"

    int main(){
    NSLog(@"test----");
    // HPExample *manager = [HPExample new];
    // [manager hp_test: nil];
    return 0;
    }
    默认clangdead code strip是生效的。
    在有分类的情况下
    看另外一个例子,我们直接用Xcode创建一个framework,设置为静态库。(Targets -> Build Settings -> Linking -> Macho-type)

    这个库有一个HPTestObject以及HPTestObject+HPAdditions。实现如下:
    HPTestObject

    //.h
    #import <Foundation/Foundation.h>

    @interface HPTestObject : NSObject

    - (void)hp_test;

    @end

    //.m
    #import "HPTestObject.h"
    #import "HPTestObject+HPAdditions.h"

    @implementation HPTestObject

    - (void)hp_test {
    [self hp_test_additions];
    }

    @end

    HPTestObject+HPAdditions

    //.h
    #import "HPTestObject.h"

    @interface HPTestObject (HPAdditions)

    - (void)hp_test_additions;

    @end

    //.m
    #import "HPTestObject+HPAdditions.h"

    @implementation HPTestObject (HPAdditions)

    - (void)hp_test_additions {
    NSLog(@"log: hp_test_additions");
    }

    @end
    HPTestObject设置为public

    我们知道分类是在运行时动态创建的,dead code strip是在链接的过程中生效的。那么应该在链接的时候会strip掉分类。
    我们创建一个workspace验证下

    workspace
    A. 可重用性。多个模块可以在多个项目中使用。节约开发和维护时间。
    B. 节省测试时间。单独模块意味着每个模块中都可以添加测试功能。
    C. 更好的理解模块化思想。

    1.File -> save as workspace




    2.创建一个project(TestApp)。



    3.打开workspace,添加一个project(创建的TestApp)(⚠️需要关闭打开的文件才会出现Add Files to TestDeadCodeStrip):





    4.ViewController.m中使用HPTestObject

    #import <HPStaticFramework/HPTestObject.h>

    - (void)viewDidLoad {
    [super viewDidLoad];
    HPTestObject *hpObject = [HPTestObject new];
    [hpObject hp_test];
    }

    5.运行

    libc++abi.dylib: terminating with uncaught exception of type NSException
    *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[HPTestObject hp_test_additions]: unrecognized selector sent to instance 0x600001048020'
    terminating with uncaught exception of type NSException

    和预想的一样直接报错了,原因是dead code strip脱掉了分类。要解决问题还是要告诉编译器不要脱。
    6.配置XCConfig告诉编译器不要脱。

    //-Xlinker 告诉 clang -all_load 参数是传给 ld 的。
    OTHER_LDFLAGS=-Xlinker -all_load

    再次运行App:

     TestApp[8958:13347736] log: hp_test_additions

    ⚠️
    -Xlinker 告诉 clang -all_load 参数是传给ld的。
    -all_load:全部链接

    OTHER_LDFLAGS=-Xlinker -all_load

    -ObjCOC相关的代码不要剥离

    //OTHER_LDFLAGS=-Xlinker -ObjC

    -force_load:指定哪些静态库不要 dead strip

    HPSTATIC_FRAMEWORK_PATH=${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/HPStaticFramework.framework/HPStaticFramework
    OTHER_LDFLAGS=-Xlinker -force_load $HPSTATIC_FRAMEWORK_PATH

    -noall_load: 默认,没有使用静态库代码则不往可执行文件添加。This is the default. This option is obsolete.

    以上4种参数仅针对静态库。dead code strip是在链接过程中连接器提供的优化方式。

    -dead_strip
    Remove functions and data that are unreachable by the entry point or exported symbols.
    移除没有被入口点(也就是main)和导出符号用到的代码。

    接着libraryDeadCodeStrip工程验证下:
    修改test.m如下:


    #import <Foundation/Foundation.h>
    //#import "HPExample.h"

    //全局函数
    void global_function() {

    }

    //entry point
    int main(){
    // global_function();
    NSLog(@"test----");
    // HPExample *manager = [HPExample new];
    // [manager hp_test: nil];
    return 0;
    }

    //本地
    static void static_function(){

    }

    运行build.sh
    可以看到没有静态库libHPExample.a相关的代码,加上all_load再查看下:
    build.sh修改增加

    -Xlinker -all_load \
    hp_test方法已经有了。
    修改-Xlinker -all_load-Xlinker -dead_strip 再查看下:

    global_functionhp_test都没有了。
    打开mainglobal_function()的注释再看下:

    所以dead code strip-all_load-ObjC-force_load-noall_load不是一个东西,他有一定规则:

    1. 入口点没有使用->干掉
    2. 没有被导出符号使用->干掉

    接着-Xlinker -dead_strip-Xlinker -all_load一起添加:


    链接器有一个参数-why_live可以查看某一个符号为什么没有被干掉,比如我们要知道global_function为什么没有被干掉:
        -Xlinker -why_live -Xlinker _global_function

    .o -> .o.o -> .a

    .o -> .o是合并成一个大的.o再去链接生成可执行文件。先组合再链接。所以这里dead code strip干不掉,可以通过LTO(Link-Time Optimization)去优化。
    .o链接静态库是.o是去使用静态库。先dead code strip再使用。





  • Do Not Embed
    用于静态库
  • Embed & Sign
    嵌入,用于动态库,动态库在运行时链接,所以它们编译的时候需要被打进bundle里面。静态库链接的时候代码就已经在一起了,所以不需要拷贝,直接Do Not Embed就可以了。可以通过file命令验证:

  • file HPStaticFramework.framework/HPStaticFramework
    HPStaticFramework.framework/HPStaticFramework: current ar archive random library

    current ar archive:说明是静态库,选择Do not embed
    Mach-0 dynamically:说明是动态库,选择Embed

    1. Embed Without Signing
      Signing:只用于动态库,如果已经有签名了就不需要再签名。终端执行codesign -dv判断:

    codesign -dv HPStaticFramework.framework
    Executable=/Users/***/Library/Developer/Xcode/DerivedData/TestDeadCodeStrip-fhbiunbplvqefkftfystdixdxmkq/Build/Products/Debug-iphonesimulator/HPStaticFramework.framework/HPStaticFramework
    Identifier=HotpotCat.HPStaticFramework
    Format=bundle with generic
    CodeDirectory v=20100 size=204 flags=0x2(adhoc) hashes=1+3 location=embedded
    Signature=adhoc
    Info.plist entries=20
    TeamIdentifier=not set
    Sealed Resources version=2 rules=10 files=2
    Internal requirements count=0 size=12

    命令总结

    clang命令参数

    -x: 指定编译文件语言类型
    -g: 生成调试信息
    -c: 生成目标文件,只运行preprocesscompileassemble不链接
    -o: 输出文件
    -isysroot: 使用的SDK路径
    -I<directory>: 在指定目录寻找头文件 header search path
    -L<directory> :指定库文件路径(.a.dylib库文件)library search path
    -l<library_name>: 指定链接的库文件名称(.a.dylib库文件)other link flags -lAFNetworking。链接的名称为libAFNetworking/AFNetworking的动态库或者静态库,查找规则:先找lib+<library_name>的动态库,找不到,再去找lib+<library_name>的静态库,还找不到,就报错。
    -F<directory>: 在指定目录寻找framework,framework search path
    -framework <framework_name>: 指定链接的framework名称,other link flags -framework AFNetworking

    test.m编译成test.o过程

    1. 使用OC
    2. 生成指定架构的代码,Big Sur是:x86_64-apple-macos11.1,之前是:x86_64-apple-macos10.15。iOS模拟器是:x86_64-apple-ios14-simulator。更多内容可以参考target部分。
    3. 使用ARC
    4. 使用的SDK的路径在:
      Big Sur是:/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.1.sdk
      之前是:/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk
      模拟器是:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk \
      更多内容可以参考sdk部分。
    5. 用到的其他库的头文件地址在./Frameworks
      命令示例:
    clang -x objective-c \
    -target x86_64-apple-ios14-simulator \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk \
    -I ./AFNetworking \
    -c test.m -o test.o

    test.o链接生成test可执行文件

    clang链接.a静态库

    顺序和生成.o差不多,不需要指定语言。
    命令示例:

    clang -target x86_64-apple-ios14-simulator \
    -fobjc-arc \
    -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk \
    -L./AFNetworking \
    -lAFNetworking \
    test.o -o test

    ld链接.framework静态库

    ld -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk \
    -lsystem -framework Foundation \
    -lAFNetworking \
    -L.AFNetworking \
    test.o -o test





    作者:HotPotCat
    链接:https://www.jianshu.com/p/298efeb8732c










    收起阅读 »

    Mac终端快捷键

    编辑命令行

    快捷键说明
    control + k删除从光标到行尾
    control + u删除从光标到行首
    control + w从光标向前一个单词剪切到剪贴板
    option + d从光标向后删除一个单词。⚠️option键需要自己配置。详见后面[终端option键配置]
    control + d删除光标下一个字母
    control + h删除光标前一个字母
    option + tswap(当前单词,上一个单词),尾部会交换前两个单词
    control + tswap(当前字母,上一个字母)
    control + y粘贴上一次删除的文本
    option + c大写当前字母,并移动光标到单词尾
    option + u大写从当前光标到单词尾
    option + c小写从当前光标到单词尾,光标后的第一个字母会大写
    control + r向后搜索历史命令,control + r后输入关键字比如i然后再control + r一直往上查找,当然也可以通过control + pcontrol + n配合查找
    control + g退出搜索
    control + p历史中上一个命令
    control + n历史中下一个命令
    option + .上一个命令的最后一个单词
    control + l / command + k清屏,当前命令前面的所有内容
    control + s停止输出(zsh中为向前搜索历史命令)
    control + q继续输出
    control + c终止当前命令
    control + z挂起当前命令
    control + d结束输出(产生一个EOF
    control + a移动光标到行首
    control + e移动光标到行尾
    option + b移动光标后退一个单词(词首)
    option + f移动光标前进一个单词(词首)
    control + b光标前进一个字母(这两个没什么实际意义,通过左右箭头就可以操作了-><-
    control + f光标后退一个字母
    control + xx当前位置与行首之间选中
    control + -撤销,类似macOS系统的control +z
    option + r取消更改,并恢复历史记录中的行(还原)
    esc + t1.光标在行尾交换光标前的最后两个单词。
    2.在中间交换光标前后单词。
    3.在行首无效。
    !!重复上一条命令,类似上箭头
    !n交换光标前的最后两个单词
    !:n-m重复最后一条命令取参数n-m,比如:!:3-4
    !:n-$重复最后一条命令取参数n-最后,比如:!:3-$
    !:q引用最后一条命令,相当于分割单词
    !:q命令
    !$上一条命令的最后一个参数
    !*上一条命令的所有参数
    !*命令
    option + 方向键光标以单词为单位移动(仅在Terminal有效,iTerm无效
    command + fn + 左/右箭头滚动到顶部/底部
    command + fn + 上/下箭头上/下一页
    optional + command + fn + 上/下箭头上/下一行
    delete/fn + delete向前/后删除一个字符

    分屏

    快捷键说明
    command + d分屏
    1.在mac默认终端Terminal下是上下分屏,显示内容一致。
    2.在iTerm下是横向分屏相当于多个终端
    command + shift + d
    1.在mac默认终端Terminal下是取消分屏。
    2.在iTerm下是纵向分屏

    标签&窗口

    快捷键说明
    command + t新建标签
    command + w关闭标签
    command + shift + 左右箭头/control + tab/control + shift + tab选择标签
    command + shift + |mac默认终端Terminal下有效。相当于Mac触摸板的四指上滑 (调度中心) 
    image.png
    command + n新建窗口
    shift + command + t显示或隐藏标签页栏
    隐藏
    显示
    shift + command + n新建命令(Terminal下有效)
    shift + command + k新建远程连接(Terminal下有效)
    command + i显示或隐藏检查器(Terminal下有效) 
    image.png
    command + +/-放大/缩小字体
    command + 重音符/command + shift + 重音符下/上一个窗口,重音符(`)


    1.使用“终端”窗口和标签页

    操作
    快捷键
    新建窗口
    Command-N
    使用相同命令新建窗口
    Control-Command-N
    新建标签页
    Command-T
    使用相同命令新建标签页
    Control-Command-T
    显示或隐藏标签页栏
    Shift-Command-T
    显示所有标签页或退出标签页概览
    Shift-Command-反斜杠 (\)
    新建命令
    Shift-Command-N
    新建远程连接
    Shift-Command-K
    显示或隐藏检查器
    Command-I
    编辑标题
    Shift-Command-I
    编辑背景颜色
    Option-Command-I
    放大字体
    Command-加号键 (+)
    缩小字体
    Command-减号键 (–)
    下一个窗口
    Command-重音符键 (`)
    上一个窗口
    Command-Shift-波浪符号 (~)
    下一个标签页
    Control-Tab
    上一个标签页
    Control-Shift-Tab
    将窗口拆分为两个面板
    Command-D
    关闭拆分面板
    Shift-Command-D
    关闭标签页
    Command-W
    关闭窗口
    Shift-Command-W
    关闭其他标签页
    Option-Command-W
    全部关闭
    Option-Shift-Command-W
    滚动到顶部
    Command-Home
    滚动到底部
    Command-End
    上一页
    Command-Page Up
    下一页
    Command-Page Down
    上一行
    Option-Command-Page Up
    下一行
    Option-Command-Page Down


    2.编辑命令行

    操作
    快捷键
    重新定位插入点
    在按住 Option 键的同时将指针移到新的插入点。
    将插入点移到行的开头
    Control-A
    将插入点移到行的结尾
    Control-E
    将插入点前移一个字符
    右箭头键
    将插入点后移一个字符
    左箭头键
    将插入点前移一个字词
    Option-右箭头键
    将插入点后移一个字词
    Option-左箭头键
    删除到行的开头
    Control-U
    删除到行的结尾
    Control-K
    向前删除到字词的结尾
    Option-D(选中将 Option 键用作 Meta 键后可用)
    向后删除到字词的开头
    Control-W
    删除一个字符
    Delete
    向前删除一个字符
    向前删除(或使用 Fn-Delete)
    转置两个字符
    Control-T


    3.在“终端”窗口中选择和查找文本

    操作
    快捷键
    选择完整文件路径
    按住 Shift-Command 键并连按路径
    选择整行文本
    点按该行三下
    选择一个词
    连按该词
    选择 URL
    按住 Shift-Command 键并连按 URL
    选择矩形块
    按住 Option 键并拖移来选择文本
    剪切
    Command-X
    拷贝
    Command-C
    不带背景颜色拷贝
    Control-Shift-Command-C
    拷贝纯文本
    Option-Shift-Command-C
    粘贴
    Command-V
    粘贴所选内容
    Shift-Command-V
    粘贴转义文本
    Control-Command-V
    粘贴转义的所选内容
    Control-Shift-Command-V
    查找
    Command-F
    查找下一个
    Command-G
    查找上一个
    Command-Shift-G
    使用选定的文本查找
    Command-E
    跳到选定的文本
    Command-J
    全选
    Command-A
    打开字符检视器
    Control-Command-Space


    4.使用标记和书签

    操作
    快捷键
    标记
    Command-U
    标记为书签
    Option-Command-U
    取消标记
    Shift-Command-U
    标记命令行并发送返回结果
    Command-Return
    发送返回结果但不标记
    Shift-Command-Return
    插入书签
    Shift-Command-M
    插入包含名称的书签
    Option-Shift-Command-M
    跳到上一个标记
    Command-上箭头键
    跳到下一个标记
    Command-下箭头键
    跳到上一个书签
    Option-Command-上箭头键
    跳到下一个书签
    Option-Command-下箭头键
    清除到上一个标记
    Command-L
    清除到上一个书签
    Option-Command-L
    清除到开头
    Command-K
    在标记之间选择
    Shift-Command-A


    5.其他快捷键

    操作
    快捷键
    进入或退出全屏幕
    Control-Command-F
    显示或隐藏颜色
    Shift-Command-C
    打开“终端”偏好设置
    Command-逗号键 (,)
    中断
    键入 Command-句点键 (.) 等于在命令行上输入 Control-C
    打印
    Command-P
    软重置终端仿真器状态
    Option-Command-R
    硬重置终端仿真器状态
    Control-Option-Command-R
    打开 URL
    按住 Command 键并连按 URL
    添加至文件的完整路径
    从“访达”将文件拖移到“终端”窗口中
    将文本导出为
    Command-S
    将选定的文本导出为
    Shift-Command-S
    反向搜索命令历史
    Control-R
    开关“允许鼠标报告”选项
    Command-R
    开关“将 Option 键用作 Meta 键”选项
    Command-Option-O
    显示备用屏幕
    Option-Command-Page Down
    隐藏备用屏幕
    Option-Command-Page Up
    打开所选内容的 man 页面
    Control-Shift-Command-问号键 (?)
    搜索所选内容的 man 页面索引
    Control-Option-Command-斜杠 (/)
    完整的目录或文件名称
    在命令行上,键入一个或多个字符,然后按下 Tab 键
    显示可能的目录或文件名称补全列表
    在命令行上,键入一个或多个字符,然后按下 Tab 键两次


    MacHomeEndPageUPPageDOWN

    • Home = Fn + 左方向
    • End = Fn + 右方向、
    • PageUP = Fn + 上方向
    • PageDOWN = Fn + 下方向
    • 向前Delete = Fn + delete

    终端option键配置

    将 Option 键用作 Meta 键

    Terminal配置

    Preferences -> Profiles -> 将optional键用作Meta键

    Terminal配置>

    iTerm配置

    iTerm需要在Preferences -> Profiles -> "your Profile" -> Keys -> left/right option key ->Esc+配置。
    ⚠️这里是配置成Esc+不是Meta




     

    作者:HotPotCat
    开码牛

    链接:https://www.jianshu.com/p/524d02ee49cf

    https://blog.csdn.net/helunqu2017/article/details/113749611

     

    Xcode多环境配置

    Xcode多环境配置一共有3种形式:TargetSchemexcconfigProject:包含了项目所有的代码、资源文件、所有信息。(一个项目是多个project的集合)Target:对指定代码和资源文件的具体构建方式。(指定某些代码如何生成ipa包,类似打...
    继续阅读 »

    Xcode多环境配置一共有3种形式:

    • Target
    • Scheme
    • xcconfig

    Project:包含了项目所有的代码、资源文件、所有信息。(一个项目是多个project的集合)
    Target:对指定代码和资源文件的具体构建方式。(指定某些代码如何生成ipa包,类似打工人的角色)
    Scheme:对指定Target的环境配置。(配置编译环境变量)
    这也就是我们修改一些配置的时候需要选中Target再去修改的原因。

    多Target配置

    在项目中选中Target复制就生成新的Target了。




    相当于可以直接分别配置Info.plist文件,在Target中修改bundleId后就相当于两个Target是两个App了。
    同时可以在Preprocessor Macros中配置一些宏定义用于代码中区分Target



    #if DEV
    #import "TestMutableConfig_dev-Swift.h"
    #else
    #import "TestMutableConfig-Swift.h"
    #endif


    ~  swiftc --help | grep -- '-D'
    -D <value> Marks a conditional compilation flag as true


    Target方式配置多环境
    1.会生成多个Info.plist文件;
    2.配置比较繁琐,需要同步配置容易混乱
    那么对于多Target的场景是可以在Build Phases中控制要编译的文件和资源。



    多scheme配置

    scheme默认有DebugReleaseconfig我们可以按需添加。在Target中添加变量的时候已经用到过了。
    配置在Project -> Info -> Configurations




    运行/打包的时候选择对应的Scheme就可以了。



    这个时候只需要切换Scheme运行就可以了。

    比如我们上传打包ipa的时候,有时候会错将debug模式下的包上传上去,尤其是在发灰度包的时候。这里有两个方案:
    1.通过config配置。
    2.打包的时候通过脚本修改info.plist文件增加一个变量。

    release包赋值为0debug包赋值为1。这样在上传ipa包的时候后端读取Info.plist做判断,debug包直接报错不让传。
    这里实现以下方式1:
    Targets -> Build Settings -> + -> Add User-Defined Setting





    到这里就完成了

    • User-Defined添加配置;
    • Info.plist暴露配置的目的。

    在代码中测试下:

        NSString *infoPath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
    NSDictionary *infoDic = [[NSDictionary alloc] initWithContentsOfFile:infoPath];
    NSLog(@"IPAFLAG = %@",infoDic[@"IPAFLAG"]);
    Debug下:

     IPAFLAG = 1
    当然也可以配置app图标:
    Assets.xcassets中添加不同的资源文件


    Scheme情况只需要在一个build setting中就能完成配置了,比多Target方便好维护。
    缺点是还需要在build setting中设置。

    xcconfig


    就是使用xcconfig配置的:

    FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking"
    GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
    HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers"
    LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks'
    OTHER_LDFLAGS = $(inherited) -framework "AFNetworking"
    PODS_BUILD_DIR = ${BUILD_DIR}
    PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
    PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
    PODS_ROOT = ${SRCROOT}/Pods
    USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES

    xcconfigkey-value的形式配置的。那么怎么对应到xcconfig文件的呢?


    Configurations中对应配置的。

    配置自己的xcconfig文件

    新建 -> Configuration Settings File





    • 1中设置是对整个Project生效。
    • 2中设置是对Target生效。

    还是以IPAFLAG为例,以xcconfig的方式配置。
    plist中的配置不变,User-Defined配置删除

        <key>IPAFLAG</key>
    <string>${IPAFLAG}</string>
    Config-TestMutableConfig.Debug.xcconfig

    IPAFLAG = 1

    Config-TestMutableConfig.Release.xcconfig

    IPAFLAG = 0
    代码中读取下:

        NSString *infoPath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
    NSDictionary *infoDic = [[NSDictionary alloc] initWithContentsOfFile:infoPath];
    NSLog(@"IPAFLAG = %@",infoDic[@"IPAFLAG"]);
    IPAFLAG = 1
    这样配置更清晰,便于管理。


    xcconfig配置总结

    key-value 组成

    配置文件由一系列键值分配组成:

    BUILD_SETTING_NAME = value

    注释

    xcconfig文件只有一种注释方式//

    //

    那么这里就有一个问题了,如果我们要配置一个域名该怎么办呢?比如:

    HOST_URL = https://127.0.0.1

    可以通过中间值解决:

    TEMP=/
    HOST_URL = https:${TEMP}/127.0.0.1

    include导入其他设置

    可以通过include关键字导入其他的xcconfig内的配置。通过include关键字后接上双引号:

    #include "Other.xcconfig"

    在引入的文件时,如果是以/开头,代表绝对路径:

    #include "/Users/zaizai/Desktop/TestMutableConfig/Pods/Target Support Files/Pods-TestMutableConfig/Pods-TestMutableConfig.debug.xcconfig"

    相对路径,以${SRCROOT}路径为开始:

    #include "Pods/Target Support Files/Pods-TestMutableConfig/Pods-TestMutableConfig.debug.xcconfig"

    变量

    变量定义,按照OC命名规则,仅由大写字母,数字和下划线_组成,原则上大写,也可以不。字符串可以是"也可以是'号。

    1. xcconfig中定义的变量与Build Settings的一致,会发生覆盖。可以通过$(inherited)让当前变量继承变量原有值。(当然对于系统的key最好都加上$(inherited)`)
    //A config
    OTHER_LDFLAGS = -framework SDWebImage
    //B config
    OTHER_LDFLAGS = $(inherited) -framework AFNetworking
    //build setting中
    // OTHER_LDFLAGS = -framework SDWebImage -framework AFNetworking

    ⚠️:有部分变量不能通过xcconfig配置到Build Settings中。如:配置PRODUCT_BUNDLE_IDENTIFIER不起作用。

    1. 引用变量,$()${}两种写法都可以
    VALUE=HotpotCat

    KEY1=$(VALUE)
    KEY2=${VALUE}
    1. 条件变量,根据SDKArchConfigration对设置进行条件化:
    // 指定`Configration`是`Debug`
    // 指定`SDK`是模拟器,还有iphoneos*、macosx*等
    // 指定生效架构为`x86_64`
    OTHER_LDFLAGS[config=Debug][sdk=iphonesimulator*][arch=x86_64]= $(inherited) -framework "HotpotCat"

    ⚠️:在Xcode 11.4及以后版本,可以使用default来指定变量为空时的默认值。

    $(BUILD_SETTING_NAME:default=value)

    优先级(高->低)

    • 手动配置Target Build Settings;
    • Target中配置的xcconfig文件;
    • 手动配置Project Build Settings;
    • Project中配置的xcconfig文件。



    作者:HotPotCat
    链接:https://www.jianshu.com/p/ca0ac4ff4fc1
    收起阅读 »

    llvm优化alloc

    为什么调用alloc最终调用了objc_alloc?objc源码中探索分析在源码中我们点击alloc会进入到+ (id)alloc方法,但是在实际调试中却是先调用的objc_alloc,系统是怎么做到的呢?可以看到在这个方法中进行了imp的重新绑定将alloc...
    继续阅读 »

    为什么调用alloc最终调用了objc_alloc


    objc源码中探索分析

    在源码中我们点击alloc会进入到+ (id)alloc方法,但是在实际调试中却是先调用的objc_alloc,系统是怎么做到的呢?




    可以看到在这个方法中进行了imp的重新绑定将alloc绑定到了objc_alloc上面。当然retainrelease等都进行了同样的操作。
    既然在_read_images中出现问题的时候尝试进行fixup,那么意味着正常情况下在_read_images之前llvm的编译阶段就完成了绑定。

    llvm源码探索分析

    那么直接在llvm中搜索objc_alloc,在ObjCRuntime.h中发现了如下注释:

      /// When this method returns true, Clang will turn non-super message sends of
    /// certain selectors into calls to the corresponding entrypoint:
    /// alloc => objc_alloc
    /// allocWithZone:nil => objc_allocWithZone

    这说明方向没有错,最中在CGObjC.cpp中找到了如下代码:


      case OMF_alloc:
    if (isClassMessage &&
    Runtime.shouldUseRuntimeFunctionsForAlloc() &&
    ResultType->isObjCObjectPointerType()) {
    // [Foo alloc] -> objc_alloc(Foo) or
    // [self alloc] -> objc_alloc(self)
    if (Sel.isUnarySelector() && Sel.getNameForSlot(0) == "alloc")
    return CGF.EmitObjCAlloc(Receiver, CGF.ConvertType(ResultType));
    // [Foo allocWithZone:nil] -> objc_allocWithZone(Foo) or
    // [self allocWithZone:nil] -> objc_allocWithZone(self)
    if (Sel.isKeywordSelector() && Sel.getNumArgs() == 1 &&
    Args.size() == 1 && Args.front().getType()->isPointerType() &&
    Sel.getNameForSlot(0) == "allocWithZone") {
    const llvm::Value* arg = Args.front().getKnownRValue().getScalarVal();
    if (isa<llvm::ConstantPointerNull>(arg))
    return CGF.EmitObjCAllocWithZone(Receiver,
    CGF.ConvertType(ResultType));
    return None;
    }
    }
    break;

    可以看出来alloc最后执行到了objc_alloc。那么具体的实现就要看CGF.EmitObjCAlloc方法:

    llvm::Value *CodeGenFunction::EmitObjCAlloc(llvm::Value *value,
    llvm::Type *resultType) {
    return emitObjCValueOperation(*this, value, resultType,
    CGM.getObjCEntrypoints().objc_alloc,
    "objc_alloc");
    }

    llvm::Value *CodeGenFunction::EmitObjCAllocWithZone(llvm::Value *value,
    llvm::Type *resultType) {
    return emitObjCValueOperation(*this, value, resultType,
    CGM.getObjCEntrypoints().objc_allocWithZone,
    "objc_allocWithZone");
    }

    llvm::Value *CodeGenFunction::EmitObjCAllocInit(llvm::Value *value,
    llvm::Type *resultType) {
    return emitObjCValueOperation(*this, value, resultType,
    CGM.getObjCEntrypoints().objc_alloc_init,
    "objc_alloc_init");
    }

    这里可以看到alloc以及objc_alloc_init相关的逻辑。这样就实现了绑定。那么系统是怎么走到OMF_alloc的逻辑的呢?
    通过发送消息走到这块流程:

    CodeGen::RValue CGObjCRuntime::GeneratePossiblySpecializedMessageSend(
    CodeGenFunction &CGF, ReturnValueSlot Return, QualType ResultType,
    Selector Sel, llvm::Value *Receiver, const CallArgList &Args,
    const ObjCInterfaceDecl *OID, const ObjCMethodDecl *Method,
    bool isClassMessage) {
    //尝试发送消息
    if (Optional<llvm::Value *> SpecializedResult =
    tryGenerateSpecializedMessageSend(CGF, ResultType, Receiver, Args,
    Sel, Method, isClassMessage)) {
    return RValue::get(SpecializedResult.getValue());
    }
    return GenerateMessageSend(CGF, Return, ResultType, Sel, Receiver, Args, OID,
    Method);
    }
  • 苹果对alloc等特殊函数做了hook,会先走底层的标记emitObjCValueOperation。最终再走到alloc等函数。
  • 第一次会走tryGenerateSpecializedMessageSend分支,第二次就走GenerateMessageSend分支了。
    • 也就是第一次alloc调用了objc_alloc,第二次alloc后就没有调用objc_alloc走了正常的objc_msgSendalloc-> objc_alloc -> callAlloc -> alloc -> _objc_rootAlloc -> callAlloc。这也就是callAlloc走两次的原因。
    • 再创建个对象调用流程就变成了:alloc -> objc_alloc -> callAlloc


  • 内存分配优化

    HPObject *hpObject = [HPObject alloc];
    NSLog(@"%@:",hpObject);

    对于hpObject我们查看它的内存数据如下:

    (lldb) x hpObject
    0x6000030cc2e0: c8 74 e6 0e 01 00 00 00 00 00 00 00 00 00 00 00 .t..............
    0x6000030cc2f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
    (lldb) p 0x000000010ee674c8
    (long) $4 = 4544951496

    可以打印的isa4544951496并不是HPObject。因为这里要&mask,在源码中有一个&mask结构。arm64`定义如下:

    #   if __has_feature(ptrauth_calls) || TARGET_OS_SIMULATOR
    # define ISA_MASK 0x007ffffffffffff8ULL
    # else
    # define ISA_MASK 0x0000000ffffffff8ULL

    这样计算后就得到isa了:

    (lldb) po 0x000000010ee674c8 & 0x007ffffffffffff8
    HPObject

    HPObjetc添加属性并赋值,修改逻辑如下:

    @interface HPObject : NSObject

    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) int age;
    @property (nonatomic, assign) double height;
    @property (nonatomic, assign) BOOL marry;

    @end
    调用:
        HPObject *hpObject = [HPObject alloc];
    hpObject.name = @"HotpotCat";
    hpObject.age = 18;
    hpObject.height = 180.0;
    hpObject.marry = YES;
    这个时候发现agemarry存在了isa后面存在了一起。
    那么多增加几个BOOL属性呢?

    @interface HPObject : NSObject

    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) int age;
    @property (nonatomic, assign) double height;
    @property (nonatomic, assign) BOOL marry;
    @property (nonatomic, assign) BOOL flag1;
    @property (nonatomic, assign) BOOL flag2;
    @property (nonatomic, assign) BOOL flag3;

    @end

    int类型的age单独存放了,5bool值放在了一起。这也就是内存分配做的优化。

    init源码探索

    既然alloc已经完成了内存分配和isa与类的关联那么init中做了什么呢?

    init
    init源码定义如下:

    - (id)init {
    return _objc_rootInit(self);
    }

    _objc_rootInit

    id
    _objc_rootInit(id obj)
    {
    // In practice, it will be hard to rely on this function.
    // Many classes do not properly chain -init calls.
    return obj;
    }




    可以看到init中调用了_objc_rootInit,而_objc_rootInit直接返回obj没有做任何事情。就是给子类用来重写的,提供接口便于扩展。所以如果没有重写init方法,那么在创建对象的时候可以不调用init方法。

    有了alloc底层骚操作的经验后,打个断点调试下:

    NSObject *obj = [NSObject alloc];
    [obj init];

    这里allocinit分开写是为了避免被优化。这时候调用流程和源码看到的相同。

    那么修改下调用逻辑:

    NSObject *obj = [[NSObject alloc] init];

    alloc init一起调用后会先进入objc_alloc_init方法。

    objc_alloc_init

    id
    objc_alloc_init(Class cls)
    {
    return [callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/) init];
    }

    objc_alloc_init调用了callAllocinit

    new源码探索

    既然alloc init 和new都能创建对象,那么它们之间有什么区别呢?
    new

    + (id)new {
    return [callAlloc(self, false/*checkNil*/) init];
    }
    alloc init一起调用的不同点是checkNil传递的是fasle
    源码调试发现new调用的是objc_opt_new

    // Calls [cls new]
    id
    objc_opt_new(Class cls)
    {
    #if __OBJC2__
    if (fastpath(cls && !cls->ISA()->hasCustomCore())) {
    return [callAlloc(cls, false/*checkNil*/) init];
    }
    #endif
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(new));
    }

    objc2下也是callAllocinit

    • init方法内部默认没有进行任何操作,只是返回了对象本身。
    • allot initnew底层实现一致,都是调用callAllocinit。所以如果自定义了init方法调用两者效果相同。

    objc_alloc_initobjc_opt_new的绑定与objc_alloc的实现相同。同样的实现绑定的还有:

    const char *AppleObjCTrampolineHandler::g_opt_dispatch_names[] = {
    "objc_alloc",//alloc
    "objc_autorelease",//autorelease
    "objc_release",//release
    "objc_retain",//retain
    "objc_alloc_init",// alloc init
    "objc_allocWithZone",//allocWithZone
    "objc_opt_class",//class
    "objc_opt_isKindOfClass",//isKindOfClass
    "objc_opt_new",//new
    "objc_opt_respondsToSelector",//respondsToSelector
    "objc_opt_self",//self
    };

    总结

    alloc调用过程:

    • objc_alloc
      • alloc底层首先调用的是objc_alloc
      • objc_allocalloc是在llvm编译阶段进行关联的。苹果会对系统特殊函数做hook进行标记。
    • callAlloc判断应该初始化的分支。
    • _class_createInstanceFromZone进行真正的开辟和关联操作:
      • instacneSize计算应该开辟的内存空间。
        • alignedInstanceSize内部进行字节对齐。
        • fastInstanceSize内部会进行内存对齐。
      • calloc开辟内存空间。
      • initInstanceIsa关联isa与创建的对象。
    • init & new
      • init方法内部默认没有进行任何操作,只是为了方便扩展。
      • allot initnew底层实现一致,都是调用callAllocinit



    作者:HotPotCat
    链接:https://www.jianshu.com/p/884275c811d5


    收起阅读 »

    OC alloc 底层探索

    一、alloc对象的指针地址和内存有如下代码://alloc后分配了内存,有了指针。 //init所指内存地址一样,init没有对指针进行操作。 HPObject *hp1 = [HPObject alloc]; HPObject *hp2 = [hp1 in...
    继续阅读 »

    一、alloc对象的指针地址和内存

    有如下代码:

    //alloc后分配了内存,有了指针。
    //init所指内存地址一样,init没有对指针进行操作。
    HPObject *hp1 = [HPObject alloc];
    HPObject *hp2 = [hp1 init];
    HPObject *hp3 = [hp1 init];
    NSLog(@"%@-%p",hp1,hp1);
    NSLog(@"%@-%p",hp2,hp2);
    NSLog(@"%@-%p",hp3,hp3);

    输出:

    <HPObject: 0x600000f84330>-0x600000f84330
    <HPObject: 0x600000f84330>-0x600000f84330
    <HPObject: 0x600000f84330>-0x600000f84330


    说明alloc后进行了内存分配有了指针,而init后所指内存地址一致,所以init没有对指针进行操作。
    修改NSLog内容如下:

    NSLog(@"%@-%p &p:%p",hp1,hp1,&hp1);
    NSLog(@"%@-%p &p:%p",hp2,hp2,&hp2);
    NSLog(@"%@-%p &p:%p",hp3,hp3,&hp3);

    输出:

    <HPObject: 0x600000e7c2c0>-0x600000e7c2c0 &p:0x7ffeefbf40d8
    <HPObject: 0x600000e7c2c0>-0x600000e7c2c0 &p:0x7ffeefbf40d0
    <HPObject: 0x600000e7c2c0>-0x600000e7c2c0 &p:0x7ffeefbf40c8


    这就说明hp1hp2hp3都指向堆空间的一块区域。而3个指针本身是在栈中连续开辟的空间,从高地址->低地址。
    那么alloc是怎么开辟的内存空间呢?


    二、底层探索思路


    1. 断点结合Step into instruction进入调用堆栈找到关键函数:


    找到了最中调用的是libobjc.A.dylibobjc_alloc:`。

    下断点后通过汇编查看调用流程Debug->Debug workflow->Always Show Disassembly通过已知符号断点确定未知符号。

    直接alloc下符号断点跟踪:


    三、alloc源码分析

    通过上面的分析已经能确定allocobjc框架中,正好苹果开源了这块代码,源码:objc源码地址:Source Browser
    最好是自己能编译一份能跑通的源码(也可以直接github上找别人编译好的)。当然也可以根据源码下符号断点跟踪调试。由于objc4-824目前下载不了,这里以objc4-824.2为例进行调试。

    HPObject定义如下:


    @interface HPObject : NSObject

    @property (nonatomic, copy) NSString *name;
    @property (nonatomic, assign) int age;

    @end

    3.1 alloc

    直接搜索alloc函数的定义发现在NSObject.mm 2543,通过断点调试类。
    调用alloc会首先调用objc_alloc:

    id
    objc_alloc(Class cls)
    {
    return callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/);
    }


    callAlloc会走到调用alloc分支。

    + (id)alloc {
    return _objc_rootAlloc(self);
    }

    alloc直接调用了_objc_rootAlloc

    id
    _objc_rootAlloc(Class cls)
    {
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
    }

    • _objc_rootAlloc传递参数checkNilfalseallocWithZonetrue直接调用了callAlloc
    • 在调用objc_alloc的时候传递的checkNiltrueallocWithZonefalse

    这里没什么好说的只是方法的一些封装,具体实现要看callAlloc



    3.2 callAlloc

    static ALWAYS_INLINE id
    callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
    {
    #if __OBJC2__
    //表示值为假的可能性更大。即执行else里面语句的机会更大
    if (slowpath(checkNil && !cls)) return nil;
    //hasCustomAWZ方法判断是否实现自定义的allocWithZone方法,如果没有实现就调用系统默认的allocWithZone方法。
    //表示值为真的可能性更大;即执行if里面语句的机会更大
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
    return _objc_rootAllocWithZone(cls, nil);
    }
    #endif

    // No shortcuts available.
    if (allocWithZone) {
    return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
    }
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
    }

    slowpath:表示值为假的可能性更大。即执行else里面语句的机会更大。
    fastpath:表示值为真的可能性更大;即执行if里面语句的机会更大。
    OBJC2:是因为有两个版本。Legacy版本(早期版本,对应Objective-C 1.0) 和 Modern版本(现行版本Objective-C 2.0)。

    • 在首次调用的时候会走alloc分支进入到alloc逻辑。
    • hasCustomAWZ意思是hasCustomAllocWithZone有没有自定义实现AllocWithZone。没有实现就走(这里进行了取反)_objc_rootAllocWithZone,实现了走allocWithZone:
    • 第二次调用直接走callAlloc的其它分支不会调用到alloc

    ⚠️:自己实现一个类的allocWithZone alloc分支就每次都被调用了


    3.3 _objc_rootAllocWithZone

    NEVER_INLINE
    id
    _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
    {
    // allocWithZone under __OBJC2__ ignores the zone parameter
    return _class_createInstanceFromZone(cls, 0, nil,
    OBJECT_CONSTRUCT_CALL_BADALLOC);
    }

    _objc_rootAllocWithZone直接调用了_class_createInstanceFromZone

    3.4 allocWithZone

    // Replaced by ObjectAlloc
    + (id)allocWithZone:(struct _NSZone *)zone {
    return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone);
    }


    _objc_rootAllocWithZone直接调用了_objc_rootAllocWithZone,与上面的3.3中的逻辑汇合了。

    3.5 _class_createInstanceFromZone

    最终会调用_class_createInstanceFromZone进程内存的计算和分配。


    static ALWAYS_INLINE id
    _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
    int construct_flags = OBJECT_CONSTRUCT_NONE,
    bool cxxConstruct = true,
    size_t *outAllocatedSize = nil)
    {
    ASSERT(cls->isRealized());

    // Read class's info bits all at once for performance
    //判断当前class或者superclass是否有.cxx_construct构造方法的实现
    bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
    //判断当前class或者superclass是否有.cxx_destruct析构方法的实现
    bool hasCxxDtor = cls->hasCxxDtor();
    //标记类是否支持优化的isa
    bool fast = cls->canAllocNonpointer();
    size_t size;
    //通过内存对齐得到实例大小,extraBytes是由对象所拥有的实例变量决定的。
    size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    //对象分配空间
    if (zone) {
    obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
    } else {
    obj = (id)calloc(1, size);
    }
    if (slowpath(!obj)) {
    if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
    return _objc_callBadAllocHandler(cls);
    }
    return nil;
    }
    //初始化实例isa指针
    if (!zone && fast) {
    obj->initInstanceIsa(cls, hasCxxDtor);
    } else {
    // Use raw pointer isa on the assumption that they might be
    // doing something weird with the zone or RR.
    obj->initIsa(cls);
    }

    if (fastpath(!hasCxxCtor)) {
    return obj;
    }

    construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
    return object_cxxConstructFromClass(obj, cls, construct_flags);
    }
  • 调用instanceSize计算空间大小。
  • 根据zone是否有值调用malloc_zone_calloccalloc进行内存分配。
    calloc之前分配的obj是一块脏内存,执行calloc后才会真正分配内存。执行前后内存地址发生了变化。

  • 根据!zone && fast分别调用initInstanceIsainitIsa进行isa实例化。
    • 执行完initInstanceIsa后再次打印就有类型了。
    • 根据是否有hasCxxCtor分别返回obj和调用object_cxxConstructFromClass

    3.6 instanceSize 申请内存

    在这个函数中调用了instanceSize计算实例大小:


    inline size_t instanceSize(size_t extraBytes) const {
    if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
    return cache.fastInstanceSize(extraBytes);
    }

    size_t size = alignedInstanceSize() + extraBytes;
    // CF requires all objects be at least 16 bytes.
    if (size < 16) size = 16;
    return size;
    }
    • 没有缓存的话会调用alignedInstanceSize,如果最终的size < 16会返回16
    • 有缓存则调用fastInstanceSize
    • 正常情况下缓存是在_read_images的时候生成的。所以这里一般会走fastInstanceSize分支。

    3.6.1 alignedInstanceSize



    #ifdef __LP64__
    # define WORD_MASK 7UL
    #else
    # define WORD_MASK 3UL

    uint32_t alignedInstanceSize() const {
    return word_align(unalignedInstanceSize());
    }

    static inline uint32_t word_align(uint32_t x) {
    return (x + WORD_MASK) & ~WORD_MASK;
    }

    uint32_t unalignedInstanceSize() const {
    ASSERT(isRealized());
    return data()->ro()->instanceSize;
    }

    xunalignedInstanceSize获取。读取的是data()->ro()->instanceSize实例变量的大小。由ivars决定。这里为8,因为默认有个isaisaClass ,Classobjc_class struct *类型。

    @interface NSObject <NSObject> {
    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Wobjc-interface-ivars"
    Class isa OBJC_ISA_AVAILABILITY;
    #pragma clang diagnostic pop
    }
    • 字节对齐算法为:(x + WORD_MASK) & ~WORD_MASKWORD_MASK 64位下为732位下为3
      那么对于HPObject对象计算方法如下:
      根据公式可得1:(8 + 7) & ~7 等价于 (8 + 7) >>3 << 3
      根据1可得2:15 & ~7
      转换为二进制:0000 1111 & ~0000 0111 = 0000 1111 & 1111 1000
      计算可得:00001000 = 8
      所以alignedInstanceSize计算就是以8字节对齐取8的倍数(算法中是往下取,对于内存分配来讲是往上取)。

    那么为什么以8字节对齐,最后最小分配16呢?
    分配16是为了做容错处理。以8字节对齐(选择8字节是因为8字节类型是最常用最多的)是以空间换取时间,提高CPU读取速度。当然这过程中会做一定的优化。

    3.6.2 fastInstanceSize

    bool hasFastInstanceSize(size_t extra) const
    {
    if (__builtin_constant_p(extra) && extra == 0) {
    return _flags & FAST_CACHE_ALLOC_MASK16;
    }
    return _flags & FAST_CACHE_ALLOC_MASK;
    }

    size_t fastInstanceSize(size_t extra) const
    {
    ASSERT(hasFastInstanceSize(extra));

    if (__builtin_constant_p(extra) && extra == 0) {
    return _flags & FAST_CACHE_ALLOC_MASK16;
    } else {
    size_t size = _flags & FAST_CACHE_ALLOC_MASK;
    // remove the FAST_CACHE_ALLOC_DELTA16 that was added
    // by setFastInstanceSize
    return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
    }
    }

    • fastInstanceSize中会调用align16,实现如下(16字节对齐):
    static inline size_t align16(size_t x) {
    return (x + size_t(15)) & ~size_t(15);
    }

    void setInstanceSize(uint32_t newSize) {
    ASSERT(isRealized());
    ASSERT(data()->flags & RW_REALIZING);
    auto ro = data()->ro();
    if (newSize != ro->instanceSize) {
    ASSERT(data()->flags & RW_COPIED_RO);
    *const_cast<uint32_t *>(&ro->instanceSize) = newSize;
    }
    cache.setFastInstanceSize(newSize);
    }

    size变化只会走会更新在缓存中。那么调用setInstanceSize的地方如下:

    • realizeClassWithoutSwift:类加载的时候计算。这里包括懒加载和非懒加载。这里会调用方法,根据类的实例变量进行size计算。这里是在_read_images的时候调用。
    • class_addIvar:动态添加属性的时候会重新计算实例大小。
    • objc_initializeClassPair_internal:动态添加类相关的初始化。

    instanceSize对于HPObject而言分配内存大小应该为8(isa) + 8(name)+4(age)= 20根据内存对齐应该分配24字节。


    3.7 initInstanceIsa

    inline void 
    objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
    {
    ASSERT(!cls->instancesRequireRawIsa());
    ASSERT(hasCxxDtor == cls->hasCxxDtor());

    initIsa(cls, true, hasCxxDtor);
    }

    initInstanceIsa最终会调用initIsainitIsa最后会对isa进行绑定:

    inline void 
    objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
    {
    ASSERT(!isTaggedPointer());

    isa_t newisa(0);

    if (!nonpointer) {
    newisa.setClass(cls, this);
    } else {
    ASSERT(!DisableNonpointerIsa);
    ASSERT(!cls->instancesRequireRawIsa());


    #if SUPPORT_INDEXED_ISA
    ASSERT(cls->classArrayIndex() > 0);
    newisa.bits = ISA_INDEX_MAGIC_VALUE;
    // isa.magic is part of ISA_MAGIC_VALUE
    // isa.nonpointer is part of ISA_MAGIC_VALUE
    newisa.has_cxx_dtor = hasCxxDtor;
    newisa.indexcls = (uintptr_t)cls->classArrayIndex();
    #else
    newisa.bits = ISA_MAGIC_VALUE;
    // isa.magic is part of ISA_MAGIC_VALUE
    // isa.nonpointer is part of ISA_MAGIC_VALUE
    # if ISA_HAS_CXX_DTOR_BIT
    newisa.has_cxx_dtor = hasCxxDtor;
    # endif
    newisa.setClass(cls, this);
    #endif
    newisa.extra_rc = 1;
    }

    // This write must be performed in a single store in some cases
    // (for example when realizing a class because other threads
    // may simultaneously try to use the class).
    // fixme use atomics here to guarantee single-store and to
    // guarantee memory order w.r.t. the class index table
    // ...but not too atomic because we don't want to hurt instantiation
    isa = newisa;
    }
    • isa_t是一个union
    • nonpointer表示是否进行指针优化。不优化直接走setClass逻辑,优化走else逻辑。


    作者:HotPotCat
    链接:https://www.jianshu.com/p/884275c811d5



    收起阅读 »

    OC 对象、位域、isa

    一、对象的本质1.1 clang1.1.1clang 概述Clang是一个C语言、C++、Objective-C语言的轻量级编译器。源代码发布于BSD协议下。 Clang将支持其普通lambda表达式、返回类型的简化处理以及更好的处理constexp...
    继续阅读 »

    一、对象的本质

    1.1 clang

    1.1.1clang 概述

    Clang是一个C语言C++Objective-C语言的轻量级编译器。源代码发布于BSD协议下。 Clang将支持其普通lambda表达式、返回类型的简化处理以及更好的处理constexpr关键字。

    Clang是一个由Apple主导编写,基于LLVMC/C++/Objective-C编译器。
    它与GNU C语言规范几乎完全兼容(当然,也有部分不兼容的内容, 包括编译命令选项也会有点差异),并在此基础上增加了额外的语法特性,比如C函数重载 (通过__attribute__((overloadable))来修饰函数),其目标(之一)就是超越GCC

    1.1.2 clang与xcrun命令

    1.1.2.1 clang

    把目标文件编译成c++文件,最简单的方式:

    clang -rewrite-objc main.m -o main.cpp

    如果包含其它SDK,比如UIKit则需要指定isysroot

    clang -rewrite-objc -fobjc-arc -fobjc-runtime=ios-14.0.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk main.m -o main.cpp

    • 如果找不到Foundation则需要排查clang版本设置是否正确。使用which clang可以直接查看路径。有些公司会使用clang-format来进行代码格式化,需要排查环境变量中是否导出了相关路径(如果导出先屏蔽掉)。正常路径为/usr/bin/clang
    • isysroot也可以导出环境变量进行配置方便使用。

    1.1.2.2 xcrun(推荐)

    xcode安装的时候顺带安装了xcrun命令,xcrun命令在clang的基础上进行了 一些封装,比clang更好用。


    模拟器命令:

    xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64simulator.cpp

    真机命令:

    xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 

    1.2 对象c++代码分析

    main.m文件如下,直接生成对应的.cpp文件对HotpotCat进行分析。

    #import <Foundation/Foundation.h>

    @interface HotpotCat : NSObject

    @end

    @implementation HotpotCat

    @end

    int main(int argc, const char * argv[]) {
    @autoreleasepool {
    }
    return 0;
    }

    1.2.1 对象在底层是结构体

    直接搜索HotpotCat

    可以看到生成了HotpotCat_IMPL是一个结构体,那么HotpotCat_IMPL就是HotpotCat的底层实现么?对HotpotCat增加属性hp_name:

    @property(nonatomic, copy) NSString *hp_name;

    重新生成.cpp文件:

    这也就验证了HotpotCat_IMPL就是HotpotCat的底层实现,那么说明: 对象在底层的本质就是结构体

    HotpotCat_IMPL结构体中又嵌套了NSObject_IMPL结构体,这可以理解为继承。
    NSObject_IMPL定义如下:


    struct NSObject_IMPL {
    Class isa;
    };

    所以NSObject_IVARS就是成员变量isa

    1.2.2 objc_object & objc_class

    HotpotCat_IMPL上面有如下代码:

    typedef struct objc_object HotpotCat;

    为什么HotpotCatobjc_object类型?这是因为NSObject的底层实现就是objc_object

    同样的Class定义如下:

    typedef struct objc_class *Class;

    objc_class类型的结构体指针。

    同样可以看到idobjc_object结构体类型指针。

    typedef struct objc_object *id;

    这也就是id声明的时候不需要*的原因。


    1.2.3 setter & getter

    .cpp文件中有以下代码:


    // @property(nonatomic, copy) NSString *hp_name;


    /* @end */


    // @implementation HotpotCat

    //这里是setter和getter 参数self _cmd 隐藏参数
    static NSString * _I_HotpotCat_hp_name(HotpotCat * self, SEL _cmd) {
    //return self + 成员变量偏移
    return (*(NSString **)((char *)self + OBJC_IVAR_$_HotpotCat$_hp_name));
    }
    extern "C" __declspec(dllimport) void objc_setProperty (id, SEL, long, id, bool, bool);

    static void _I_HotpotCat_setHp_name_(HotpotCat * self, SEL _cmd, NSString *hp_name) {
    objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct HotpotCat, _hp_name), (id)hp_name, 0, 1);
    }
    // @end
    • 根据系统默认注释和函数名称确认这里是hp_namesettergetter方法。
    • getter方法返回hp_name是通过self + 成员变量偏移获取的。getter同理。

    二、位域

    struct Direction {
    BOOL left;
    BOOL right;
    BOOL front;
    BOOL back;
    };

    上面是一个记录方向的结构体。这个结构体占用4字节32位:00000000 00000000 00000000 00000000。但是对于BOOL值只有两种情况YES/NO。那么如果能用40000来代替前后左右,就只需要0.5个字节就能表示这个数据结构了(虽然只需要0.5字节,但是数据单元最小为1字节)。那么Direction的实现显然浪费了3倍的空间。有什么优化方式呢?位域

    2.1 结构体位域

    修改DirectionHPDirection:


    struct HPDirection {
    BOOL left : 1;
    BOOL right : 1;
    BOOL front : 1;
    BOOL back : 1;
    };

    格式为:数据类型 位域名称:位域长度

    验证:

    struct Direction dir;
    dir.left = YES;
    dir.right = YES;
    dir.front = YES;
    dir.back = YES;
    struct HPDirection hpDir;
    hpDir.left = YES;
    hpDir.right = YES;
    hpDir.front = YES;
    hpDir.back = YES;
    printf("\nDirection size:%zu\nHPDirection size:%zu\n",sizeof(dir),sizeof(hpDir));



    2.2 联合体

    2.2.1结构体&联合体对比


    //结构体联合体对比
    //共存
    struct HPStruct {
    char *name;
    int age;
    double height;
    };

    //互斥
    union HPUnion {
    char *name;
    int age;
    double height;
    };


    void testStructAndUnion() {
    struct HPStruct s;
    union HPUnion u;
    s.name = "HotpotCat";
    u.name = "HotpotCat";
    s.age = 18;
    u.age = 18;
    s.height = 180.0;
    u.height = 180.0;
    }
    分别定义了HPStruct结构体和HPUnion共用体,在整个赋值过程中变化如下:

    总结:

    • 结构体(struct)中所有变量是“共存”的。
      优点:“有容乃大”, 全面;
      缺点:struct内存空间的分配是粗放的,不管用不用全分配。
    • 联合体/共用体(union)中是各变量是“互斥”的。
      缺点:不够“包容”;
      优点:内存使用更为精细灵活,节省了内存空间。
    • 联合体在未进行赋值前数据成员会存在脏数据。

    2.2.2 联合体位域

    HPDirectionItem.h

    @interface HPDirectionItem : NSObject

    @property (nonatomic, assign) BOOL left;
    @property (nonatomic, assign) BOOL right;
    @property (nonatomic, assign) BOOL front;
    @property (nonatomic, assign) BOOL back;

    @end
    HPDirectionItem.m:

    #define HPDirectionLeftMask   (1 << 0)
    #define HPDirectionRightMask (1 << 1)
    #define HPDirectionFrontMask (1 << 2)
    #define HPDirectionBackMask (1 << 3)

    #import "HPDirectionItem.h"

    @interface HPDirectionItem () {
    //这里bits和struct用任一一个就可以,结构体相当于是对bits的解释。因为是共用体用同一块内存。
    union {
    char bits;
    //位域,这里是匿名结构体(anonymous struct)
    struct {
    char left : 1;
    char right : 1;
    char front : 1;
    char back : 1;
    };
    }_direction;
    }

    @end

    @implementation HPDirectionItem

    - (instancetype)init {
    self = [super init];
    if (self) {
    _direction.bits = 0b00000000;
    }
    return self;
    }

    - (void)setLeft:(BOOL)left {
    if (left) {
    _direction.bits |= HPDirectionLeftMask;
    } else {
    _direction.bits &= ~HPDirectionLeftMask;
    }
    }

    - (BOOL)left {
    return _direction.bits & HPDirectionLeftMask;
    }
    //……
    //其它方向设置同理
    //……
    @end

    • HPDirectionItem是一个方向类,类中有一个_direction的共用体。
    • _direction中有bitsanonymous struct,这里anonymous struct相当于是对bits的一个解释(因为是共用体,同一个字节内存。下面的调试截图很好的证明力这一点)。
    • 通过对bits位移操作来进行数据的存储,其实就相当于对结构体位域的操作。连这个可以互相操作。

    所以可以将settergetter通过结构体去操作,效果和操作bits相同:

    - (void)setLeft:(BOOL)left {

        _direction.left = left;
    }

    - (BOOL)left {
    return _direction.left;
    }

    当然也可以两者混用:

    - (void)setLeft:(BOOL)left {
    _direction.left = left;
    }

    - (BOOL)left {
    return _direction.bits & HPDirectionLeftMask;
    }

    根本上还是对同一块内存空间进行操作。

    调用:

    void testUnionBits() {
    HPDirectionItem *item = [HPDirectionItem alloc];
    item.left = 1;
    item.right = 1;
    item.front = 1;
    item.back = 1;
    item.right = 0;
    item.back = 0;
    NSLog(@"testUnionBits");
    }



    这样整个赋值流程就符合预期满足需求了。

    • 联合体位域作用:优化内存空间和访问速度。

    三、 isa

    alloc分析的文章中已经了解到执行完initIsa后将alloc开辟的内存与类进行了关联。在initIsa中首先创建了isa_t也就是isa,去掉方法后它的主要结构如下:

    union isa_t {
    //……
    uintptr_t bits;
    private:
    Class cls;
    public:
    #if defined(ISA_BITFIELD)
    struct {
    ISA_BITFIELD; // defined in isa.h
    };
    //……
    #endif
    //……
    };

    它是一个union,包含了bitscls(私有)和一个匿名结构体,所以这3个其实是一个内容,不同表现形式罢了。这个结构似曾相识,与2.2.2中联合体位域一样。不同的是isa_t占用8字节64位。

    没有关联类时isa分布(默认都是0,没有指向):





    bitscls分析起来比较困难,既然三者一样,那么isa_t的核心就是ISA_BITFIELD


    作者:HotPotCat
    链接:https://www.jianshu.com/p/84749f140139

    收起阅读 »

    Core Image 和视频

    在这篇文章中,我们将研究如何将 Core Image 应用到实时视频上去。我们会看两个例子:首先,我们把这个效果加到相机拍摄的影片上去。之后,我们会将这个影响作用于拍摄好的视频文件。它也可以做到离线渲染,它会把渲染结果返回给视频,而不是直接显示在屏幕上。总览当...
    继续阅读 »

    在这篇文章中,我们将研究如何将 Core Image 应用到实时视频上去。我们会看两个例子:首先,我们把这个效果加到相机拍摄的影片上去。之后,我们会将这个影响作用于拍摄好的视频文件。它也可以做到离线渲染,它会把渲染结果返回给视频,而不是直接显示在屏幕上。

    总览

    当涉及到处理视频的时候,性能就会变得非常重要。而且了解黑箱下的原理 —— 也就是 Core Image 是如何工作的 —— 也很重要,这样我们才能达到足够的性能。在 GPU 上面做尽可能多的工作,并且最大限度的减少 GPU 和 CPU 之间的数据传送是非常重要的。之后的例子中,我们将看看这个细节。

    优化资源的 OpenGL ES

    CPU 和 GPU 都可以运行 Core Image,在这个例子中,我们要使用 GPU,我们做如下几样事情。

    我们首先创建一个自定义的 UIView,它允许我们把 Core Image 的结果直接渲染成 OpenGL。我们可以新建一个 GLKView 并且用一个 EAGLContext 来初始化它。我们需要指定 OpenGL ES 2 作为渲染 API,在这两个例子中,我们要自己触发 drawing 事件 (而不是在 -drawRect: 中触发),所以在初始化 GLKView 的时候,我们将 enableSetNeedsDisplay 设置为 false。之后我们有可用新图像的时候,我们需要主动去调用 -display

    在这个视图里,我们保持一个对 CIContext 的引用,它提供一个桥梁来连接我们的 Core Image 对象和 OpenGL 上下文。我们创建一次就可以一直使用它。这个上下文允许 Core Image 在后台做优化,比如缓存和重用纹理之类的资源等。重要的是这个上下文我们一直在重复使用。

    上下文中有一个方法,-drawImage:inRect:fromRect:,作用是绘制出来一个 CIImage。如果你想画出来一个完整的图像,最容易的方法是使用图像的 extent。但是请注意,这可能是无限大的,所以一定要事先裁剪或者提供有限大小的矩形。一个警告:因为我们处理的是 Core Image,绘制的目标以像素为单位,而不是点。由于大部分新的 iOS 设备配备 Retina 屏幕,我们在绘制的时候需要考虑这一点。如果我们想填充整个视图,最简单的办法是获取视图边界,并且按照屏幕的 scale 来缩放图片 (Retina 屏幕的 scale 是 2)。


    从相机获取像素数据

    对于 AVFoundation 如何工作的概述,我们想从镜头获得 raw 格式的数据。我们可以通过创建一个 AVCaptureDeviceInput 对象来选定一个摄像头。使用 AVCaptureSession,我们可以把它连接到一个 AVCaptureVideoDataOutput。这个 data output 对象有一个遵守 AVCaptureVideoDataOutputSampleBufferDelegate 协议的代理对象。这个代理每一帧将接收到一个消息:

    func captureOutput(captureOutput: AVCaptureOutput!,
    didOutputSampleBuffer: CMSampleBuffer!,
    fromConnection: AVCaptureConnection!) {

    我们将用它来驱动我们的图像渲染。在我们的示例代码中,我们已经将配置,初始化以及代理对象都打包到了一个叫做 CaptureBufferSource 的简单接口中去。我们可以使用前置或者后置摄像头以及一个回调来初始化它。对于每个样本缓存区,这个回调都会被调用,并且参数是缓冲区和对应摄像头的 transform:

    source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
    (buffer, transform) in
    ...
    }

    我们需要对相机返回的数据进行变换。无论你如何转动 iPhone,相机的像素数据的方向总是相同的。在我们的例子中,我们将 UI 锁定在竖直方向,我们希望屏幕上显示的图像符合照相机拍摄时的方向,为此我们需要后置摄像头拍摄出的图片旋转 -π/2。前置摄像头需要旋转 -π/2 并且加一个镜像效果。我们可以用一个 CGAffineTransform 来表达这种变换。请注意如果 UI 是不同的方向 (比如横屏),我们的变换也将是不同的。还要注意,这种变换的代价其实是非常小的,因为它是在 Core Image 渲染管线中完成的。

    接着,要把 CMSampleBuffer 转换成 CIImage,我们首先需要将它转换成一个 CVPixelBuffer。我们可以写一个方便的初始化方法来为我们做这件事:

    extension CIImage {
    convenience init(buffer: CMSampleBuffer) {
    self.init(CVPixelBuffer: CMSampleBufferGetImageBuffer(buffer))
    }
    }

    现在我们可以用三个步骤来处理我们的图像。首先,把我们的 CMSampleBuffer 转换成 CIImage,并且应用一个形变,使图像旋转到正确的方向。接下来,我们用一个 CIFilter 滤镜来得到一个新的 CIImage 输出。我们使用了 Florian 的文章 提到的创建滤镜的方式。在这个例子中,我们使用色调调整滤镜,并且传入一个依赖于时间而变化的调整角度。最终,我们使用之前定义的 View,通过 CIContext 来渲染 CIImage。这个流程非常简单,看起来是这样的:

    source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
    [unowned self] (buffer, transform) in
    let input = CIImage(buffer: buffer).imageByApplyingTransform(transform)
    let filter = hueAdjust(self.angleForCurrentTime)
    self.coreImageView?.image = filter(input)
    }

    当你运行它时,你可能会因为如此低的 CPU 使用率感到吃惊。这其中的奥秘是 GPU 做了几乎所有的工作。尽管我们创建了一个 CIImage,应用了一个滤镜,并输出一个 CIImage,最终输出的结果是一个 promise:直到实际渲染才会去进行计算。一个 CIImage 对象可以是黑箱里的很多东西,它可以是 GPU 算出来的像素数据,也可以是如何创建像素数据的一个说明 (比如使用一个滤镜生成器),或者它也可以是直接从 OpenGL 纹理中创建出来的图像。

    下面是演示视频

    从影片中获取像素数据

    我们可以做的另一件事是通过 Core Image 把这个滤镜加到一个视频中。和实时拍摄不同,我们现在从影片的每一帧中生成像素缓冲区,在这里我们将采用略有不同的方法。对于相机,它会推送每一帧给我们,但是对于已有的影片,我们使用拉取的方式:通过 display link,我们可以向 AVFoundation 请求在某个特定时间的一帧。

    display link 对象负责在每帧需要绘制的时候给我们发送消息,这个消息是按照显示器的刷新频率同步进行发送的。这通常用来做 自定义动画,但也可以用来播放和操作视频。我们要做的第一件事就是创建一个 AVPlayer 和一个视频输出:

    player = AVPlayer(URL: url)
    videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferDict)
    player.currentItem.addOutput(videoOutput)

    接下来,我们要创建 display link。方法很简单,只要创建一个 CADisplayLink 对象,并将其添加到 run loop。

    let displayLink = CADisplayLink(target: self, selector: "displayLinkDidRefresh:")
    displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)

    现在,唯一剩下的就是在 displayLinkDidRefresh: 调用的时候获取视频每一帧。首先,我们获取当前的时间,并且将它转换成当前播放项目里的时间比。然后我们询问 videoOutput,如果当前时间有一个可用的新的像素缓存区,我们把它复制一下并且调用回调方法:

    func displayLinkDidRefresh(link: CADisplayLink) {
    let itemTime = videoOutput.itemTimeForHostTime(CACurrentMediaTime())
    if videoOutput.hasNewPixelBufferForItemTime(itemTime) {
    let pixelBuffer = videoOutput.copyPixelBufferForItemTime(itemTime, itemTimeForDisplay: nil)
    consumer(pixelBuffer)
    }
    }

    我们从一个视频输出获得的像素缓冲是一个 CVPixelBuffer,我们可以把它直接转换成 CIImage。正如上面的例子,我们会加上一个滤镜。在这个例子里,我们将组合多个滤镜:我们使用一个万花筒的效果,然后用渐变遮罩把原始图像和过滤图像相结合,这个操作是非常轻量级的。

    创意地使用滤镜

    大家都知道流行的照片效果。虽然我们可以将这些应用到视频,但 Core Image 还可以做得更多。

    Core Image 里所谓的滤镜有不同的类别。其中一些是传统的类型,输入一张图片并且输出一张新的图片。但有些需要两个 (或者更多) 的输入图像并且混合生成一张新的图像。另外甚至有完全不输入图片,而是基于参数的生成图像的滤镜。

    通过混合这些不同的类型,我们可以创建意想不到的效果。

    混合图片

    在这个例子中,我们使用这些东西:

    Combining filters

    上面的例子可以将图像的一个圆形区域像素化。

    它也可以创建交互,我们可以使用触摸事件来改变所产生的圆的位置。

    Core Image Filter Reference 按类别列出了所有可用的滤镜。请注意,有一部分只能用在 OS X。

    生成器和渐变滤镜可以不需要输入就能生成图像。它们很少自己单独使用,但是作为蒙版的时候会非常强大,就像我们例子中的 CIBlendWithMask 那样。

    混合操作和 CIBlendWithAlphaMask 还有 CIBlendWithMask 允许将两个图像合并成一个。

    CPU vs. GPU

    iOS 和 OS X 的图形栈。需要注意的是 CPU 和 GPU 的概念,以及两者之间数据的移动方式。

    在处理实时视频的时候,我们面临着性能的挑战。

    首先,我们需要能在每一帧的时间内处理完所有的图像数据。我们的样本中采用 24 帧每秒的视频,这意味着我们有 41 毫秒 (1/24 秒) 的时间来解码,处理以及渲染每一帧中的百万像素。

    其次,我们需要能够从 CPU 或者 GPU 上面得到这些数据。我们从视频文件读取的字节数最终会到达 CPU 里。但是这个数据还需要移动到 GPU 上,以便在显示器上可见。

    避免转移

    一个非常致命的问题是,在渲染管线中,代码可能会把图像数据在 CPU 和 GPU 之间来回移动好几次。确保像素数据仅在一个方向移动是很重要的,应该保证数据只从 CPU 移动到 GPU,如果能让数据完全只在 GPU 上那就更好。

    如果我们想渲染 24 fps 的视频,我们有 41 毫秒;如果我们渲染 60 fps 的视频,我们只有 16 毫秒,如果我们不小心从 GPU 下载了一个像素缓冲到 CPU 里,然后再上传回 GPU,对于一张全屏的 iPhone 6 图像来说,我们在每个方向将要移动 3.8 MB 的数据,这将使帧率无法达标。

    当我们使用 CVPixelBuffer 时,我们希望这样的流程:

    Flow of image data

    CVPixelBuffer 是基于 CPU 的 (见下文),我们用 CIImage 来包装它。构建滤镜链不会移动任何数据;它只是建立了一个流程。一旦我们绘制图像,我们使用了基于 EAGL 上下文的 Core Image 上下文,而这个 EAGL 上下文也是 GLKView 进行图像显示所使用的上下文。EAGL 上下文是基于 GPU 的。请注意,我们是如何只穿越 GPU-CPU 边界一次的,这是至关重要的部分。

    工作和目标

    Core Image 的图形上下文可以通过两种方式创建:使用 EAGLContext 的 GPU 上下文,或者是基于 CPU 的上下文。

    这个定义了 Core Image 工作的地方,也就是像素数据将被处理的地方。与工作区域无关,基于 GPU 和基于 CPU 的图形上下文都可以通过执行 createCGImage(…)render(_, toBitmap, …) 和 render(_, toCVPixelBuffer, …),以及相关的命令来向 CPU 进行渲染。

    重要的是要理解如何在 CPU 和 GPU 之间移动像素数据,或者是让数据保持在 CPU 或者 GPU 里。将数据移过这个边界是需要很大的代价的。

    缓冲区和图像

    在我们的例子中,我们使用了几个不同的缓冲区图像。这可能有点混乱。这样做的原因很简单,不同的框架对于这些“图像”有不同的用途。下面有一个快速总览,以显示哪些是以基于 CPU 或者基于 GPU 的:

    描述
    CIImage它们可以代表两种东西:图像数据或者生成图像数据的流程。
    CIFilter 的输出非常轻量。它只是如何被创建的描述,并不包含任何实际的像素数据。
    如果输出时图像数据的话,它可能是纯像素的 NSData,一个 CGImage, 一个 CVPixelBuffer,或者是一个 OpenGL 纹理
    CVImageBuffer这是 CVPixelBuffer (CPU) 和 CVOpenGLESTexture (GPU) 的抽象父类.
    CVPixelBufferCore Video 像素缓冲 (Pixel Buffer) 是基于 CPU 的。
    CMSampleBufferCore Media 采样缓冲 (Sample Buffer) 是 CMBlockBuffer 或者 CVImageBuffer 的包装,也包括了元数据。
    CMBlockBufferCore Media 区块缓冲 (Block Buffer) 是基于 GPU 的

    需要注意的是 CIImage 有很多方便的方法,例如,从 JPEG 数据加载图像或者直接加载一个 UIImage 对象。在后台,这些将会使用一个基于 CGImage 的 CIImage 来进行处理。

    结论

    Core Image 是操纵实时视频的一大利器。只要你适当的配置下,性能将会是强劲的 —— 只要确保 CPU 和 GPU 之间没有数据的转移。创意地使用滤镜,你可以实现一些非常炫酷的效果,神马简单色调,褐色滤镜都弱爆啦。所有的这些代码都很容易抽象出来,深入了解下不同的对象的作用区域 (GPU 还是 CPU) 可以帮助你提高代码的性能。


    原文:http://www.objc.io/issue-23/core-image-video.html

    译者:考高这点小事

    高考这件小事


    收起阅读 »

    使用 Swift 进行函数式信号处理

    作为一个和 Core Audio 打过很长时间交道的工程师,苹果发布 Swift 让我感到兴奋又疑惑。兴奋是因为 Swift 是一个为性能打造的现代编程语言,但是我又不是非常确定函数式编程是否可以应用到 “我的世界”。幸运的是,很多人已经探索和克服了这些问题,...
    继续阅读 »

    作为一个和 Core Audio 打过很长时间交道的工程师,苹果发布 Swift 让我感到兴奋又疑惑。兴奋是因为 Swift 是一个为性能打造的现代编程语言,但是我又不是非常确定函数式编程是否可以应用到 “我的世界”。幸运的是,很多人已经探索和克服了这些问题,所以我决定将我从这些项目中学习到的东西应用到 Swift 编程语言中去。


    信号

    信号处理的基本当然是信号。在 Swift 中,我可以这样定义信号:

    public typealias Signal = Int -> SampleType

    你可以把 Signal 类想象成一个离散时间函数,这个函数会返回一个时间点上的信号值。在大多数信号处理的教科书中,这个会被写做 x[t], 这样一来它就很符合我的世界观了。

    现在我们来定义一个给定频率的正弦波:

    public func sineWave(sampleRate: Int, frequency: ParameterType) -> Signal {
    let phi = frequency / ParameterType(sampleRate)
    return { i in
    return SampleType(sin(2.0 * ParameterType(i) * phi * ParameterType(M_PI)))
    }
    }

    sineWave 函数会返回一个 SignalSignal 本身是一个将采样点的索引映射为输出样点的函数。我将这些不需要“输入”的信号称为信号发生器,因为它们不需要任何其他的东西就能创造信号。

    但是我们正在讨论信号处理。那么如何更改一个信号呢?

    任何关于信号处理的高层面的讨论,都不可能离开一个基础,那就是如何控制增益 (或者音量):

    public func scale(s: Signal, amplitude: ParameterType) -> Signal {
    return { i in
    return SampleType(s(i) * SampleType(amplitude))
    }
    }

    scale 函数接受一个名为 s 的 Signal 作为输入,然后返回一个施加了标量之后的新 Signal。每次调用这个经过 scale 后的信号,返回的值都是对应的 s(i) 然后通过所提供的 amplitude 进行加成,来作为输出。很容易对吧?但是很快这些构件就会变得混乱起来。来看看以下的例子:

    public func mix(s1: Signal, s2: Signal) -> Signal {
    return { i in
    return s1(i) + s2(i)
    }
    }

    这让我们能够将两个信号混合成一个信号。我们甚至可以混合任意多个信号:

    public func mix(signals: [Signal]) -> Signal {
    return { i in
    return signals.reduce(SampleType(0)) { $0 + $1(i) }
    }
    }

    这可以让我们干很多事情;但是一个 Signal 仅仅限于一个单一的音频频道,有些音效需要复杂的操作的组合同时发生才能做到。

    处理 Block

    我们如何才能以更灵活的方式在信号和处理器之间建立联系,来让信号处理更接近于我们所想呢?有很多流行的环境,比如说 Max 和 PureData,这些环境会建立信号处理的 “blocks”,并以此来创造强大的音效和演奏工具。

    Faust 是一个为此设计出来的函数式编程语言,它是一个用来编写高度复杂 (而且高性能) 的信号处理代码的强大工具。Faust 定义了一系列运算符来让你建立 blocks (处理器),这和信号流图像很相似。

    类似地,我用同样的方式建立了一个可以高效工作的环境。

    使用我们之前定义的 Signal,我们可以基于这个概念进行扩展。

    public protocol BlockType {
    typealias SignalType
    var inputCount: Int { get }
    var outputCount: Int { get }
    var process: [SignalType] -> [SignalType] { get }

    init(inputCount: Int, outputCount: Int, process: [SignalType] -> [SignalType])
    }

    一个 Block 有多个输入,多个输出,和一个 process 函数,这个函数将信号从输入集合转换成输出集合。Blocks 可以有零个或多个输入,也可以有零个或多个输出。

    你可以用以下的方法来建立串行的 blocks。

    public func serial<B: BlockType>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
    return rhs.process(lhs.process(inputs))
    })
    }

    这个函数将 lhs block 的输出当做 rhs block 的输入,然后返回结果。就好像在两个 blocks 中间连起一根线一样。当你想要并行地执行多个 blocks 的时候,事情就变得有意思起来:

    public func parallel<B: BlockType>(lhs: B, rhs: B) -> B {
    let totalInputs = lhs.inputCount + rhs.inputCount
    let totalOutputs = lhs.outputCount + rhs.outputCount

    return B(inputCount: totalInputs, outputCount: totalOutputs, process: { inputs in
    var outputs: [B.SignalType] = []

    outputs += lhs.process(Array(inputs[0..<lhs.inputCount]))
    outputs += rhs.process(Array(inputs[lhs.inputCount..<lhs.inputCount+rhs.inputCount]))

    return outputs
    })
    }

    一组并行运行的 blocks 将输入和输出结合在一起,并创建了一个更大的 block。比如一对产生的正弦波的 Block 组合在一起可以创建一个 DTMF 音调,或者两个单频延迟的 Block 可以组成一个立体延迟 Block等。这个概念在实践中是非常强大的。

    那么混合器呢?我们如何从多个输入得到一个单频道的结果?我们可以用如下函数来将多个 block 合并在一起:

    public func merge<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
    let leftOutputs = lhs.process(inputs)
    var rightInputs: [B.SignalType] = []

    let k = lhs.outputCount / rhs.inputCount
    for i in 0..<rhs.inputCount {
    var inputsToSum: [B.SignalType] = []
    for j in 0..<k {
    inputsToSum.append(leftOutputs[i+(rhs.inputCount*j)])
    }
    let summed = inputsToSum.reduce(NullSignal) { mix($0, $1) }
    rightInputs.append(summed)
    }

    return rhs.process(rightInputs)
    })
    }

    从 Faust 借用一个惯例,输入的混合是这样进行的:右手边 block 的输入来自于左手边对输入取模后的输出。举个例子,将六个频道的三个立体声轨变成一个立体输出的 block:输出频道 0,2,4 被混合 (比如相加) 进输入频道 0,然后输出频道 1,3,5 会被混合进输入频道 1。

    同样的,你可以用相反的方法将 block 的输出分开。

    public func split<B: BlockType>(lhs: B, rhs: B) -> B {
    return B(inputCount: lhs.inputCount, outputCount: rhs.outputCount, process: { inputs in
    let leftOutputs = lhs.process(inputs)
    var rightInputs: [B.SignalType] = []

    // 从 lhs 将频道逐个复制输入中
    let k = lhs.outputCount
    for i in 0..<rhs.inputCount {
    rightInputs.append(leftOutputs[i%k])
    }

    return rhs.process(rightInputs)
    })
    }

    对于输出我们也使用一个类似的惯例,一个立体声 block 作为三个立体声 block 的输入 (总共接受六个声道),也就是说,频道 0 作为输入 0,2,4,而频道 1 作为 1,3,5 的输入。

    我们当然不想被这些很长的函数束缚住手脚,所以我写了这些运算符:

    // 并行
    public func |-<B: BlockType>(lhs: B, rhs: B) -> B

    // 串行
    public func --<B: BlockType>(lhs: B, rhs: B) -> B

    // 分割
    public func -<<B: BlockType>(lhs: B, rhs: B) -> B

    // 合并
    public func >-<B: BlockType where B.SignalType == Signal>(lhs: B, rhs: B) -> B

    (我觉得“并行”运算符的定义并不是特别好,因为它看上去和几何中的“垂直”尤其相似,但是现在就这样,非常欢迎大家的意见)

    现在有了这些运算符,你可以建立一些有趣的 blocks “图”。比如说 DTMF 音调发生器:

    let dtmfFrequencies = [
    ( 941.0, 1336.0 ),

    ( 697.0, 1209.0 ),
    ( 697.0, 1336.0 ),
    ( 697.0, 1477.0 ),

    ( 770.0, 1209.0 ),
    ( 770.0, 1336.0 ),
    ( 770.0, 1477.0 ),

    ( 852.0, 1209.0 ),
    ( 852.0, 1336.0 ),
    ( 852.0, 1477.0 ),
    ]

    func dtmfTone(digit: Int, sampleRate: Int) -> Block {
    assert( digit < dtmfFrequencies.count )
    let (f1, f2) = dtmfFrequencies[digit]

    let f1Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f1)] })
    let f2Block = Block(inputCount: 0, outputCount: 1, process: { _ in [sineWave(sampleRate, f2)] })

    return ( f1Block |- f2Block ) >- Block(inputCount: 1, outputCount: 1, process: { return $0 })
    }

    dtmfTone 函数处理两个并行的正弦发生器,然后将它们融合成一个 “单位元 block”,这个 block 只是将自己的输入复制到输出。记住这个函数的返回值本身就是一个 block,所以你可以在更大的系统中使用这个block。

    可以看得出来这个想法蕴含了很多的潜力。通过创建可以使用更紧凑和容易理解的 DSL (domain specific language) 来描述复杂系统的环境,我们可以花更少的时间来思考单个 block 的细节,并轻易地把所有东西组合到一起。

    实践

    如果我今天要开始做一个要求最高性能以及丰富功能的新项目,我会毫不犹豫的使用 Faust。如果你对函数式音频编程感兴趣的话,我极力推荐 Faust。

    话虽如此,我一上提到想法的可行性很大程度上依赖于苹果对编译器的改进,编译器需要具有能识别我们定义在 block 中的模式,并输出更智能的代码的能力。也就是说,苹果需要像编译 Haskell 一样来编译 Swift。在 Haskell 中函数式编程模式会被压缩成某一个目标 CPU 的矢量运算。

    说实话,我觉得 Swift 在苹果的管理下是很好的,我们也会在将来看见我在以上呈现的想法会变得很常见,而且性能也会变得非常好。


    原文链接:http://www.objc.io/issue-24/functional-signal-processing.html

    译者:李子轩



    收起阅读 »

    Kotlin 源码 | 降低代码复杂度的法宝

    随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简...
    继续阅读 »

    随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。

    Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简单的背后都蕴藏着复杂”。

    启动线程和读取文件容是 Android 开发中两个颇为常见的场景。分别给出 Java 和 Kotlin 的实现,在惊叹两种语言表达力上悬殊的差距的同时,逐层剖析 Kotlin 语法简单背后的复杂。

    启动线程

    先看一个简单的业务场景,在 java 中用下面的代码启动一个新线程:

     Thread thread = new Thread() {
    @Override
    public void run() {
    doSomething() // 业务逻辑
    super.run();
    }
    };
    thread.setDaemon(false);
    thread.setPriority(-1);
    thread.setName("thread");
    thread.start();

    启动线程是一个常用操作,其中除了 doSomething() 之外的其他代码都具有通用性。难道每次启动线程时都复制粘贴这一坨代码吗?不优雅!得抽象成一个静态方法以便到处调用:

    public class ThreadUtil {
    public static Thread startThread(Callback callback) {
    Thread thread = new Thread() {
    @Override
    public void run() {
    if (callback != null) callback.action();
    super.run();
    }
    };
    thread.setDaemon(false);
    thread.setPriority(-1);
    thread.setName("thread");
    thread.start();
    return thread;
    }

    public interface Callback {
    void action();
    }
    }

    仔细分析下这里引入的复杂度,一个新的类ThreadUtil及静态方法startThread(),还有一个新的接口Callback

    然后就可以像这样构建线程了:

    ThreadUtil.startThread( new Callback() {
    @Override
    public void action() {
    doSomething();
    }
    })

    对比下 Kotlin 的解决方案thread()

    public fun thread(
    start: Boolean = true,
    isDaemon: Boolean = false,
    contextClassLoader: ClassLoader? = null,
    name: String? = null,
    priority: Int = -1,
    block: () -> Unit
    ): Thread {
    val thread = object : Thread() {
    public override fun run() {
    block()
    }
    }
    if (isDaemon)
    thread.isDaemon = true
    if (priority > 0)
    thread.priority = priority
    if (name != null)
    thread.name = name
    if (contextClassLoader != null)
    thread.contextClassLoader = contextClassLoader
    if (start)
    thread.start()
    return thread
    }

    thread()方法把构建线程的细节全都隐藏在方法内部。

    然后就可以像这样启动一个新线程:

    thread { doSomething() }

    这简洁的背后是一系列语法特性的支持:

    1. 顶层函数

    Kotlin 中把定义在类体外,不隶属于任何类的函数称为顶层函数thread()就是这样一个函数。这样定义的好处是,可以在任意位置,方便地访问到该函数。

    Kotlin 的顶层函数被编译成 java 代码后就变成一个类中的静态函数,类名是顶层函数所在文件名+Kt 后缀。

    2. 高阶函数

    若函数的参数或者返回值是 lambda 表达式,则称该函数为高阶函数

    thread()方法的最后一个参数是 lambda 表达式。在 Kotlin 中当调用函数只传入一个 lambda 类型的参数时,可以省去括号。所以就有了thread { doSomething() }这样简洁的调用。

    3. 参数默认值 & 命名参数

    thread()函数包含了 6 个参数,为啥在调用时可以只传最后一个参数?因为其余的参数都在定义时提供了默认值。这个语法特性叫参数默认值

    当然也可以忽略默认值,重新为参数赋值:

    thread(isDaemon = true) { doSomething() }

    当只想重新为某一个参数赋值时,不用将其余参数都重写一遍,只需用参数名 = 参数值,这个语法特性叫命名参数

    逐行读取文件内容

    再看一个稍复杂的业务场景:“读取文件中每一行的内容并打印”,用 Java 实现的代码如下:

    File file = new File(path)
    BufferedReader bufferedReader = null;
    try {
    bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
    String line;
    // 循环读取文件中的每一行并打印
    while ((line = bufferedReader.readLine()) != null) {
    System.out.println(line);
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    } finally {
    // 关闭资源
    if (bufferedReader != null) {
    try {
    bufferedReader.close();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }
    }

    对比一下 Kotlin 的解决方案:

    File(path).readLines().foreach { println(it) }

    一句话搞定,就算没学过 Kotlin 也能猜到这是在干啥,语义是如此简洁清晰。这样的代码写的时候畅快,读的时候悦目。

    之所以简单,是因为 Kotlin 通过各种语法特性将复杂度分层并隐藏在了后背。

    1. 扩展方法

    拨开简单的面纱,探究背后隐藏的复杂:

    // 为 File 扩展方法 readLines()
    public fun File.readLines(charset: Charset = Charsets.UTF_8): List<String> {
    // 构建字符串列表
    val result = ArrayList<String>()
    // 遍历文件的每一行并将内容添加到列表中
    forEachLine(charset) { result.add(it) }
    // 返回列表
    return result
    }

    扩展方法是 Kotlin 在类体外给类新增方法的语法,它用类名.方法名()表达。

    把 Kotlin 编译成 java,扩展方法就是新增了一个静态方法:

    final class FilesKt__FileReadWriteKt {
    // 静态函数的第一个参数是 File
    public static final List readLines(@NotNull File $this$readLines, @NotNull Charset charset) {
    Intrinsics.checkNotNullParameter($this$readLines, "$this$readLines");
    Intrinsics.checkNotNullParameter(charset, "charset");
    final ArrayList result = new ArrayList();
    FilesKt.forEachLine($this$readLines, charset, (Function1)(new Function1() {
    public Object invoke(Object var1) {
    this.invoke((String)var1);
    return Unit.INSTANCE;
    }

    public final void invoke(@NotNull String it) {
    Intrinsics.checkNotNullParameter(it, "it");
    result.add(it);
    }
    }));
    return (List)result;
    }
    }

    静态方法中的第一个参数是被扩展对象的实例,所以在扩展方法中可以通过this访问到类实例及其公共方法。

    File.readLines() 的语义简单明了:遍历文件的每一行,将其添加到列表中并返回。

    复杂度都被隐藏在了forEachLine(),它也是 File 的扩展方法,此处应该是this.forEachLine(charset) { result.add(it) },this 通常可以省略。forEachLine()是个好名字,一眼看去就知道是在遍历文件的每一行。

    public fun File.forEachLine(charset: Charset = Charsets.UTF_8, action: (line: String) -> Unit): Unit {
    BufferedReader(InputStreamReader(FileInputStream(this), charset)).forEachLine(action)
    }

    forEachLine()中将 File 层层包裹最终形成一个 BufferReader 实例,并且调用了 Reader 的扩展方法forEachLine()

    public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
    useLines { it.forEach(action) }

    forEachLine()调用了同是 Reader 的扩展方法useLines(),从名字细微的差别就可以看出uselines()完成了文件所有行内容的整合,而且这个整合的结果是可以被遍历的。

    2. 泛型

    哪个类能整合一组元素,并可以被遍历?沿着调用链继续往下:

    public inline fun <T> Reader.useLines(block: (Sequence<String>) -> T): T =
    buffered().use { block(it.lineSequence()) }

    Reader 在useLines()中被缓冲化:

    public inline fun Reader.buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE): BufferedReader =
    // 如果已经是 BufferedReader 则直接返回,否则再包一层
    if (this is BufferedReader) this else BufferedReader(this, bufferSize)

    紧接着调用了use(),使用 BufferReader:

    // Closeable 的扩展方法
    public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
    contract {
    callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var exception: Throwable? = null
    try {
    // 触发业务逻辑(扩展对象实例被传入)
    return block(this)
    } catch (e: Throwable) {
    exception = e
    throw e
    } finally {
    // 无论如何都会关闭资源
    when {
    apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
    this == null -> {}
    exception == null -> close()
    else ->
    try {
    close()
    } catch (closeException: Throwable) {}
    }
    }
    }

    这次的扩展函数不是一个具体类,而是一个泛型,并且该泛型的上界是Closeable,即为所有可以被关闭的类新增一个use()方法。

    use()扩展方法中,lambda 表达式block代表了业务逻辑,扩展对象作为实参传入其中。业务逻辑在try-catch代码块中被执行,最后在finally中关闭了资源。上层可以特别省心地使用这个扩展方法,因为不再需要在意异常捕获和资源关闭。

    3. 重载运算符 & 约定

    读取文件内容的场景中,use() 中的业务逻辑是将BufferReader转换成LineSequence,然后遍历它。这里的遍历和类型转换分别是怎么实现的?

    // 将 BufferReader 转化成 Sequence
    public fun BufferedReader.lineSequence(): Sequence<String> =
    LinesSequence(this).constrainOnce()

    还是通过扩展方法,直接构造了LineSequence对象并将BufferedReader传入。这种通过组合方式实现的类型转换和装饰者模式颇为类似(关于装饰者模式的详解可以点击使用组合的设计模式 | 美颜相机中的装饰者模式

    LineSequence 是一个 Sequence:

    // 序列
    public interface Sequence<out T> {
    // 定义如何构建迭代器
    public operator fun iterator(): Iterator<T>
    }

    // 迭代器
    public interface Iterator<out T> {
    // 获取下一个元素
    public operator fun next(): T
    // 判断是否有后续元素
    public operator fun hasNext(): Boolean
    }

    Sequence是一个接口,该接口需要定义如何构建一个迭代器iterator。迭代器也是一个接口,它需要定义如何获取下一个元素及是否有后续元素。

    2 个接口中的 3 个方法都被保留词operator修饰,它表示重载运算符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个约定就是iterator() + next() + hasNext()for循环的约定。

    for 循环在 Kotlin 中被定义为“遍历迭代器提供的元素”,需要和in保留词一起使用:

    public inline fun <T> Sequence<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
    }

    Sequence 有一个扩展法方法forEach()来简化遍历语法,内部就使用了“for + in”来遍历序列中所有的元素。

    所以才可以在Reader.forEachLine()中用如此简单的语法实现遍历文件中的所有行。

    public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
    useLines { it.forEach(action) }

    关于 Sequence 的用法实例可以点击Kotlin 基础 | 望文生义的 Kotlin 集合操作

    LineSequence 的语义是 Sequence 中每一个元素都是文件中的一行,它在内部实现iterator()接口,构造了一个迭代器实例:

    // 行序列:在 BufferedReader 外面包一层 LinesSequence
    private class LinesSequence(private val reader: BufferedReader) : Sequence<String> {
    override public fun iterator(): Iterator<String> {
    // 构建迭代器
    return object : Iterator<String> {
    private var nextValue: String? = null // 下一个元素值
    private var done = false // 迭代是否结束

    // 判断迭代器中是否有下一个元素,并顺便获取下一个元素存入 nextValue
    override public fun hasNext(): Boolean {
    if (nextValue == null && !done) {
    // 下一个元素是文件中的一行内容
    nextValue = reader.readLine()
    if (nextValue == null) done = true
    }
    return nextValue != null
    }

    // 获取迭代器中下一个元素
    override public fun next(): String {
    if (!hasNext()) {
    throw NoSuchElementException()
    }
    val answer = nextValue
    nextValue = null
    return answer!!
    }
    }
    }
    }

    LineSequence 内部的迭代器在hasNext()中获取了文件中一行的内容,并存储在nextValue中,完成了将文件中每一行的内容转换成 Sequence 中的一个元素。

    当在 Sequence 上遍历时,文件中每一行的内容就一个个出现在迭代中。这样做的好处是对内存更加友好,LineSequence 并没有持有文件中所有行的内容,它只是定义了如何获取文件中下一行的内容,所有的内容只有等待遍历时,才一个个地浮现出来。

    用一句话总结 Kotlin 逐行读取文件内容的算法:用缓冲流(BufferReader)包裹文件,再用行序列(LineSequence)包裹缓冲流,序列迭代行为被定义为读取文件中一行的内容。遍历序列时,文件内容就一行行地被添加到列表中。

    总结

    顶层函数、高阶函数、默认参数、命名参数、扩展方法、泛型、重载运算符,Kotlin 利用了这些语法特性隐藏了实现常用业务功能的复杂度,并且在内部将复杂度分层。

    分层是降低复杂度的惯用手段,它不仅让复杂度分散,使得同一时刻只需面对有限的复杂度,并且可以通过对每一层取一个好名字来概括本层的语义。除此之外,它还有助于定位问题(缩小问题范围)并增加代码可复用性(每层单独复用)。

    是不是也可以效仿这种分层的思想方法,在写代码之前,琢磨一下,复杂度是不是太高了?可以运用那些语言特性实现合理的抽象将复杂度分层?以避免复杂度在一个层次被铺开。

    收起阅读 »

    Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?(一)

    CoroutineContext是 Kotlin 协程中的核心概念,它是用来干嘛的?它由哪些元素组成?它为什么要这样设计?这篇试着分析源码以回答这些问题。 indexed set 既是 set 又是 map? CoroutineContext的定义如下: /*...
    继续阅读 »

    CoroutineContext是 Kotlin 协程中的核心概念,它是用来干嘛的?它由哪些元素组成?它为什么要这样设计?这篇试着分析源码以回答这些问题。


    indexed set 既是 set 又是 map?


    CoroutineContext的定义如下:


    /**
    * Persistent context for the coroutine. It is an indexed set of [Element] instances.
    * An indexed set is a mix between a set and a map.
    * Every element in this set has a unique [Key].
    */
    public interface CoroutineContext { ... }

    暂且把CoroutineContext译成协程上下文,简称上下文。


    从注解来看,上下文是一个Element的集合,这种集合被称为indexed set。它是介于 set 和 map 之间的一种结构。set 意味着其中的元素有唯一性,map 意味着每个元素都对应一个键。


    public interface CoroutineContext {
    // Element 也是一个上下文
    public interface Element : CoroutineContext { ... }
    }

    没想到Element也是一个上下文,所以协程上下文是包含了一系列上下文的集合(自己包含自己)。暂且称在协程上下文内部的一系列上下文为子上下文


    上下文如何保证子上下文各自的唯一性?


    public interface CoroutineContext {
    public interface Key<E : Element>
    }

    上下文为每个子上下文分配了一个Key,它是一个带有类型信息的接口。这个接口通常被实现为companion object


    // 子上下文:Job
    public interface Job : CoroutineContext.Element {
    // Job 的静态 Key
    public companion object Key : CoroutineContext.Key<Job> { ... }
    }

    // 子上下文:拦截器
    public interface ContinuationInterceptor : CoroutineContext.Element {
    // 拦截器的静态 Key
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    }

    // 子上下文:协程名
    public data class CoroutineName( val name: String ) : AbstractCoroutineContextElement(CoroutineName) {
    // 协程名的静态 Key
    public companion object Key : CoroutineContext.Key<CoroutineName>
    }

    // 子上下文:异常处理器
    public interface CoroutineExceptionHandler : CoroutineContext.Element {
    // 异常处理器的静态 Key
    public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
    }

    列举了若干源码中定义的子上下文,它们有一个共性,都会在内部声明一个静态的Key,类内部的静态变量意味着被所有类实例共享,即全局唯一的 Key 实例可以对应多个子上下文实例。然而在一个类似 map 的结构中,每个键必须是唯一的,因为对相同的键 put 两次值,新值会代替旧值。如此一来,键的唯一性这就保证了上下文中的所有子上下文实例都是唯一的。这就是indexed set集合的内涵。


    做个阶段性总结:





    1. 协程上下文是一个元素的集合,单个元素本身也是一个上下文,所以协程上下文的定义是递归的,自包含的(自己包含若干个自己)。




    2. 协程上下文这个集合有点像 set 结构,因为其中的元素都是唯一的,不重复的。为了做到这一点,每一个元素都配有一个静态的键实例,构成一组键值对,这使得它又有点像 map 结构。这种介于 set 和 map 之间的结构称为indexed set





    从 indexed set 获取元素


    集合必然提供了存取其中元素的方法,CoroutineContextElement元素的集合,取元素的方法定义如下:


    public interface CoroutineContext {
    // 根据 key 在上下文中查找元素
    public operator fun <E : Element> get(key: Key<E>): E?
    }

    get()方法输入 Key 返回 Element。CoroutineContext 的子类Element有一个get()的实现:


    public interface CoroutineContext {
    // 元素
    public interface Element : CoroutineContext {
    // 元素的键
    public val key: Key<*>
    public override operator fun <E : Element> get(key: Key<E>): E? =
    // 如果给定键和元素本身键相同,则返回当前元素,否则返回空
    if (this.key == key) this as E else null
    }
    }

    协程上下文是元素的集合,而元素也是一个上下文,所以元素也是一个元素的集合(解释递归的定义有点像绕口令)。只不过这个元素集合有一点特别,它只包含一个元素,即它本身。这从Element.get()方法的实现中也可以看出:当从 Element 的元素集合中获取元素时,要么返回自身,要么返回空。


    协程上下文还有一个实现类叫CombinedContext混合上下文,它的get()实现如下:


    // 混合上下文(大蒜)
    internal class CombinedContext(
    // 左上下文
    private val left: CoroutineContext,
    // 右元素
    private val element: Element
    ) : CoroutineContext, Serializable {
    // 根据 key 在上下文中查找元素
    override fun <E : Element> get(key: Key<E>): E? {
    var cur = this
    while (true) {
    // 如果输入 key 和右元素的 key 相同,则返回右元素(剥去大蒜的一片)
    cur.element[key]?.let { return it }
    // 若右元素不匹配,则向左继续查找
    val next = cur.left
    // 如果左上下文是混合上下文,则开始向左递归(剥去一片后还是一个大蒜,继续剥)
    if (next is CombinedContext) {
    cur = next
    }
    // 若左上下文不是混合上下文,则结束递归
    else {
    return next[key]
    }
    }
    }
    }

    CombinedContext.get() 用 while 循环实现了类似递归的效果。CombinedContext的定义本身就是递归的,它包含两个成员:leftelement,其中left是一个协程上下文,若left实例是另一个CombinedContext,就发生了自己包含自己的递归情况,这结构非常像大蒜:left是“蒜体”,element是“蒜皮”。当剥开一片蒜皮后,发现还是一颗大蒜,只是变小了而已。


    CombinedContext.get() 这个算法就好比是“找到一棵大蒜中指定的一片蒜皮”,每剥去一片,都检查一下是不是想要的那一片,若不是就继续剥下一片,就这样递归地进行下去,直到命中了指定片或大蒜被剥空了。


    CombinedContext这颗大蒜还是偏心的,即它的最后一片不在正中心,而是在最左边(当left的类型不再是CombinedContext时),但遍历这颗大蒜是从最右边开始向左进行的,这使得每一片蒜皮拥有不同的优先级,越早被遍历到,优先级越高。


    做一个阶段性总结:



    CombinedContext是协程上下文的一个具体实现,就像协程上下文一样,它也包含了一组元素,这组元素被组织成 “偏心大蒜” 这种自包含的结构。偏心大蒜也是 indexed set 的一种具体实现,即它用唯一键对应唯一值的方式保证了集合中元素的唯一性。但和 set 和 map 这种“平”的结构不同的是,偏心大蒜内元素天然是有层级的,遍历大蒜结构是从外层向内(从右到左)进行的,越先被遍历到的元素自然具有较高的优先级。



    向 indexed set 追加元素


    说完取元素操作,接着说存元素:


    public interface CoroutineContext {
    // 重载操作符
    public operator fun plus(context: CoroutineContext): CoroutineContext =
    // 若追加上下文是空的(等于啥也没追加),则直接返回当前山下文(高性能返回)
    if (context === EmptyCoroutineContext) this else
    // 以当前上下文为初始值进行累加
    context.fold(this) { acc, element -> // 累加算法 }
    }

    CoroutineContext 使用operator保留词重载了plus操作符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个就是plus()+的约定。当两个 CoroutineContext 实例通过+相连时,就等价于调用了plus()方法,这样做的目的是增加代码可读性。


    plus() 的返回值是CoroutineContext,这使得c1 + c2 + c3这样的链式调用变得方便。


    EmptyCoroutineContext是一个特殊的上下文,它不包含任何元素,这从它的get()方法的实现中可见一斑:


    // 空协程上下文
    public object EmptyCoroutineContext : CoroutineContext, Serializable {
    // 返回空元素
    public override fun <E : Element> get(key: Key<E>): E? = null
    ...
    }

    plus() 中调用的CoroutineContext.fold()是将协程上下文中元素进行累加的接口:


    public interface CoroutineContext {
    public fun <R> fold(initial: R, operation: (R, Element) -> R): R
    }

    fold() 需要输入一个累加初始值initial和累加算法operation。先来看看 plus() 方法中定义的累加算法:


    public interface CoroutineContext {
    public operator fun plus(context: CoroutineContext): CoroutineContext =
    if (context === EmptyCoroutineContext) this else
    // 以当前上下文为初始值进行累加
    context.fold(this) { acc, element ->
    // 将追加的元素抽出以便将其重定位
    val removed = acc.minusKey(element.key)
    // 若集合中只包含追加元素,则不需要重定位,直接返回
    if (removed === EmptyCoroutineContext) element else {
    // 获取元素集合中的 Interceptor
    val interceptor = removed[ContinuationInterceptor]
    // 如果元素集合中不包含 Interceptor 则将追加元素作为最外层蒜皮
    if (interceptor == null) CombinedContext(removed, element) else {
    // 如果元素集合中包含 Interceptor 则将其抽出以便将其重定位
    val left = removed.minusKey(ContinuationInterceptor)
    // 元素集合中只包含 Interceptor 和追加元素
    if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
    // 将 Interceptor 作为最外层蒜皮,追加元素作为次外层蒜皮
    CombinedContext(CombinedContext(left, element), interceptor)
    }
    }
    }
    }

    累加算法有两个输入参数,一个代表当前累加值acc,另一个代表新追加的元素element。上述算法可以概括为:“当向协程上下文中追加元素时,总是会将所有元素重定位。定位原则如下:将 Interceptor 和新追加的元素依次放在偏心大蒜的最外层和次外层。”


    minusKey()


    其中minusKey()也是协程上下文的一个接口:


    public interface CoroutineContext {
    public fun minusKey(key: Key<*>): CoroutineContext
    }

    minusKey()返回一个协程上下文,该上下文的元素集合中去掉了 key 对应的元素。Element 对该接口的实现如下:


    public interface Element : CoroutineContext {
    public override fun minusKey(key: Key<*>): CoroutineContext =
    if (this.key == key) EmptyCoroutineContext else this
    }

    因为 Element 只包含一个元素,如果要去掉的元素就是它自己,则返回一个空上下文,否则返回自己。


    CombineContext 对 minusKey() 的实现如下:


    internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element
    ) : CoroutineContext, Serializable {
    public override fun minusKey(key: Key<*>): CoroutineContext {
    // 1. 如果最外层就是要去掉的元素,则直接返回左上下文
    element[key]?.let { return left }
    // 2. 在左上下文中去掉对应元素
    val newLeft = left.minusKey(key)
    return when {
    // 2.1 左上下文中也不包含对应元素
    newLeft === left -> this
    // 2.2 左上下文中除了对应元素外不包含任何元素,返回右元素
    newLeft === EmptyCoroutineContext -> element
    // 2.3 将移除了对应元素的左上下文和右元素组合成新得混合上下文
    else -> CombinedContext(newLeft, element)
    }
    }

    可以总结为:在偏心大蒜结构中找到对应的蒜皮,并把它剔除,然后将剩下的所有蒜皮按原来的顺序重新组合成偏心大蒜结构。


    Element.fold()


    分析完累加算法之后,看看Elementfold()的实现:


    public interface CoroutineContext {
    public interface Element : CoroutineContext {
    public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
    operation(initial, this)
    }

    Element 在这个方法中将自己作为追加值。结合上面的累加算法,可以这样理解 Element 累加:“Element 总是将自己作为被追加的元素,即 Element 总是会出现在偏心大蒜的最外层。”


    举个例子:


    val e1 = Element()
    val e2 = Element()
    val context = e1 + e2

    上述代码中的 context 是一个什么结构?推理如下:



    • e1 + e2 等价于e2.fold(e1)

    • 因为 e2 是 Element 类型,所以调用 Element.fold(),等价于operation(e1, e2)

    • operation 就是上述累加算法,结合累加算法,最终得出 context = CombinedContext(e1, e2)


    再举一个更复杂的例子:


    val e1 = Element()
    val e2 = Element()
    val e3 = Element()
    val c = CombinedContext(e1, e2)
    val context = c + e3

    上述代码中的 context 是一个什么结构?推理如下:



    • c + e3 等价于e3.fold(c)

    • 因为 e3 是 Element 类型,所以调用 Element.fold(),等价于operation(c, e3)

    • operation 就是上述累加算法,结合累加算法,最终得出 context = CombinedContext(c, e2)

    • 将 context 完全展开如下:CombinedContext(CombinedContext(e1, e2), e3)


    做一个阶段性总结:



    两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 Element 类型的,即被加数中只包含一个元素,则该元素总是被追加到偏心的大蒜的最外层。



    CombinedContext.fold()


    再来看看CombinedContextfold()的实现:


    internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element
    ) : CoroutineContext, Serializable {
    public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
    operation(left.fold(initial, operation), element)
    }

    这就比 Element 的复杂多了,因为有递归。


    还是举一个例子:


    val e1 = Element()
    val e2 = Element()
    val e3 = Element()
    val c = CombinedContext(e1, e2)
    val context = e3 + c // 和上一个例子几乎是一样的,只是换了下加数与被加数的位置

    上述代码中的 context 是一个什么结构?推理如下:



    • e3 + c 等价于c.fold(e3)

    • 因为 c 是 CombinedContext 类型,所以调用 CombinedContext.fold(),等价于operation(e1.fold(e3), e2)

    • 其中e1.fold(e3)等价于operation(e3, e1),它的值为 CombinedContext(e3, e1)

    • 将第三步结果代入第二步,最终得出 context = CombinedContext(CombinedContext(e3, e1), e2)


    再做一个阶段性总结:



    两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 CombinedContext 类型的,即被加数包含一个左侧的蒜体和一个右侧的蒜皮,则蒜皮还是在原来的位置待着,蒜体会和加数融合成新的偏心大蒜结构。



    总结


    这一篇介绍了 CoroutineContext 的数据结构,它包含如下特征:



    1. 协程上下文是一个元素的集合,单个元素本身也是一个上下文,所以协程上下文的定义是递归的,自包含的(自己包含若干个自己)。

    2. 协程上下文这个集合有点像 set 结构,因为其中的元素都是唯一的,不重复的。为了做到这一点,每一个元素都配有一个静态的键实例,构成一组键值对,这使得它又有点像 map 结构。这种介于 set 和 map 之间的结构称为indexed set

    3. CombinedContext是协程上下文的一个具体实现,就像协程上下文一样,它也包含了一组元素,这组元素被组织成 “偏心大蒜” 这种自包含的结构。偏心大蒜也是 indexed set 的一种具体实现,即它用唯一键对应唯一值的方式保证了集合中元素的唯一性。但和 set 和 map 这种“平”的结构不同的是,偏心大蒜内元素天然是有层级的,遍历大蒜结构是从外层向内(从右到左)进行的,越先被遍历到的元素自然具有较高的优先级。

    4. 两个协程上下文做加法运算意味着将它们的元素合并形成一个新的更大的偏心大蒜。若被加数是 Element 类型的,即被加数中只包含一个元素,则该元素总是被追加到偏心的大蒜的最外层。若被加数是 CombinedContext 类型的,即被加数包含一个左侧的蒜体和一个右侧的蒜皮,则蒜皮还是在原来的位置待着,蒜体会和加数融合成新的偏心大蒜结构。

    作者:唐子玄
    链接:https://juejin.cn/post/6978613779252641799
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Compose Column控件讲解并且实现一个淘宝商品item的效果

    前情提要本篇文章主要对 Compose 中的 Column 进行使用解析,文章结束会使用 Column 和 Row 配合实现一个淘宝商品 Item 的效果,最终效果预览:如果您对 Column 的用法比较娴熟,可以直接看最后一节的内容Column 简单说明Co...
    继续阅读 »


    前情提要

    本篇文章主要对 Compose 中的 Column 进行使用解析,文章结束会使用 Column 和 Row 配合实现一个淘宝商品 Item 的效果,

    最终效果预览:

    如果您对 Column 的用法比较娴熟,可以直接看最后一节的内容

    Column 简单说明

    Column 对应于我们开发中的 LinearLayout.vertical,可以垂直的摆放内部控件

    因为 Row 和 Column 是想通的,只不过 Column 是垂直方向布局的,而 Row 是水平方向布局。所以讲完了 Column 你只需要把例子代码中的 Column 换成 Row 就可以自行查看 Row 的效果了

    Column 参数介绍

    modifier

    用来定义 Column 的各种属性,比如可以定义宽度、高度、背景等

    1. 示例代码

      设置 modifier 的时候可以链式调用

    @Composable
    fun DefaultPreview() {
    Column(modifier = Modifier
    .width(300.dp)
    .height(200.dp)
    .background(color = Color.Green)) {

    }
    }
    1. 实现效果

    展示了一个绿色填充的矩形

    verticalArrangement

    实现内部元素的竖直对齐效果

    关于 verticalArrangement 我们的示例代码如下

    后面介绍每种效果的时候会更改 verticalArrangement 的值进行展示

    Row() {
    Spacer(modifier = Modifier.width(100.dp))
    Column(
    modifier = Modifier
    .width(50.dp)
    .height(200.dp)
    .background(color = Color.Green),
    // verticalArrangement = Arrangement.SpaceAround
    ) {
    Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
    Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
    Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
    Image(modifier = Modifier.size(20.dp),painter = painterResource(id = R.drawable.apple), contentDescription = null)
    }
    }
    不设置该属性的效果
    1. 效果

    2. 结论

      不设置该属性的时候,内部元素贴着顶部紧凑排列

    Arrangement.Center
    1. 效果

    1. 结论

      所有元素垂直居中,紧凑排列

    Arrangement.SpaceBetween
    1. 效果

    1. 结论

      元素之间均分空间,与顶部和底部之间无间距

    SpaceAround 效果
    1. 效果

    1. 结论

      内部元素等分空间,并且顶部和底部留间距(顶部元素距离顶部的距离和底部元素距离底部的距离与元素等分的长度不一致)

    Arrangement.SpaceEvenly
    1. 效果

    1. 结论

      所有元素均分空间(顶部元素距离顶部的距离和底部元素距离底部的距离与元素等分的长度一致)

    Arrangement.Bottom
    1. 效果

    1. 结论

      所有元素靠近底部,紧凑排列

    Arrangement.spacedBy(*.dp)

    可以设置元素间的等分距离

    比如我们设置 20dp,Arrangement.spacedBy(20.dp)

    1. 效果

    1. 结论

    元素之间距离为 20dp,靠近顶部排列

    horizontalAlignment

    实现 Column 的水平约束

    Alignment.Start 居开始的位置对齐
    1. 效果

    1. 结论

      当前模拟器 Start 就是 Right,所以内部元素居左侧对齐

    Alignment.CenterHorizontally 水平居中
    1. 效果

    2. 结论

      内部元素水平居中对齐

    Alignment.End
    1. 效果

    1. 结论

      当前模拟器 End 就是 Left,所以内部元素居右侧对齐

    content

    关于这个属性,注释中都没写他我也就先不研究了

    使用 Column 实现淘宝商品 item 布局

    1. 本例中目标效果图如下

    1. 代码
    @Composable
    fun DefaultPreview2() {
    Row(
    modifier = Modifier.fillMaxSize(1f).background(color= Color.Gray),
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.Center
    ) {
    Column(
    modifier = Modifier
    .width(200.dp)
    .background(color = Color.White)
    .padding(all = 10.dp)
    ) {
    Image(
    painter = painterResource(id = R.drawable.apple),
    contentDescription = null,
    modifier = Modifier.size(180.dp).clip(RoundedCornerShape(10.dp))
    )
    Text(
    text = "当天发,不要钱",
    fontSize = 20.sp,
    style = TextStyle(fontWeight = FontWeight.Bold),
    modifier = Modifier.padding(vertical = 2.dp)
    )
    Row(
    modifier = Modifier.padding(vertical = 2.dp),
    verticalAlignment = Alignment.CenterVertically
    ) {
    Text(
    text = "¥说了不要钱",
    fontSize = 14.sp,
    color = Color(0xff9f8722)
    )
    Text(text = "23人免费拿", fontSize = 12.sp)
    }
    Row(
    modifier = Modifier
    .width(200.dp)
    .fillMaxWidth()
    .padding(vertical = 2.dp),
    verticalAlignment = Alignment.CenterVertically
    ) {
    Text(text = "不要钱")
    Spacer(modifier = Modifier.weight(1f))//通过设置weight让Spacer把Row撑开,实现后面的图片居右对齐的效果
    Image(
    painter = painterResource(id = android.R.drawable.btn_star_big_on),
    contentDescription = null,
    )
    }
    }
    }
    }
    1. 实现说明

    本商品 item 分为四部分:

    第一部分:图片,我们使用 Image 实现

    第二部分:商品描述,使用一个 Text

    第三部分:价格,使用 Row 套两个 Text 实现

    第四部分:分期情况,使用 Row 套一个 Text 和 Image 完成,注意因为图片要居右对齐,所以中间需要使用一个 Spacer 挤满剩余宽度。

    淘宝商品 item 实现要点

    1. 我们可以使用 modifier = Modifier .width(200.dp) 设置 Column 的宽度
    2. Modifier.padding(all = 10.dp)可以设置四个方向的内边距
    3. modifier = Modifier.size(180.dp).clip(RoundedCornerShape(10.dp)可以设置圆角,因为本例中图片背景和控件背景都是白色,所以看不出来效果
    4. 最底部的控件需要让收藏按钮贴近父控件右侧对齐,使用 Modifier.weight 实现: Spacer(modifier = Modifier.weight(1f))
    收起阅读 »

    Android 布局打气筒 (一):玩转 LayoutInflater

    前言很高兴遇见你~今天准备和大家分享的是 LayoutInflater,我给它取名:布局打气筒,很形象,其实就是根据英文翻译过来的😂。我们知道气球打气筒可以给气球打气从而改变它的形状。而布局打气筒的作用就是给我们的 Xml 布局打气让它变成一个个 View 对...
    继续阅读 »

    前言

    很高兴遇见你~

    今天准备和大家分享的是 LayoutInflater,我给它取名:布局打气筒,很形象,其实就是根据英文翻译过来的😂。我们知道气球打气筒可以给气球打气从而改变它的形状。而布局打气筒的作用就是给我们的 Xml 布局打气让它变成一个个 View 对象。在我们的日常工作中,经常会接触到他,因为只要你写了 Xml 布局,你就要使用 LayoutInflater,下面我们就来好好讲讲它。

    注意:本文所展示的系统源码都是基于Android-30 ,并提取核心部分进行分析

    一、基本使用

    1、LayoutInflater 实例获取

    1)、通过 LayoutInflater 的静态方法 from 获取

    2)、通过系统服务 getSystemService 方法获取

    3)、如果是在 Activity 或 Fragment 可直接获取到实例

    //1、通过 LayoutInflater 的静态方法 from 获取
    val layoutInflater: LayoutInflater = LayoutInflater.from(this)

    //2、通过系统服务 getSystemService 方法获取
    val layoutInflater: LayoutInflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater

    //3、如果是在 Activity 或 Fragment 可直接获取到实例
    layoutInflater //相当于调用 getLayoutInflater()

    实际上,1 是 2 的简单写法,只是 Android 给我们做了一下封装。拿到 LayoutInflater 实例后,我们就可以调用它的 inflate 系列方法了,这几个方法是本篇文章的一个重点,如下:

    image-20210622163719911

    从 Xml 布局到创建 View 对象,这几个方法扮演着至关重要的作用,其中我们用的最多就是第一个和第三个重载方法,现在我们就来使用一下

    二、例子

    1、创建一个新项目,MainActivity 对应的布局如下:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/cons_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"/>

    2、创建一个新的布局取名 item_main.xml,如下图:

    image-20210622174620878

    3、修改 MainActivity 中的代码

    class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val consMain = findViewById<ConstraintLayout>(R.id.cons_main)
    val itemMain = layoutInflater.inflate(R.layout.item_main, null)
    consMain.addView(itemMain)
    }
    }

    上述代码我们使用了两个参数的 inflate 重载方法,第二个参数 root 传了一个 null ,然后把当前布局添加到 Activity 中,运行看下效果:

    image-20210622175552693

    啥情况?怎么和预想的不一样呢?我的背景颜色怎么不见了?把这个问题 1 先记着

    接下来,我们修改一下 MainActivity 中的代码,如下:

    val itemMain = layoutInflater.inflate(R.layout.item_main, consMain)
    //等同下面这行代码
    val itemMain = layoutInflater.inflate(R.layout.item_main, consMain,true)

    实际上上面这句代码就相当于调用了三个参数的重载方法,且第三个参数为 true,我们看下它两个参数的源码:

    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
    return inflate(resource, root, root != null);
    }

    现在在运行看下结果:

    image-20210622190018488

    报错了,提示我们当前 child 已经有了一个父 View,你必须先调用父 View 的 removeView 方法移除当前 child 才行。是不是疑问更多了呢?把这个问题 2 也先记着

    我们在修改一下 MainActivity 中的代码,如下:

    val itemMain = layoutInflater.inflate(R.layout.item_main, consMain,false)

    在运行看下结果:

    image-20210622190835239

    嗯,现在达到了我们预期的效果

    现在回到上面那两个问题,分析发现是 LayoutInflater inflate 方法传了不同的参数导致的,那这些参数到底有什么玄乎的地方呢?接下来跟着我的脚步分析下源码,或许你就豁然开朗了

    三、LayoutInflater inflate 系列方法源码分析

    在分析源码之前,我们需要明白一些基础知识:

    我们一般都会使用 layout_width 和 layout_height 来设置 View 的大小,实际上是要满足一个条件,那就是这个 View 必须存在于一个容器或布局中,否则没有意义,之后如果将 layout_width 设置成 match_parent 表示让 View 的宽度填充满布局,如果设置成 wrap_content 表示让 View 的宽度刚好可以包含其内容,如果设置成具体的数值则 View 的宽度会变成相应的数值。这也是为什么这两个属性叫作 layout_width 和layout_height,而不是 width 和 height 。

    明白了上面这些知识,我们继续往下看

    实际上,我们调用 LayoutInflater inflate 系列方法,最终都会走到上述截图的第 4 个重载方法,看下它的源码,仅贴出关键代码:

    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {
    //...
    //获取布局 Xml 里面的属性集合
    AttributeSet attrs = Xml.asAttributeSet(parser);
    // 将传入的 root 赋值 给 result
    View result = root;

    // 创建根 View 赋值给 temp
    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

    ViewGroup.LayoutParams params = null;

    if (root != null) {
    //...
    //如果传入的 root 不为空,通过 root 和布局属性生成布局参数
    params = root.generateLayoutParams(attrs);
    if (!attachToRoot) {
    // 如果传入的 attachToRoot 为 false 则给当前创建的根 View 设置布局参数
    temp.setLayoutParams(params);
    }
    }

    //递归创建子 View 并添加到父布局中
    rInflateChildren(parser, temp, attrs, true);

    if (root != null && attachToRoot) {
    //如果 root 不为空且 attachToRoot 为 true,添加当前创建的根 View 到 root
    root.addView(temp, params);
    }

    if (root == null || !attachToRoot) {
    //如果 root 为空或者 attachToRoot 为 false, 将当前创建的根 View 赋值给 result
    result = temp;
    }

    //...
    //返回当前 result
    return result;
    }
    }

    上述代码我们可以得到一些结论:

    1、如果传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 设置布局参数

    注意:Xml 布局生成的根 View 并没有被添加到任何其他 View 中,此时根 View 的布局属性不会生效,但是我们给它设置了布局参数,那么它就会生效,只是没有被添加到任何其他 View 中

    2、如果传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中

    注意:此时 Xml 布局生成的根 View 已经被添加到其他 View 中,注意避免重复添加而报错

    3、如果传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

    注意:此时 Xml 布局生成的根 View 既没有被添加到其他 View 中,也没有设置布局参数,那么它的布局参数将会失效

    明白了上面这些知识点,我们在看下为啥为会出现之前那些问题

    四、问题分析

    1、问题 1

    上述问题 1 实际上我们是调用了 LayoutInflater 两个参数的 inflate 重载方法:

    inflate(@LayoutRes int resource, @Nullable ViewGroup root)

    传入的实参: resouce 传入了一个 Xml 布局,root 传入了 null

    根据我们上面源码得到的结论,当传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

    那么此时这个布局根 View 不在任何 View 中,因此它的布局属性失效了,但是 TextView 在一个布局中,它的布局属性会生效,因此就出现了上述截图中的效果

    2、问题 2

    上述问题 2 我们调用的还是 LayoutInflater 两个参数的构造方法

    传入的实参: resouce 传入了一个 Xml 布局,root 传入了 consMain

    实际又会调用 LayoutInflater 三个参数的 inflate 重载方法:

    inflate(@LayoutRes int resource, @Nullable ViewGroup root,boolean attachToRoot)

    此时传入实参变为:resouce 传入了一个 Xml 布局,root 传入了 consMain,attachToRoot 传入了 true

    根据我们上面源码得到的结论:当传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中

    此时我们在 MainActivity 中又重复调用了 addView 方法,因此就报那个错了。如果想不报错,把 MainActivity 中的那行 addView 去掉就可以了

    3、预期效果

    上述预期效果,我们调用的是 LayoutInflater 三个参数的 inflate 重载方法

    传入的实参:resouce 传入了一个 Xml 布局,root 传入了 consMain,attachToRoot 传入了 false

    根据我们上面源码得到的结论:当传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 对象设置布局参数

    此时根 View 的布局属性会生效,只不过没有被添加到任何 View 中,而又因为 MainActivity 中调用了 addView 方法,把当前根 View 添加了进去,所以达到了我们预期的效果

    到这里,你是否明白了 LayoutInflater inflate 方法的应用了呢?

    如果还有疑问,欢迎评论区给我提问,我们一起讨论

    五、为啥 Activity 中布局根 View 的布局属性会生效?

    看下面这张图:

    注意:Android 版本号和应用主题会影响到 Activity 页面组成,这里以常见页面为例

    image-20210622210219600

    我们的页面中有一个顶级 View 叫 DecorView,DecorView 中包含一个竖直方向的 LinearLayout,LinearLayout 由两部分组成,第一部分是标题栏,第二部分是内容栏,内容栏是一个FrameLayout,我们在 Activity 中调用 setContentView 就是将 View 添加到这个FrameLayout 中。

    看到这里你应该也明白了:Activity 中布局根 View 的布局属性之所以能生效,是因为 Android 会自动在布局文件的最外层再嵌套一个FrameLayout

    六、总结

    本篇文章重点内容:

    1、 LayoutInflater inflate 方法参数的应用,记住下面这个规律:

    • 当传入的 root 不为 null 且 attachToRoot 为 false,此时会给 Xml 布局生成的根 View 设置布局参数
    • 当传入的 root 不为 null 且 attachToRoot 为 true,此时会将 Xml 布局生成的根 View 通过 addView 方法携带布局参数添加到 root 中
    • 当传入的 root 为 null ,此时会将 Xml 布局生成的根 View 对象直接返回

    2、Activity 中布局根 View 的布局属性会生效是因为 Android 会自动在布局文件的最外层再嵌套一个 FrameLayout

    收起阅读 »

    通俗易懂的Android屏幕刷新机制

    前言我们买手机的时候经常听说这个手机多少多少HZ刷新率。目前手机大多都是60HZ,现在有的手机都到144HZ的高刷新率了。这个刷新率指标是干什么的呢?屏幕又是如何将数据显示到Android手机屏幕上的呢?玩游戏时的卡顿是怎么形成的? 基于对这些问题的好奇,小研...
    继续阅读 »

    前言

    我们买手机的时候经常听说这个手机多少多少HZ刷新率。目前手机大多都是60HZ,现在有的手机都到144HZ的高刷新率了。这个刷新率指标是干什么的呢?屏幕又是如何将数据显示到Android手机屏幕上的呢?玩游戏时的卡顿是怎么形成的? 基于对这些问题的好奇,小研究了一番,就有了以下这些内容:

    Android屏幕刷新机制导图.png

    相关基础概念

    人眼视觉残留

    当物体在快速运动时, 当人眼所看到的影像消失后,人眼仍能继续保留其影像1/24秒左右的图像,这种现象被称为视觉暂留现象,是人眼具有的一种性质。

    这是因为:人眼观看物体时,成像于视网膜上,并由视神经输入人脑,感觉到物体的像。但当物体移去时,视神经对物体的印象不会立即消失,而要延续1/24秒左右的时间。

    逐行扫描

    显示器并不是一次性将画面显示到屏幕上,而是从左到右边,从上到下逐行扫描,顺序显示整屏的一个个像素点。

    帧、帧率(数)、刷新率

    在视频领域,是指每一张画面。

    需要注意帧率和刷新率不是一个概念:

    • 帧率(frame rate)指的是显卡1秒钟渲染好并发送给显示器多少张画面。

    • 刷新率指的是显示器逐行扫描刷新的速度。以 60 Hz 刷新率的屏幕为例,就是1s会刷60帧,一帧需要1000 / 60 ,约等于16ms,这个速度快到普通人眼感受不到屏幕在扫描。

    画面撕裂

    画面撕裂的形成,简单点说就是显示器把两帧或两帧以上的数据同时显示在同一个画面的现象。就像这样:

    图像撕裂.png

    屏幕刷新频率是固定的,通常是60Hz。比如在60Hz的屏幕下,每16.6ms从Buffer取一帧数据并显示。理想情况下,GPU绘制完一帧,显示器显示一帧。

    但现在显卡性能大幅提高,帧率太高出现画面撕裂。屏幕刷新频率是固定的,通常是60Hz,如果显卡的输出高于60fps,两者不同步,画面便会显示撕裂的效果。其实,帧率太低也是会出现画面撕裂。

    所以背后的本质问题就是,当刷新率和帧率不一致就会出现,就很容易出现画面撕裂现象

    拓展知识点:显卡与数据流动到显示屏过程

    显卡主要负责把主机向显示器发出的显示信号转化为一般电器信号(数模转换),使得显示器能明白个人电脑在让它做什么。显卡的主要芯片叫“显示芯片”(Video chipset,也叫GPUVPU,图形处理器或视觉处理器),是显卡的主要处理单元。显卡上也有和电脑存储器相似的存储器,称为“显示存储器”,简称显存。

    数据离开CPU到达显示屏,中间经历比较关键的步骤:

    1.从总线进入GPU:将CPU送来的数据送到北桥(简单理解成连接显卡等高速设备的),再送到GPU里面进行处理

    2.将芯片处理完的数据送到显存。

    3.从显存读取出数据再送到随机读写存储,数模转换器进行数模转换的工作(但是如果是DVI接口类型的显卡,直接输出数字信号)

    4.从DAC进入显示器:将转换完的模拟信号送到显示屏

    所以显卡很关键的作用是起数据处理和数模转换。

    那么等显示器显示完再去绘制下一帧数据不就没有这个问题了吗?

    这么简单一想好像是没问题。但问题关键就出在图像绘制和屏幕读取这一帧数据使用的是一块Buffer。屏幕读取数据过程是无法确保这个Buffer不会被修改。由于屏幕是逐行扫描,它不会被打断仍然会继续上一行的位置扫描,当出现Buffer里有些数据根本没被显示器显示完就被重写了(即Buffer里的数据是来自不同帧的混合),这样就出现了画面撕裂的现象。

    双缓存

    针对上面的问题关键:图像绘制和屏幕读取这一帧数据使用的是一块Buffer

    可以想到的一种解决方案是:不让它们使用同一块Buffer,用两块让它们各自为战不就好了,这么想的思路确实是对的。分析下这个具体过程:

    当图像绘制和屏幕显示有各自的Buffer后,GPU将绘制完的一帧图像写入到后缓存(Back Buffer),显示器显示的时候只会去扫描前缓存的数据(Frame Buffer),在显示器未扫描完一帧前,前缓存区内数据不改变,屏幕就只会显示一帧的数据,避免了撕裂。

    双缓存.png

    但这样做的最关键一步是,什么时候去交换两块Buffer的数据?

    Back Buffer准备完一帧数据就进行?这很明显是不可以的,这样就和只有一个缓存区的效果一样了,还是会出现撕裂现象。

    根据逐行扫描的特性,当扫描完一个屏幕后,显示器会重新回到第一行进行下次的扫描,在这个间隙过程,屏幕没有在刷新,此时就是进行缓存区交换比较好的时机。

    VBlank阶段和帧传递:

    显示器扫描完一帧重新回到第一行的过程称为显示器的VBlank阶段。

    缓存区交换被称为BufferSwap,帧传递。

    Andrid屏幕刷新机制的演变

    VSync

    那是谁控制这个缓冲区交换时机,或者说专业点,什么时机进行帧传递呢?

    这里就要提到VSync了,它翻译过来叫垂直同步,它会强制帧传递发生在显示器的VBlank阶段

    需要注意的是:开启垂直同步后,就算显卡准备好了Back Buffer的数据,但显示器没有逐行扫描完前缓冲区的,就不允许发生帧传递。显卡就空载着,等待显示器扫描完毕后的VBlank阶段

    这就解释了在玩游戏的时候,如果开启了垂直功能,游戏中显示的帧率一直处于一个帧率之下,这个显示帧率值就是屏幕刷新率。

    那这个过程具体是怎么样的,真的就可以解决问题了?上面看着说的很有道理,但抽象到还是似懂非懂...

    别急,下面就用几张图带你分析下具体的过程。

    Jank

    在下面的图中,你将会经常看到Jank一词语,它术语翻译,叫做卡顿。卡顿很容易理解了,比如我们在打游戏时,经常会遇到同一帧画面在那显示很久没有变化,这就是所谓的Jank

    场景1

    先看下最原始的,只有双缓冲,没有VSync影响下,它会发生什么:

    vsync1.png

    图中Display 为显示屏, VSync 仅仅指双缓冲的交换。

    (1)Display显示第0帧,此时 CPU/GPU 渲染第1帧画面,并且在 Display 显示下一帧前完成。

    (2)Display 正常渲染第一帧

    (3)出于某种原因,如 CPU 资源被占用,系统没有及时处理第2帧数据,当 Display 显示下一帧时,由于数据没处理完,所以依然显示第1帧,即发生“Jank” ,

    Jank术语翻译为卡顿,就是我们打游戏感受到的延迟。

    上图出现的情况就是第2帧没有在显示前及时处理,导致屏幕多显示第一帧一次,导致后面的帧都延时了。根本原因是因为第2帧的数据没能在VBlank时(即本次完成到下次扫描开始前的时间间隙)完成。

    上图可以看到的是由于CPU资源被抢,导致第2帧的数据处理时机太晚,假设在双缓存交换完成后,CPU资源可以立刻为处理第二帧所用,就可以处理完成该帧的数据(当前前提是该帧的处理数据不超过刷新一帧的时间),也就避免了Jank的出现。

    场景2

    在双缓冲下,有了VSync会怎么样呢?

    vsync2.png

    如图,当且仅当收到VSync通知(比如16ms触发一次),CPUGPU 立刻开始计算然后把数据写入BufferVSync同步信号的出现让绘制速度和屏幕刷新速度保持一致,使CPUGPU 充分利用了这16.6 ms的时间,减少了jank。

    场景3

    但是如果界面比较复杂,CPU/GPU处理时间真的超过16.6ms的话,就会发生:

    vsync3.png

    图中可以看出当第1个 VSync 到来时GPU还在处理数据,这时缓冲区在处理数据B,被占用了,此时的VBlank阶段就无法进行缓冲区交换,屏幕依然显示前缓冲区的数据A,发生了jank。当下一个信号到来时,此时 GPU 已经处理完了,那么就可以交换缓冲区,此时屏幕就会显示交互后缓冲区的数据B了。

    由于硬件性能限制,我们无法改变 CPU/GPU 渲染的时间,所以第一次的Jank是无法避免的,但是在第二次信号来的时候,由于GPU占用了后缓冲区,没能实现缓冲区交换,导致屏幕依然显示上一帧A。由于此时,后缓冲区被占用了,就算此时CPU是空闲的也不能处理下一帧数据。增大了后期Jank的概率,比如图中第二个Jank的出现。

    出现该问题本质的原因是,两个缓冲区各自被GPU/CPU、屏幕显示所占用。导致下一帧的数据不能被处理

    三缓存

    找到问题的本质了,那很容易想到,再加一个Buffer(这里叫它中Buffer)参与,让添加的这个中Buffer后Buffer交换,这样既不会影响到显示器读取前Buffer,又可以在后Buffer缓冲区不能处理时,让中Buffer来处理。像下图这样:

    vsync4.png

    当第一个信号到来时,前缓冲区在显示A、后缓冲区在处理B,它们都被占用。此时 CPU 就可以使用中缓冲区,来处理下一帧数据C。这样的话,C数据可以提前处理完成,之前第二次发生的Jank就不存在了,有效的降低了Jank出现的几率。

    到这里,可以看出,不管是双缓冲和三缓冲,都会有卡顿、延时问题,只是三缓冲下,减少了卡顿的次数。

    那是不是 Buffer 越多越好呢?

    答案是否定的,Buffer存储的缓存数据是占有内存的,Buffer越多,缓存数据就越多,内存占用就会增大,所以Buffer只要3个就足够了。

    Choreographer

    那么在Android App层面,呈现在我们眼前的视觉效果(比如动画)是怎么出来的?是否和上述介绍的屏幕刷新机制呼应?或者说,它是怎么基于这个刷新机制原理实现的UI刷新?

    对UI绘制流程熟悉的都知道,UI绘制会先走到ViewRootImpl#scheduleTraversals(),之后才会执行UI绘制。

    #ViewRootImpl
    void scheduleTraversals() {
    if (!mTraversalScheduled) {
    mTraversalScheduled = true;
    mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
    //重点关注这里:绘制的操作封装在mTraversalRunnable里,交给`Choreographer`类处理
    mChoreographer.postCallback(
    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
    //...
    }
    }

    重点关注mChoreographer.postCallback(..),UI绘制的操作被封装在mTraversalRunnable里,交由mChoreographerpostCallback方法处理。

    mChoreographerChoreographer对象。那Choreographer类是做啥的呢,翻译为编舞者。这个类的命名很有意思,直接意思感觉和绘制毫无关联。但一只舞蹈的节奏控制是由编舞者掌控,就像绘制的过程的时机也需要类似这样一个角色控制一般。可见这个类的作者应该很喜欢舞蹈吧~

    走入mChoreographer.postCallback看看做了什么

    #Choreographer
    public void postCallback(int callbackType, Runnable action, Object token) {
    postCallbackDelayed(callbackType, action, token, 0);
    }
    public void postCallbackDelayed(int callbackType,
    Runnable action, Object token, long delayMillis) {
    //...
    postCallbackDelayedInternal(callbackType, action, token, delayMillis);
    }

    真正做事的是postCallbackDelayedInternal

    #Choreographer
    private void postCallbackDelayedInternal(int callbackType,
    Object action, Object token, long delayMillis) {
    synchronized (mLock) {
    //把当前的runnable加入到callback队列中
    mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);
    //达到期限时间
    if (dueTime <= now) {
    scheduleFrameLocked(now);
    } else {
    Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
    msg.arg1 = callbackType;
    msg.setAsynchronous(true);
    mHandler.sendMessageAtTime(msg, dueTime);
    }
    }
    }

    如果这个任务达到约定的延时时间,那么就会直接执行scheduleFrameLocked方法,如果没有达到就通过Handler发送一个延时异步消息,最终也会走到scheduleFrameLocked方法:

    #Choreographer
    //默认使用VSync同步机制
    private static final boolean USE_VSYNC = SystemProperties.getBoolean(
    "debug.choreographer.vsync", true);
    private void scheduleFrameLocked(long now) {
    if (!mFrameScheduled) {
    mFrameScheduled = true;
    //是否使用VSync同步机制
    if (USE_VSYNC) {
    //是否在主线程
    if (isRunningOnLooperThreadLocked()) {
    scheduleVsyncLocked();
    } else {
    Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_VSYNC);
    msg.setAsynchronous(true);
    mHandler.sendMessageAtFrontOfQueue(msg);
    }
    } else {
    final long nextFrameTime = Math.max(
    mLastFrameTimeNanos / TimeUtils.NANOS_PER_MS + sFrameDelay, now);
    if (DEBUG_FRAMES) {
    Log.d(TAG, "Scheduling next frame in " + (nextFrameTime - now) + " ms.");
    }
    Message msg = mHandler.obtainMessage(MSG_DO_FRAME);
    msg.setAsynchronous(true);
    mHandler.sendMessageAtTime(msg, nextFrameTime);
    }
    }
    }

    scheduleFrameLocked()会根据是否是使用VSync同步机制,来执行不同的逻辑。下面顺着使用同步的情况分析:

    判断当前线程的Looper是否是创建Choreographer时的线程Looper,由于是在ViewRootImpl中传入的,正常情况它是在主线程,所以就等价于判断当前线程是否在主线程。

    如果不是就把这个消息加入到主线程,不管如何,最后都会走到scheduleVsyncLocked方法:

    #Choreographer
    private final FrameDisplayEventReceiver mDisplayEventReceiver;
    private void scheduleVsyncLocked() {
    //调用DisplayEventReceiver的scheduleVsync
    mDisplayEventReceiver.scheduleVsync();
    }

    mDisplayEventReceiverFrameDisplayEventReceiver的对象。而FrameDisplayEventReceiver继承了DisplayEventReceiver这个抽象类。

    DisplayEventReceiver如它的命名一样直观,显示事件的接收者。在DisplayEventReceiver的构造方法里面,会调用native方法nativeInit初始化一个接收者。在scheduleVsync方法里面,会调用native方法nativeScheduleVsync,把初始化的接收者对象传进去。

    #DisplayEventReceiver
    public abstract class DisplayEventReceiver {
    public DisplayEventReceiver(Looper looper, int vsyncSource) {
    //初始化一个接收者
    mReceiverPtr = nativeInit(new WeakReference<DisplayEventReceiver>(this), mMessageQueue,
    vsyncSource);
    }

    public void scheduleVsync() {
    //初始化的接收者对象mReceiverPtr传进去
    nativeScheduleVsync(mReceiverPtr);
    }
    }

    FrameDisplayEventReceiverDisplayEventReceiver更具体一点,叫做帧显示的事件接收者。在前面介绍过,当收到同步信号过来后,就希望显示下一帧数据。那是怎么接收同步信号的呢?魔法就在上述那两个native方法里面,调用这两个方法之后。就会接收到`onVsync'方法的回调。这就是同步信号到来的时机。

    #Choreographer.FrameDisplayEventReceiver
    private final class FrameDisplayEventReceiver extends DisplayEventReceiver
    implements Runnable {
    public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
    super(looper, vsyncSource);
    }

    @Override
    public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
    //...
    long now = System.nanoTime();
    mTimestampNanos = timestampNanos;
    mFrame = frame;
    Message msg = Message.obtain(mHandler, this);
    msg.setAsynchronous(true);
    //timestampNanos / TimeUtils.NANOS_PER_MS 时间后走run方法
    mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
    }

    @Override
    public void run() {
    mHavePendingVsync = false;
    //接收到同步信号后执行
    doFrame(mTimestampNanos, mFrame);
    }
    }

    onVsync里,主要做的一件事就是在发送一个延时消息,时间是同步信号的时间戳,因为这个类是一个Runnable,这个消息会在run方法里面处理,之后就会执行doFrame()方法。

    doFrame()从它的命名,十有八九就是我们一直提的接收到VSync同步信号后,处理帧数据的地方了:

    void doFrame(long frameTimeNanos, int frame) {
    final long startNanos;
    synchronized (mLock) {
    if (!mFrameScheduled) {
    return; // no work to do
    }
    long intendedFrameTimeNanos = frameTimeNanos;
    startNanos = System.nanoTime();
    //抖动时间: 当前时间 - 同步信号通知的时间
    final long jitterNanos = startNanos - frameTimeNanos;
    //mFrameIntervalNanos = (long)(1000000000 / getRefreshRate()) 类似 1s/60hz = 16.6ms,不过这里是纳秒为单位
    //抖动时间超过了一帧刷新的时间,即发生了Jank
    if (jitterNanos >= mFrameIntervalNanos) {
    final long skippedFrames = jitterNanos / mFrameIntervalNanos;
    //计算调帧数,超过一定限制(默认30),就表示应用在主线程做了大量工作,影响了绘制,打印提示
    if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
    Log.i(TAG, "Skipped " + skippedFrames + " frames! "
    + "The application may be doing too much work on its main thread.");
    }
    final long lastFrameOffset = jitterNanos % mFrameIntervalNanos;
    frameTimeNanos = startNanos - lastFrameOffset;
    }
    //...
    }

    try {
    //按顺序执行任务(这里只留了核心代码)
    doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
    doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
    } finally {}
    }

    在doFrame的最后,按顺序根据CallBack的类型执行任务,和我们在本节最开始的ViewRootImpl的这部分代码,关连起来了。我们post的这个类型是 Choreographer.CALLBACK_TRAVERSAL

     mChoreographer.postCallback(
    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

    终于快结束的节奏了,看看doCallbacks是做什么的

    void doCallbacks(int callbackType, long frameTimeNanos) {
    CallbackRecord callbacks;
    synchronized (mLock) {
    final long now = System.nanoTime();
    callbacks = mCallbackQueues[callbackType].extractDueCallbacksLocked(
    now / TimeUtils.NANOS_PER_MS);
    if (callbacks == null) {
    return;
    }
    mCallbacksRunning = true;
    try {
    for (CallbackRecord c = callbacks; c != null; c = c.next) {
    //注意这里:执行CallbackRecord的run方法
    c.run(frameTimeNanos);
    }
    } finally {
    synchronized (mLock) {
    mCallbacksRunning = false;
    do {
    final CallbackRecord next = callbacks.next;
    //回收处理完的CallbackRecord
    recycleCallbackLocked(callbacks);
    callbacks = next;
    } while (callbacks != null);
    }
    }
    }

    CallbackRecord是记录callBack信息的类,它是个链表结构,具有next指针。它记录了callback所要执行任务或者说行为,比如Runnbable或者FrameCallback

    private static final class CallbackRecord {
    public CallbackRecord next;
    public long dueTime;
    public Object action; // Runnable or FrameCallback
    public Object token;

    public void run(long frameTimeNanos) {
    if (token == FRAME_CALLBACK_TOKEN) {
    ((FrameCallback)action).doFrame(frameTimeNanos);
    } else {
    //执行我们最初post的Runnable
    ((Runnable)action).run();
    }
    }
    }

    对应我们最开始的postCallback方法,这个action也就是我们的mTraversalRunnable

     mChoreographer.postCallback(
    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

    到这里,postCallback的操作形成一个完整的闭环。关于Choreographer的介绍也就算完了。

    总结

    最后,我想分享一下本文的构思过程:

    1.以屏幕显示的基础概念谈起,了解屏幕上的像素点是怎么显示出来的,对后面屏幕刷新的理解会变得更容易。

    2.分析Android屏幕刷新机制的演变过程,更轻松的理解目前的刷新机制是怎么出来的,为什么要有双缓冲、三缓冲。

    3.从ViewRootImpl的触发绘制为开始,到ChoreographerdoCallbacks结束,形成了完整的闭环。通过对这部分源码的分析,看到Choreographer这个编舞者是如何利用VSync同步机制,来掌控整个UI的刷新过程。

    收起阅读 »

    OpenGL ES 文字渲染

    在音视频或 OpenGL 开发中,文字渲染是一个高频使用的功能,比如制作一些酷炫的字幕、为视频添加水印、设置特殊字体等等。实际上 OpenGL 并没有定义渲染文字的方式,所以我们最能想到的办法是:将带有文字的图像上传到纹理,然后进行纹理贴图。本文分别介绍下在应...
    继续阅读 »

    在音视频或 OpenGL 开发中,文字渲染是一个高频使用的功能,比如制作一些酷炫的字幕、为视频添加水印、设置特殊字体等等。

    实际上 OpenGL 并没有定义渲染文字的方式,所以我们最能想到的办法是:将带有文字的图像上传到纹理,然后进行纹理贴图。

    本文分别介绍下在应用层和 C++ 层常用的文字渲染方式。

    OpenGL ES 文字渲染

    基于 Canvas 绘制生成 Bitmap

    在应用层实现文字渲染主要是利用 Canvas 将文本绘制成 Bitmap ,然后生成一张小图,然后在渲染的时候进行贴图。

    在实际的生产环境中,一般会将这张小图转换成灰度图,减少不必要的数据拷贝和内存占用,然后在渲染的时候可以为灰度图上色,作为字体的颜色。

    // 创建一个 bitmap 
    Bitmap bitmap = Bitmap.createBitmap(width, hight, Bitmap.Config.ARGB_8888);
    // 初始化画布绘制的图像到 bitmap 上
    Canvas canvas = new Canvas(bitmap);
    // 建立画笔
    Paint paint = new Paint();
    // 获取更清晰的图像采样,防抖动
    paint.setDither(true);
    paint.setFilterBitmap(true);
    // 绘制文字到 bitmap
    canvas.drawText text, x, y,paint);

    然后生成纹理,将 bitmap 上传到纹理。

    int[] textureIds = new int[1];
    //创建纹理
    GLES20.glGenTextures(1, textureIds, 0);
    mTexId = textureIds[0];
    //绑定纹理
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTexId);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

    ByteBuffer bitmapBuffer = ByteBuffer.allocate(bitmap.getHeight() * bitmap.getWidth() * 4);//RGBA
    bitmap.copyPixelsToBuffer(bitmapBuffer);
    bitmapBuffer.flip();

    //设置内存大小绑定内存地址
    GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, mWatermarkBitmap.getWidth(), mWatermarkBitmap.getHeight(),
    0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, bitmapBuffer);

    //解绑纹理
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

    最后将带有文字的纹理映射到对应的位置(纹理贴图)。

    FreeType

    FreeType 是一个基于 C 语言实现的用于文字渲染的开源库,它小巧、高效、高度可定制,主要用于加载字体并将其渲染到位图,支持多种字体的相关操作。

    FreeType 也是一个非常受欢迎的跨平台字体库,支持 Android、 iOS、 Linux 等操作系统。TrueType 字体不采用像素或其他不可缩放的方式来定义,而是一些通过数学公式(曲线的组合)。这些字形,类似于矢量图像,可以根据你需要的字体大小来生成像素图像。

    FreeType 官网地址:

    https://www.freetype.org/

    FreeType 编译

    本小节主要介绍使用 NDK 编译 Android 平台使用的 FreeType 库。首先在官网上下载最新版的 FreeType 源码,然后新建一个 jni 文件夹,将源码放到 jni 文件夹里,目录结构如下所示:

    FreeType 目录结构

    新建构建文件 Android.mk 和 Application.mk。

    Android.mk 参考 Google 的构建脚本:

    LOCAL_PATH:= $(call my-dir)

    include $(CLEAR_VARS)


    LOCAL_SRC_FILES := \
    ./src/autofit/autofit.c \
    ./src/base/ftbase.c \
    ./src/base/ftbbox.c \
    ./src/base/ftbdf.c \
    ./src/base/ftbitmap.c \
    ./src/base/ftcid.c \
    ./src/base/ftdebug.c \
    ./src/base/ftfstype.c \
    ./src/base/ftgasp.c \
    ./src/base/ftglyph.c \
    ./src/base/ftgxval.c \
    ./src/base/ftinit.c \
    ./src/base/ftlcdfil.c \
    ./src/base/ftmm.c \
    ./src/base/ftotval.c \
    ./src/base/ftpatent.c \
    ./src/base/ftpfr.c \
    ./src/base/ftstroke.c \
    ./src/base/ftsynth.c \
    ./src/base/ftsystem.c \
    ./src/base/fttype1.c \
    ./src/base/ftwinfnt.c \
    ./src/bdf/bdf.c \
    ./src/bzip2/ftbzip2.c \
    ./src/cache/ftcache.c \
    ./src/cff/cff.c \
    ./src/cid/type1cid.c \
    ./src/gzip/ftgzip.c \
    ./src/lzw/ftlzw.c \
    ./src/pcf/pcf.c \
    ./src/pfr/pfr.c \
    ./src/psaux/psaux.c \
    ./src/pshinter/pshinter.c \
    ./src/psnames/psmodule.c \
    ./src/raster/raster.c \
    ./src/sfnt/sfnt.c \
    ./src/smooth/smooth.c \
    ./src/tools/apinames.c \
    ./src/truetype/truetype.c \
    ./src/type1/type1.c \
    ./src/type42/type42.c \
    ./src/winfonts/winfnt.c



    LOCAL_C_INCLUDES += $(LOCAL_PATH)/include

    LOCAL_CFLAGS += -W -Wall
    LOCAL_CFLAGS += -fPIC -DPIC
    LOCAL_CFLAGS += "-DDARWIN_NO_CARBON"
    LOCAL_CFLAGS += "-DFT2_BUILD_LIBRARY"

    LOCAL_CFLAGS += -O2

    LOCAL_MODULE:= freetype

    include $(BUILD_STATIC_LIBRARY)
    #https://android.googlesource.com/platform/external/freetype/+/android-6.0.1_r28/Android.mk

    Application.mk:

    APP_OPTIM := release
    APP_CPPFLAGS := -std=c++14 -frtti
    NDK_TOOLCHAIN_VERSION := clang
    APP_PLATFORM := android-28
    APP_STL := c++_static
    APP_ABI := arm64-v8a,armeabi-v7a

    最后 jni 目录下命令行执行 ndk-build 指令即可,如果不想编译,也可以直接到下面项目取现成的静态库:

    https://github.com/githubhaohao/NDK_OpenGLES_3_0

    OpenGL 使用 FreeType 渲染文字

    FreeType 的使用

    引入头文件:

    #include "ft2build.h"
    #include

    然后要加载一个字体,我们需要做的是初始化 FreeType 并且将这个字体加载为 FreeType 称之为面 Face 的东西。这里我在 Windows 下找了个字体文件 Antonio-Regular.ttf ,放到 sdcard 下面供 FreeType 加载。


    FT_Library ft;

    if (FT_Init_FreeType(&ft))
    LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Could not init FreeType Library");


    FT_Face face;
    if (FT_New_Face(ft, "/sdcard/fonts/Antonio-Regular.ttf", 0, &face))
    LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Failed to load font");


    FT_Set_Pixel_Sizes(face, 0, 96);

    代码片段中,FT_Set_Pixel_Sizes 用于设置文字的大小,此函数设置了字体面的宽度和高度,将宽度值设为0表示我们要从字体面通过给出的高度中动态计算出字形的宽度。

    一个字体面中 Face 包含了所有字形的集合,我们可以通过调用 FT_Load_Char 函数来激活当前要表示的字形。这里我们选在加载字母字形 'A':

    if (FT_Load_Char(face, 'A', FT_LOAD_RENDER))
    std::cout << "ERROR::FREETYTPE: Failed to load Glyph" << std::endl;

    通过将 FT_LOAD_RENDER 设为一个加载标识,我们告诉 FreeType 去创建一个 8 位的灰度位图,我们可以通过face->glyph->bitmap 来取得这个位图。

    使用 FreeType 加载的字形位图并不像我们使用位图字体那样持有相同的尺寸大小。使用FreeType生产的字形位图的大小是恰好能包含这个字形的尺寸。例如生产用于表示 '.' 的位图的尺寸要比表示 'A' 的小得多。

    因此,FreeType在加载字形的时候还生产了几个度量值来描述生成的字形位图的大小和位置。下图展示了 FreeType 的所有度量值的涵义。

    glyph.png

    那么多属性其实不用刻意取记住,这里只是作为概念性了解。最后,使用完 FreeType 记得释放相关资源:

    FT_Done_Face(face);
    FT_Done_FreeType(ft);

    OpenGL 文字渲染

    按照前面的思路,使用 FreeType 加载字形的位图然后生成纹理,然后进行纹理贴图。

    然而每次渲染的时候都去重新加载位图显然不是高效的,我们应该将这些生成的数据储存在应用程序中,在渲染过程中再去取,重复利用。

    方便起见,我们需要定义一个用来储存这些属性的结构体,并创建一个字符表来存储这些字形属性。

    struct Character {
    GLuint textureID; // ID handle of the glyph texture
    glm::ivec2 size; // Size of glyph
    glm::ivec2 bearing; // Offset from baseline to left/top of glyph
    GLuint advance; // Horizontal offset to advance to next glyph
    };

    std::map m_Characters;

    简单起见,我们只生成表示 128 个 ASCII 字符的字符表,并为每一个字符储存纹理和一些度量值。这样,所有需要的字符就被存下来备用了。

    void TextRenderSample::LoadFacesByASCII() {
    // FreeType
    FT_Library ft;
    // All functions return a value different than 0 whenever an error occurred
    if (FT_Init_FreeType(&ft))
    LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Could not init FreeType Library");

    // Load font as face
    FT_Face face;
    if (FT_New_Face(ft, "/sdcard/fonts/Antonio-Regular.ttf", 0, &face))
    LOGCATE("TextRenderSample::LoadFacesByASCII FREETYPE: Failed to load font");

    // Set size to load glyphs as
    FT_Set_Pixel_Sizes(face, 0, 96);

    // Disable byte-alignment restriction
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    // Load first 128 characters of ASCII set
    for (unsigned char c = 0; c < 128; c++)
    {
    // Load character glyph
    if (FT_Load_Char(face, c, FT_LOAD_RENDER))
    {
    LOGCATE("TextRenderSample::LoadFacesByASCII FREETYTPE: Failed to load Glyph");
    continue;
    }
    // Generate texture
    GLuint texture;
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexImage2D(
    GL_TEXTURE_2D,
    0,
    GL_LUMINANCE,
    face->glyph->bitmap.width,
    face->glyph->bitmap.rows,
    0,
    GL_LUMINANCE,
    GL_UNSIGNED_BYTE,
    face->glyph->bitmap.buffer
    );

    // Set texture options
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    // Now store character for later use
    Character character = {
    texture,
    glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows),
    glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
    static_cast(face->glyph->advance.x)
    };
    m_Characters.insert(std::pair(c, character));
    }
    glBindTexture(GL_TEXTURE_2D, 0);
    // Destroy FreeType once we're finished
    FT_Done_Face(face);
    FT_Done_FreeType(ft);

    }

    针对 OpenGL ES 灰度图要使用的纹理格式是 GL_LUMINANCE 而不是 GL_RED 。

    OpenGL 纹理对应的图像默认要求 4 字节对齐,这里需要设置为 1 ,确保宽度不是 4 倍数的位图(灰度图)能够正常渲染。

    渲染文字使用的 shader :

    //vertex shader
    #version 300 es
    layout(location = 0) in vec4 a_position;//
    uniform mat4 u_MVPMatrix;
    out vec2 v_texCoord;
    void main()
    {
    gl_Position = u_MVPMatrix * vec4(a_position.xy, 0.0, 1.0);;
    v_texCoord = a_position.zw;
    }

    //fragment shader
    #version 300 es
    precision mediump float;
    in vec2 v_texCoord;
    layout(location = 0) out vec4 outColor;
    uniform sampler2D s_textTexture;
    uniform vec3 u_textColor;

    void main()
    {
    vec4 color = vec4(1.0, 1.0, 1.0, texture(s_textTexture, v_texCoord).r);
    outColor = vec4(u_textColor, 1.0) * color;
    }

    片段着色器有两个 uniform 变量:一个是单颜色通道的字形位图纹理,另一个是文字的颜色,我们可以同调整它来改变最终输出的字体颜色。

    开启混合,去掉文字背景。

    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    生成一个 VAO 和一个 VBO ,用于管理的存储顶点、纹理坐标数据,GL_DYNAMIC_DRAW 表示我们后面要使用 glBufferSubData 不断刷新 VBO 的缓存。


    glGenVertexArrays(1, &m_VaoId);
    glGenBuffers(1, &m_VboId);

    glBindVertexArray(m_VaoId);
    glBindBuffer(GL_ARRAY_BUFFER, m_VboId);
    glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * 6 * 4, nullptr, GL_DYNAMIC_DRAW);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), 0);
    glBindBuffer(GL_ARRAY_BUFFER, GL_NONE);
    glBindVertexArray(GL_NONE);

    每个 2D 方块需要 6 个顶点,每个顶点又是由一个 4 维向量(一个纹理坐标和一个顶点坐标)组成,因此我们将VBO 的内存分配为 6*4 个 float 的大小。

    最后进行文字渲染,其中传入 viewport 主要是针对屏幕坐标进行归一化:

    void TextRenderSample::RenderText(std::string text, GLfloat x, GLfloat y, GLfloat scale,
    glm::vec3 color, glm::vec2 viewport) {
    // 激活合适的渲染状态
    glUseProgram(m_ProgramObj);
    glUniform3f(glGetUniformLocation(m_ProgramObj, "u_textColor"), color.x, color.y, color.z);
    glBindVertexArray(m_VaoId);
    GO_CHECK_GL_ERROR();
    // 对文本中的所有字符迭代
    std::string::const_iterator c;
    x *= viewport.x;
    y *= viewport.y;
    for (c = text.begin(); c != text.end(); c++)
    {
    Character ch = m_Characters[*c];

    GLfloat xpos = x + ch.bearing.x * scale;
    GLfloat ypos = y - (ch.size.y - ch.bearing.y) * scale;

    xpos /= viewport.x;
    ypos /= viewport.y;

    GLfloat w = ch.size.x * scale;
    GLfloat h = ch.size.y * scale;

    w /= viewport.x;
    h /= viewport.y;

    LOGCATE("TextRenderSample::RenderText [xpos,ypos,w,h]=[%f, %f, %f, %f]", xpos, ypos, w, h);

    // 当前字符的VBO
    GLfloat vertices[6][4] = {
    { xpos, ypos + h, 0.0, 0.0 },
    { xpos, ypos, 0.0, 1.0 },
    { xpos + w, ypos, 1.0, 1.0 },

    { xpos, ypos + h, 0.0, 0.0 },
    { xpos + w, ypos, 1.0, 1.0 },
    { xpos + w, ypos + h, 1.0, 0.0 }
    };

    // 在方块上绘制字形纹理
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, ch.textureID);
    glUniform1i(m_SamplerLoc, 0);
    GO_CHECK_GL_ERROR();
    // 更新当前字符的VBO
    glBindBuffer(GL_ARRAY_BUFFER, m_VboId);
    glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);
    GO_CHECK_GL_ERROR();
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    // 绘制方块
    glDrawArrays(GL_TRIANGLES, 0, 6);
    GO_CHECK_GL_ERROR();
    // 更新位置到下一个字形的原点,注意单位是1/64像素
    x += (ch.advance >> 6) * scale; //(2^6 = 64)
    }
    glBindVertexArray(0);
    glBindTexture(GL_TEXTURE_2D, 0);
    }

    使用 RenderText 渲染 2 个文本:

    	// (x,y)为屏幕坐标系的位置,即原点位于屏幕中心,x(-1.0,1.0), y(-1.0,1.0)
    RenderText("My WeChat ID is Byte-Flow.", -0.9f, 0.2f, 1.0f, glm::vec3(0.8, 0.1f, 0.1f), viewport);
    RenderText("Welcome to add my WeChat.", -0.9f, 0.0f, 2.0f, glm::vec3(0.2, 0.4f, 0.7f), viewport);

    完整实现代码见项目: github.com/githubhaoha…

    收起阅读 »

    Andorid进阶二:LeakCanary源码分析,从头到尾搞个明白

    四,ObjectWatcher 保留对象检查分析我们转到 ObjectWatcher 的 expectWeaklyReachable 方法看看@Synchronized override fun expectWeaklyReachable( watched...
    继续阅读 »

    四,ObjectWatcher 保留对象检查分析

    我们转到 ObjectWatcher 的 expectWeaklyReachable 方法看看

    @Synchronized override fun expectWeaklyReachable(
    watchedObject: Any,
    description: String
    ) {
    //是否启用 , AppWatcher 持有的ObjectWatcher 默认是启用的
    if (!isEnabled()) {
    return
    }
    ///移除之前已经被回收的监听对象
    removeWeaklyReachableObjects()
    val key = UUID.randomUUID()
    .toString()
    val watchUptimeMillis = clock.uptimeMillis()
    //(1) 创建弱引用
    val reference =
    KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
    SharkLog.d {
    "Watching " +
    (if (watchedObject is Class<*>) watchedObject.toString() else "instance of ${watchedObject.javaClass.name}") +
    (if (description.isNotEmpty()) " ($description)" else "") +
    " with key $key"
    }

    watchedObjects[key] = reference
    checkRetainedExecutor.execute {
    //(2)
    moveToRetained(key)
    }
    }

    继续分析源码中标注的地方。

    (1) 创建弱引用

    标注(1.2.4)处的代码是初始化的主要代码,创建要观察对象的弱引用,传入queue 作为gc 后的对象信息存储队列,WeakReference 中,当持有对象呗gc的时候,会将其包装对象压入队列中。可以在后续对该队列进行观察。

    (2) moveToRetained(key),检查对应key对象的保留

    作为Executor的runner 执行,在AppWatcher中,默认延迟五秒后执行该方法 查看源码分析

    @Synchronized private fun moveToRetained(key: String) {
    ///移除已经被回收的观察对象
    removeWeaklyReachableObjects()
    val retainedRef = watchedObjects[key]
    if (retainedRef != null) {
    //记录泄漏时间
    retainedRef.retainedUptimeMillis = clock.uptimeMillis()
    //回调泄漏监听
    onObjectRetainedListeners.forEach { it.onObjectRetained() }
    }
    }

    从上述代码可知,ObjectWatcher 监测内存泄漏总共有以下几步

    1. 清除已经被内存回收的监听对象
    2. 创建弱引用,传入 ReferenceQueue 作为gc 信息保存队列
    3. 在延迟指定的时间后,再次检查针对的对象是否被回收(通过检查ReferenceQueue队列内有无该WeakReference实例)
    4. 检测到对象没有被回收后,回调 onObjectRetainedListeners 们的 onObjectRetained

    五,dumpHeap,怎么个DumpHeap流程

    (1.1)objectWatcher 添加 OnObjectRetainedListeners 监听

    回到最初AppWatcher的 manualInstall 方法。 可以看到其中执行了loadLeakCanary 方法。 代码如下:

    ///(2)
    LeakCanaryDelegate.loadLeakCanary(application)
    //反射获取InternalLeakCanary实例
    val loadLeakCanary by lazy {
    try {
    val leakCanaryListener = Class.forName("leakcanary.internal.InternalLeakCanary")
    leakCanaryListener.getDeclaredField("INSTANCE")
    .get(null) as (Application) -> Unit
    } catch (ignored: Throwable) {
    NoLeakCanary
    }
    }

    该方法通过反射获取了 InternalLeakCanary 的静态实例。 并且调用了他的 invoke(application: Application)方法,所以我们接下来看InternalLeakCanary的该方法:

    override fun invoke(application: Application) {
    _application = application

    checkRunningInDebuggableBuild()
    //(1.2)添加 addOnObjectRetainedListener
    AppWatcher.objectWatcher.addOnObjectRetainedListener(this)

    val heapDumper = AndroidHeapDumper(application, createLeakDirectoryProvider(application))
    //Gc触发器
    val gcTrigger = GcTrigger.Default

    val configProvider = { LeakCanary.config }

    val handlerThread = HandlerThread(LEAK_CANARY_THREAD_NAME)
    handlerThread.start()
    val backgroundHandler = Handler(handlerThread.looper)
    ///(1.3)
    heapDumpTrigger = HeapDumpTrigger(
    application, backgroundHandler, AppWatcher.objectWatcher, gcTrigger, heapDumper,
    configProvider
    )
    ///(1.4) 添加application前后台变化监听
    application.registerVisibilityListener { applicationVisible ->
    this.applicationVisible = applicationVisible
    heapDumpTrigger.onApplicationVisibilityChanged(applicationVisible)
    }
    //(1.5)
    registerResumedActivityListener(application)
    //(1.6)
    addDynamicShortcut(application)

    // 6 判断是否应该DumpHeap
    // We post so that the log happens after Application.onCreate()
    mainHandler.post {
    // https://github.com/square/leakcanary/issues/1981
    // We post to a background handler because HeapDumpControl.iCanHasHeap() checks a shared pref
    // which blocks until loaded and that creates a StrictMode violation.
    backgroundHandler.post {
    SharkLog.d {
    when (val iCanHasHeap = HeapDumpControl.iCanHasHeap()) {
    is Yup -> application.getString(R.string.leak_canary_heap_dump_enabled_text)
    is Nope -> application.getString(
    R.string.leak_canary_heap_dump_disabled_text, iCanHasHeap.reason()
    )
    }
    }
    }
    }
    }

    我们看到初始化的时候做了这么6步

    • (1.2) 将自己加入到ObjectWatcher 的对象异常持有监听器中
    • (1.3)创建内存快照转储触发器 HeapDumpTrigger
    • (1.4)监听application 前后台变动,并且记录来到后台时间,便于LeakCanary 针对刚刚切入后台的一些destroy操作做泄漏监测
    • (1.5)注册activity生命周期回调,获取当前resumed的activity实例
    • (1.6)添加动态的桌面快捷入口
    • (1.7)在异步线程中,判断是否处于可dumpHeap的状态,如果处于触发一次内存泄漏检查 其中最重要的是 1.2,我们重点分析作为ObjectRetainedListener 他在回调中做了哪些工作。

    (1.2)添加对象异常持有监听

    可以看到代码(1.2),在objectWatcher将自己加入到泄漏监测回调中。 当ObjectWatcher监测到对象依然被异常持有的时候,会回调 onObjectRetained 方法。 从源码中可知,其中调用了 heapDumpTrigger的 scheduleRetainedObjectCheck方法, 代码如下。

    fun scheduleRetainedObjectCheck() {
    if (this::heapDumpTrigger.isInitialized) {
    heapDumpTrigger.scheduleRetainedObjectCheck()
    }
    }

    HeapDumpTrigger 顾名思义,就是内存快照转储的触发器。在回调中最终调用了HeapDumpTrigger 的 checkRetainedObjects方法来检查内存泄漏。

    (1.3)检查内存泄漏checkRetainedObjects

    private fun checkRetainedObjects() {
    val iCanHasHeap = HeapDumpControl.iCanHasHeap()

    val config = configProvider()
    //省略一些代码,主要是判断 iCanHasHeap。
    //如果当前处于不dump内存快照的状态,就先不处理。如果有新的异常持有对象被发现则发送通知提示
    //%d retained objects, tap to dump heap
    /** ...*/

    var retainedReferenceCount = objectWatcher.retainedObjectCount

    //主动触发gc
    if (retainedReferenceCount > 0) {
    gcTrigger.runGc()
    //重新获取异常持有对象
    retainedReferenceCount = objectWatcher.retainedObjectCount
    }
    //如果泄漏数量小于阈值,且app在前台,或者刚转入后台,就展示泄漏通知,并先返回
    if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

    //如果泄漏数量到达dumpHeap要求,继续往下
    ///转储内存快照在 WAIT_BETWEEN_HEAP_DUMPS_MILLIS (默认60秒)只会触发一次,如果之前刚触发过,就先不生成内存快照,直接发送通知了事。
    //省略转储快照时机判断,不满足的话会提示 Last heap dump was less than a minute ago
    /**...*/

    dismissRetainedCountNotification()
    val visibility = if (applicationVisible) "visible" else "not visible"
    ///转储内存快照
    dumpHeap(
    retainedReferenceCount = retainedReferenceCount,
    retry = true,
    reason = "$retainedReferenceCount retained objects, app is $visibility"
    )
    }

    这一块也可以看出检测是否需要dumpHeap分为4步。

    1. 如果没有检测到异常持有的对象,返回
    2. 如果有异常对象,主动触发gc
    3. 如果还有异常对象,就是内存泄漏了。
    4. 判断泄漏数量是否到达需要dump的地步
    5. 判断一分钟内是否叫进行过dump了
    6. dumpHeap 前面都是判断代码,关键重点在于dumpHeap方法

    (1.4)dumpHeap 转储内存快照

    private fun dumpHeap(
    retainedReferenceCount: Int,
    retry: Boolean,
    reason: String
    ) {
    saveResourceIdNamesToMemory()
    val heapDumpUptimeMillis = SystemClock.uptimeMillis()
    KeyedWeakReference.heapDumpUptimeMillis = heapDumpUptimeMillis
    when (val heapDumpResult = heapDumper.dumpHeap()) {
    is NoHeapDump -> {
    //省略 dump失败,等待重试代码和发送失败通知代码
    }
    is HeapDump -> {
    lastDisplayedRetainedObjectCount = 0
    lastHeapDumpUptimeMillis = SystemClock.uptimeMillis()
    ///清除 objectWatcher 中,在heapDumpUptimeMillis之前持有的对象,也就是已经dump的对象
    objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)
    // 发送文件到HeapAnalyzerService解析
    HeapAnalyzerService.runAnalysis(
    context = application,
    heapDumpFile = heapDumpResult.file,
    heapDumpDurationMillis = heapDumpResult.durationMillis,
    heapDumpReason = reason
    )
    }
    }
    }

    HeapDumpTrigger#dumpHeap中调用到了 AndroidHeapDumper#dumpHeap方法。 并且在dump后马上调用 HeapAnalyzerService.runAnalysis 进行内存分析工作,该方法在下一节分析。先看AndroidHeapDumper#dumHeap源码

    override fun dumpHeap(): DumpHeapResult {
    //创建新的hprof 文件
    val heapDumpFile = leakDirectoryProvider.newHeapDumpFile() ?: return NoHeapDump

    val waitingForToast = FutureResult<Toast?>()
    ///展示dump吐司
    showToast(waitingForToast)

    ///如果展示吐司时间超过五秒,就不dump了
    if (!waitingForToast.wait(5, SECONDS)) {
    SharkLog.d { "Did not dump heap, too much time waiting for Toast." }
    return NoHeapDump
    }

    //省略dumpHeap通知栏提示消息代码
    val toast = waitingForToast.get()

    return try {
    val durationMillis = measureDurationMillis {
    //调用DumpHprofData
    Debug.dumpHprofData(heapDumpFile.absolutePath)
    }
    if (heapDumpFile.length() == 0L) {
    SharkLog.d { "Dumped heap file is 0 byte length" }
    NoHeapDump
    } else {
    HeapDump(file = heapDumpFile, durationMillis = durationMillis)
    }
    } catch (e: Exception) {
    SharkLog.d(e) { "Could not dump heap" }
    // Abort heap dump
    NoHeapDump
    } finally {
    cancelToast(toast)
    notificationManager.cancel(R.id.leak_canary_notification_dumping_heap)
    }
    }

    在该方法内,最终调用 Debug.dumpHprofData 方法 完成hprof 快照的生成。

    六,分析内存 HeapAnalyzerService

    上面代码分析中可以看到,在dumpHeap后紧跟着就是启动内存分析服务的方法。 现在我们跳转到HeapAnalyzerService的源码处。

    override fun onHandleIntentInForeground(intent: Intent?) {
    //省略参数获取代码
    val config = LeakCanary.config
    val heapAnalysis = if (heapDumpFile.exists()) {
    analyzeHeap(heapDumpFile, config)
    } else {
    missingFileFailure(heapDumpFile)
    }
    //省略完善分析结果属性的代码
    onAnalysisProgress(REPORTING_HEAP_ANALYSIS)
    config.onHeapAnalyzedListener.onHeapAnalyzed(fullHeapAnalysis)
    }

    可以看到重点在于 analyzeHeap,其中调用了 HeapAnalyzer#analyze HeapAnalyzer 类位于shark模块中。

    (1)HeapAnalyzer#analyze

    内存分析方法代码如下:

    fun analyze(
    heapDumpFile: File,
    leakingObjectFinder: LeakingObjectFinder,
    referenceMatchers: List<ReferenceMatcher> = emptyList(),
    computeRetainedHeapSize: Boolean = false,
    objectInspectors: List<ObjectInspector> = emptyList(),
    metadataExtractor: MetadataExtractor = MetadataExtractor.NO_OP,
    proguardMapping: ProguardMapping? = null
    ): HeapAnalysis {

    //省略内存快照文件不存在的处理代码

    return try {
    listener.onAnalysisProgress(PARSING_HEAP_DUMP)
    ///io读取 内存快照
    val sourceProvider = ConstantMemoryMetricsDualSourceProvider(FileSourceProvider(heapDumpFile))
    sourceProvider.openHeapGraph(proguardMapping).use { graph ->
    val helpers =
    FindLeakInput(graph, referenceMatchers, computeRetainedHeapSize, objectInspectors)
    //关键代码:在此处找到泄漏的结果以及其对应调用栈
    val result = helpers.analyzeGraph(
    metadataExtractor, leakingObjectFinder, heapDumpFile, analysisStartNanoTime
    )
    val lruCacheStats = (graph as HprofHeapGraph).lruCacheStats()
    ///io读取状态
    val randomAccessStats =
    "RandomAccess[" +
    "bytes=${sourceProvider.randomAccessByteReads}," +
    "reads=${sourceProvider.randomAccessReadCount}," +
    "travel=${sourceProvider.randomAccessByteTravel}," +
    "range=${sourceProvider.byteTravelRange}," +
    "size=${heapDumpFile.length()}" +
    "]"
    val stats = "$lruCacheStats $randomAccessStats"
    result.copy(metadata = result.metadata + ("Stats" to stats))
    }
    } catch (exception: Throwable) {
    //省略异常处理
    }
    }

    通过分析代码可知:分析内存快照分为以下5步:

    1. 读取hprof内存快照文件
    2. 找到LeakCanary 标记的泄漏对象们的数量和弱引用包装 ids,class name 为com.squareup.leakcanary.KeyedWeakReference

    代码在 KeyedWeakReferenceFinder#findLeakingObjectIds

    1. 找到泄漏对象的gcRoot开始的路径

    代码在PathFinder#findPathsFromGcRoots

    1. 返回分析结果,走结果回调
    2. 回调内 展示内存分析成功或者失败的通知栏消息,并将泄漏列表存储到数据库中

    详情代码看 DefaultOnHeapAnalyzedListener#onHeapAnalyzed 以及 LeaksDbHelper

    1. 点开通知栏跳转到LeaksActivity 展示内存泄漏信息。

    七,总结

    终于从头到尾,总算是梳理了一波LeakCanary 源码

    过程中学习到了这么多—>

    • 主动调用Gc的方式 GcTrigger.Default.runGc()
    Runtime.getRuntime().gc()
    • seald class 密封类来表达状态,比如以下几个(关键好处在于使用when可以直接覆盖所有情况,而不必使用else)。
    sealed class ICanHazHeap {
    object Yup : ICanHazHeap()
    abstract class Nope(val reason: () -> String) : ICanHazHeap()
    class SilentNope(reason: () -> String) : Nope(reason)
    class NotifyingNope(reason: () -> String) : Nope(reason)
    }
    sealed class Result {
    data class Done(
    val analysis: HeapAnalysis,
    val stripHeapDumpDurationMillis: Long? = null
    ) : Result()
    data class Canceled(val cancelReason: String) : Result()
    }
    • 了解了系统创建内存快照的api
     Debug.dumpHprofData(heapDumpFile.absolutePath)
    • 知道了通过 ReferenceQueue 检测内存对象是否被gc,之前WeakReference都很少用。
    • 学习了leakCanary的分模块思想。作为sdk,很多功能模块引入自动开启。比如 leakcanary-android-process 自动开启对应进程等。
    • 学习了通过反射hook代码,替换实例达成添加钩子的操作。比如在Service泄漏监听代码中,替换HandleractivityManager的操作。
    收起阅读 »

    Andorid进阶一:LeakCanary源码分析,从头到尾搞个明白

    "内存优化会不会?知道怎么定位内存问题吗?"面试官和蔼地坐在小会议室的一侧,亲切地问有些拘谨地小张。"就是...那个,用LeakCanary 检测一下泄漏,然后找到对应泄漏的地方,把错误的代码改一下,没回收的引用回收掉,优化下长短生命周期线程的依赖关系吧""那...
    继续阅读 »

    "内存优化会不会?知道怎么定位内存问题吗?"面试官和蔼地坐在小会议室的一侧,亲切地问有些拘谨地小张。

    "就是...那个,用LeakCanary 检测一下泄漏,然后找到对应泄漏的地方,把错误的代码改一下,没回收的引用回收掉,优化下长短生命周期线程的依赖关系吧"

    "那你了解LeakCanary 分析内存泄漏的原理吗?"

    "不好意思,平时没有注意去看过" 小张心想:面试怎么老问这个,我只是个普通的菜鸟啊。

    前言

    app性能优化总是开发中必不可少的一环,而其中内存优化又是重点之一。内存泄漏带来的内存溢出崩溃,内存抖动带来的卡顿不流畅。都在切切实实地影响着用户的体验。我们常常会使用LeakCanary来定位内存泄漏问题。也是时候来探索一下人家是怎么实现的了。

    名词理解

    hprof : hprof 文件是 Java 的 内存快照文件(Heap Profile 的缩写),格式后缀为 .hprof,在leakCanary 中用于内存保存分析 WeakReference : 弱引用,当一个对象仅仅被weak reference(弱引用)指向, 而没有任何其他strong reference(强引用)指向的时候, 如果这时GC运行, 那么这个对象就会被回收,不论当前的内存空间是否足够,这个对象都会被回收。在leakCanary 中用于监测该回收的无用对象是否被释放。 curtains:Square 的另一个开源框架,Curtains 提供了用于处理 Android 窗口的集中式 API。在leakCanary中用于监测window rootView 在detached 后的内存泄漏。

    目录

    本文主要从以下几点入手分析

    1. 如何在项目中使用 LeakCanary工具
    2. 官方原理说明
    3. 默认如何监听Activity ,view ,fragment 和 viewmodel
    4. Watcher.watch(object) 如何监听内存泄漏
    5. 如何保存内存泄漏内存文件
    6. 如何分析内存泄漏文件
    7. 展示内存泄漏堆栈到ui中 不支持在 Docs 外粘贴 block

    一,怎么用?

    查看官网文档 可以看出使用方法非常简单,基础用法只需要添加相关依赖就行

    //(1)
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
    复制代码

    debugImplementation 只在debug模式的编译和最终的debug apk打包时有效 注(1):标注的代码中用了一行就实现了初始化,怎么做到的呢? 通过查看源码可以看到,leakcanary 通过 ContentProvider 进行初始化,在AppWatcherInstaller 类的oncreate方法中调用了真正的初始化代码AppWatcher.manualInstall(application)。在AndroidManifest.xml中注册该provider,注册的ContentProvider会在 application 启动的时候自动回调 oncreate方法。

    internal sealed class AppWatcherInstaller : ContentProvider() {
    /**[MainProcess] automatically sets up the LeakCanary code that runs in the main app process. */
    // (1)
    internal class MainProcess : AppWatcherInstaller()
    internal class LeakCanaryProcess : AppWatcherInstaller()
    override fun onCreate(): Boolean {
    val application = context!!.applicationContext as Application
    ///(2)
    AppWatcher.manualInstall(application)
    return true
    }
    //...
    }
    复制代码

    说明一下源码中的数字标注

    1. 代码中定义了两个内部类继承自 AppWatcherInstaller。当用户额外依赖 leakcanary-android-process 模块的时候,自动在 process=":leakcanary" 也注册该provider。

    代码参见 leakcanary-android-process 模块中的AndroidManifest.xml

    1. 这是真正的初始化代码注册入口

    二,官方阐述

    官方说明

    本小节来自于官方网站的工作原理说明精简 安装 LeakCanary 后,它会通过 4 个步骤自动检测并报告内存泄漏:

    1. 检测被持有的对象

      LeakCanary 挂钩到 Android 生命周期以自动检测活动和片段何时被销毁并应进行垃圾收集。这些被销毁的对象被传递给一个ObjectWatcher,它持有对它们的弱引用。 可以主动观察一个不再需要的对象比如一个 dettached view 或者 已经销毁的 presenter

    AppWatcher.objectWatcher.watch(myDetachedView, "View was detached")
    复制代码

    如果ObjectWatcher等待 5 秒并运行垃圾收集后没有清除持有的弱引用,则被监视的对象被认为是保留的,并且可能会泄漏。LeakCanary 将此记录到 Logcat:

    D LeakCanary: Watching instance of com.example.leakcanary.MainActivity
    (Activity received Activity#onDestroy() callback)

    ... 5 seconds later ...

    D LeakCanary: Scheduling check for retained objects because found new object
    retained
    复制代码
    1. Dumping the heap 转储堆信息到文件中

      当保留对象的数量达到阈值时,LeakCanary 将 Java 内存快照 dumping 转储到 Android 文件系统上的.hprof文件(堆内存快照)中。转储堆会在短时间内冻结应用程序,并展示下图的吐司: img

    2. 分析堆内存

      LeakCanary使用Shark解析.hprof文件并在该内存快照文件中定位被保留的泄漏对象。 对于每个保留对象,LeakCanary 找到该对象的引用路径,该引用阻止了垃圾收集器对它的回收。也就是泄漏跟踪。 LeakCanary为每个泄漏跟踪创建一个签名 (对持有的引用属性进行相加做sha1Hash),并将具有相同签名的泄漏(即由相同错误引起的泄漏)组合在一起。如何创建签名和通过签名分组有待后文分析。

    3. 分类内存泄漏

      LeakCanary 将它在您的应用中发现的泄漏分为两类:Application Leaks (应用程序泄漏)和Library Leaks(库泄漏)。一个Library Leaks是由已知的第三方库导致的,你没有控制权。这种泄漏正在影响您的应用程序,但不幸的是,修复它可能不在您的控制范围内,因此 LeakCanary 将其分离出来。 这两个类别分开Logcat结果中打印:

    ====================================
    HEAP ANALYSIS RESULT
    ====================================
    0 APPLICATION LEAKS
    ====================================
    1 LIBRARY LEAK
    ...
    ┬───
    │ GC Root: Local variable in native code

    ...
    复制代码

    LeakCanary在其泄漏列表展示中会将其用Library Leak 标签标记: img LeakCanary 附带一个已知泄漏的数据库,它通过引用名称的模式匹配来识别。例如:

    Leak pattern: instance field android.app.Activity$1#this$0
    Description: Android Q added a new IRequestFinishCallback$Stub class [...]
    ┬───
    │ GC Root: Global variable in native code

    ├─ android.app.Activity$1 instance
    │ Leaking: UNKNOWN
    │ Anonymous subclass of android.app.IRequestFinishCallback$Stub
    │ ↓ Activity$1.this$0
    │ ~~~~~~
    ╰→ com.example.MainActivity instance
    复制代码

    Library Leaks 通常我们都无力对齐进行修复 您可以在AndroidReferenceMatchers类中查看已知泄漏的完整列表。如果您发现无法识别的 Android SDK 泄漏,请报告。您还可以自定义已知库泄漏的列表

    三,监测activity,fragment,rootView和viewmodel

    前面提到初始化的代码如下,所以我们 查看manualInstall 的内部细节。

    ///初始化代码
    AppWatcher.manualInstall(application)

    ///AppWatcher 的 manualInstall 代码
    @JvmOverloads
    fun manualInstall(
    application: Application,
    retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
    watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
    ) {
    //*******检查是否为主线程********/
    checkMainThread()
    if (isInstalled) {
    throw IllegalStateException(
    "AppWatcher already installed, see exception cause for prior install call", installCause
    )
    }
    check(retainedDelayMillis >= 0) {
    "retainedDelayMillis $retainedDelayMillis must be at least 0 ms"
    }
    installCause = RuntimeException("manualInstall() first called here")
    this.retainedDelayMillis = retainedDelayMillis
    if (application.isDebuggableBuild) {
    LogcatSharkLog.install()
    }
    // Requires AppWatcher.objectWatcher to be set
    ///(2)
    LeakCanaryDelegate.loadLeakCanary(application)
    ///(1)
    watchersToInstall.forEach {
    it.install()
    }
    }
    复制代码

    AppWatcher 作为Android 平台使用 ObjectWatcher 封装的api中心。自动安装配置默认的监听。 以上代码关键的地方用数字标出了

    (1)Install 默认的监听观察

    标注(1)处的代码执行了 InstallableWatcher 的 install 操作,在调用的时候并没有传递 watchersToInstall 参数,所以使用的是 appDefaultWatchers(application)。该处代码在下面,提供了 四个默认监听的Watcher

    fun appDefaultWatchers(
    application: Application,
    ///(1.1)
    reachabilityWatcher: ReachabilityWatcher = objectWatcher
    ): List<InstallableWatcher> {
    return listOf(
    ///(1.2)
    ActivityWatcher(application, reachabilityWatcher),
    ///(1.3)
    FragmentAndViewModelWatcher(application, reachabilityWatcher),
    ///(1.4)
    RootViewWatcher(reachabilityWatcher),
    ///(1.5)
    ServiceWatcher(reachabilityWatcher)
    )
    }
    复制代码

    用数字标出的四个我们逐个分析

    (1.1) reachabilityWatcher 参数

    标注(1.1)处的代码是一个 ReachabilityWatcher 参数,reachabilityWatcher 在后续的四个实例创建时候都有用到,代码中可以看到reachabilityWatcher实例是AppWatcher 的成员变量:objectWatcher,对应的实例化代码如下。

    /**
    * The [ObjectWatcher] used by AppWatcher to detect retained objects.
    * Only set when [isInstalled] is true.
    */

    val objectWatcher = ObjectWatcher(
    clock = { SystemClock.uptimeMillis() },
    checkRetainedExecutor = {
    check(isInstalled) {
    "AppWatcher not installed"
    }
    mainHandler.postDelayed(it, retainedDelayMillis)
    },
    isEnabled = { true }
    )
    复制代码

    可以看到objectWatcher 是一个 ObjectWatcher对象,该对象负责检测持有对象的泄漏情况,会在第三小节进行分析。 回到 ActivityWatcher 实例的创建,继续往下看标注的代码

    (1.2)ActivityWatcher 实例 完成Activity 实例的监听

    回到之前,标注(1.2)处的代码创建了ActivityWatcher实例,并在install 的时候安装,查看ActivityWatcher 类的源码,看监听Activity泄漏是怎么实现的

    class ActivityWatcher(
    private val application: Application,
    private val reachabilityWatcher: ReachabilityWatcher
    ) : InstallableWatcher {

    private val lifecycleCallbacks =
    //(1.2.1) 通过动态代理,构造出生命周期回调的实现类
    object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
    override fun onActivityDestroyed(activity: Activity) {
    //(1.2.3)
    reachabilityWatcher.expectWeaklyReachable(
    activity, "${activity::class.java.name} received Activity#onDestroy() callback"
    )
    }
    }

    override fun install() {
    //(1.2.3)
    application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
    }

    override fun uninstall() {
    application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
    }
    复制代码

    (1.2.1) lifecycleCallbacks 实例

    标注(1.2.1)处的代码创建了ActivityLifecycleCallbacks实例,该实例实现了Application.ActivityLifecycleCallbacks。通过 by ``*noOpDelegate*``() ,利用动态代理实现了其他回调方法,感兴趣的可以查看 noOpDelegate 的源码

    (1.2.2) activity监听器的 install 方法

    标注(1.2.2)处的代码是初始化的主要代码,该方法很简单,就是在application的 中注册 lifecycleCallbacks,在activity 被destroy 的时候会走到其中实现的方法

    (1.2.3) 监听activity 的 onActivityDestroyed 回调

    标注(1.2.3)处的代码是初始化的主要代码,在 activity被销毁的时候,回调该方法,在其中检查该实例是否有泄漏,调用AppWatcher.objectWatcher. expectWeaklyReachable 方法,在其中完成activity的泄漏监测。 这时候又回到了 1.1 提到的 ObjectWatcher源码,相关分析看第四节 。

    (1.2-end)Activity监测相关总结

    这样ActivityInstaller 就看完了,了解了Activity 的初始化代码以及加入监听的细节。总结一下分为如下几步:

    1. 调用ActivityInstaller.install 初始化方法
    2. 在Application 注册ActivityLifecycleCallbacks
    3. 在所有activity onDestroy的时候调用ObjectWatcher的 expectWeaklyReachable方法,检查过五秒后activity对象是否有被内存回收。标记内存泄漏。下一节分析。
    4. 检测到内存泄漏的后续操作。后文分析。

    (1.3) FragmentAndViewModelWatcher 监测 Fragment 和Viewodel实例

    (1.3)处是创建了 FragmentAndViewModelWatcher 实例。监测fragment和viewmodel的内存泄漏。

    该类实现了 SupportFragment和 androidxFragment以及androidO 的兼容,作为sdk开发来说,这种 兼容方式可以学习一下。

    private val lifecycleCallbacks =
    object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
    override fun onActivityCreated(
    activity: Activity,
    savedInstanceState: Bundle?
    ) {
    for (watcher in fragmentDestroyWatchers) {
    watcher(activity)
    }
    }
    }

    override fun install() {
    application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
    }
    复制代码

    ActivityWatcher 同样的,install是注册了生命周期监听。不过是在对每个 activity create 的时候,交给 fragmentDestroyWatchers 元素们监听。所以 fragmentDestroyWatchers才是真正的fragmentviewmodel 监听者。 接下来看 fragmentDestroyWatchers 的元素们创建:

    private val fragmentDestroyWatchers: List<(Activity) -> Unit> = run {
    val fragmentDestroyWatchers = mutableListOf<(Activity) -> Unit>()

    //(1.3.1) android框架自带的fragment泄漏监测支持从 AndroidO(26)开始。
    if (SDK_INT >= O) {
    fragmentDestroyWatchers.add(
    AndroidOFragmentDestroyWatcher(reachabilityWatcher)
    )
    }
    //(1.3.2)
    getWatcherIfAvailable(
    ANDROIDX_FRAGMENT_CLASS_NAME,
    ANDROIDX_FRAGMENT_DESTROY_WATCHER_CLASS_NAME,
    reachabilityWatcher
    )?.let {
    fragmentDestroyWatchers.add(it)
    }
    //(1.3.3)
    getWatcherIfAvailable(
    ANDROID_SUPPORT_FRAGMENT_CLASS_NAME,
    ANDROID_SUPPORT_FRAGMENT_DESTROY_WATCHER_CLASS_NAME,
    reachabilityWatcher
    )?.let {
    fragmentDestroyWatchers.add(it)
    }
    fragmentDestroyWatchers
    }
    复制代码

    可以看到内部创建了AndroidOFragmentDestroyWatcher 来针对Fragment 进行监听。原理是利用在 FragmentManager 中注册 FragmentManager.FragmentLifecycleCallbacks 来监听fragment 和 fragment.view 以及viewmodel 的实例泄漏。 从官方文档可知,android内部的 fragment 在Api 26中才添加。所以LeakCanary针对于android框架自带的fragment泄漏监测支持也是从 AndroidO(26)开始,见代码(1.3.1)。 标注的 1.3.1,1.3.2,1.3.3 实例化的三个Wathcer 分别是 AndroidOFragmentDestroyWatcher,AndroidXFragmentDestroyWatcher,AndroidSupportFragmentDestroyWatcher。内部实现代码大同小异,通过反射实例化不同的Watcher实现了androidX 和support 以及安卓版本间的兼容。

    (1.3.1) AndroidOFragmentDestroyWatcher 实例

    (1.3.1)处的代码添加了一个androidO的观察者实例。详情见代码,因为实现大同小异,分析参考1.3.2.

    (1.3.2) AndroidXFragmentDestroyWatcher 实例

    (1.3.2)处的代码 调用 getWatcherIfAvailable 通过反射创建了AndroidXFragmentDestroyWatcher实例,如果不存在Androidx库则返回null。 现在跳到 AndroidXFragmentDestroyWatcher 的源码分析

    internal class AndroidXFragmentDestroyWatcher(
    private val reachabilityWatcher: ReachabilityWatcher
    ) : (Activity) -> Unit {

    private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

    override fun onFragmentCreated(
    fm: FragmentManager,
    fragment: Fragment,
    savedInstanceState: Bundle?
    ) {
    //(1.3.2.1)初始化 ViewModelClearedWatcher
    ViewModelClearedWatcher.install(fragment, reachabilityWatcher)
    }

    override fun onFragmentViewDestroyed(
    fm: FragmentManager,
    fragment: Fragment
    ) {
    //监测 fragment.view 的泄漏情况
    val view = fragment.view
    if (view != null) {
    reachabilityWatcher.expectWeaklyReachable(
    view, "${fragment::class.java.name} received Fragment#onDestroyView() callback " +
    "(references to its views should be cleared to prevent leaks)"
    )
    }
    }

    override fun onFragmentDestroyed(
    fm: FragmentManager,
    fragment: Fragment
    ) {
    //监测 fragment 的泄漏情况
    reachabilityWatcher.expectWeaklyReachable(
    fragment, "${fragment::class.java.name} received Fragment#onDestroy() callback"
    )
    }
    }

    ///初始化,注册fragmentLifecycleCallbacks
    override fun invoke(activity: Activity) {
    if (activity is FragmentActivity) {
    val supportFragmentManager = activity.supportFragmentManager
    supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
    //注册activity的 viewModel 监听回调
    ViewModelClearedWatcher.install(activity, reachabilityWatcher)
    }
    }
    }
    复制代码

    通过源码可以看到,初始化该watcher是通过以下几步。

    1. FragmentManager.registerFragmentLifecycleCallbacks 注册监听回调
    2. ViewModelClearedWatcher.install 初始化了对于activity.viewModel的监听
    3. 在回调onFragmentCreated 中回调中使用ViewModelClearedWatcher.install注册了对于fragment.viewModel的监听。
    4. 在 onFragmentViewDestroyed 监听 fragment.view 的泄漏
    5. 在 onFragmentDestroyed 监听 fragment的泄漏。 监听方法和ActivityWatcher大同小异,不同是多了个 ViewModelClearedWatcher.install 。现在分析这一块的源码,也就是标注中的 (1.3.2.1)。
    //该watcher 继承了ViewModel,生命周期被 ViewModelStoreOwner 管理。
    internal class ViewModelClearedWatcher(
    storeOwner: ViewModelStoreOwner,
    private val reachabilityWatcher: ReachabilityWatcher
    ) : ViewModel() {

    private val viewModelMap: Map<String, ViewModel>?

    init {
    //(1.3.2.3)通过反射获取所有的 store 存储的所有viewModelMap
    viewModelMap = try {
    val mMapField = ViewModelStore::class.java.getDeclaredField("mMap")
    mMapField.isAccessible = true
    @Suppress("UNCHECKED_CAST")
    mMapField[storeOwner.viewModelStore] as Map<String, ViewModel>
    } catch (ignored: Exception) {
    null
    }
    }

    override fun onCleared() {
    ///(1.3.2.4) viewmodle 被清理释放的时候回调,检查所有viewmodle 是否会有泄漏
    viewModelMap?.values?.forEach { viewModel ->
    reachabilityWatcher.expectWeaklyReachable(
    viewModel, "${viewModel::class.java.name} received ViewModel#onCleared() callback"
    )
    }
    }

    companion object {
    fun install(
    storeOwner: ViewModelStoreOwner,
    reachabilityWatcher: ReachabilityWatcher
    ) {
    val provider = ViewModelProvider(storeOwner, object : Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel?> create(modelClass: Class<T>): T =
    ViewModelClearedWatcher(storeOwner, reachabilityWatcher) as T
    })
    ///(1.3.2.2) 获取ViewModelClearedWatcher实例
    provider.get(ViewModelClearedWatcher::class.java)
    }
    }
    }
    复制代码

    通过代码,可以看到viewModel的泄漏监测是通过创建一个新的viewModel实例来实现。在该实例的onCleared处监听storeOwner的其余 viewModel 是否有泄漏。标注出的代码逐一分析:

    (1.3.2.2 ) 处代码:

    获取ViewModelClearedWatcher 实例,在自定义的 Factory中传入storeOwner 和 reachabilityWatcher。

    (1.3.2.3 ) 处代码:

    通过反射获取storeOwner 的viewModelMap

    (1.3.2.4 ) 处代码:

    在ViewModel完成使命OnClear的时候,开始监测storeOwner旗下所有ViewModel的内存泄漏情况。

    (1.3-end)Fragment 和 viewmodel 监测泄漏总结:

    监测方式都是通过ObjectWatcher的 expectWeaklyReachable 方法进行。fragment 利用FragmentLifecyclerCallback回调注册实现,ViewModel 则是在对应StoreOwner下创建了监测viewModel来实现生命周期的响应。 其中我们也能学习到通过反射来创建对应的平台兼容实现对象方式。以及借助创建viewModel来监听其余ViewModel生命周期的想法。

    (1.4) RootViewWatcher 的源码分析

    默认的四个Watcher中,来到了接下来的 RootViewWatcher。window rootview 监听依赖了squre自家的Curtains框架。

    implementation "com.squareup.curtains:curtains:1.0.1"
    复制代码

    类的关键源码如下:

     private val listener = OnRootViewAddedListener { rootView ->
    //如果是 Dialog TOOLTIP, TOAST, UNKNOWN 等类型的windows
    //trackDetached 为true
    if (trackDetached) {
    rootView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {

    val watchDetachedView = Runnable {
    reachabilityWatcher.expectWeaklyReachable(
    rootView, "${rootView::class.java.name} received View#onDetachedFromWindow() callback"
    )
    }

    override fun onViewAttachedToWindow(v: View) {
    mainHandler.removeCallbacks(watchDetachedView)
    }

    override fun onViewDetachedFromWindow(v: View) {
    mainHandler.post(watchDetachedView)
    }
    })
    }
    }

    override fun install() {
    Curtains.onRootViewsChangedListeners += listener
    }

    override fun uninstall() {
    Curtains.onRootViewsChangedListeners -= listener
    }
    }
    复制代码

    看到关键代码,就是 在Curtains中添加onRootViewsChangedListeners 监听器。当windowsType类型为 **Dialog** ***TOOLTIP***, ***TOAST*****,**或者未知的时候 ,在 onViewDetachedFromWindow 的时候监听泄漏情况。 Curtains中的监听器会在windows rootView 变化的时候被全局调用。Curtains是squareup 的另一个开源库,Curtains 提供了用于处理 Android 窗口的集中式 API。具体移步他的官方仓库

    (1.5) ServiceWatcher 监听Service内存泄漏

    接下来就是AppWatcher中的最后一个Watcher。 ServiceWatcher。代码比较长,截取关键点分析。

    (1.5.1)先看成员变量 activityThreadServices :

    private val servicesToBeDestroyed = WeakHashMap<IBinder, WeakReference<Service>>()
    private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") }
    private val activityThreadInstance by lazy {
    activityThreadClass.getDeclaredMethod("currentActivityThread").invoke(null)!!
    }

    private val activityThreadServices by lazy {
    val mServicesField =
    activityThreadClass.getDeclaredField("mServices").apply { isAccessible = true }

    @Suppress("UNCHECKED_CAST")
    mServicesField[activityThreadInstance] as Map<IBinder, Service>
    }
    复制代码

    activityThreadServices 是个装了所有<IBinder, Service> 对的Map。代码中可以看到很粗暴地,直接通过反射从ActivityThread实例中拿到了mServices 变量 。赋值给activityThreadServices。 源码中有多个swap操作,在install的时候执行,主要目的是将原来的一些service相关生命周期回调加上一些钩子,用来监测内存泄漏,并且会在unInstall的时候给换回来。

    (1.5.2)swapActivityThreadHandlerCallback :

    拿到ActivityThread 的Handler,将其回调的 handleMessage,换成加了料的Handler.Callback,加料代码如下

    Handler.Callback { msg ->
    if (msg.what == STOP_SERVICE) {
    val key = msg.obj as IBinder
    activityThreadServices[key]?.let {
    onServicePreDestroy(key, it)
    }
    }
    mCallback?.handleMessage(msg) ?: false
    }
    复制代码

    代码中可以看到,主要是对于 STOP_SERVICE 的操作做了一个钩子,在之前执行 onServicePreDestroy。主要作用是为该service 创建一个弱引用,并且加到servicesToBeDestroyed[token] 中 。

    (1.5.3)然后再看 swapActivityManager 方法。

    该方法完成了将ActivityManager替换成IActivityManager的一个动态代理类。代码如下:

    Proxy.newProxyInstance(
    activityManagerInterface.classLoader, arrayOf(activityManagerInterface)
    ) { _, method, args ->
    //private const val METHOD_SERVICE_DONE_EXECUTING = "serviceDoneExecuting"
    if (METHOD_SERVICE_DONE_EXECUTING == method.name) {
    val token = args!![0] as IBinder
    if (servicesToBeDestroyed.containsKey(token)) {
    ///(1.5.3)
    onServiceDestroyed(token)
    }
    }
    try {
    if (args == null) {
    method.invoke(activityManagerInstance)
    } else {
    method.invoke(activityManagerInstance, *args)
    }
    } catch (invocationException: InvocationTargetException) {
    throw invocationException.targetException
    }
    }
    复制代码

    代码所示,替换后的ActivityManager 在调用serviceDoneExecuting 方法的时候添加了个钩子,如果该service在之前加入的servicesToBeDestroyed map中,则调用onServiceDestroyed 监测该service内存泄漏。

    (1.5.4)代码的onServiceDestroyed具体代码如下

    private fun onServiceDestroyed(token: IBinder) {
    servicesToBeDestroyed.remove(token)?.also { serviceWeakReference ->
    serviceWeakReference.get()?.let { service ->
    reachabilityWatcher.expectWeaklyReachable(
    service, "${service::class.java.name} received Service#onDestroy() callback"
    )
    }
    }
    }
    复制代码

    这里面的代码很熟悉,和之前监测activity等是一样的。 回到swapActivityManager方法,看代理ActivityManager的具体类型。 可以看到代理的对象如下面代码所示,根据版本不同可能是ActivityManager 实例或者是ActivityManagerNative实例。 代理的接口是 Class.forName("android.app.IActivityManager")

    val (className, fieldName) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    "android.app.ActivityManager" to "IActivityManagerSingleton"
    } else {
    "android.app.ActivityManagerNative" to "gDefault"
    }
    复制代码

    (1.5-end)Service 泄漏监测总结

    总结一下,service的泄漏分析通过加钩子的方式,对一些系统执行做了监听。主要分为以下几步:

    1. 获取ActivityThread中mService变量,得到service实例的引用
    2. 通过swapActivityThreadHandlerCallback 在ActivityThread 的 Handler.sendMessage 中添加钩子,在执行到msg.what == STOP_SERVICE 的时候


    收起阅读 »

    Android Compose 初探!

    使用前的准备工作android studio Arctic Fox版本或更新的版本如果是一个新项目,可以在创建的时候,新建一个Empty Compose Activity在module的build.gradle文件中添加android { buildF...
    继续阅读 »

    使用前的准备工作

    1. android studio Arctic Fox版本或更新的版本

    2. 如果是一个新项目,可以在创建的时候,新建一个Empty Compose Activity

      image.png

    3. 在module的build.gradle文件中添加

      android {
      buildFeatures {
      compose true
      }
      composeOptions {
      kotlinCompilerExtensionVersion compose_version
      kotlinCompilerVersion '1.4.32'
      }
      }
      dependencies {
      implementation 'androidx.core:core-ktx:1.3.2'
      implementation 'androidx.appcompat:appcompat:1.2.0'
      implementation 'com.google.android.material:material:1.3.0'
      implementation "androidx.compose.ui:ui:$compose_version"
      implementation "androidx.compose.material:material:$compose_version"
      implementation "androidx.compose.ui:ui-tooling:$compose_version"
      implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
      implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
      }

    需要添加

    buildFeatures {
    compose true
    }

    组件

    组件的定义

    在Compose中一个UI组件就是一个带有@Composable注解的函数

    @Composable
    fun Greeting(name: String) {
    Text(text = "Hello $name!")
    }

    布局组件

    如果没有采用布局组件,直接单视图写到一个Compose中,会存在异常的情况。官方是这么说的:

    A Composable function might emit several UI elements. However, if you don't provide guidance on how they should be arranged, Compose might arrange the elements in a way you don't like

    • Row 横向排列视图, Row的相关属性如下:
      inline fun Row(
      modifier: Modifier = Modifier,
      horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
      verticalAlignment: Alignment.Vertical = Alignment.Top,
      content: @Composable RowScope.() -> Unit
      )
    • Column 纵向排列视图, 其属性和上面的Row类似
    • Box 将一个元素覆盖在另一个上面, 类似于FrameLayout这种

    视图组件

    • Text 类似于原生View中的TextView
    • Button 按钮
    • LazyColumn 类似于原生RecyclerView
    • Image 图片控件 关于网络图片,可以采用Coil框架
    • TextField 文件输入框
    • Surface用来控制组件的背景,边框,文本颜色等
    • AlertDialog 弹窗控件,类似于原生View中的AlertDialog

    组件的状态管理

    remember

    通过remember来记录组件某些相关属性值,当属性发生变化,会自动触发UI的更新。

    @Composable
    fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
    var nameState = remember { mutableStateOf("") }
    var name = nameState.value;
    Text(
    text = "Hello, $name!",
    modifier = Modifier.padding(bottom = 8.dp),
    style = MaterialTheme.typography.h5
    )
    TextField(
    value = name,
    onValueChange = { println("data----->$it");nameState.value = it }
    )
    }
    }

    这段代码实现的功能就是当用户在一个输入框中输入文字的时候,即时回显在页面上。当采用这种方式编码时,状态是耦合在组件中,当调用者不关心内部的状态的,这种方式是ok的,但它的弊端就是不利于组件的复用。我们可以将状态和组件分离开,此时,便就是利用状态提升(state hoisting)的手段

    @Composable
    fun HelloScreen() {
    var nameState = remember { mutableStateOf("") }
    HelloContent(name = nameState.value, onNameChange = { nameState.value = it })
    }

    @Composable
    fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
    Text(
    text = "Hello, $name!",
    modifier = Modifier.padding(bottom = 8.dp),
    style = MaterialTheme.typography.h5
    )
    TextField(
    value = name,
    onValueChange = { onNameChange(it) }
    )
    }
    }

    这里是将状态提到HelloContent的外面, 方面HelloContent组件的复用

    rememberSaveable

    remember类似,区别在于rememberSaveable进行状态管理时,当activity或进程重新创建了(如屏幕旋转),其状态信息不会丢失。 将上面的var nameState = remember { mutableStateOf("") } 中的remember换成rememberSaveable就可以了

    ViewModel

    可以利用ViewModel进行全局的状态管理

    class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
    _name.value = newName
    }
    }

    @Composable
    fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
    }

    Modifers

    Modifers是用来装饰composable, Modifiers用来告诉一个UI元素如何布局,显示,和相关的行为。

    布局相关的属性

    • fillMaxWidth
    • matchParentSize
    • height
    • width
    • padding
    • size

    显示

    • background
    • clip: 如Modifier.clip(RoundedCornerShape(4.dp)),一个圆角便出来了

    绑定事件

    利用clickable来绑定事件

    Row(
    Modifier
    .fillMaxWidth()
    .clickable { onClick(); },
    verticalAlignment = Alignment.CenterVertically
    ) {
    ...
    }

    实例

    采用Compose方案的开发体验非常接近于用Vue或React, 代码结构非常清晰,不用xml来画UI确实省了不少事,以下是一段代码片断来画一个微信的个人中心页

    image.png

    @Preview(showBackground = true)
    @Composable
    fun PersonalCenter() {
    Column() {
    Header("Hello World", "Wechat_0001")
    Divider(
    Modifier
    .fillMaxWidth()
    .height(8.dp), GrayBg
    )
    RowList()
    Divider(
    Modifier
    .fillMaxHeight(), GrayBg
    )
    }
    }

    @Composable
    fun Header(nickName: String, wechatNo: String) {
    Row(
    Modifier
    .fillMaxWidth()
    .padding(24.dp, 24.dp, 16.dp, 24.dp),
    verticalAlignment = Alignment.CenterVertically
    ) {
    Image(
    painter = painterResource(R.drawable.avatar),
    contentDescription = "头像",
    Modifier
    .size(50.dp)
    .clip(
    RoundedCornerShape(4.dp)
    )
    )
    Column() {
    Text(nickName, Modifier.padding(12.dp, 2.dp, 0.dp, 0.dp), TextColor, fontSize = 18.sp)
    Row(verticalAlignment = Alignment.CenterVertically) {
    Text(
    "微信号 :$wechatNo",
    Modifier
    .padding(12.dp, 10.dp, 0.dp, 0.dp)
    .weight(1.0f), TextColorGray, fontSize = 14.sp
    )
    Icon(painterResource(R.drawable.ic_qrcode), "二维码", Modifier.size(16.dp))
    Icon(
    painterResource(R.drawable.right_arrow_3),
    contentDescription = "more",
    Modifier.padding(12.dp, 0.dp, 0.dp, 0.dp)
    )
    }
    }
    }
    }

    @Composable
    fun RowItem(@DrawableRes icon: Int, title: String, onClick: () -> Unit) {
    Row(
    Modifier
    .fillMaxWidth()
    .clickable { onClick(); },
    verticalAlignment = Alignment.CenterVertically
    ) {
    Image(
    painter = painterResource(icon), contentDescription = title + "icon",
    Modifier
    .padding(16.dp, 12.dp, 16.dp, 12.dp)
    .size(24.dp)
    )
    Text(title, Modifier.weight(1f), TextColor, fontSize = 15.sp)
    Icon(
    painterResource(R.drawable.right_arrow_3),
    contentDescription = "more",
    Modifier.padding(0.dp, 0.dp, 16.dp, 0.dp)
    )
    }
    }

    @Composable
    fun RowList() {
    var context = LocalContext.current;
    Column() {
    RowItem(icon = R.drawable.ic_pay, title = "支付") { onItemClick(context, "payment") }
    Divider(
    Modifier
    .fillMaxWidth()
    .height(8.dp), GrayBg
    )
    RowItem(icon = R.drawable.ic_collections, title = "收藏") {
    onItemClick(context, "收藏")
    }
    Divider(
    Modifier
    .fillMaxWidth()
    .padding(56.dp, 0.dp, 0.dp, 0.dp)
    .height(0.2.dp), GrayBg
    )
    RowItem(icon = R.drawable.ic_photos, title = "相册") {
    onItemClick(context, "相册")
    }
    Divider(
    Modifier
    .fillMaxWidth()
    .padding(56.dp, 0.dp, 0.dp, 0.dp)
    .height(0.2.dp), GrayBg
    )
    RowItem(icon = R.drawable.ic_cards, title = "卡包") {
    Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
    }
    Divider(
    Modifier
    .fillMaxWidth()
    .padding(56.dp, 0.dp, 0.dp, 0.dp)
    .height(0.2.dp), GrayBg
    )
    RowItem(icon = R.drawable.ic_stickers, title = "表情") {
    Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
    }
    Divider(
    Modifier
    .fillMaxWidth()
    .height(8.dp), GrayBg
    )
    RowItem(icon = R.drawable.ic_settings, title = "设置") {
    Toast.makeText(context, "payment", Toast.LENGTH_SHORT).show()
    }
    }
    }

    fun onItemClick(context: Context, data: String) {
    Toast.makeText(context, data, Toast.LENGTH_SHORT).show()
    }

    View中嵌Compose

    var view = LinearLayout(this)
    view.addView(ComposeView(this).apply {
    setContent {
    PersonalCenter();
    }
    })

    Compose中嵌View

    @Compose
    fun RowList() {
    ...
    AndroidView({View(context)}, Modifier.width(20.dp).height(20.dp).background(Color.Green)){}
    ...
    }

    总结

    • Compose使用了一套新的布局,渲染机制, 它里面的元素和我们以前写的各种View是有区别的,比如Compose里面的Text并不是我们以前认识的TextView或其它的原生控件, 它采用了更底层的api来实现
    • 数据的自动订阅(完成双向绑定)
    • 声明式UI: compose通过自动订阅机制来完成UI的自动更新
    • compose和现有的原生View混用
    收起阅读 »

    [译] R8 优化:字节码常量操作

    1. Log Tags(日志标签)关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()。private static final String TAG ...
    继续阅读 »

    1. Log Tags(日志标签)

    关于在类中定义标记字符串的最佳方法,有一个正在进行的争论(如果您甚至可以这样称呼它的话)。历史上有两种策略:字符串文本和对类调用 getSimpleName()

    private static final String TAG = "MyClass";
    // or
    private static final String TAG = MyClass.class.getSimpleName();

    究竟孰好孰坏,让我们写个例子测试下。

    class MyClass {
    private static final String TAG_STRING = "MyClass";
    private static final String TAG_CLASS = MyClass.class.getSimpleName();

    public static void main(String... args) {
    Log.d(TAG_STRING, "String tag");
    Log.d(TAG_CLASS, "Class tag");
    }
    }

    对上面的代码执行,Compilingdexing 然后查看 Dalvik 字节码。

    [000194] MyClass.:()V
    0000: const-class v0, LMyClass;
    0002: invoke-virtual {v0}, Ljava/lang/Class;.getSimpleName:()Ljava/lang/String;
    0005: move-result-object v0
    0006: sput-object v0, LMyClass;.TAG_CLASS:Ljava/lang/String;
    0008: return-void

    [000120] MyClass.main:([Ljava/lang/String;)V
    0000: const-string v1, "MyClass"
    0002: const-string v0, "String tag"
    0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    0007: sget-object v1, LMyClass;.a:Ljava/lang/String;
    0009: const-string v0, "Class tag"
    000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    000e: return-void

    在 main 函数中,0000 位置处加载 tag 的字符串常量,在 0007 处,查找该静态字段并读取值。在  方法中,静态字段是通过加载 MyClass 类然后在运行时调用 getSimpleName 方法获取。这个方法在类第一次加载的时候调用。

    可以看到使用字符串常量效率更高,但使用 Class.getSimpleName() 对于重构之类需求更灵活。我们同样使用 R8 进行编译。

    [000120] MyClass.main:([Ljava/lang/String;)V
    0000: const-string v1, "MyClass"
    0002: const-string v0, "String tag"
    0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    0007: const-string v0, "Class tag"
    0009: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    000c: return-void

    可以看到在 0004 位置后面的操作中将变量 v1 的 MyClass 值进行了重复。

    由于 myClass 的名称在编译时已知,R8 已将 myClass.class.getSimpleName() 替换为字符串变量 "myClass"。因为字段值现在是常量,所以  方法变为空并被删除。在调用位置上,用常量字符串替换了 sget 对象字节码。最后,对引用同一字符串的两个常量字符串字节码进行了重复数据消除,并进行重用。

    因此,R8 确保不会进行额外的加载。因为 getSimpleName() 计算很简单,D8 实际上也会执行这种优化!

    2. Applicability(拓展)

    在 MyClass.class 上能够获取 getSimpleName()(以及 getName() 和 getCanonicalName()),这种方式的用途似乎有限——甚至可能仅限于此日志标记案例。优化只适用于类文本引用– getClass() 不起作用!再次结合其他 R8 特性,这种优化开始应用得更多。

    我们来看下面的一个示例:

    class Logger {
    static Logger get(Class cls) {
    return new Logger(cls.getSimpleName());
    }
    private Logger(String tag) { /* … */ }

    }

    class MyClass {
    private static final Logger logger = Logger.get(MyClass.class);
    }

    如果 Logger.get 内嵌在所有调用处,则对以前具有方法参数动态输入的 class.getSimpleName 的调用将更改为类引用的静态输入(在本例中为 myClass.class)。R8 现在可以用字符串文字替换调用,从而产生直接调用构造函数的字段初始值设定项(也将删除其私有修饰符)。

    class MyClass {
    private static final Logger logger = new Logger("MyClass");
    }

    这依赖于 get 方法足够小或者满足 R8 的内联调用方式。

    Kotlin 语言提供了强制函数内联的能力。它还允许将内联函数上的泛型类型参数标记为 reified,从而确保编译器知道在编译时解析为哪个类。使用这些特性,我们可以确保我们的函数始终是内联的,并且总是在显式类引用上调用 getSimpleName

    class Logger private constructor(val tag: String) {

    }
    inline fun <reified T : Any> logger() = Logger(T::class.java.simpleName)

    class MyClass {

    companion object {
    private val logger = logger()
    }
    }

    logger 函数的初始值将始终具有与 myClass.Class.GetSimpleName() 等效的字节码,然后 R8 可以替换为字符串常量。

    对于其他 Kotlin 示例,类型推断通常允许省略显式类型参数。

    inline fun <reified T> typeAndValue(value: T) = "${T::class.java.name}: $value"
    fun main() {
    println(typeAndValue("hey"))
    }

    上面示例输出结果为:“java.lang.String: hey”,同时编译后的字节码中只有两个字符串常量,并且用 StringBuilder 连接,然后调用 System.out.println 输出,如果这个问题被解决,你会发现只有一个字符串常量调用 System.out.println

    3. 混淆和优化

    由于这种优化是在字节码上进行的,因此它必须与R8 的其他功能交互,这些功能可能会影响类,如 Obfuscation(混淆) 和 Optimization(优化)

    让我们回到原来的例子。

    class MyClass {
    private static final String TAG_STRING = "MyClass";
    private static final String TAG_CLASS = MyClass.class.getSimpleName();

    public static void main(String... args) {
    Log.d(TAG_STRING, "String tag");
    Log.d(TAG_CLASS, "Class tag");
    }
    }

    如果这个类被混淆了会发生什么?如果 R8 没有替换 getSimpleName 的调用,第一条日志消息将有一个 myclass 标记,第二条日志消息将有一个与模糊类名(如“a”)匹配的标记。

    为了允许 R8 替换 getSimpleName,需要使用一个与运行时行为匹配的值。值得庆幸的是,由于 R8 也是执行混淆处理的工具,所以它可以直到类被赋予其最终名称时才进行替换。

    [000158] a.main:([Ljava/lang/String;)V
    0000: const-string v1, "MyClass"
    0002: const-string v0, "String tag"
    0004: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    0007: const-string v1, "a"
    0009: const-string v0, "Class tag"
    000b: invoke-static {v1, v0}, Landroid/util/Log;.d:(Ljava/lang/String;Ljava/lang/String;)I
    000e: return-void

    请注意 0007 现在将如何为第二个日志调用加载标记值(与原始 R8 输出不同),以及它如何正确反映混淆名称。

    即使禁用了混淆,R8 还有其它优化会影响类名。虽然我打算在以后的文章中介绍它,如果 R8 能够证明不需要超类,并且子类是唯一的, 有时 R8 会将一个超类合并成一个子类。发生这种情况时,类名字符串优化将正确反映子类型名称,即使原始代码等效于 superType.class.getSimpleName()

    3. String Data Section

    前一篇文章讨论了如何在编译时执行 string.substring 或字符串串联之类的操作,从而导致 dex 文件的 string 部分的大小增加。本文中讨论的优化也会生成一些不存在的字符串,也可能会变大。

    所以有两种场景需要考虑:“什么时候开启混淆?什么时候关闭混淆”。

    启用混淆处理时,对 getSimpleName() 的调用不应创建新字符串。类和方法都将使用同一个字典进行混淆处理,默认字典以单个字母开头。这意味着,对于名为 b 的混淆类,插入字符串 “b” 几乎总是免费的,因为将有一个方法或字段的名称也是b。在DEX文件中,所有字符串都存储在一个池中,该池包含文字、类名、方法名和字段名,使模糊时匹配的可能性大于Y高。

    但是,在禁用模糊处理的情况下,替换getSimpleName()永远都不是免费的。尽管dex文件有统一的字符串部分,类名还是以类型描述符的形式存储。这包括包名称,使用/作为分隔符,前缀为L,后缀为;。对于myclass,如果在假设的com.example包中,字符串数据包含lcom/example/myclass;的条目。由于这种格式,字符串“myclass”不存在,需要添加。

    getName() 和 getCanonicalName() 都会产生新的字符串,都会返回全限定符字符串,而不是考虑存在的限定符。

    由于混淆潜在创建了大量的字符串对象,所以它现在除了对顶级类型才可用。在 MyClass 中起作用,但是对于匿名类和内部类无法起作用。同样有研究表明不在一个单独的方法中使用混淆,来避免增加 dex 文件大小。

    4. 总结

    下篇文章中,我们将讨论 R8 的另一个优化。

    收起阅读 »

    CocoaPods 都做了什么

    稍有 iOS 开发经验的人应该都是用过 CocoaPods,而对于 CI、CD 有了解的同学也都知道 Fastlane。而这两个在 iOS 开发中非常便捷的第三方库都是使用 Ruby 来编写的,这是为什么?先抛开这个话题不谈,我们来看一下 CocoaPods ...
    继续阅读 »

    稍有 iOS 开发经验的人应该都是用过 CocoaPods,而对于 CI、CD 有了解的同学也都知道 Fastlane。而这两个在 iOS 开发中非常便捷的第三方库都是使用 Ruby 来编写的,这是为什么?

    先抛开这个话题不谈,我们来看一下 CocoaPods 和 Fastlane 是如何使用的,首先是 CocoaPods,在每一个工程使用 CocoaPods 的工程中都有一个 Podfile:

    source 'https://github.com/CocoaPods/Specs.git'

    target 'Demo' do
    pod 'Mantle', '~> 1.5.1'
    pod 'SDWebImage', '~> 3.7.1'
    pod 'BlocksKit', '~> 2.2.5'
    pod 'SSKeychain', '~> 1.2.3'
    pod 'UMengAnalytics', '~> 3.1.8'
    pod 'UMengFeedback', '~> 1.4.2'
    pod 'Masonry', '~> 0.5.3'
    pod 'AFNetworking', '~> 2.4.1'
    pod 'Aspects', '~> 1.4.1'
    end

    这是一个使用 Podfile 定义依赖的一个例子,不过 Podfile 对约束的描述其实是这样的:

    source('https://github.com/CocoaPods/Specs.git')

    target('Demo') do
    pod('Mantle', '~> 1.5.1')
    ...
    end

    Ruby 代码在调用方法时可以省略括号。

    Podfile 中对于约束的描述,其实都可以看作是对代码简写,上面的代码在解析时可以当做 Ruby 代码来执行。

    Fastlane 中的代码 Fastfile 也是类似的:

    lane :beta do
    increment_build_number
    cocoapods
    match
    testflight
    sh "./customScript.sh"
    slack
    end

    使用描述性的”代码“编写脚本,如果没有接触或者使用过 Ruby 的人很难相信上面的这些文本是代码的。

    Ruby 概述

    在介绍 CocoaPods 的实现之前,我们需要对 Ruby 的一些特性有一个简单的了解,在向身边的朋友“传教”的时候,我往往都会用优雅这个词来形容这门语言(手动微笑)。

    除了优雅之外,Ruby 的语法具有强大的表现力,并且其使用非常灵活,能快速实现我们的需求,这里简单介绍一下 Ruby 中的一些特性。

    一切皆对象

    在许多语言,比如 Java 中,数字与其他的基本类型都不是对象,而在 Ruby 中所有的元素,包括基本类型都是对象,同时也不存在运算符的概念,所谓的 1 + 1,其实只是 1.+(1) 的语法糖而已。

    得益于一切皆对象的概念,在 Ruby 中,你可以向任意的对象发送 methods 消息,在运行时自省,所以笔者在每次忘记方法时,都会直接用 methods 来“查文档”:

    2.3.1 :003 > 1.methods
    => [:%, :&, :*, :+, :-, :/, :<, :>, :^, :|, :~, :-@, :**, :<=>, :<<, :>>, :<=, :>=, :==, :===, :[], :inspect, :size, :succ, :to_s, :to_f, :div, :divmod, :fdiv, :modulo, :abs, :magnitude, :zero?, :odd?, :even?, :bit_length, :to_int, :to_i, :next, :upto, :chr, :ord, :integer?, :floor, :ceil, :round, :truncate, :downto, :times, :pred, :to_r, :numerator, :denominator, :rationalize, :gcd, :lcm, :gcdlcm, :+@, :eql?, :singleton_method_added, :coerce, :i, :remainder, :real?, :nonzero?, :step, :positive?, :negative?, :quo, :arg, :rectangular, :rect, :polar, :real, :imaginary, :imag, :abs2, :angle, :phase, :conjugate, :conj, :to_c, :between?, :instance_of?, :public_send, :instance_variable_get, :instance_variable_set, :instance_variable_defined?, :remove_instance_variable, :private_methods, :kind_of?, :instance_variables, :tap, :is_a?, :extend, :define_singleton_method, :to_enum, :enum_for, :=~, :!~, :respond_to?, :freeze, :display, :send, :object_id, :method, :public_method, :singleton_method, :nil?, :hash, :class, :singleton_class, :clone, :dup, :itself, :taint, :tainted?, :untaint, :untrust, :trust, :untrusted?, :methods, :protected_methods, :frozen?, :public_methods, :singleton_methods, :!, :!=, :__send__, :equal?, :instance_eval, :instance_exec, :__id__]

    比如在这里向对象 1 调用 methods 就会返回它能响应的所有方法。

    一切皆对象不仅减少了语言中类型的不一致,消灭了基本数据类型与对象之间的边界;这一概念同时也简化了语言中的组成元素,这样 Ruby 中只有对象和方法,这两个概念,这也降低了我们理解这门语言的复杂度:

    • 使用对象存储状态
    • 对象之间通过方法通信

    block

    Ruby 对函数式编程范式的支持是通过 block,这里的 block 和 Objective-C 中的 block 有些不同。

    首先 Ruby 中的 block 也是一种对象,所有的 Block 都是 Proc 类的实例,也就是所有的 block 都是 first-class 的,可以作为参数传递,返回。

    def twice(&proc)
    2.times { proc.call() } if proc
    end

    def twice
    2.times { yield } if block_given?
    end

    yield 会调用外部传入的 block,block_given? 用于判断当前方法是否传入了 block。

    在这个方法调用时,是这样的:

    twice do 
    puts "Hello"
    end

    eval

    最后一个需要介绍的特性就是 eval 了,早在几十年前的 Lisp 语言就有了 eval 这个方法,这个方法会将字符串当做代码来执行,也就是说 eval 模糊了代码与数据之间的边界。

    > eval "1 + 2 * 3"
    => 7

    有了 eval 方法,我们就获得了更加强大的动态能力,在运行时,使用字符串来改变控制流程,执行代码;而不需要去手动解析输入、生成语法树。

    手动解析 Podfile

    在我们对 Ruby 这门语言有了一个简单的了解之后,就可以开始写一个简易的解析 Podfile 的脚本了。

    在这里,我们以一个非常简单的 Podfile 为例,使用 Ruby 脚本解析 Podfile 中指定的依赖:

    source 'http://source.git'
    platform :ios, '8.0'

    target 'Demo' do
    pod 'AFNetworking'
    pod 'SDWebImage'
    pod 'Masonry'
    pod "Typeset"
    pod 'BlocksKit'
    pod 'Mantle'
    pod 'IQKeyboardManager'
    pod 'IQDropDownTextField'
    end

    因为这里的 source、platform、target 以及 pod 都是方法,所以在这里我们需要构建一个包含上述方法的上下文:

    # eval_pod.rb
    $hash_value = {}

    def source(url)
    end

    def target(target)
    end

    def platform(platform, version)
    end

    def pod(pod)
    end

    使用一个全局变量 hash_value 存储 Podfile 中指定的依赖,并且构建了一个 Podfile 解析脚本的骨架;我们先不去完善这些方法的实现细节,先尝试一下读取 Podfile 中的内容并执行会不会有什么问题。

    在 eval_pod.rb 文件的最下面加入这几行代码:

    content = File.read './Podfile'
    eval content
    p $hash_value

    这里读取了 Podfile 文件中的内容,并把其中的内容当做字符串执行,最后打印 hash_value 的值。

    $ ruby eval_pod.rb

    运行这段 Ruby 代码虽然并没有什么输出,但是并没有报出任何的错误,接下来我们就可以完善这些方法了:

    def source(url)
    $hash_value['source'] = url
    end

    def target(target)
    targets = $hash_value['targets']
    targets = [] if targets == nil
    targets << target
    $hash_value['targets'] = targets
    yield if block_given?
    end

    def platform(platform, version)
    end

    def pod(pod)
    pods = $hash_value['pods']
    pods = [] if pods == nil
    pods << pod
    $hash_value['pods'] = pods
    end

    在添加了这些方法的实现之后,再次运行脚本就会得到 Podfile 中的依赖信息了,不过这里的实现非常简单的,很多情况都没有处理:

    $ ruby eval_pod.rb
    {"source"=>"http://source.git", "targets"=>["Demo"], "pods"=>["AFNetworking", "SDWebImage", "Masonry", "Typeset", "BlocksKit", "Mantle", "IQKeyboardManager", "IQDropDownTextField"]}

    CocoaPods 中对于 Podfile 的解析与这里的实现其实差不多,接下来就进入了 CocoaPods 的实现部分了。

    CocoaPods 的实现

    在上面简单介绍了 Ruby 的一些语法以及如何解析 Podfile 之后,我们开始深入了解一下 CocoaPods 是如何管理 iOS 项目的依赖,也就是 pod install 到底做了些什么。

    Pod install 的过程

    pod install 这个命令到底做了什么?首先,在 CocoaPods 中,所有的命令都会由 Command 类派发到将对应的类,而真正执行 pod install 的类就是 Install:

    module Pod
    class Command
    class Install < Command
    def run
    verify_podfile_exists!
    installer = installer_for_config
    installer.repo_update = repo_update?(:default => false)
    installer.update = false
    installer.install!
    end
    end
    end
    end

    这里面会从配置类的实例 config 中获取一个 Installer 的实例,然后执行 install! 方法,这里的 installer 有一个 update 属性,而这也就是 pod install 和 update 之间最大的区别,其中后者会无视已有的 Podfile.lock 文件,重新对依赖进行分析

    module Pod
    class Command
    class Update < Command
    def run
    ...

    installer = installer_for_config
    installer.repo_update = repo_update?(:default => true)
    installer.update = true
    installer.install!
    end
    end
    end
    end

    Podfile 的解析

    Podfile 中依赖的解析其实是与我们在手动解析 Podfile 章节所介绍的差不多,整个过程主要都是由 CocoaPods-Core 这个模块来完成的,而这个过程早在 installer_for_config 中就已经开始了:

    def installer_for_config
    Installer.new(config.sandbox, config.podfile, config.lockfile)
    end

    这个方法会从 config.podfile 中取出一个 Podfile 类的实例:

    def podfile
    @podfile ||= Podfile.from_file(podfile_path) if podfile_path
    end

    类方法 Podfile.from_file 就定义在 CocoaPods-Core 这个库中,用于分析 Podfile 中定义的依赖,这个方法会根据 Podfile 不同的类型选择不同的调用路径:

    Podfile.from_file
    `-- Podfile.from_ruby
    |-- File.open
    `-- eval

    from_ruby 类方法就会像我们在前面做的解析 Podfile 的方法一样,从文件中读取数据,然后使用 eval 直接将文件中的内容当做 Ruby 代码来执行。

    def self.from_ruby(path, contents = nil)
    contents ||= File.open(path, 'r:utf-8', &:read)

    podfile = Podfile.new(path) do
    begin
    eval(contents, nil, path.to_s)
    rescue Exception => e
    message = "Invalid `#{path.basename}` file: #{e.message}"
    raise DSLError.new(message, path, e, contents)
    end
    end
    podfile
    end

    在 Podfile 这个类的顶部,我们使用 Ruby 的 Mixin 的语法来混入 Podfile 中代码执行所需要的上下文:

    include Pod::Podfile::DSL

    Podfile 中的所有你见到的方法都是定义在 DSL 这个模块下面的:

    module Pod
    class Podfile
    module DSL
    def pod(name = nil, *requirements) end
    def target(name, options = nil) end
    def platform(name, target = nil) end
    def inhibit_all_warnings! end
    def use_frameworks!(flag = true) end
    def source(source) end
    ...
    end
    end
    end

    这里定义了很多 Podfile 中使用的方法,当使用 eval 执行文件中的代码时,就会执行这个模块里的方法,在这里简单看一下其中几个方法的实现,比如说 source 方法:

    def source(source)
    hash_sources = get_hash_value('sources') || []
    hash_sources << source
    set_hash_value('sources', hash_sources.uniq)
    end

    该方法会将新的 source 加入已有的源数组中,然后更新原有的 sources 对应的值。

    稍微复杂一些的是 target 方法:

    def target(name, options = nil)
    if options
    raise Informative, "Unsupported options `#{options}` for " \
    "target `#{name}`."
    end

    parent = current_target_definition
    definition = TargetDefinition.new(name, parent)
    self.current_target_definition = definition
    yield if block_given?
    ensure
    self.current_target_definition = parent
    end

    这个方法会创建一个 TargetDefinition 类的实例,然后将当前环境系的 target_definition 设置成这个刚刚创建的实例。这样,之后使用 pod 定义的依赖都会填充到当前的 TargetDefinition 中:

    def pod(name = nil, *requirements)
    unless name
    raise StandardError, 'A dependency requires a name.'
    end

    current_target_definition.store_pod(name, *requirements)
    end

    当 pod 方法被调用时,会执行 store_pod 将依赖存储到当前 target 中的 dependencies 数组中:

    def store_pod(name, *requirements)
    return if parse_subspecs(name, requirements)
    parse_inhibit_warnings(name, requirements)
    parse_configuration_whitelist(name, requirements)

    if requirements && !requirements.empty?
    pod = { name => requirements }
    else
    pod = name
    end

    get_hash_value('dependencies', []) << pod
    nil
    end

    总结一下,CocoaPods 对 Podfile 的解析与我们在前面做的手动解析 Podfile 的原理差不多,构建一个包含一些方法的上下文,然后直接执行 eval 方法将文件的内容当做代码来执行,这样只要 Podfile 中的数据是符合规范的,那么解析 Podfile 就是非常简单容易的。

    安装依赖的过程

    Podfile 被解析后的内容会被转化成一个 Podfile 类的实例,而 Installer 的实例方法 install! 就会使用这些信息安装当前工程的依赖,而整个安装依赖的过程大约有四个部分:

    • 解析 Podfile 中的依赖
    • 下载依赖
    • 创建 Pods.xcodeproj 工程
    • 集成 workspace
    def install!
    resolve_dependencies
    download_dependencies
    generate_pods_project
    integrate_user_project
    end

    在上面的 install 方法调用的 resolve_dependencies 会创建一个 Analyzer 类的实例,在这个方法中,你会看到一些非常熟悉的字符串:

    def resolve_dependencies
    analyzer = create_analyzer

    plugin_sources = run_source_provider_hooks
    analyzer.sources.insert(0, *plugin_sources)

    UI.section 'Updating local specs repositories' do
    analyzer.update_repositories
    end if repo_update?

    UI.section 'Analyzing dependencies' do
    analyze(analyzer)
    validate_build_configurations
    clean_sandbox
    end
    end

    在使用 CocoaPods 中经常出现的 Updating local specs repositories 以及 Analyzing dependencies 就是从这里输出到终端的,该方法不仅负责对本地所有 PodSpec 文件的更新,还会对当前 Podfile 中的依赖进行分析:

    def analyze(analyzer = create_analyzer)
    analyzer.update = update
    @analysis_result = analyzer.analyze
    @aggregate_targets = analyzer.result.targets
    end

    analyzer.analyze 方法最终会调用 Resolver 的实例方法 resolve:

    def resolve
    dependencies = podfile.target_definition_list.flat_map do |target|
    target.dependencies.each do |dep|
    @platforms_by_dependency[dep].push(target.platform).uniq! if target.platform
    end
    end
    @activated = Molinillo::Resolver.new(self, self).resolve(dependencies, locked_dependencies)
    specs_by_target
    rescue Molinillo::ResolverError => e
    handle_resolver_error(e)
    end

    这里的 Molinillo::Resolver 就是用于解决依赖关系的类。

    解决依赖关系(Resolve Dependencies)

    CocoaPods 为了解决 Podfile 中声明的依赖关系,使用了一个叫做 Milinillo 的依赖关系解决算法;但是,笔者在 Google 上并没有找到与这个算法相关的其他信息,推测是 CocoaPods 为了解决 iOS 中的依赖关系创造的算法。

    Milinillo 算法的核心是 回溯(Backtracking) 以及 向前检查(forward check)),整个过程会追踪栈中的两个状态(依赖和可能性)。

    在这里并不想陷入对这个算法执行过程的分析之中,如果有兴趣可以看一下仓库中的 ARCHITECTURE.md 文件,其中比较详细的解释了 Milinillo 算法的工作原理,并对其功能执行过程有一个比较详细的介绍。

    Molinillo::Resolver 方法会返回一个依赖图,其内容大概是这样的:

    Molinillo::DependencyGraph:[
    Molinillo::DependencyGraph::Vertex:AFNetworking(#<Pod::Specification name="AFNetworking">),
    Molinillo::DependencyGraph::Vertex:SDWebImage(#<Pod::Specification name="SDWebImage">),
    Molinillo::DependencyGraph::Vertex:Masonry(#<Pod::Specification name="Masonry">),
    Molinillo::DependencyGraph::Vertex:Typeset(#<Pod::Specification name="Typeset">),
    Molinillo::DependencyGraph::Vertex:CCTabBarController(#<Pod::Specification name="CCTabBarController">),
    Molinillo::DependencyGraph::Vertex:BlocksKit(#<Pod::Specification name="BlocksKit">),
    Molinillo::DependencyGraph::Vertex:Mantle(#<Pod::Specification name="Mantle">),
    ...
    ]

    这个依赖图是由一个结点数组组成的,在 CocoaPods 拿到了这个依赖图之后,会在 specs_by_target 中按照 Target 将所有的 Specification 分组:

    {
    #<Pod::Podfile::TargetDefinition label=Pods>=>[],
    #<Pod::Podfile::TargetDefinition label=Pods-Demo>=>[
    #<Pod::Specification name="AFNetworking">,
    #<Pod::Specification name="AFNetworking/NSURLSession">,
    #<Pod::Specification name="AFNetworking/Reachability">,
    #<Pod::Specification name="AFNetworking/Security">,
    #<Pod::Specification name="AFNetworking/Serialization">,
    #<Pod::Specification name="AFNetworking/UIKit">,
    #<Pod::Specification name="BlocksKit/Core">,
    #<Pod::Specification name="BlocksKit/DynamicDelegate">,
    #<Pod::Specification name="BlocksKit/MessageUI">,
    #<Pod::Specification name="BlocksKit/UIKit">,
    #<Pod::Specification name="CCTabBarController">,
    #<Pod::Specification name="CategoryCluster">,
    ...
    ]
    }

    而这些 Specification 就包含了当前工程依赖的所有第三方框架,其中包含了名字、版本、源等信息,用于依赖的下载。

    下载依赖

    在依赖关系解决返回了一系列 Specification 对象之后,就到了 Pod install 的第二部分,下载依赖:

    def install_pod_sources
    @installed_specs = []
    pods_to_install = sandbox_state.added | sandbox_state.changed
    title_options = { :verbose_prefix => '-> '.green }
    root_specs.sort_by(&:name).each do |spec|
    if pods_to_install.include?(spec.name)
    if sandbox_state.changed.include?(spec.name) && sandbox.manifest
    previous = sandbox.manifest.version(spec.name)
    title = "Installing #{spec.name} #{spec.version} (was #{previous})"
    else
    title = "Installing #{spec}"
    end
    UI.titled_section(title.green, title_options) do
    install_source_of_pod(spec.name)
    end
    else
    UI.titled_section("Using #{spec}", title_options) do
    create_pod_installer(spec.name)
    end
    end
    end
    end

    在这个方法中你会看到更多熟悉的提示,CocoaPods 会使用沙盒(sandbox)存储已有依赖的数据,在更新现有的依赖时,会根据依赖的不同状态显示出不同的提示信息:

    -> Using AFNetworking (3.1.0)

    -> Using AKPickerView (0.2.7)

    -> Using BlocksKit (2.2.5) was (2.2.4)

    -> Installing MBProgressHUD (1.0.0)
    ...

    虽然这里的提示会有三种,但是 CocoaPods 只会根据不同的状态分别调用两种方法:

    • install_source_of_pod
    • create_pod_installer

    create_pod_installer 方法只会创建一个 PodSourceInstaller 的实例,然后加入 pod_installers 数组中,因为依赖的版本没有改变,所以不需要重新下载,而另一个方法的 install_source_of_pod 的调用栈非常庞大:

    installer.install_source_of_pod
    |-- create_pod_installer
    | `-- PodSourceInstaller.new
    `-- podSourceInstaller.install!
    `-- download_source
    `-- Downloader.download
    `-- Downloader.download_request
    `-- Downloader.download_source
    |-- Downloader.for_target
    | |-- Downloader.class_for_options
    | `-- Git/HTTP/Mercurial/Subversion.new
    |-- Git/HTTP/Mercurial/Subversion.download
    `-- Git/HTTP/Mercurial/Subversion.download!
    `-- Git.clone

    在调用栈的末端 Downloader.download_source 中执行了另一个 CocoaPods 组件 CocoaPods-Download 中的方法:

    def self.download_source(target, params)
    FileUtils.rm_rf(target)
    downloader = Downloader.for_target(target, params)
    downloader.download
    target.mkpath

    if downloader.options_specific?
    params
    else
    downloader.checkout_options
    end
    end

    方法中调用的 for_target 根据不同的源会创建一个下载器,因为依赖可能通过不同的协议或者方式进行下载,比如说 Git/HTTP/SVN 等等,组件 CocoaPods-Downloader 就会根据 Podfile 中依赖的参数选项使用不同的方法下载依赖。

    大部分的依赖都会被下载到 ~/Library/Caches/CocoaPods/Pods/Release/ 这个文件夹中,然后从这个这里复制到项目工程目录下的 ./Pods 中,这也就完成了整个 CocoaPods 的下载流程。

    生成 Pods.xcodeproj

    CocoaPods 通过组件 CocoaPods-Downloader 已经成功将所有的依赖下载到了当前工程中,这里会将所有的依赖打包到 Pods.xcodeproj 中:

    def generate_pods_project(generator = create_generator)
    UI.section 'Generating Pods project' do
    generator.generate!
    @pods_project = generator.project
    run_podfile_post_install_hooks
    generator.write
    generator.share_development_pod_schemes
    write_lockfiles
    end
    end

    generate_pods_project 中会执行 PodsProjectGenerator 的实例方法 generate!:

    def generate!
    prepare
    install_file_references
    install_libraries
    set_target_dependencies
    end

    这个方法做了几件小事:

    • 生成 Pods.xcodeproj 工程
    • 将依赖中的文件加入工程
    • 将依赖中的 Library 加入工程
    • 设置目标依赖(Target Dependencies)

    这几件事情都离不开 CocoaPods 的另外一个组件 Xcodeproj,这是一个可以操作一个 Xcode 工程中的 Group 以及文件的组件,我们都知道对 Xcode 工程的修改大多数情况下都是对一个名叫 project.pbxproj 的文件进行修改,而 Xcodeproj 这个组件就是 CocoaPods 团队开发的用于操作这个文件的第三方库。

    生成 workspace

    最后的这一部分与生成 Pods.xcodeproj 的过程有一些相似,这里使用的类是 UserProjectIntegrator,调用方法 integrate! 时,就会开始集成工程所需要的 Target:

    def integrate!
    create_workspace
    integrate_user_targets
    warn_about_xcconfig_overrides
    save_projects
    end

    对于这一部分的代码,也不是很想展开来细谈,简单介绍一下这里的代码都做了什么,首先会通过 Xcodeproj::Workspace 创建一个 workspace,之后会获取所有要集成的 Target 实例,调用它们的 integrate! 方法:

    def integrate!
    UI.section(integration_message) do
    XCConfigIntegrator.integrate(target, native_targets)

    add_pods_library
    add_embed_frameworks_script_phase
    remove_embed_frameworks_script_phase_from_embedded_targets
    add_copy_resources_script_phase
    add_check_manifest_lock_script_phase
    end
    end

    方法将每一个 Target 加入到了工程,使用 Xcodeproj 修改 Copy Resource Script Phrase 等设置,保存 project.pbxproj,整个 Pod install 的过程就结束了。

    总结

    最后想说的是 pod install 和 pod update 区别还是比较大的,每次在执行 pod install 或者 update 时最后都会生成或者修改 Podfile.lock 文件,其中前者并不会修改 Podfile.lock 中显示指定的版本,而后者会会无视该文件的内容,尝试将所有的 pod 更新到最新版。

    CocoaPods 工程的代码虽然非常多,不过代码的逻辑非常清晰,整个管理并下载依赖的过程非常符合直觉以及逻辑。

    作者:Draveness

    链接:https://zhuanlan.zhihu.com/p/22652365


    收起阅读 »

    kotlin 协变、逆变 - 猫和鱼的故事

    网上找的一段协变、逆变比较正式的定义:逆变与协变用来描述类型转换后的继承关系,其定义:如果 A、B 表示类型,f(⋅) 表示类型转换,≦ 表示继承关系(比如,A≦B 表示 A 是由 B 派生出来的子类): 当 A ≦ B 时,如果有 f(A) ≦ f(B) ,...
    继续阅读 »

    网上找的一段协变、逆变比较正式的定义:

    逆变与协变用来描述类型转换后的继承关系,其定义:如果 A、B 表示类型,f(⋅) 表示类型转换, 表示继承关系(比如,A≦B 表示 A 是由 B 派生出来的子类): 当 A ≦ B 时,如果有 f(A) ≦ f(B) ,那么 f 是协变的; 当 A ≦ B 时,如果有 f(B) ≦ f(A) ,那么 f 是逆变的; 如果上面两种关系都不成立,即 (A) 与 f(B) 相互之间没有继承关系,则叫做不变的。

    java 中可以通过如下泛型通配符以支持协变和逆变:

    • ? extends 来使泛型支持协变。修饰的泛型集合只能读取不能修改,这里的修改仅指对泛型集合添加元素,如果是 remove(int index) 以及 clear 当然是可以的。
    • ? super 来使泛型支持逆变。修饰的泛型集合只能修改不能读取,这里说的不能读取是指不能按照泛型类型读取,你如果按照 Object 读出来再强转当然也是可以的。

    以动物举例,看代码。

    abstract class Animal {
    void eat() {
    System.out.println("我是" + myName() + ", 我最喜欢吃" + myFavoriteFood());
    }

    abstract String myName();

    abstract String myFavoriteFood();
    }

    class Fish extends Animal {

    @Override
    String myName() {
    return "鱼";
    }

    @Override
    String myFavoriteFood() {
    return "虾米";
    }
    }

    class Cat extends Animal {

    @Override
    String myName() {
    return "猫";
    }

    @Override
    String myFavoriteFood() {
    return "小鱼干";
    }
    }

    public static void extendsFun() {
    List fishList = new ArrayList<>();
    fishList.add(new Fish());
    List catList = new ArrayList<>();
    catList.add(new Cat());
    List animals1 = fishList;
    List animals2 = catList;

    animals2.add(new Fish()); // 报错
    Animal animal1 = animals1.get(0);
    Animal animal2 = animals2.get(0);
    animal1.eat();
    animal2.eat();
    }

    //输出结果:
    我是鱼, 我最喜欢吃虾米
    我是猫, 我最喜欢吃小鱼干

    协变就好比有多个集合,每个集合存储的是某中特定动物(extends Animal),但是不告诉你那个集合里存储的是鱼,哪个是猫。所以你虽然可以从任意一个集合中读取一个动物信息,没有问题,但是你没办法将一条鱼的信息存储到鱼的集合里,因为仅从变量 animals1、animals2 的类型声明上来看你不知道哪个集合里存储的是鱼,哪个集合里是猫。 假如报错的代码不报错了,那不就说明把一条鱼塞进了一堆猫里,这属于给猫加菜啊,所以肯定是不行的。? extends 类型通配符所表达的协变就是这个意思。

    那逆变是什么意思呢?还是以上面的动物举例:

    public static void superFun() {
    List fishList = new ArrayList<>();
    fishList.add(new Fish());
    List animalList = new ArrayList<>();
    animalList.add(new Cat());
    animalList.add(new Fish());
    List fish1 = fishList;
    List fish2 = animalList;

    fish1.add(new Fish());
    Fish fish = fish2.get(0); //报错
    }

    从变量 fish1、fish2 的类型声明上只能知道里面存储的都是鱼的父类,如果这里也不报错的话可就从 fish2 的集合里拿出一只猫赋值给一条鱼了,这属于谋杀亲鱼。所以肯定也是不行。? super 类型通配符所表达的逆变就是这个意思。

    kotlin 中对于协变和逆变也提供了两个修饰符:

    • out:声明协变;
    • in:声明逆变。

    它们有两种使用方式:

    • 第一种:和 java 一样在使用处声明;
    • 第二种:在类或接口的定义处声明。

    当和 java 一样在使用处声明时,将上面 java 示例转换为 kotlin

    fun extendsFun() {
    val fishList: MutableList = ArrayList()
    fishList.add(Fish())
    val catList: MutableList = ArrayList()
    catList.add(Cat())
    val animals1: MutableList = fishList
    val animals2: MutableList = catList
    animals2.add(Fish()) // 报错
    val animal1 = animals1[0]
    val animal2 = animals2[0]
    animal1.eat()
    animal2.eat()
    }

    fun superFun() {
    val fishList: MutableList = ArrayList()
    fishList.add(Fish())
    val animalList: MutableList = ArrayList()
    animalList.add(Cat())
    animalList.add(Fish())
    val fish1: MutableList = fishList
    val fish2: MutableList = animalList
    fish1.add(Fish())
    val fish: Fish = fish2[0] //报错
    }

    可以看到在 kotlin 代码中除了将 ? extends 替换为了 out,将 ? super 替换为了 in,其他地方并没有发生变化,而产生的结果是一样的。那在类或接口的定义处声明 in、out 的作用是什么呢。

    假设有一个泛型接口 Source,该接口中不存在任何以 T 作为参数的方法,只是方法返回 T 类型值:

    // Java
    interface Source {
    T nextT();
    }

    那么,在 Source  类型的变量中存储 Source  实例的引用是极为安全的——没有消费者-方法可以调用。但是 Java 并不知道这一点,并且仍然禁止这样操作:

    // Java
    void demo(Source strs) {
    Source objects = strs; // !!!在 Java 中不允许
    // ……
    }

    为了修正这一点,我们必须声明对象的类型为 Source,但这样的方式很复杂。而在 kotlin 中有一种简单的方式向编译器解释这种情况。我们可以标注 Source 的类型参数 T 来确保它仅从 Source 成员中返回(生产),并从不被消费。为此我们使用 out 修饰符修饰泛型 T

    interface Source {
    fun nextT(): T
    }

    fun demo(strs: Source) {
    val objects: Source = strs // 这个没问题,因为 T 是一个 out-参数
    // ……
    }

    还记得开篇协变的定义吗?

    当 A ≦ B 时,如果有 f(A) ≦ f(B) ,那么 f 是协变的; 当 A ≦ B 时,如果有 f(B) ≦ f(A) ,那么 f 是逆变的;

    也就是说:

    当一个类 C 的类型参数 T 被声明为 out 时,那么就意味着类 C 在参数 T 上是协变的;参数 T 只能出现在类 C 的输出位置,不能出现在类 C 的输入位置。

    同样的,对于 in 修饰符来说

    当一个类 C 的类型参数 T 被声明为 in 时,那么就意味着类 C 在参数 T 上是逆变的;参数 T 只能出现在类 C 的输如位置,不能出现在类 C 的输出位置。

    interface Comparable {
    operator fun compareTo(other: T): Int
    }

    fun demo(x: Comparable) {
    x.compareTo(1.0) // 1.0 拥有类型 Double,它是 Number 的子类型
    // 因此,我们可以将 x 赋给类型为 Comparable 的变量
    val y: Comparable = x // OK!
    }

    总结如下表:

    image

    收起阅读 »

    Cocoapods原理总结

    CocoaPods是IOS项目的依赖管理工具,类似于Android的gradle,不过gradle不仅有依赖管理功能,还能负责构建。CocoaPods只负责管理依赖,即对第三方库的依赖,像gradle一样支持传递依赖,即如果A依赖于B,B依赖C,我们在A工程里...
    继续阅读 »

    CocoaPods是IOS项目的依赖管理工具,类似于Android的gradle,不过gradle不仅有依赖管理功能,还能负责构建。CocoaPods只负责管理依赖,即对第三方库的依赖,像gradle一样支持传递依赖,即如果A依赖于B,B依赖C,我们在A工程里指出A依赖于B,CocoaPods会自动为我们下载C,并在构建时链接C库。

    IOS工程有3种库项目,framework,static library,meta library,我们通常只使用前两种。我们在使用static library库工程时,一般使用它编译出来的静态库libxxx.a,以及对应的头文件,在写应用时,将这些文件拷贝到项目里,然后将静态库添加到链接的的依赖库路径里,并将头文件目录添加到头文件搜索目录中。而framework库的依赖会简单很多,framework是资源的集合,将静态库和其头文件包含在framework目录里。framework库类似于Android工程的aar库。而static library类似于Android工程的jar包。

    CocoaPods同时支持static library和framework的依赖管理,下面介绍这两种情况下CocoaPods是如何实现构建上的依赖的

    static library

    先看一下使用CocoaPods管理依赖前项目的文件结构

    1
    2
    3
    4
    5
    6
    7
    8
    CardPlayer
    ├── CardPlayer
    │   ├── CardPlayer
    │   ├── CardPlayer.xcodeproj
    │   ├── CardPlayerTests
    │   └── CardPlayerUITests
    ├── exportOptions.plist
    └── wehere-dev-cloud.mobileprovision

    然后我们使用Pod来管理依赖,编写的PodFile如下所示:

    1
    2
    3
    4
    5
    6
    project 'CardPlayer/CardPlayer.xcodeproj'

    target 'CardPlayer' do
    pod 'AFNetworking', '~> 1.0'
    end

    文件结构的变化

    然后使用pod install,添加好依赖之后,项目的文件结构如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    CardPlayer
    ├── CardPlayer
    │   ├── CardPlayer
    │   ├── CardPlayer.xcodeproj
    │   ├── CardPlayerTests
    │   └── CardPlayerUITests
    ├── CardPlayer.xcworkspace
    │   └── contents.xcworkspacedata
    ├── PodFile
    ├── Podfile.lock
    ├── Pods
    │   ├── AFNetworking
    │   ├── Headers
    │   ├── Manifest.lock
    │   ├── Pods.xcodeproj
    │   └── Target\ Support\ Files
    ├── exportOptions.plist
    └── wehere-dev-cloud.mobileprovision

    可以看到我们添加了如下文件

    1. PodFile 依赖描述文件

    2. Podfile.lock 当前安装的依赖库的版本

    3. CardPlayer.xcworkspace

      xcworkspace文件,使用CocoaPod管理依赖的项目,XCode只能使用workspace编译项目,如果还只打开以前的xcodeproj文件进行开发,编译会失败

      xcworkspace文件实际是一个文件夹,实际Workspace信息保存在contents.xcworkspacedata里,该文件的内容非常简单,实际上只指示它所使用的工程的文件目录

      如下所示:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      <?xml version="1.0" encoding="UTF-8"?>
      <Workspace
      version = "1.0">
      <FileRef
      location = "group:CardPlayer/CardPlayer.xcodeproj">
      </FileRef>
      <FileRef
      location = "group:Pods/Pods.xcodeproj">
      </FileRef>
      </Workspace>

    4. Pods目录

      1. Pods.xcodeproj,Pods工程,所有第三方库由Pods工程构建,每个第3方库对应Pods工程的1个target,并且这个工程还有1个Pods-Xxx的target,接下来在介绍工程时再详细介绍

      2. AFNetworking 每个第3方库,都会在Pods目录下有1个对应的目录

      3. Headers

        在Headers下有两个目录,Private和Public,第3方库的私有头文件会在Private目录下有对应的头文件,不过是1个软链接,链接到第3方库的头文件 第3方库的Pubic头文件会在Public目录下有对应的头文件,也是软链接

        如下所示:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        Headers/
        ├── Private
        │   └── AFNetworking
        │   ├── AFHTTPClient.h -> ../../../AFNetworking/AFNetworking/AFHTTPClient.h
        │   ├── AFHTTPRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFHTTPRequestOperation.h
        │   ├── AFImageRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFImageRequestOperation.h
        │   ├── AFJSONRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFJSONRequestOperation.h
        │   ├── AFNetworkActivityIndicatorManager.h -> ../../../AFNetworking/AFNetworking/AFNetworkActivityIndicatorManager.h
        │   ├── AFNetworking.h -> ../../../AFNetworking/AFNetworking/AFNetworking.h
        │   ├── AFPropertyListRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFPropertyListRequestOperation.h
        │   ├── AFURLConnectionOperation.h -> ../../../AFNetworking/AFNetworking/AFURLConnectionOperation.h
        │   ├── AFXMLRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFXMLRequestOperation.h
        │   └── UIImageView+AFNetworking.h -> ../../../AFNetworking/AFNetworking/UIImageView+AFNetworking.h
        └── Public
        └── AFNetworking
        ├── AFHTTPClient.h -> ../../../AFNetworking/AFNetworking/AFHTTPClient.h
        ├── AFHTTPRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFHTTPRequestOperation.h
        ├── AFImageRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFImageRequestOperation.h
        ├── AFJSONRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFJSONRequestOperation.h
        ├── AFNetworkActivityIndicatorManager.h -> ../../../AFNetworking/AFNetworking/AFNetworkActivityIndicatorManager.h
        ├── AFNetworking.h -> ../../../AFNetworking/AFNetworking/AFNetworking.h
        ├── AFPropertyListRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFPropertyListRequestOperation.h
        ├── AFURLConnectionOperation.h -> ../../../AFNetworking/AFNetworking/AFURLConnectionOperation.h
        ├── AFXMLRequestOperation.h -> ../../../AFNetworking/AFNetworking/AFXMLRequestOperation.h
        └── UIImageView+AFNetworking.h -> ../../../AFNetworking/AFNetworking/UIImageView+AFNetworking.h

      4. Manifest.lock manifest文件 描述第3方库对其它库的依赖

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        PODS:
        - AFNetworking (1.3.4)

        DEPENDENCIES:
        - AFNetworking (~> 1.0)

        SPEC CHECKSUMS:
        AFNetworking: cf8e418e16f0c9c7e5c3150d019a3c679d015018

        PODFILE CHECKSUM: 349872ccf0789fbe3fa2b0f912b1b5388eb5e1a9

        COCOAPODS: 1.3.1

      5. Target Support Files 支撑target的文件

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        Target\ Support\ Files/
        ├── AFNetworking
        │   ├── AFNetworking-dummy.m
        │   ├── AFNetworking-prefix.pch
        │   └── AFNetworking.xcconfig
        └── Pods-CardPlayer
        ├── Pods-CardPlayer-acknowledgements.markdown
        ├── Pods-CardPlayer-acknowledgements.plist
        ├── Pods-CardPlayer-dummy.m
        ├── Pods-CardPlayer-frameworks.sh
        ├── Pods-CardPlayer-resources.sh
        ├── Pods-CardPlayer.debug.xcconfig
        └── Pods-CardPlayer.release.xcconfig

        在Target Support Files目录下每1个第3方库都会有1个对应的文件夹,比如AFNetworking,该目录下有一个空实现文件,也有预定义头文件用来优化头文件编译速度,还会有1个xcconfig文件,该文件会在工程配置中使用,主要存放头文件搜索目录,链接的Flag(比如链接哪些库)

        在Target Support Files目录下还会有1个Pods-XXX的文件夹,该文件夹存放了第3方库声明文档markdown文档和plist文件,还有1个dummy的空实现文件,还有debug和release各自对应的xcconfig配置文件,另外还有2个脚本文件,Pods-XXX-frameworks.sh脚本用于实现framework库的链接,当依赖的第3方库是framework形式才会用到该脚本,另外1个脚本文件: Pods-XXX-resources.sh用于编译storyboard类的资源文件或者拷贝*.xcassets之类的资源文件

    工程结构的变化

    上一节里提到在引入CocoaPods管理依赖后,会新增workspace文件,新增的workspace文件会引用原有的应用主工程,还会引用新增的Pods工程。后续不能再直接打开原来的应用主工程进行编译,否则会失败。实际上是因为原来的应用主工程的配置现在也有了变化。下面分别介绍一下Pods工程以及主工程的变化。

    Pods工程

    Pods工程配置

    Pods工程会为每个依赖的第3方库定义1个Target,还会定义1个Pods-Xxx的target,每个Target会生成1个静态库,如下图所示:

    cocoapods_pod_project_target

    Pods工程会新建Debug和Release两个Configuration,每个Configuration会为不同的target设置不同的xcconfig,xcconfig指出了头文件查找目录,要链接的第3方库,链接目录等信息,如下图所示:

    cocoapods_project_target_configuration

    AFNetworking.xcconfig文件的内容如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CONFIGURATION_BUILD_DIR = $PODS_CONFIGURATION_BUILD_DIR/AFNetworking
    GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
    HEADER_SEARCH_PATHS = "${PODS_ROOT}/Headers/Private" "${PODS_ROOT}/Headers/Private/AFNetworking" "${PODS_ROOT}/Headers/Public" "${PODS_ROOT}/Headers/Public/AFNetworking"
    OTHER_LDFLAGS = -framework "CoreGraphics" -framework "MobileCoreServices" -framework "Security" -framework "SystemConfiguration"
    PODS_BUILD_DIR = $BUILD_DIR
    PODS_CONFIGURATION_BUILD_DIR = $PODS_BUILD_DIR/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
    PODS_ROOT = ${SRCROOT}
    PODS_TARGET_SRCROOT = ${PODS_ROOT}/AFNetworking
    PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
    SKIP_INSTALL = YES

    上述内容说明了AFNetworking编译时查找头文件的目录Header_SERACH_PATHS,OTHER_LD_FLAGS指明了要链接的framework

    Pods-CardPlayer.debug.xcconfig文件的内容如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
    HEADER_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/Headers/Public" "${PODS_ROOT}/Headers/Public/AFNetworking"
    LIBRARY_SEARCH_PATHS = $(inherited) "$PODS_CONFIGURATION_BUILD_DIR/AFNetworking"
    OTHER_CFLAGS = $(inherited) -isystem "${PODS_ROOT}/Headers/Public" -isystem "${PODS_ROOT}/Headers/Public/AFNetworking"
    OTHER_LDFLAGS = $(inherited) -ObjC -l"AFNetworking" -framework "CoreGraphics" -framework "MobileCoreServices" -framework "Security" -framework "SystemConfiguration"
    PODS_BUILD_DIR = $BUILD_DIR
    PODS_CONFIGURATION_BUILD_DIR = $PODS_BUILD_DIR/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
    PODS_PODFILE_DIR_PATH = ${SRCROOT}/..
    PODS_ROOT = ${SRCROOT}/../Pods

    Pods-CardPlayer.debug文件中OTHER_LDFLAGS说明了编译Pods时需要链接AFNetworking库,还需要链接其它framework

    所以我们在xcode里能看到AFNetworking依赖的framework:

    cocoapods_target_lib_dependency

    Pods工程文件组织

    IOS工程在XCode上看到的结构和文件系统的结构并不一致,在XCode上看到的文件夹并不是物理的文件夹,而是叫做Group,在组织IOS工程时,会将逻辑关系较近的文件放在同一个Group下。如下图所示:

    cocoapods_pods_project_files

    coacoapods_pods_project_afnetworking_support

    可以看到Group的组织大概是以下形式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    Pods
    ├── Podfile # 指向根目录下的Podfile 说明依赖的第3方库
    ├── Frameworks # 文件系统并没有对应的目录 这只是1个虚拟的group 表示需要链接的frameowork
    ├── └── iOS # 文件系统并没有对应的目录 这只是1个虚拟的group 这里表示是ios需要链接的framework
    ├── └── Xxx.framework # 链接的frameowork列表
    ├── Pods # 虚拟的group 管理所有第3方库
    │   └── AFNetwoking #AFNetworking库 虚拟group 对应文件系统Pods/AFNetworking/AFNetworking目录下的内容
    │   ├── xxx.h #AFNetworking库的头文件 对应文件系统Pods/AFNetworking/AFNetworking目录下的所有头文件
    │ ├── xxx.m #AFNetworking库的实现文件 对应文件系统Pods/AFNetworking/AFNetworking目录下的所有实现文件
    │   └── Support Files # 虚拟group 支持文件 没有直接对应的文件系统目录,该group下的文件都属于目录: Pods/Target Support Files/AFNetworking/
    │ ├── AFNetworking.xcconfig # AFNetworking编译的工程配置文件
    │ ├── AFNetworking-prefix.pch # AFNetworking编译用的预编译头文件
    │ └── AFNetworking-dummy.m # 空实现文件
    ├── Products # 虚拟group
    │ ├── libAFNetworking.a # AFNetworking target将生成的静态库
    │ └── libPods-CardPlayer.a # Pods-CardPlayer target将生成的静态库
    └── Targets Support Files # 虚拟group 管理支持文件
    └── Pods-CardPlayer # 虚拟group Pods-CardPlayer target
    ├── Pods-CardPlayer-acknowledgements.markdown # 协议说明文档
    ├── Pods-CardPlayer-acknowledgements.plist # 协议说明文档
    ├── Pods-CardPlayer-dummy.m # 空实现
    ├── Pods-CardPlayer-frameworks.sh # 安装framework的脚本
    ├── Pods-CardPlayer-resources.sh # 安装resource的脚本
    ├── Pods-CardPlayer.debug.xcconfig # debug configuration 的 配置文件
    └── Pods-CardPlayer.release.xcconfig # release configuration 的 配置文件

    主工程

    引入CocoaPods之后, 主工程的设置其实也会变化, 我们先看一下引入之前,主工程的Configuration设置,如下图所示:       

    cocoapods_before_project_config

    可以看到Debug和Release的Configuration没有设置任何配置文件,再看引入CocoaPods之后,主工程的Configuration如下图所示:

    cocoapod_main_project_configuration

    可以看到采用CocoaPods之后,Debug Configuration设置了配置文件Pods-CardPlayer.debug.xcconfig文件,Release Configuration则设置了配置文件Pods-CardPlayer.release.xcconfig文件,这些配置文件指明了头文件的查找目录,要链接的第三方库

    编译并链接第3方库的原理

       

    1. 头文件的查找

      上一节里已经讲到主工程的Configuration已经设置了配置文件,而这份配置文件里说明了头文件的查找目录:

      1
      2
      HEADER_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/Headers/Public" "${PODS_ROOT}/Headers/Public/AFNetworking"
      OTHER_CFLAGS = $(inherited) -isystem "${PODS_ROOT}/Headers/Public" -isystem "${PODS_ROOT}/Headers/Public/AFNetworking"

      所以主工程可以引用第3方库的头文件,比如像这样: #import <AFNetworking/AFHTTPClient.h>

    2. 如何链接库

      配置文件同样说明了链接库的查找目录以及要链接的库:

      1
      2
      3
      LIBRARY_SEARCH_PATHS = $(inherited) "$PODS_CONFIGURATION_BUILD_DIR/AFNetworking"
      OTHER_LDFLAGS = $(inherited) -ObjC -l"AFNetworking" -framework "CoreGraphics" -framework "MobileCoreServices" -framework "Security" -framework "SystemConfiguration"

      而在我们主工程的main target还会添加对libPods-CardPlayer.a的链接,如下图所示:

      cocoapod_main_project_dependency_pods

    3. 编译顺序

      我们的主工程的main target显示指出了需要链接库libPods-CardPlayer.a,而libPods-CardPlayer.a由target Pods-CardPlayer产生,所以主工程的main target将会隐式依赖于target Pods-CardPlayer,而在target Pods-CardPlayer的配置中,显示指出了依赖对第三方库对应的target的依赖,如下所示:

      cocoapods_pods_dendency

      所以main target -> target Pods-CardPlayer -> 第3方库对应的target

      因为存在上述依赖关系,所以能保证编译顺序,保证编译链接都不会有问题

    framework

    如果我们在PodFile设置了use_frameworks!,则第3方库使用Framework形式的库,PodFile的内容如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    project 'CardPlayer/CardPlayer.xcodeproj'

    use_frameworks!

    target 'CardPlayer' do
    pod 'AFNetworking', '~> 1.0'
    end

    framework这类型的库和static library比较类似,在文件结构上没什么太大变化,都是新增了Pods工程,和管理Pods工程及原主工程的workspace,但是Pods工程设置的target的类型都是framework,而不是static library,而主工程对Pods的依赖,也不再是依赖libPods-CardPlayer.a,而是Pods_CardPlayer.framework。

    如下图所示:

    cocoapods_framework_dependency

    cocoapods_pods_framework_thrid_party

    另外编译配置文件也有一些不同:

    AFNetworking.xcconfig文件如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CONFIGURATION_BUILD_DIR = $PODS_CONFIGURATION_BUILD_DIR/AFNetworking
    GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
    HEADER_SEARCH_PATHS = "${PODS_ROOT}/Headers/Private" "${PODS_ROOT}/Headers/Public"
    OTHER_LDFLAGS = -framework "CoreGraphics" -framework "MobileCoreServices" -framework "Security" -framework "SystemConfiguration"
    PODS_BUILD_DIR = $BUILD_DIR
    PODS_CONFIGURATION_BUILD_DIR = $PODS_BUILD_DIR/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
    PODS_ROOT = ${SRCROOT}
    PODS_TARGET_SRCROOT = ${PODS_ROOT}/AFNetworking
    PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
    SKIP_INSTALL = YES

    而Pods-CardPlayer.debug.xcconfig文件的内容如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    FRAMEWORK_SEARCH_PATHS = $(inherited) "$PODS_CONFIGURATION_BUILD_DIR/AFNetworking"
    GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
    LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks'
    OTHER_CFLAGS = $(inherited) -iquote "$PODS_CONFIGURATION_BUILD_DIR/AFNetworking/AFNetworking.framework/Headers"
    OTHER_LDFLAGS = $(inherited) -framework "AFNetworking"
    PODS_BUILD_DIR = $BUILD_DIR
    PODS_CONFIGURATION_BUILD_DIR = $PODS_BUILD_DIR/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
    PODS_PODFILE_DIR_PATH = ${SRCROOT}/..
    PODS_ROOT = ${SRCROOT}/../Pods

    使用framework形式的库之后,Pods-CardPlayer-frameworks.sh脚本也有一些不同,

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ...
    f [[ "$CONFIGURATION" == "Debug" ]]; then
    install_framework "${BUILT_PRODUCTS_DIR}/AFNetworking/AFNetworking.framework"
    fi
    if [[ "$CONFIGURATION" == "Release" ]]; then
    install_framework "${BUILT_PRODUCTS_DIR}/AFNetworking/AFNetworking.framework"
    fi
    if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
    wait
    fi

    编译framework后,它会将AFNetworking.framework安装到产品编译目录下,这样才能在运行时链接该framework

    而我们的主工程的main target配置Build Phases有一项安装pod的framework,会调用Pod-CardPlayer-frameworks.sh,所以能保证正确安装framework,如下图所示:

    cocoapods_target_embed_pods_framework



    本文原创作者:Cloud Chou

    链接:http://www.cloudchou.com/ios/post-990.html



    收起阅读 »