这段时间看到很多博主都有在发一些面试题,各种各样的都有,毕竟现在这紧张的行情的确是要时刻多准备一些,我其实老早就想着也写一篇关于面试题的文章,但是一直没动静,主要面试题跟其他文章不一样,得到处搜集一些比较典型,有参考价值的面试题,这种事情得花很多时间的,但我太懒了,所以没这么做,但某一天忽然有了个想法,为啥不让AI帮我出面试题呢,我只负责答题就好,就像是我自己在参加个面试一样,本来想用GPT的,但手头的GPT忽然不能用了,那么只好让文心一言做我的"面试官"了,面试的内容就考kotlin相关的知识
第一题
这考的是扩展函数,扩展函数基本都用过,这道题考的就是在日常开发过程中使用扩展函数解决了什么问题,肯定有很多,我这边先举两个例子
字符串处理
在String
类里面,已经提供了不少api让我们去解决一些字符串处理上的问题,比如判断字符串是否以某个字符或者字符串开头的,我们用startsWith
函数,根据某个字符将字符串拆分成一个字符串数组,我们使用split
函数,但是在某些场景下,String
类里面现有的api已经无法满足我们的需求了,这个时候我们通常会写一个工具方法,在方法里面对字符串进行处理后将结果return
出去,在调用方调用这个写好的工具方法完成这个需求,像这样
这样做肯定是没问题的,但是我们有更简洁的方式,那就是在String
类上扩展出一个功能,即给String
类增加一个扩展函数,让String
本身多出来这样的一个功能,举个例子,比如现在想让String
支持在一个字符串中,除第一个字符前面以及最后一个字符后面,其他字符之间都插入一个符号,符号根据传参定义,那么代码该这样写
insertSymbol
函数就是在String
类上新增的扩展函数,接收的传参是一个函数类型的参数,也就是在调用方需要使用一个lambda表达式作为传参,调用方的代码如下
可以看到调用方代码十分简洁,运行结果也看到了,是符合我们的要求的
简化视图展示与隐藏
在项目当中,展示或者隐藏一个视图我们通常会调用setVisibility
函数,里面传入View.GONE
或者View.VISIBLE
,如果是在一个逻辑复杂一点的页面中,这样的View.GONE
和View.VISIBLE
会有很多处,所以针对这种现象,就可以使用扩展函数来简化它
可以看到在View
上增加了两个扩展函数hide
和show
,分别用来隐藏和展示视图,调用方代码如下
同样是隐藏和展示视图,现在这样就显的很简洁了,不用去重复写View.VISIBLE
或者View.GONE
了,那么这第一道面试题算是答完了,好像简单了点,再去文心一言里面换个问法
第二题
要了个中级工程师的面试题,文心一言果断给我来了个协程相关的问题,看来要成为中级工程师,协程是一定要掌握的(好像说了句废话...),我们看下这道题,是让我们说下在项目中使用协程处理异步操作的经验,那么典型的场景就是网络请求,每一个网络请求它都是异步的,它会等待服务端有响应后才会将数据返回,这个过程会在挂起函数中进行,这个特性有什么好处呢?在处理一些有依赖关系的接口上,代码会变的十分简洁,增强可读性,我们下面来模拟一个请求用户id接口并且用这个id去请求用户详情接口的过程
这里写了两个挂起函数分别模拟两个接口,函数userID
里面会延迟个两秒,然后返回一个随机数以及当前时间,函数userDetail
也会延迟个三秒,然后输出用户信息文本并且也带上当前时间,挂起函数当然是需要在一个协程作用域里面进行,所以这里使用runBlocking
来创建个协程作用域
实际开发中不建议使用runBlocking来创建协程作用域,因为它会阻塞主线程
在runBlocking
的作用域里面就是整个请求用户id以及使用用户id请求详情的过程,排除打印日志的代码,整个过程只有两行代码,如果使用传统做法,那必须在用户id接口的成功回调里面,再去请求用户详情,那整个代码的可读性和可维护性就变差了,要知道这里只模拟了两个接口,如果依赖的接口变多了呢?我们运行下这段代码看看结果
看运行结果主要是看下每一步里面打印出来的时间,从开始到结束,整个过程花了五秒,时间上是没有问题的,因为详情接口的触发时机也是要等到第一个接口有响应数据以后才开始,就算第一个接口花了一分钟,第二个接口也得等,但是如果两个接口之间不存在依赖关系呢,第二个接口也要等到第一个接口有响应时候才触发吗,显然这样做的体验是不好的,下面写个简单的例子
比如这里有两个接口requestA
和requestB
,A接口耗时两秒返回结果,B接口耗时三秒返回结果,我们用之前的方式调用下这俩接口试试
可以看到果然这样的处理最终都执行完花了五秒,那么如何优化呢?我们可以在当前协程作用域里面再创建两个子协程,在这两个子协程的作用域里面执行AB俩接口,代码修改一下
可以看到将不同的接口放在不同的子协程中,最终两个接口就并发执行了,这个时候脑海中忽然飘过一行字...遥遥领,不对,协程是一种能够在代码中实现顺序操作的同时处理异步任务的并发机制,这道题也答完了,接着看下一题。
第三题
这考的就是高阶函数了,问的是如何减少代码重复和提高代码可读性,那么我们先想一想平时开发中哪些代码看着让你感觉难受,比如下面这段代码
这段代码相信在每个项目的WebViewActivity里面都会有,就是对WebSettings
的一系列设置,有的项目里面甚至更多,而像这样的代码里面,每一次设置一条属性都要前面加上webSetting.就显的特别的啰嗦重复,以前用java写没办法,但是现在用kotlin的话,我们通常会简写成这样
使用库函数apply
就可以把上述代码简化成前面可以不用加webSetting.,代码变得也简洁清爽很多,为什么可以这样实现呢?其实库函数apply
本身就用到了高阶函数的知识,可以看下apply
内部源码
可以看到apply
函数内部接收的参数其实就是一个高阶函数,而且还是一个带接收者的lambda表达式,这就能在lambda表达式内部调用接受者里面的属性或者方法的时候,可以不用显示的将接收者写出来,减少了重复代码,也增强了代码可读性。
第四题
内存泄漏的这种面试题基本都快被问烂了,但是这里特地说明了在kotlin项目中,那么就要从kotlin语言特性角度入手,从这方面去分析有可能会导致内存泄漏的原因
Lambda表达式
Lambda表达式算是kotlin语言里面经常用到的,那么如何检测你的lambda表达式是否存在内存泄漏的隐患呢?只需要将代码反编译成java,然后看下lambda的代码块里面是否持有外部引用就好了,来看下面这段代码
这是一段在Activity里面开启一个线程的代码,我们将这段代码转成java文件看看
可以看到反编译成java文件之后,在原本的lambda代码块里面并没有持有外部引用,那么我们稍微在上面的kotlin代码处做点修改,改成下面这样
将代码改成了在lambda代码里面打印了外部的一个变量,我们再看下反编译后的结果
可以看到反编译的结果里面,原本的lambda块中已经出现了这么一行代码
这就说明了lambda表达式里面如果显式的持有外部类引用,就有可能导致内存泄漏
高阶函数
高阶函数也是Kotlin语言里面的一大特性,我们看下高阶函数有没有内存泄漏的隐患,同样先写一个简单的例子
写了个简单的高阶函数jobOne
,然后在OnCreate
里面去调用这个函数,这个时候lambda表达式里面还没有持有任何外部类的引用,我们看下这段代码的字节码
看GETSTATIC
指令和INVOKESPECIAL
指令,调用jobOne
函数的引用是直接从静态变量中拿出来的,说明在生成该字节码之前,该引用已经被初始化了,我们再来看看当高阶函数中持有外部类引用会怎么样
这个时候再分别看下调用jobOne
和内部Lambda
表达式里面的字节码
这个时候,引用就不是从静态变量里面取出来的了,而是直接创建出来的,所以如果高阶函数被回收掉了,里面引用还在,就会有内存泄漏的隐患
第五题
经过三道中级的面试题,琢磨着再加大点难度吧,问个高级的试试,看看会出个啥
高级的也出协程?不过仔细一看好像跟刚才那道不太一样,刚刚那题问的是协程对异步编程的理解与使用,而这一题问的则是对挂起恢复的理解,那么既然题目说的是给一个实际应用的例子,那么我们先写个简单的例子再开始说
这段代码算是模拟了一个延迟请求的一个过程,requestA
是一个挂起函数,在runBlocking
的scope
中运行,这段代码如何执行相信不用跑大家都知道,那么在这段代码中如何体现出挂起和恢复的概念呢,我们先将它们转换成java代码看看
比较长,主要分两部分,第一段代码是main
函数里面执行的过程,第二段代码是requestA
函数里面执行的过程,两段代码中都有一个共同点,就是都有一个invokeSuspend
函数,main
里面调用这个函数的是Continuation
,这个是一个接口,在requestA
里面是Continuation
的实现类ContinuationImpl
调用的invokeSuspend
函数,而Continuation
在kotlin里面其实就是关键字suspend
,runBlocking
的协程体其实本身也是个挂起函数,也有关键字suspend
可以看到在两段代码中都分别将各自协程的初始状态设置成了IntrinsicsKt.getCOROUTINE_SUSPENDED()
,这也就是为什么协程刚创建好就是挂起状态的原因,而执行流程是先在main
里面不断执行下面这段代码,当label
的值为0的时候去调用requestA
,并将label
设置成1,如果当前requestA
仍旧是挂起状态,那么return
出函数,下次重新执行,如果requestA
返回结果了,就走下一个case
分支,将结果给到var10000
变量并输出,也就是kotlin代码中println(data)
这一句
在requestA
里面,也是同样的流程,从label
为0开始,先是不断执行DelayKt.delay
这个挂起函数,当它返回结果仍旧是挂起状态时候,就return
出去,直到三秒后才跳到下一个case
,下一个case
对结果做了失败处理,并且将requestA
的执行结果返回
从上面可以发现,所有流程的关键就是不停的在走invokeSuspend
函数来判断label
,而调用invokeSuspend
的地方,我们就要去刚刚提到的Continuation
的实现类ContinuationImpl
里面去看
ContinuationImpl
继承了一个抽象类BaseContinuationImpl
,再点到抽象类里面去看看
一眼就看到了一个重写函数resumeWith
,这个函数就定义在了接口Continuation
里面,resumeWith
就是用来处理协程挂起恢复的核心,看到了里面有个While(true)
的循环体,里面先调用invokeSupend
函数并且对该函数的返回值做判断,如果还是挂起就不做别的事情,直接return
掉,如果不是挂起则说明上层的协程有了执行结果,开始执行恢复的逻辑,触发下一次invokeSuspend
函数的调用,也就是刚才讲到的label
从0变为1进入下一个case
,以上就是协程挂起和恢复的所有流程,不知道我说明白了没有?源码追的有点晕乎了,休息一下咱们再来一题。
第六题
这次来了个新名词,听过递归,没听过尾递归啊,这个是什么东西?网上查了一下才知道,尾递归是函数在调用完自己之后没有其他操作的递归。 说白了也就是最后一步仍旧是调用的函数自己,来举个简单的例子,做累加,输入一个目标值,一个初始值,算出最终值,这个我们用寻常的循环方式算就是下面这样
而用尾递归的方式怎么做呢,看代码
这两个函数执行的结果是一样的,我们写段代码测试下
在main
函数里面分别打印出addA
和addB
的执行结果,运行一下可以看到结果如下
不用说结果肯定都是一样的,刚才是50个数字的累加,计算量还比较小,如果我们换成50000个数字累加呢,我们试试
run了一下后发现,addB
函数抛异常了
出现这个的原因是因为递归是比较消耗性能的,每一次递归都会开辟一个新的方法栈,递归次数少可能不会有太大的影响,但是次数多了就会有损性能了,但是咱也不能因为这个而不用递归了,毕竟递归算法在逻辑上还是很简单的,那么这里的问题就跑到如何去优化这种计算量大的递归算法上,方法就是添加tailrec
关键字,先不说原因,我们在addB
函数前加个tailrec
关键字试试看
重新run一下main
函数,发现这次不报错了,结果都算出来了
为啥这次不报错了呢?还得反编译看看tailrec
干了啥事情
看到没有,tailrec
关键字可以把递归函数转成一个普通的循环迭代函数,基本跟addA
差不多了,那自然无论累加到多少,也不可能开辟多余的方法栈了,那是不是tailrec
可以完全解决递归算法带来的性能损耗了呢,我们换种递归方式来试试,再来个addC
函数
可以看到这里直接在函数前面加上了关键字tailrec
,然后在main
函数里面试试,也是直接计算50000个数字累加
可以看到加了关键字的addC
函数还是会报错,这是为啥呢?看下反编译后的java代码就知道了
可以看到尽管加了个tailrec
关键字,但是addC
在反编译后,并没有像addB
一样转换成普通的循环迭代方式,还是递归的方式,所以可以得出结论,tailrec关键字只能优化尾递归,不能优化其他递归 ,毕竟人家名字里面就有tail,明摆着只能做"尾巴"相关的事情,而且在编译器里面当你给非尾递归函数加上tailrec
关键字后,也会有提示告诉你这里的关键字不会起作用
总结
面试就到此结束,总共六道kotlin面试题,虽然不多,但是涵盖面还是比较广的,有扩展函数,高阶函数,性能优化,协程和算法优化,这种方式写面试题文章我感觉还是比较有意思的,因为我之前也写过一篇面试题文章,出的题基本都是我会的,然后把思路写出来,但是用AI提问的方式就不一样了,问的问题不一定是自己会的,比如那道尾递归优化,我也是先去学了之后再把解题过程写下来的,等写完文章后,自己本身也学到了东西