注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Git-flow作者称其不适用于持续交付?

Git
前言 Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,在这10年中,Git-flow在许多软件团队中变得非常流行,以至于人们开始将其视为某种标准。 不过最近Vincent Driessen更新了他10年前那篇著名的A...
继续阅读 »

前言


Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,在这10年中,Git-flow在许多软件团队中变得非常流行,以至于人们开始将其视为某种标准。
不过最近Vincent Driessen更新了他10年前那篇著名的A successful Git branching model,大意是Git-flow已不适用于当今持续交付的软件工程方式,推荐更简单的Github flow等模型


Git-flow作者都承认Git-flow不适合持续交付了,那我们更有必要好好研究一下了,以免掉坑里。
本文主要包括以下内容:
1.Git-flow介绍
2.为什么Git-flow不适用于持续交付?
3.Github flow介绍
4.Gitlab flow介绍


1. Git-flow是什么?


Git-flow是由Vincent Driessen在2010年提出的一个Git分支模型,其结构图如下所示,相信大家都看过

Git-flow主要包括以下分支



  • master 是长期分支,一般用于管理对外发布版本,每个commit对一个tag,也就是一个发布版本

  • develop 是长期分支,一般用于作为日常开发汇总,即开发版的代码

  • feature 是短期分支,一般用于一个新功能的开发

  • hotfix 是短期分支 ,一般用于正式发布以后,出现bug,需要创建一个分支,进行bug修补。

  • release 是短期分支,一般用于发布正式版本之前(即合并到 master 分支之前),需要对预发布的版本进行测试。release 分支在经历测试之后,测试确认验收,将会被合并到 developmaster


1.1 Git-flow工作流程


一般工作流程如下:



  • 1.日常在develop开发

  • 2.如果有比较大的功能或者其他需求,那么新开分支:feature/xxx 来做,并在这个分支上进行打包和提测。

  • 3.在封版日,将该版本上线的需求合并到develop,然后将开个新的分支release/版本号(如release/1.0.1),将develop合并至该分支。

  • 4.灰度阶段,在releases/版本号 分支上修复BUG,打包并发布,发布完成后反合入masterdevelop分支

  • 5.如果在全量发布后,发现有线上问题,那么在对应的master分支上新开分支hotfix/{版本号}来修复,并升级版本号,修复完成后,然后将hotfix合并到master,同时将合并到develop


2. 为什么Git-flow不适用于持续交付?



在这 10 年中,Git 本身已经席卷全球,并且使用 Git 开发的最受欢迎的软件类型正在更多地转向 Web 应用程序——至少在我的过滤器气泡中。 Web 应用程序通常是持续交付的,而不是回滚的,而且您不必支持同时 运行的多个版本的软件。



Vincent Driessen所述。Git-flow描述了feature分支、release分支、masterdevelop分支以及hotfix分支是如何相互关联的。
这种方法非常适用于用户下载的打包软件,例如库和桌面应用程序。


然而,对于许多Web应用来说,Git-flow是矫枉过正的。有时,您的develop分支和release分支之间没有足够大的差异来区分值得。或者,您的hotfix分支和feature分支的工作流程可能相同。
在这种情况下,Vincent Driessen推荐Github flow分支模型


Git-flow的主要优点在于结构清晰,每个分支的任务划分的很清楚,而它的缺点自然就是有些复杂了
Git-flow需要同时维护两个长期分支。大多数工具都将master当作默认分支,可是开发是在develop分支进行的,这导致经常要切换分支,非常烦人。
更大问题在于,这个模式是基于"版本发布"的,目标是一段时间以后产出一个新版本。但是,很多网站项目是"持续发布",代码一有变动,就部署一次。这时,master分支和develop分支的差别不大,没必要维护两个长期分支


2.1 Git-fow何时值得额外的复杂性


当然,是否使用Git-flow取决于你的业务复杂性,有时使用Git-flow是必须的,主要是当你需要同时维护多版本的时候,适合的是需要『多个版本并存』的场景
所谓『多版本并存』,就是说开发团队要同时维护多个有客户使用的版本,对于传统软件,比如我开发一个新的操作系统叫做Doors,先卖v1,卖出去1000万份,然后看在v1的基础上开发v2,但是客户会持续给v1bug,这些bug既要在v1的后续补丁中fix,也要在v2fix,等v2再卖出去2000万份开始开发v3的时候,v1依然有客户,我就必须要维持v1v2v3三个多版本都要支持。


关于Git-flow同时支持多个版本,很多人可能会有疑问,因为develop只针对一个版本能持续交付
说实话我也感觉挺疑问的,后面查阅资料发现还有一个衍生的support分支,可以同时支持多个版本,在兴趣的同学可参考:mindsers.blog/post/severa…


3.Github flow介绍



Github flow它只有一个长期分支,就是master,因此用起来非常简单。



  • 第一步:根据需求,从master拉出新分支,不区分功能分支或补丁分支。

  • 第二步:新分支开发完成后,或者需要讨论的时候,就向master发起一个pull request(简称PR)。

  • 第三步:Pull Request既是一个通知,让别人注意到你的请求,又是一种对话机制,大家一起评审和讨论你的代码。对话过程中,你还可以不断提交代码

  • 第四步:布署流程:当项目负责人同意新功能可以发布,且代码也通过审核了。但是在代码合并之前还是要进行测试。所以要把feature分支的代码部署到测试环境进行测试

  • 第五步:你的Pull Request被接受,合并进master,重新部署到生产环境后,原来你拉出来的那个分支就被删除。

  • 第六步:修复正式环境bug流程:从master分支切一个HotFix分支,经过以上同样的流程发起PR合并即可


3.1 Github flow的优点


Github flow的最大优点就是简单,对于"持续发布"的产品,可以说是最合适的流程。


3.2 Github flow的缺点


它的问题也在于它的假设:master分支的更新与产品的发布是一致的。也就是说,master分支的最新代码,默认就是当前的线上代码。
可是,有些时候并非如此,代码合并进入master分支,并不代表它就能立刻发布。比如,苹果商店的APP提交审核以后,等一段时间才能上架。这时,如果还有新的代码提交,master分支就会与刚发布的版本不一致。另一个例子是,有些公司有发布窗口,只有指定时间才能发布,这也会导致线上版本落后于master分支。
上面这种情况,只有master一个主分支就不够用了。通常,你不得不在master分支以外,另外新建一个production分支跟踪线上版本。


同时对于Github flow我还有个疑问,合并到master分支后即会部署到生产环境,但是在merge后的代码难道不会产生冲突吗?合并冲突难道不需要重新测试吗?如果评论区有了解的小伙伴可以解惑下


Github flow用起来比较简单,但是在很多公司的业务开发过程中一般都有开发、测试、预发布、生产几个环境,没有强有力的工具来支撑,我认为很难用这种简单的模式来实现管理。
看起来这种模式特别适合小团队,人少,需求少,比较容易通过这种方式管理分支。


4.Gitlab flow介绍


Gitlab flowGit-flowGithub flow的综合。它吸取了两者的优点,既有适应不同开发环境的弹性,又有单一主分支的简单和便利。它是Gitlab.com推荐的做法。
Gitlab flow的最大原则叫做”上游优先”(upsteam first),即只存在一个主分支master,它是所有其他分支的”上游”。只有上游分支采纳的代码变化,才能应用到其他分支。
Gitlab flow分为持续发布与版本发布两种情况,以适应不同的发布类型


4.1 持续发布



对于”持续发布”的项目,它建议在master分支以外,再建立不同的环境分支。
比如,”开发环境”的分支是master,”预发环境”的分支是pre-production,”生产环境”的分支是production


开发分支是预发分支的"上游",预发分支又是生产分支的"上游"。代码的变化,必须由"上游"向"下游"发展。比如,生产环境出现了bug,这时就要新建一个功能分支,先把它合并到master,确认没有问题,再cherry-pickpre-production,这一步也没有问题,才进入production


只有紧急情况,才允许跳过上游,直接合并到下游分支。


4.2 版本发布



对于"版本发布"的项目,建议的做法是每一个稳定版本,都要从master分支拉出一个分支,比如2-3-stable2-4-stable等等。
以后,只有修补bug,才允许将代码合并到这些分支,并且此时要更新小版本号。


4.3 Gitlab flow开发流程


对于Android开发,我们一般使用版本发布,因此我们使用Gitlab flow开发的工作流为



  • 1.新的迭代开始,所有开发人员从主干master拉个人分支开发特性, 分支命名规范 feature-name

  • 2.开发完成后,在迭代结束前,合入master分支

  • 3.master分支合并后,自动cicddev环境

  • 4.开发自测通过后,从master拉取要发布的分支,release-$version,将这个分支部署到测试环境进行测试

  • 5.测出的bug,通过从release-$versio拉出分支进行修复,修复完成后,再合入release-$versio

  • 6.正式发布版本,如果上线后,又有bug,根据5的方式处理

  • 7.等发布版本稳定后,将release-$versio反合入主干master分支


值得注意的是,按照Github flow规范,第5步如果测出bug,应该在master上修改,然后cherry-pickreleases上来,但是这样做太麻烦了,直接在releases分支上修复bug然后再反合入master分支应该是一个简单而且可以接受的做法


总结


正如Vincent Driessen所说的,总而言之,请永远记住,灵丹妙药并不存在。考虑你自己的背景。不要讨厌。自己决定


Git-flow适用于大团队多版本并存迭代的开发流程
Github-flow适用于中小型团队持续集成的开发流程
Gitlab-flow适用范围则介于上面二者之间,支持持续发布与版本发布两种情况


总得来说,各种Git工作流自有其适合工作的场景,毕竟软件工程中没有银弹,读者可根据自己的项目情况对比选择使用,自己决定~


参考资料


如何看待 Git flow 发明人称其不适用于持续交付?
Git 开发工作流程:Git Flow 与 GitHub Flow
Git 工作流程
高效团队的gitlab flow最佳实践

收起阅读 »

Jetpack Compose初体验--(导航、生命周期等)

普通导航 在Jetpack Compose中导航可以使用Jetpack中的Navigation组件,引入相关的扩展依赖就可以了 Navigation官方文档 implementation "androidx.navigation:navigation-co...
继续阅读 »

普通导航


在Jetpack Compose中导航可以使用Jetpack中的Navigation组件,引入相关的扩展依赖就可以了 Navigation官方文档


implementation "androidx.navigation:navigation-compose:2.4.0-alpha01"

使用Navigation导航用到两个比较重要的对象NavHost和NavController。



  • NavHost用来承载页面,和管理导航图

  • NavController用来控制如何导航还有参数回退栈等


导航的路径使用字符串来表示,当使用NavController导航到某个页面的时候,NavHost内部会自动进行页面重组。


来个小栗子实践一下


@Composable
fun MainView(){
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first_screen"){
composable("first_screen"){
FirstScreen(navController = navController)
}
composable("second_screen"){
SecondScreen(navController = navController)
}
composable("third_screen"){
ThirdScreen(navController = navController)
}
}
}


  • 通过rememberNavController()方法创建navController对象

  • 创建NavHost对象,传入navController并指定首页

  • 通过composable()方法来往NavHost中添加页面,构造方法中的字符串就代表该页面的路径,后面的第二个参数就是具体的页面。


下面把这三个页面写出来,每个页面里面都有个按钮继续执行其他导航


@Composable
fun FirstScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize().background(Color.Blue),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
navController.navigate("second_screen")
}) {
Text(text = "I am First 点击我去Second")
}
}
}
@Composable
fun SecondScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("third_screen")
}) {
Text(text = "I am Second 点击我去Third")
}
}
}
@Composable
fun ThirdScreen(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("first_screen")
}) {
Text(text = "I am Third 点击我去first")
}
}
}

这样一个简单的导航效果就完成了,感觉用了这个之后,要跟activity和fragment说拜拜了~~ ,全场只需一个activity加一堆可组合项(@Composable),新建一个页面简单了太多太多。


当然页面之间跳转传参是少不了的,Compose中如何传参呢?


参数传递肯定有发送端和接收端,navController是发送端,NavHost是接收端。先在NavHost中配置参数占位符,和接收取参数的方法。


@Composable
fun MainView(){
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first_screen"){
composable("first_screen"){
FirstScreen(navController = navController)
}
composable("second_screen/{userId}/{isShow}",
//默认情况下 所有参数都会被解析为字符串 如果不是字符串需要单独指定 type
arguments = listOf(navArgument("isShow"){type = NavType.BoolType})
){ backStackEntry ->
SecondScreen(navController = navController,
backStackEntry.arguments?.getString("userId"),
backStackEntry.arguments?.getBoolean("isShow")!!
)
}
composable("third_screen?selectable={selectable}",
arguments = listOf(navArgument("selectable"){defaultValue = "哈哈哈我是可选参数的默认值"})){
ThirdScreen(navController = navController,it.arguments?.getString("selectable"))
}
composable("four_screen"){
FourScreen(navController = navController)
}
}
}

如上代码,接收参数直接在在该页面地址后面添加参数占位符类似second_screen/{userId}/{isShow},然后通过arguments参数来接收arguments = listOf(navArgument("isShow"){type = NavType.BoolType})。还可以通过defaultValue来定义参数的默认值。


默认情况下 所有参数都会被解析为字符串 如果不是字符串需要单独指定 type。


参数发送端更简单,参数直接跟到页面路径后面就可以,类似navController.navigate("second_screen/12345/true") 下面给前面的页面添加上参数


@Composable
fun FirstScreen(navController: NavController){
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Blue),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
navController.navigate("second_screen/12345/true"){
}
}) {
Text(text = "I am First 点击我去Second")
}
Spacer(modifier = Modifier.size(30.dp))
}
}
@Composable
fun SecondScreen(navController: NavController,userId:String?,isShow:Boolean){
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Green),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("third_screen?selectable=测试可选参数"){
popUpTo(navController.graph.startDestinationId){saveState = true}
}
}) {
Text(text = "I am Second 点击我去Third")
}
Spacer(modifier = Modifier.size(30.dp))
Text(text = "arguments ${userId}")
if(isShow){
Text(text = "测试boolean值")
}
}
}
@Composable
fun ThirdScreen(navController: NavController,selectable:String?){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
navController.navigate("first_screen")
}) {
Text(text = "I am Third 点击我去first")
}
Spacer(modifier = Modifier.size(30.dp))
Button(onClick = {
navController.navigate("four_screen")
}) {
Text(text = "I am Third 点击我去four")
}
selectable?.let { Text(text = it) }
}
}

效果如下


copmose_21.gif


生命周期


既然新的界面不使用activity或者fragment了,但是activity和fragment中的生命周期是非常有用的比如创建和销毁某些对象。那么Jetpack Compose中的每个组合函数的生命周期是怎样的呢?


可组合项的生命周期比视图比activity 和 fragment 的生命周期更简单,一般是进入组合、执行0次或者多次重组、退出组合。生命周期相关的函数主要有下面的几个,使用@Composable修饰的可组合函数中没有自带的生命周期函数,想要监听其生命周期,需要使用Effect API



  • LaunchedEffect:第一次调用Compose函数的时候调用

  • DisposableEffect:内部有一个 onDispose()函数,当页面退出时调用

  • SideEffect:compose函数每次执行都会调用该方法


来个小例子体验一下


@Composable
fun LifecycleDemo() {
val count = remember { mutableStateOf(0) }

Column {
Button(onClick = {
count.value++
}) {
Text("Click me")
}

LaunchedEffect(Unit){
Log.d("Compose", "onactive with value: " + count.value)
}
DisposableEffect(Unit) {
onDispose {
Log.d("Compose", "onDispose because value=" + count.value)
}
}
SideEffect {
Log.d("Compose", "onChange with value: " + count.value)
}
Text(text = "You have clicked the button: " + count.value.toString())
}
}

效果如下:


copmose_26.gif


然后把前面的例子稍微改一下,我们把LaunchedEffect和DisposableEffect一起放到一个if语句里面


@Composable
fun LifecycleDemo() {
val count = remember { mutableStateOf(0) }

Column {
Button(onClick = {
count.value++
}) {
Text("Click me")
}

if (count.value < 3) {
LaunchedEffect(Unit){
Log.d("Compose", "onactive with value: " + count.value)
}
DisposableEffect(Unit) {
onDispose {
Log.d("Compose", "onDispose because value=" + count.value)
}
}
}

SideEffect {
Log.d("Compose", "onChange with value: " + count.value)
}
Text(text = "You have clicked the button: " + count.value.toString())
}
}

那么此时的生命周期就是:当首次进入if语句的时候执行LaunchedEffect函数,离开if语句的时候,就执行DisposableEffect方法。


底部导航


说到导航就不得不说底部导航和顶部导航,底部导航的实现非常简单,直接使用JetPack Compose提供的脚手架在结合navController和NavHost就能轻松实现


@Composable
fun BottomMainView(){
val bottomItems = listOf(Screen.First,Screen.Second,Screen.Third)
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
bottomItems.forEach{screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite,"") },
label = { Text(stringResource(screen.resourceId)) },
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route){
//当底部导航导航到在非首页的页面时,执行手机的返回键 回到首页
popUpTo(navController.graph.startDestinationId){saveState = true}
//从名字就能看出来 跟activity的启动模式中的SingleTop模式一样 避免在栈顶创建多个实例
launchSingleTop = true
//切换状态的时候保存页面状态
restoreState = true
}
})
}

}
}
){
NavHost(navController = navController, startDestination = Screen.First.route ){
composable(Screen.First.route){
First(navController)
}
composable(Screen.Second.route){
Second(navController)
}
composable(Screen.Third.route){
Third(navController)
}
}
}
}
@Composable
fun First(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "First",fontSize = 30.sp)
}
}
@Composable
fun Second(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Second",fontSize = 30.sp)
}
}
@Composable
fun Third(navController: NavController){
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Third",fontSize = 30.sp)
}
}

效果如下


copmose_22.gif


顶部导航


顶部导航使用TabRow和ScrollableTabRow这两个组件,其内部都是由一个一个的Tab组件组成。TabRow是平分整个屏幕的宽度,ScrollableTabRow可以超出屏幕宽度并且可以滑动,用法都是一样。


@Composable
fun TopTabRow(){
var state by remember { mutableStateOf(0) }
var titles = listOf("Java","Kotlin","Android","Flutter")
Column {
TabRow(selectedTabIndex = state) {
titles.forEachIndexed{index,title ->
run {
Tab(
selected = state == index,
onClick = { state = index },
text = {
Text(text = title)
})
}
}
}
Column(Modifier.weight(1f)) {
when (state){
0 -> TopTabFirst()
1 -> TopTabSecond()
2 -> TopTabThird()
3 -> TopTabFour()
}
}
}
}
@Composable
fun TopTabFirst(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Java")
}
}
@Composable
fun TopTabSecond(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Kotlin")
}
}
@Composable
fun TopTabThird(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Android")
}
}
@Composable
fun TopTabFour(){
Column(modifier = Modifier.fillMaxSize(), verticalArrangement=Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Flutter")
}
}

copmose_23.gif


上面只能实现点击每个Tab 切换不同的页面,如果我们想要实现类似我们在xml布局中的ViewPage+TabLayout的效果呢


在Jetpack中怎么实现ViewPage的效果呢,Google的github上提供了一个半官方的库名字叫pager:github.com/google/acco…


implementation "com.google.accompanist:accompanist-pager:0.13.0"

该库目前还是实验性的,以后API都可能会修改,目前使用的时候需要使用@ExperimentalPagerApi注解标记。


@ExperimentalPagerApi
@Composable
fun TopScrollTabRow(){
var titles = listOf("Java","Kotlin","Android","Flutter","scala","python")
val scope = rememberCoroutineScope()
var pagerState = rememberPagerState(
pageCount = titles.size, //总页数
initialOffscreenLimit = 2, //预加载的个数
infiniteLoop = true, //无限循环
initialPage = 0, //初始页面
)
Column {
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.wrapContentSize(),
edgePadding = 16.dp
) {
titles.forEachIndexed{index,title ->
run {
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.scrollToPage(index)
}
},
text = {
Text(text = title)
})
}
}
}
HorizontalPager(
state=pagerState,
modifier = Modifier.weight(1f)
) {index ->
Column(modifier = Modifier.fillMaxSize(),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = titles[index])
}
}
}
}

pagerState.scrollToPage(index)方法可以控制pager滚动,不过它是一个suspend修饰的方法,需要运行在协程中,在jetpack compose中使用协程可以使用rememberCoroutineScope()方法来获取一个compose中的协程的作用域


效果如下:


copmose_24.gif


Banner


pager库都引入了那顺便吧Banner效果也练习一下,为了显示网络图片还得引入一个新的库,accompanist-coil。在JetPack Compose中官方提供了两个显示网络图片的库accompanist-coil和accompanist-glide,这里使用accompanist-coil。


implementation 'com.google.accompanist:accompanist-coil:0.11.1'

@ExperimentalPagerApi
@Composable
fun Third(navController: NavController){
var pics = listOf("https://wanandroid.com/blogimgs/8a0131ac-05b7-4b6c-a8d0-f438678834ba.png",
"https://www.wanandroid.com/blogimgs/62c1bd68-b5f3-4a3c-a649-7ca8c7dfabe6.png",
"https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"https://www.wanandroid.com/blogimgs/90c6cc12-742e-4c9f-b318-b912f163b8d0.png")
Column(modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Third",fontSize = 30.sp)
var pagerState = rememberPagerState(
pageCount = 4, //总页数
initialOffscreenLimit = 2, //预加载的个数
infiniteLoop = true, //无限循环
initialPage = 0, //初始页面
)
Box(modifier = Modifier
.fillMaxWidth()
.height(260.dp)
.background(color = Color.Yellow)) {
HorizontalPager(
state=pagerState,
modifier = Modifier.fillMaxSize()
) {index ->
Image(modifier = Modifier.fillMaxSize(),
painter = rememberCoilPainter(request = pics[index]),
contentScale=ContentScale.Crop,
contentDescription = "图片描述")
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.padding(16.dp).align(Alignment.BottomStart),
)
}
}
}

使用Jetpack Compose写页面感觉比使用xml简单了很多,相信未来Android中的xml布局会像前端的jquary一样用的越来越少。



作者:Chsmy
链接:https://juejin.cn/post/6983968223209193480
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

使用更为安全的方式收集 Android UI 数据流

在 Android 应用中,通常需要从 UI 层收集 Kotlin 数据流,以便在屏幕上显示数据更新。同时,您也会希望通过收集这些数据流,来避免产生不必要的操作和资源浪费 (包括 CPU 和内存),以及防止在 View 进入后台时泄露数据。 本文将会带您学习如...
继续阅读 »

在 Android 应用中,通常需要从 UI 层收集 Kotlin 数据流,以便在屏幕上显示数据更新。同时,您也会希望通过收集这些数据流,来避免产生不必要的操作和资源浪费 (包括 CPU 和内存),以及防止在 View 进入后台时泄露数据。


本文将会带您学习如何使用 LifecycleOwner.addRepeatingJobLifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle API 来避免资源的浪费;同时也会介绍为什么这些 API 适合作为在 UI 层收集数据流时的默认选择。


资源浪费


无论数据流生产者的具体实现如何,我们都 推荐 从应用的较底层级暴露 Flow API。不过,您也应该保证数据流收集操作的安全性。


使用一些现存 API (如 CoroutineScope.launchFlow.launchInLifecycleCoroutineScope.launchWhenX) 收集基于 channel 或使用带有缓冲的操作符 (如 bufferconflateflowOnshareIn) 的冷流的数据是 不安全的,除非您在 Activity 进入后台时手动取消启动了协程的 Job。这些 API 会在内部生产者在后台发送项目到缓冲区时保持它们的活跃状态,而这样一来就浪费了资源。



注意: 冷流 是一种数据流类型,这种数据流会在新的订阅者收集数据时,按需执行生产者的代码块。



例如下面的例子中,使用 callbackFlow 发送位置更新的数据流:‍


// 基于 Channel 实现的冷流,可以发送位置的更新
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
try { offer(result.lastLocation) } catch(e: Exception) {}
}
}
requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
.addOnFailureListener { e ->
close(e) // 在出现异常时关闭 Flow
}
// 在 Flow 收集结束时进行清理操作
awaitClose {
removeLocationUpdates(callback)
}
}
复制代码


注意: callbackFlow 内部使用 channel 实现,其概念与阻塞 队列 十分类似,并且默认容量为 64。



使用任意前述 API 从 UI 层收集此数据流都会导致其持续发送位置信息,即使视图不再展示数据也不会停止!示例如下:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 最早在 View 处于 STARTED 状态时从数据流收集数据,并在
// 生命周期进入 STOPPED 状态时 SUSPENDS(挂起)收集操作。
// 在 View 转为 DESTROYED 状态时取消数据流的收集操作。
lifecycleScope.launchWhenStarted {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
// 同样的问题也存在于:
// - lifecycleScope.launch { /* 在这里从 locationFlow() 收集数据 */ }
// - locationProvider.locationFlow().onEach { /* ... */ }.launchIn(lifecycleScope)
}
}
复制代码

lifecycleScope.launchWhenStarted 挂起了协程的执行。虽然新的位置信息没有被处理,但 callbackFlow 生产者仍然会持续发送位置信息。使用 lifecycleScope.launchlaunchIn API 会更加危险,因为视图会持续消费位置信息,即使处于后台也不会停止!这种情况可能会导致您的应用崩溃。


为了解决这些 API 所带来的问题,您需要在视图转入后台时手动取消收集操作,以取消 callbackFlow 并避免位置提供者持续发送项目并浪费资源。举例来说,您可以像下面的例子这样操作:


class LocationActivity : AppCompatActivity() {

// 位置的协程监听器
private var locationUpdatesJob: Job? = null

override fun onStart() {
super.onStart()
locationUpdatesJob = lifecycleScope.launch {
locationProvider.locationFlow().collect {
// 新的位置!更新地图。
}
}
}

override fun onStop() {
// 在视图进入后台时停止收集数据
locationUpdatesJob?.cancel()
super.onStop()
}
}
复制代码

这是一个不错的解决方案,美中不足的是有些冗长。如果这个世界有一个有关 Android 开发者的普遍事实,那一定是我们都不喜欢编写模版代码。不必编写模版代码的一个最大好处就是——写的代码越少,出错的概率越小!


LifecycleOwner.addRepeatingJob


现在我们境遇相同,并且也知道问题出在哪里,是时候找出一个解决方案了。我们的解决方案需要: 1. 简单;2. 友好或者说便于记忆与理解;更重要的是 3. 安全!无论数据流的实现细节如何,它都应能够应对所有用例。


事不宜迟——您应该使用的 API 是 lifecycle-runtime-ktx 库中所提供的 LifecycleOwner.addRepeatingJob。请参考下面的代码:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 最早在 View 处于 STARTED 状态时从数据流收集数据,并在
// 生命周期进入 STOPPED 状态时 STOPPED(停止)收集操作。
// 它会在生命周期再次进入 STARTED 状态时自动开始进行数据收集操作。
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
}
}
复制代码

addRepeatingJob 接收 Lifecycle.State 作为参数,并用它与传入的代码块一起,在生命周期到达该状态时,自动创建并启动新的协程;同时也会在生命周期低于该状态时取消正在运行的协程


由于 addRepeatingJob 会在协程不再被需要时自动将其取消,因而可以避免产生取消操作相关的模版代码。您也许已经猜到,为了避免意外行为,这一 API 需要在 Activity 的 onCreate 或 Fragment 的 onViewCreated 方法中调用。下面是配合 Fragment 使用的示例:


class LocationFragment: Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// ...
viewLifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// 新的位置!更新地图
}
}
}
}
复制代码


注意: 这些 API 在 lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 库或其更新的版本中可用。



使用 repeatOnLifecycle


出于提供更为灵活的 API 以及保存调用中的 CoroutineContext 的目的,我们也提供了 挂起函数 Lifecycle.repeatOnLifecycle 供您使用。repeatOnLifecycle 会挂起调用它的协程,并会在进出目标状态时重新执行代码块,最后在 Lifecycle 进入销毁状态时恢复调用它的协程。


如果您需要在重复工作前执行一次配置任务,同时希望任务可以在重复工作开始前保持挂起,该 API 可以帮您实现这样的操作。示例如下:


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

lifecycleScope.launch {
// 单次配置任务
val expensiveObject = createExpensiveObject()

lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// 在生命周期进入 STARTED 状态时开始重复任务,在 STOPED 状态时停止
// 对 expensiveObject 进行操作
}

// 当协程恢复时,`lifecycle` 处于 DESTROY 状态。repeatOnLifecycle 会在
// 进入 DESTROYED 状态前挂起协程的执行
}
}
}
复制代码

Flow.flowWithLifecycle


当您只需要收集一个数据流时,也可以使用 Flow.flowWithLifecycle 操作符。这一 API 的内部也使用 suspend Lifecycle.repeatOnLifecycle 函数实现,并会在生命周期进入和离开目标状态时发送项目和取消内部的生产者。


class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

locationProvider.locationFlow()
.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
// 新的位置!更新地图
}
.launchIn(lifecycleScope)
}
}
复制代码


注意: Flow.flowWithLifecycle API 的命名以 Flow.flowOn(CoroutineContext) 为先例,因为它会在不影响下游数据流的同时修改收集上游数据流的 CoroutineContext。与 flowOn 相似的另一点是,Flow.flowWithLifecycle 也加入了缓冲区,以防止消费者无法跟上生产者。这一特点源于其实现中使用的 callbackFlow



配置内部生产者


即使您使用了这些 API,也要小心那些可能浪费资源的热流,就算它们没有被收集亦是如此!虽然针对这些热流有一些合适的用例,但是仍要多加注意并在必要时进行记录。另一方面,在一些情况下,即使可能造成资源的浪费,令处于后台的内部数据流生产者保持活跃状态也会利于某些用例,如: 您需要即时刷新可用数据,而不是去获取并暂时展示陈旧数据。您可以根据用例决定生产者是否需要始终处于活跃状态


您可以使用 MutableStateFlowMutableSharedFlow 两个 API 中暴露的 subscriptionCount 字段来控制它们,当该字段值为 0 时,内部的生产者就会停止。默认情况下,只要持有数据流实例的对象还在内存中,它们就会保持生产者的活跃状态。针对这些 API 也有一些合适的用例,比如使用 StateFlowUiState 从 ViewModel 中暴露给 UI。这么做很合适,因为它意味着 ViewModel 总是需要向 View 提供最新的 UI 状态。


相似的,也可以为此类操作使用 共享开始策略 配置 Flow.stateInFlow.shareIn 操作符。WhileSubscribed() 将会在没有活跃的订阅者时停止内部的生产者!相应的,无论数据流是 Eagerly (积极) 还是 Lazily (惰性) 的,只要它们使用的 CoroutineScope 还处于活跃状态,其内部的生产者就会保持活跃。



注意: 本文中所描述的 API 可以很好的作为默认从 UI 收集数据流的方式,并且无论数据流的实现方式如何,都应该使用它们。这些 API 做了它们要做的事: 在 UI 于屏幕中不可见时,停止收集其数据流。至于数据流是否应该始终处于活动状态,则取决于它的实现。



在 Jetpack Compose 中安全地收集数据流


Flow.collectAsState 函数可以在 Compose 中收集来自 composable 的数据流,并可以将值表示为 State,以便能够更新 Compose UI。即使 Compose 在宿主 Activity 或 Fragment 处于后台时不会重组 UI,数据流生产者仍会保持活跃并会造成资源的浪费。Compose 可能会遭遇与 View 系统相同的问题。


在 Compose 中收集数据流时,可以使用 Flow.flowWithLifecycle 操作符,示例如下:


@Composable
fun LocationScreen(locationFlow: Flow<Flow>) {

val lifecycleOwner = LocalLifecycleOwner.current
val locationFlowLifecycleAware = remember(locationFlow, lifecycleOwner) {
locationFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}

val location by locationFlowLifecycleAware.collectAsState()

// 当前位置,可以拿它做一些操作
}
复制代码

注意,您 需要记得 生命周期感知型数据流使用 locationFlowlifecycleOwner 作为键,以便始终使用同一个数据流,除非其中一个键发生改变。


Compose 的副作用 (Side-effect) 便是必须处在 受控环境中,因此,使用 LifecycleOwner.addRepeatingJob 不安全。作为替代,可以使用 LaunchedEffect 来创建跟随 composable 生命周期的协程。在它的代码块中,如果您需要在宿主生命周期处于某个 State 时重新执行一个代码块,可以调用挂起函数 Lifecycle.repeatOnLifecycle


对比 LiveData


您也许会觉得,这些 API 的表现与 LiveData 很相似——确实是这样!LiveData 可以感知 Lifecycle,而且它的重启行为使其十分适合观察来自 UI 的数据流。同理 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle 等 API 亦是如此。


在纯 Kotlin 应用中,使用这些 API 可以十分自然地替代 LiveData 收集数据流。如果您使用这些 API 收集数据流,换成 LiveData (相对于使用协程和 Flow) 不会带来任何额外的好处。而且由于 Flow 可以从任何 Dispatcher 收集数据,同时也能通过它的 操作符 获得更多功能,所以 Flow 也更为灵活。相对而言,LiveData 的可用操作符有限,且它总是从 UI 线程观察数据。


数据绑定对 StateFlow 的支持


另一方面,您会想要使用 LiveData 的原因之一,可能是它受到数据绑定的支持。不过 StateFlow 也一样!更多有关数据绑定对 StateFlow 的支持信息,请参阅 官方文档


在 Android 开发中,请使用 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycle Flow.flowWithLifecycle 从 UI 层安全地收集数据流。


作者:Android_开发者
链接:https://juejin.cn/post/6984258307293151239
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

TurboDex: 在Android瞬间加载Dex

众所周知,Android中在Runtime加载一个 未优化的Dex文件 (尤其在 ART 模式)需要花费 很长的时间. 当你在App中使用 插件化框架 的时候, 首次加载插件就需要耗费很长的时间.Qu...
继续阅读 »

TurboDex: 在Android瞬间加载Dex

众所周知,Android中在Runtime加载一个 未优化的Dex文件 (尤其在 ART 模式)需要花费 很长的时间. 当你在App中使用 插件化框架 的时候, 首次加载插件就需要耗费很长的时间.

TurboDex 就是为了解决这一问题而生, 就像是给AndroidVM开启了上帝模式, 在引入TurboDex后, 无论你加载了多大的Dex文件,都可以在毫秒级别内完成.

Quick Start Guide

Building TurboDex

TurboDex的 pre-compiled 版本在 /Prebuilt 目录下, 如果你想要构建自己的TurboDex, 你需要安装 Android-NDK.

 lody@MacBook-Pro  ~/TurboDex/TurboDex/jni> ndk-build                  
SharedLibrary : libturbo-dex.so
Install : libturbo-dex.so => libs/armeabi/libturbo-dex.so
SharedLibrary : libturbo-dex.so
Install : libturbo-dex.so => libs/x86/libturbo-dex.so

Config

Maven


com.github.asLody
turbodex
1.1.0
pom

Gradle

compile 'com.github.asLody:turbodex:1.1.0'

Usage

使用TurboDex, 你需要将library 添加到你的项目中, 在 Application 中写入以下代码:


@Override
protected void attachBaseContext(Context base) {
TurboDex.enableTurboDex();
super.attachBaseContext(base);
}

开启 TurboDex后, 下列调用都不再成为拖慢你App运行的元凶:

new DexClassLoader(...):

DexFile.loadDex(...);

##其它的分析和评论 http://note.youdao.com/share/?id=28e62692d218a1f1faef98e4e7724f22&type=note#/

然而,不知道这篇笔记的作者为什么会认为Hook模块是我实现的, 我并没有给Substrate那部分的模块自己命名,而是采用了原名:MSHook, 而且, 所有的Cydia源码我也保留了头部的协议申明,你知道源码的出处,却没有意识到这一点?

代码下载:lody-WelikeAndroid-master.zip

收起阅读 »

WelikeAndroid 是一款引入即用的便捷开发框架,一行代码完成http请求,bitmap异步加载,数据库增删查改,同时拥有最超前的异常隔离机制!

##WelikeAndroid 是什么? WelikeAndroid 是一款引入即用的便捷开发框架,致力于为程序员打造最佳的编程体验,使用WelikeAndroid, 你会觉得写代码是一件很轻松的事情.##Welike带来了哪些特征?WelikeAndroid...
继续阅读 »

##WelikeAndroid 是什么? WelikeAndroid 是一款引入即用的便捷开发框架,致力于为程序员打造最佳的编程体验,
使用WelikeAndroid, 你会觉得写代码是一件很轻松的事情.

##Welike带来了哪些特征?

WelikeAndroid目前包含五个大模块:

  • 异常安全隔离模块(实验阶段):当任何线程抛出任何异常,我们的异常隔离机制都会让UI线程继续运行下去.
  • Http模块: 一行代码完成POST、GET请求和Download,支持上传, 高度优化Disk的缓存加载机制,
    自由设置缓存大小、缓存时间(也支持永久缓存和不缓存).
  • Bitmap模块: 一行代码完成异步显示图片,无需考虑OOM问题,支持加载前对图片做自定义处理.
  • Database模块: 支持NotNull,Table,ID,Ignore等注解,Bean无需Getter和Setter,一键式部署数据库.
  • ui操纵模块: 我们为Activity基类做了完善的封装,继承基类可以让代码更加优雅.
  • :请不要认为功能相似,框架就不是原创,源码摆在眼前,何不看一看?

使用WelikeAndroid需要以下权限:

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

##下文将教你如何圆润的使用WelikeAndroid:
###通过WelikeContext在任意处取得上下文:

  • WelikeContext.getApplication(); 就可以取得当前App的上下文
  • WelikeToast.toast("你好!"); 简单一步弹出Toast.

##WelikeGuard(异常安全隔离机制用法):

  • 第一步,开启异常隔离机制:
WelikeGuard.enableGuard();
  • 第二步,注册一个全局异常监听器:

WelikeGuard.registerUnCaughtHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread thread, Throwable ex) {

WelikeGuard.newThreadToast("出现异常了: " + ex.getMessage() );

}
});
  • 你也可以自定义异常:

/**
*
* 自定义的异常,当异常被抛出后,会自动回调onCatchThrowable函数.
*/
@Catch(process = "onCatchThrowable")
public class CustomException extends IllegalAccessError {

public static void onCatchThrowable(Thread t){
WeLog.e(t.getName() + " 抛出了一个异常...");
}
}
  • 另外,继承自UncaughtThrowable的异常我们不会对其进行拦截.

使用Welike做屏幕适配:

Welike的ViewPorter类提供了屏幕适配的Fluent-API,我们可以通过一组流畅的API轻松做好屏幕适配.

        ViewPorter.from(button).ofScreen().divWidth(2).commit();//宽度变为屏幕的二分之一
ViewPorter.from(button).of(viewGroup).divHeight(2).commit();//高度变为viewGroup的二分之一
ViewPorter.from(button).div(2).commit();//宽度和高度变为屏幕的四分之一
ViewPorter.from(button).of(this).fillWidth().fillHeight().commit();//宽度和高度铺满Activity
ViewPorter.from(button).sameAs(imageView).commit();//button的宽度和高度和imageView一样

WelikeHttp入门:

首先来看看框架的调试信息,是不是一目了然. DEBUG DEBUG2

  • 第一步,取得WelikeHttp默认实例.
WelikeHttp welikeHttp = WelikeHttp.getDefault();
  • 第二步,发送一个Get请求.
HttpParams params = new HttpParams();
params.putParams("app","qr.get",
"data","Test");//一次性放入两对 参数 和 值

//发送Get请求
HttpRequest request = welikeHttp.get("http://api.k780.com:88", params, new HttpResultCallback() {
@Override
public void onSuccess(String content) {
super.onSuccess(content);
WelikeToast.toast("返回的JSON为:" + content);
}

@Override
public void onFailure(HttpResponse response) {
super.onFailure(response);
WelikeToast.toast("JSON请求发送失败.");
}

@Override
public void onCancel(HttpRequest request) {
super.onCancel(request);
WelikeToast.toast("请求被取消.");
}
});

//取消请求,会回调onCancel()
request.cancel();

当然,我们为满足需求提供了多种扩展的Callback,目前我们提供以下Callback供您选择:

  • HttpCallback(响应为byte[]数组)
  • FileUploadCallback(仅在上传文件时使用)
  • HttpBitmapCallback(建议使用Bitmap模块)
  • HttpResultCallback(响应为String)
  • DownloadCallback(仅在download时使用)

如需自定义Http模块的配置(如缓存时间),请查看HttpConfig.

WelikeBitmap入门:

  • 第一步,取得默认的WelikeBitmap实例:

//取得默认的WelikeBitmap实例
WelikeBitmap welikeBitmap = WelikeBitmap.getDefault();
  • 第二步,异步加载一张图片:
BitmapRequest request = welikeBitmap.loadBitmap(imageView,
"http://img0.imgtn.bdimg.com/it/u=937075122,1381619862&fm=21&gp=0.jpg",
android.R.drawable.btn_star,//加载中显示的图片
android.R.drawable.ic_delete,//加载失败时显示的图片
new BitmapCallback() {

@Override
public Bitmap onProcessBitmap(byte[] data) {
//如果需要在加载时处理图片,可以在这里处理,
//如果不需要处理,就返回null或者不复写这个方法.
return null;
}

@Override
public void onPreStart(String url) {
super.onPreStart(url);
//加载前回调
WeLog.d("===========> onPreStart()");
}

@Override
public void onCancel(String url) {
super.onCancel(url);
//请求取消时回调
WeLog.d("===========> onCancel()");
}

@Override
public void onLoadSuccess(String url, Bitmap bitmap) {
super.onLoadSuccess(url, bitmap);
//图片加载成功后回调
WeLog.d("===========> onLoadSuccess()");
}

@Override
public void onRequestHttp(HttpRequest request) {
super.onRequestHttp(request);
//图片需要请求http时回调
WeLog.d("===========> onRequestHttp()");
}

@Override
public void onLoadFailed(HttpResponse response, String url) {
super.onLoadFailed(response, url);
//请求失败时回调
WeLog.d("===========> onLoadFailed()");
}
});
  • 如果需要自定义Config,请看BitmapConfig这个类.

##WelikeDAO入门:

  • 首先写一个Bean.

/*表名,可有可无,默认为类名.*/
@Table(name="USER",afterTableCreate="afterTableCreate")
public class User{
@ID
public int id;//id可有可无,根据自己是否需要来加.

/*这个注解表示name字段不能为null*/
@NotNull
public String name;

public static void afterTableCreate(WelikeDao dao){
//在当前的表被创建时回调,可以在这里做一些表的初始化工作
}
}
  • 然后将它写入到数据库
WelikeDao db = WelikeDao.instance("Welike.db");
User user = new User();
user.name = "Lody";
db.save(user);
  • 从数据库取出Bean

User savedUser = db.findBeanByID(1);
  • SQL复杂条件查询
List<User> users = db.findBeans().where("name = Lody").or("id = 1").find();
  • 更新指定ID的Bean
User wantoUpdateUser = new User();
wantoUpdateUser.name = "NiHao";
db.updateDbByID(1,wantoUpdateUser);
  • 删除指ID定的Bean
db.deleteBeanByID(1);
  • 更多实例请看DEMO和API文档.

##十秒钟学会WelikeActivity

  • 我们将Activity的生命周期划分如下:

=>@initData(所有标有InitData注解的方法都最早在子线程被调用)
=>initRootView(bundle)
=>@JoinView(将标有此注解的View自动findViewByID和setOnClickListener)
=>onDataLoaded(数据加载完成时回调)
=>点击事件会回调onWidgetClick(View Widget)

###关于@JoinView的细节:

  • 有以下三种写法:
@JoinView(name = "welike_btn")
Button welikeBtn;
@JoinView(id = R.id.welike_btn)
Button welikeBtn;
@JoinView(name = "welike_btn",click = false)
Button welikeBtn;
  • clicktrue时会自动调用view的setOnClickListener方法,并在onWidgetClick回调.
  • 当需要绑定的是一个Button的时候, click属性默认为true,其它的View则默认为false.
收起阅读 »

Java原生的Http网络框架,底层基于HttpNet,动态代理+构建的!

#Elegant项目结构如下 Elegant采用Retrofit动态代理+构建的思想,本身并不做网络请求,网络部分基于HttpNet实现,本着简洁清晰的思想,保持了和Retrofit相似的API##gradlecompile 'com.haibin:...
继续阅读 »


#Elegant项目结构如下 输入图片说明

Elegant采用Retrofit动态代理+构建的思想,本身并不做网络请求,网络部分基于HttpNet实现,本着简洁清晰的思想,保持了和Retrofit相似的API

##gradle

compile 'com.haibin:elegant:1.1.9'

##创建API接口

public interface LoginService {

//普通POST
@Headers({"Cookie:cid=adcdefg;"})
@POST("api/users/login")
Call<BaseModel<User>> login(@Form("email") String email,
@Form("pwd") String pwd,
@Form("versionNum") int versionNum,
@Form("dataFrom") int dataFrom);

// 上传文件
@POST("action/apiv2/user_edit_portrait")
@Headers("Cookie:xxx=hbbb;")
Call<String> postAvatar(@File("portrait") String file);


//JSON POST
@POST("action/apiv2/user_edit_portrait")
@Headers("Cookie:xxx=hbbb;")
Call<String> postJson(@Json String file);

//PATCH
@PATCH("mobile/user/{uid}/online")
Call<ResultBean<String>> handUp(@Path("uid") long uid);
}

##执行请求

public static final String API = "http://www.oschina.net/";
public static Elegant elegant = new Elegant();

static {
elegant.registerApi(API);
}

LoginService service = elegant.from(LoginService.class)
.login("xxx@qq.com", "123456", 2, 2);
.withHeaders(Headers...)
.execute(new CallBack<BaseModel<User>>() {
@Override
public void onResponse(Response<BaseModel<User>> response) {

}

@Override
public void onFailure(Exception e) {

}                               });

代码下载:dev-Elegant-master.zip

收起阅读 »

CSS 奇思妙想 | 巧妙的实现带圆角的三角形

之前在这篇文章中 -- 《老生常谈之 CSS 实现三角形》,介绍了 6 种使用 CSS 实现三角形的方式。 但是其中漏掉了一个非常重要的场景,如何使用纯 CSS 实现带圆角的三角形呢?,像是这样: 本文将介绍几种实现带圆角的三角形的实现方式。 法一. 全兼容...
继续阅读 »

之前在这篇文章中 -- 《老生常谈之 CSS 实现三角形》,介绍了 6 种使用 CSS 实现三角形的方式。


但是其中漏掉了一个非常重要的场景,如何使用纯 CSS 实现带圆角的三角形呢?,像是这样:


A triangle with rounded


本文将介绍几种实现带圆角的三角形的实现方式。


法一. 全兼容的 SVG 大法


想要生成一个带圆角的三角形,代码量最少、最好的方式是使用 SVG 生成。


使用 SVG 的 多边形标签 <polygon> 生成一个三边形,使用 SVG 的 stroke-linejoin="round" 生成连接处的圆角。


代码量非常少,核心代码如下:


<svg  width="250" height="250" viewBox="-50 -50 300 300">
<polygon class="triangle" stroke-linejoin="round" points="100,0 0,200 200,200"/>
</svg>

.triangle {
fill: #0f0;
stroke: #0f0;
stroke-width: 10;
}

实际图形如下:


A triangle with rounded


这里,其实是借助了 SVG 多边形的 stroke-linejoin: round 属性生成的圆角,stroke-linejoin 是什么?它用来控制两条描边线段之间,有三个可选值:



  • miter 是默认值,表示用方形画笔在连接处形成尖角

  • round 表示用圆角连接,实现平滑效果

  • bevel 连接处会形成一个斜接



我们实际是通过一个带边框,且边框连接类型为 stroke-linejoin: round 的多边形生成圆角三角形的


如果,我们把底色和边框色区分开,实际是这样的:


.triangle {
fill: #0f0;
stroke: #000;
stroke-width: 10;
}


通过 stroke-width 控制圆角大小


那么如何控制圆角大小呢?也非常简单,通过控制 stroke-width 的大小,可以改变圆角的大小。


当然,要保持三角形大小一致,在增大/缩小 stroke-width 的同时,需要缩小/增大图形的 width/height



完整的 DEMO 你可以戳这里:CodePen Demo -- 使用 SVG 实现带圆角的三角形


法二. 图形拼接


不过,上文提到了,使用纯 CSS 实现带圆角的三角形,但是上述第一个方法其实是借助了 SVG。那么仅仅使用 CSS,有没有办法呢?


当然,发散思维,CSS 有意思的地方正在于此处,用一个图形,能够有非常多种巧妙的解决方案!


我们看看,一个圆角三角形,它其实可以被拆分成几个部分:



所以,其实我们只需要能够画出一个这样的带圆角的菱形,通过 3 个进行旋转叠加,就能得到圆角三角形:



绘制带圆角的菱形


那么,接下来我们的目标就变成了绘制一个带圆角的菱形,方法有很多,本文给出其中一种方式:



  1. 首先将一个正方形变成一个菱形,利用 transform 有一个固定的公式:



<div></div>

div {
width: 10em;
height: 10em;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
}



  1. 将其中一个角变成圆角:


div {
width: 10em;
height: 10em;
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
+ border-top-right-radius: 30%;
}


至此,我们就顺利的得到一个带圆角的菱形了!


拼接 3 个带圆角的菱形


接下来就很简单了,我们只需要利用元素的另外两个伪元素,再生成 2 个带圆角的菱形,将一共 3 个图形旋转位移拼接起来即可!


完整的代码如下:


<div></div>

div{
position: relative;
background-color: orange;
}
div:before,
div:after {
content: '';
position: absolute;
background-color: inherit;
}
div,
div:before,
div:after {
width: 10em;
height: 10em;
border-top-right-radius: 30%;
}
div {
transform: rotate(-60deg) skewX(-30deg) scale(1,.866);
}
div:before {
transform: rotate(-135deg) skewX(-45deg) scale(1.414, .707) translate(0,-50%);
}
div:after {
transform: rotate(135deg) skewY(-45deg) scale(.707, 1.414) translate(50%);
}

就可以得到一个圆角三角形了!效果如下:


image


完整的代码你可以戳这里:CodePen Demo -- A triangle with rounded


法三. 图形拼接实现渐变色圆角三角形


完了吗?没有!


上述方案,虽然不算太复杂,但是有一点还不算太完美的。就是无法支持渐变色的圆角三角形。像是这样:



如果需要实现渐变色圆角三角形,还是有点复杂的。但真就还有人鼓捣出来了,下述方法参考至 -- How to make 3-corner-rounded triangle in CSS


同样也是利用了多块进行拼接,但是这次我们的基础图形,会非常的复杂。


首先,我们需要实现这样一个容器外框,和上述的方法比较类似,可以理解为是一个圆角菱形(画出 border 方便理解):



<div></div>

div {
width: 200px;
height: 200px;
transform: rotate(30deg) skewY(30deg) scaleX(0.866);
border: 1px solid #000;
border-radius: 20%;
}

接着,我们同样使用两个伪元素,实现两个稍显怪异的图形进行拼接,算是对 transform 的各种用法的合集:


div::before,
div::after {
content: "";
position: absolute;
width: 200px;
height: 200px;
}
div::before {
border-radius: 20% 20% 20% 55%;
transform: scaleX(1.155) skewY(-30deg) rotate(-30deg) translateY(-42.3%) skewX(30deg) scaleY(0.866) translateX(-24%);
background: red;
}
div::after {
border-radius: 20% 20% 55% 20%;
background: blue;
transform: scaleX(1.155) skewY(-30deg) rotate(-30deg) translateY(-42.3%) skewX(-30deg) scaleY(0.866) translateX(24%);
}

为了方便理解,制作了一个简单的变换动画:



本质就是实现了这样一个图形:


image


最后,给父元素添加一个 overflow: hidden 并且去掉父元素的 border 即可得到一个圆角三角形:



由于这两个元素重叠空间的特殊结构,此时,给两个伪元素添加同一个渐变色,会完美的叠加在一起:


div::before,
div::after, {
background: linear-gradient(#0f0, #03a9f4);
}

最终得到一个渐变圆角三角形:



上述各个图形的完整代码,你可以戳这里:CodePen Demo -- A triangle with rounded and gradient background


最后


本文介绍了几种在 CSS 中实现带圆角三角形的方式,虽然部分有些繁琐,但是也体现了 CSS ”有趣且折磨人“ 的一面,具体应用的时候,还是要思考一下,对是否使用上述方式进行取舍,有的时候,切图也许是更好的方案。


链接:https://juejin.cn/post/6984599136842547213

收起阅读 »

微前端模块共享你真的懂了吗

前言:我们运用微前端架构解决了应用体积庞大的问题,通过实践微前端的理念,将前端应用拆分为多个微应用(可独立部署、松散耦合的应用)。同时微应用的存在,使得我们无需在构建一个庞大的应用,而是按需构建,极大了加快了构建效率。但只是解决了应用层面的问题,在中后台应用场...
继续阅读 »

前言:我们运用微前端架构解决了应用体积庞大的问题,通过实践微前端的理念,将前端应用拆分为多个微应用(可独立部署、松散耦合的应用)。同时微应用的存在,使得我们无需在构建一个庞大的应用,而是按需构建,极大了加快了构建效率。但只是解决了应用层面的问题,在中后台应用场景中,不同微应用和基座之间可能存在通用的模块依赖,那么如果应用间可以实现模块共享,那么可以大大优化单应体积大小



image.png


1.Npm 依赖



最简单的方式,就是把需要共享的模块抽出,可能是一个工具库,有可能是一个组件库,然后讲其打包成为npm包,然后在每个子应用中都安装该模块依赖,以此达到多个项目复用的效果



也就代表每个应用都有相同的npm包,本质上没有真正意义上的实现模块共享和复用,只是代码层次共享和复用了,应用打包构建时,还是会将依赖包一起打包


image.png


劣势有以下👇 几点:



  • 每个微应用都会打包该模块,导致依赖的包冗余,没有真正意义上的共享复用

  • npm包进行更新发布了,微应用还需要重新构建,调试麻烦且低效 (除非用npm link


2.Git Submodule (子模块)



阿乐童鞋: 那如果我们没有搭建npm内网,又不想把模块开源出去,而且依赖npm,只要涉及变更需要重新发布,有没有其他方式可以解决以上问题呀?



image.png


2.1 对比 npm


你可以试试 Git Submodule ,它提供了一种类似于npm package的依赖管理机制,两者差别如下图所示👇


image.png


2.2 如何使用


通过在应用项目中,通过git submodule add <submodule_url>远程拉取子模块项目,这时会发现应用项目中多了两个文件.gitmodules子模块目录


image.png


这个子模块就是我们共享的模块,它是一个完整的Git仓库,换句话说:我们在应用项目目录中无论使用git add/commit都对其不影响,即子模块拥有自身独立的版本控制


总结: submodule本质上是通过git submodule add把项目依赖的模块加起来,最终构成一个完整的项目。而且add进来的模块,项目中并不实际包含,而只是一个包含索引信息,也就是上文提到的 .gitmodule来存储子模块的联系方式, 以此实现同步关联子模块。当下载到本地运行的时候才会再拉取文件


部分命令行:




  • git submodule add <子模块repository> <path> : 添加子模块




  • git submodule update --recursive --remote : 拉取所有子模块的更新




2.3 Monorepo



阿乐童鞋: 🌲 树酱,我记得有个叫Monorepo又是什么玩意,跟 Git Submodule 有啥区别?



image.png


Monorepo 全称叫monolithic respoitory,即单体式仓库,核心是允许我们将多个项目放到同一个仓库里面进行管理。主张不拆分repo,而是在单仓库里统一管理各个模块的构建流程、版本号等等


这样可以避免大量的冗余node_module冗余,因为每个项目都会安装vue、vue-router等包,再或者本地开发需要的webpack、babel、mock等都会造成储存空间的浪费


那么Monorepo是怎么管理的呢? 开源社区中诸如babel、vue的项目都是基于Monorepo去维护的(Lerna工具)


我们以Babel为例,在github中可以看到其每个模块都在指定的packages目录下, 也就意味着将所有的相关package都放入一个repository来管理,这不是显得项目很臃肿?


image.png
也就这个问题,啊乐同学和啊康同学展开了辩论~


image.png



最终是选用Monorepo单体式仓库还是Multirepo多仓库管理, 具体还是要看你业务场景来定,Monorepo集中管理带来的便利性,比如方便版本、依赖等管理、方便调试,但也带来了不少不便之处 👇




  • 统一构建工具所带来更高的要求

  • 仓库体积过大,维护成本也高


🌲 酱 不小心扯多了,还有就是Monorepo 跟 Git Submodule 的区别




  • 前者:monorepo在单repo里存放所有子模块源码




  • 后者:submodules只在主repo里存放所有子模块“索引”




目前内部还未使用Monorepo进行落地实际,目前基于微前端架构中后台应用存在依赖重叠过多的情况,后期会通过实践来深入分享


3. Webpack external



我们知道webpack中有externals的配置,主要是用来配置:webpack输出的bundle中排除依赖,换句话说通过在external定义的依赖,最终输出的bundle不存在该依赖,主要适用于不需要经常打包更新的第三方依赖,以此来实现模块共享。



下面是一个vue.config.js 的配置文件,通过配置exteral移除不经常更新打包的第三方依赖👇
carbon (26).png


你可以通过在packjson中script定义的命令后添加--report查看打包📦后的分析图,如果是webpack就是用使用插件webpack-bundle-analyzer



阿乐童鞋: 🌲 树酱,那移除了这些依赖之后,如何保证应用正常使用?



浏览器环境:我们使用cdn的方式在入口文件引入,当然你也可以预先打包好,比如把vue全家桶打包成vue-family.min.js文件,最终达成多应用共享模块的效果


<script src="<%= VUE_APP_UTILS_URL %>static/js/vue-family.min.js"></script>


总结:避免公共模块包(package) 一起打到bundle 中,而是在运行时再去从外部获取这些扩展依赖


通过这种形式在微前端基座应用加载公共模块,并将微应用引用同样模块的external移除掉,就可以实现模块共享了
但是存在微应用技术栈多样化不统一的情况,可能有的使用vue3,有的使用react开发,但externals 并无法支持多版本共存的情况,针对这种情况该方式就不太适用


4. Webpack DLL


官方介绍:"DLL" 一词代表微软最初引入的动态链接库, 换句话说我的理解,可以把它当做缓存,通过预先编译好的第三方外部依赖bundle,来节省应用在打包时混入的时间



Webpack DLL 跟 上一节提到的external本质是解决同样的问题:就是避免将第三方外部依赖打入到应用的bundle中(业务代码),然后在运行时再去加载这部分依赖,以此来实现模块复用,也提升了编译构建速度



webpack dll模式下需要配置两份webpack配置,下面是主要两个核心插件


image.png


4.1 DllPlugin


DllPlugin:在一个独立的webpack进行配置webpack.dll.config.js,目的是为了创建一个把所有的第三方库依赖打包到一起的bundle的dll文件里面,同时还会生成一个manifest.json的文件,用于:让使用该第三方依赖集合的应用配置的DllReferencePlugin能映射到相关的依赖上去 具体配置看下图👇


carbon.png


image.png


4.2 DllReferencePlugin


DllReferencePlugin:插件核心是把上一节提到的通过webpack.dll.config.js中打包生成的dll文件,引用到需要实际项目中使用,引用机制就是通过DllReferencePlugin插件来读取vendor-manifest.json文件,看看是否有该第三方库,最后通过add-asset-html-webpack-plugin插件在入口html自动插入上一节生成的vendor.dll.js 文件, 具体配置看下图👇
carbon (1).png


5. 联邦模块 Module Federation


模块联邦是 Webpack5 推出的一个新的重要功能,可以真正意义上实现让跨应用间做到模块共享,解决了从前用 NPM 公共包方式共享的不便利,同时也可以作为微前端的落地方案,完美秒杀了上两节介绍webpack特征


用过qiankun的小伙伴应该知道,qiankun微前端架构控制的粒度是在应用层面,而Module Federation控制的粒度是在模块层面。相比之下,后者粒度更小,可以有更多的选择


与qiankun等微前端架构不同的另一点是,我们一般都是需要一个中心基座去控制微应用的生命周期,而Module Federation则是去中心化的,没有中心基座的概念,每一个模块或者应用都是可以导入或导出,我们可以称为:host和remote,应用或模块即可以是host也可以是remote,亦或者两者共同体


image.png


看看下面这个例子👇


carbon (3).png


核心在于 ModuleFederationPlugin中的几个属性



  • remote : 示作为 Host 时,去消费哪些 Remote;

  • exposes :表示作为 Remote 时,export 哪些属性提供给 Host 消费

  • shared: 可以让远程加载的模块对应依赖改为使用本地项目的 vue,换句话说优先用 Host 的依赖,如果 Host 没有,最后再使用自己的


后期也会围绕 Module Federation 去做落地分享


链接:https://juejin.cn/post/6984682096291741704

收起阅读 »

全自动jQuery与渣男的故事

我是个恋旧的人,Github头像还是上古时期端游仙剑奇侠传的截图。 对于前端,如果能jQuery一把梭,我是很开心的。 React、Vue的普及让大家习惯了虚拟DOM的存在。但是虚拟DOM一定是最优解么? 举个例子,要进行如下DOM移动操作: // 变化前 ...
继续阅读 »

我是个恋旧的人,Github头像还是上古时期端游仙剑奇侠传的截图。



对于前端,如果能jQuery一把梭,我是很开心的。


ReactVue的普及让大家习惯了虚拟DOM的存在。但是虚拟DOM一定是最优解么?


举个例子,要进行如下DOM移动操作:


// 变化前
abcd
// 变化后
dabc

jQuery时调用insertBefored挪到a前面就行。而React基于虚拟DOMDiff会依次对abc执行appendChild,将他们依次挪到最后。


1次DOM操作 vs 3次DOM操作,显然前者更高效。


那么有没有框架能砍掉虚拟DOM,直接对DOM节点执行操作,实现全自动jQuery


有的,这就是最近出的petite-vue


阅读完本文,你会从原理层面了解该框架,如果你还有精力,可以在此基础上深入框架源码。


全自动jQuery的实现


可以将原理概括为一句话:



建立状态更新DOM的方法之间的联系



比如,对于如下DOM


<p v-show="showName">我是卡颂</p>

期望showName状态的变化能影响p的显隐(通过改变diaplay)。


实际是建立showName的变化调用如下方法的联系:


() => {
el.style.display = get() ? initialDisplay : 'none'
}

其中el代表pget()获取showName当前值。


再比如,对于如下DOM


<p v-text="name"></p>

name改变后ptextContent会变为对应值。


实际是建立name的变化调用如下方法的联系:


() => {
el.textContent = toDisplayString(get())
}

所以,整个框架的工作原理呼之欲出:初始化时遍历所有DOM,根据各种v-xx属性建立DOM操作DOM的方法之间的联系。


当改变状态后,会自动调用与其有关的操作DOM的方法,简直就是全自动jQuery



所以,框架的核心在于:如何建立联系?


一个渣男的故事


这部分源码都收敛在@vue/reactivity库中。我并不想带你精读源码,因为这样很没意思,看了还容易忘。


接下来我会通过一个故事为你展示其工作原理,当你了解原理后如果感兴趣可以自己去看源码。



我们的目标是描述:状态变化更新DOM的方法之间的联系。说得再宽泛点,是建立状态副作用之间的联系。


即:状态变化 -> 执行副作用


对于一段关系,可以从当事双方的角度描述,比如:


男生指着女生说:这是我女朋友。


接着女生指着男生说:这是我男朋友。


你作为旁观者,通过双方的描述就知道他们处于一段恋爱关系。


推广到状态副作用,则是:


副作用指着状态说:我依赖这个状态,他变了我就会执行。


状态指着副作用说:我订阅了这个副作用,当我变了后我会通知他。



可以看到,发布订阅其实是对一段关系站在双方视角的阐述



举个例子,如下DOM结构:


<div v-scope="{num: 0}">
<button @click="num++">add 1</button>
<p v-show="num%2">
<span v-text="num"></span>
</p>
</div>

经过petite-vue遍历后的关系图:



框架的交互流程为:




  1. 触发点击事件,状态num变化




  2. 通知其订阅的副作用effect1effect2),执行对应DOM操作




如果从情侣关系角度解读,就是:


num指着effect1说:这是我女朋友。


effect1指着num说:这是我男朋友。


num指着effect2说:这是我女朋友。


effect2指着num说:这是我男朋友。



总结


今天我们学习了一个框架petite-vue,他的底层实现由多段混乱的男女关系组成,上层是一个个直接操作DOM的方法。


不知道看完后你有没有兴趣深入了解下这种关系呢?


感兴趣的话可以看看Vue MasteryVue 3 Reactivity课程。



链接:https://juejin.cn/post/6984710323945078820

收起阅读 »

拖拽竟然还能这样玩!

在大多数低代码平台中的设计器都支持组件拖拽的功能,这样大大地提高了用户的设计体验。而拖拽另一个比较常见的场景就是文件上传,通过拖拽的方式,可以让用户方便地上传文件。其实利用拖拽功能,我们还可以 跨越浏览器的边界,实现数据共享。 那么如何 跨越浏览器的边界,实现...
继续阅读 »

在大多数低代码平台中的设计器都支持组件拖拽的功能,这样大大地提高了用户的设计体验。而拖拽另一个比较常见的场景就是文件上传,通过拖拽的方式,可以让用户方便地上传文件。其实利用拖拽功能,我们还可以 跨越浏览器的边界,实现数据共享


那么如何 跨越浏览器的边界,实现数据共享 呢?本文阿宝哥将介绍谷歌的一个开源项目 —— transmat,利用该项目可以实现上述功能。不仅如此,该项目还可以帮助我们实现一些比较好玩的功能,比如针对不同的可释放目标,做出不同的响应。


下面我们先通过 4 张 Gif 动图来感受一下,使用 transmat 开发的 神奇、好玩 的拖拽功能。


图 1(把可拖拽的元素,拖拽至富文本编辑器)



图 2(把可拖拽的元素,拖拽至 Chrome 浏览器,也支持其他浏览器)



图 3(把可拖拽的元素,拖拽至自定义的释放目标)



图 4(把可拖拽的元素,拖拽至 Chrome 开发者工具)




以上示例使用的浏览器版本:Chrome 91.0.4472.114(正式版本) (x86_64)



以上 4 张图中的 可拖拽元素都是同一个元素,当它被放置到不同的可释放目标时,产生了不同的效果。同时,我们也跨越了浏览器的边界,实现了数据的共享。看完以上 4 张动图,你是不是觉得挺神奇的。其实除了拖拽之外,该示例也支持复制、粘贴操作。不过,在详细介绍如何使用 transmat 实现上述功能之前,我们先来简单介绍一下 transmat 这个库。


一、Transmat 简介


Transmat 是一个围绕 DataTransfer API 的小型库 ,它使用 drag-dropcopy-paste 交互简化了在 Web 应用程序中传输和接收数据的过程。 DataTransfer API 能够将多种不同类型的数据传输到用户设备上的其他应用程序,该 API 所支持的数据类型,常见的有这几种:text/plaintext/htmlapplication/json 等。



(图片来源:google.github.io/transmat/)


了解完 transmat 是什么之后,我们来看一下它的应用场景:



  • 想以便捷的方式与外部应用程序集成。

  • 希望为用户提供与其他应用程序共享数据的能力,即使是那些你不知道的应用程序。

  • 希望外部应用程序能够与你的 Web 应用程序深度集成。

  • 想让你的应用程序更好地适应用户现有的工作流程。


现在你已经对 transmat 有了一定的了解,下面我们来分析如何使用 transmat 实现以上 4 张 Gif 动图对应的功能。


二、Transmat 实战


2.1 transmat-source


html


在以下代码中,我们为 div#source 元素添加了 draggable 属性,该属性用于标识元素是否允许被拖动,它的取值为 truefalse


<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="source" draggable="true" tabindex="0">大家好,我是阿宝哥</div>

css


#source {
background: #eef;
border: solid 1px rgba(0, 0, 255, 0.2);
border-radius: 8px;
cursor: move;
display: inline-block;
margin: 1em;
padding: 4em 5em;
}

js


const { Transmat, addListeners, TransmatObserver } = transmat;

const source = document.getElementById("source");

addListeners(source, "transmit", (event) => {
const transmat = new Transmat(event);
transmat.setData({
"text/plain": "大家好,我是阿宝哥!",
"text/html": `
<h1>大家好,我是阿宝哥</h1>
<p>聚焦全栈,专注分享 TS、Vue 3、前端架构等技术干货。
<a href="https://juejin.cn/user/764915822103079">访问我的主页</a>!
</p>
<img src="https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/
075d8e781ba84bf64035ac251988fb93~300x300.image" border="1" />
`,
"text/uri-list": "https://juejin.cn/user/764915822103079",
"application/json": {
name: "阿宝哥",
wechat: "semlinker",
},
});
});

在以上代码中,我们利用 transmat 这个库提供的 addListeners 函数为 div#source 元素,添加了 transmit 的事件监听。在对应的事件处理器中,我们先创建了 Transmat 对象,然后调用该对象上的 setData 方法设置不同 MIME 类型的数据。


下面我们来简单回顾一下,示例中所使用的 MIME 类型:



  • text/plain:表示文本文件的默认值,一个文本文件应当是人类可读的,并且不包含二进制数据。

  • text/html:表示 HTML 文件类型,一些富文本编辑器会优先从 dataTransfer 对象上获取 text/html 类型的数据,如果不存在的话,再获取 text/plain 类型的数据。

  • text/uri-list:表示 URI 链接类型,大多数浏览器都会优先读取该类型的数据,如果发现是合法的 URI 链接,则会直接打开该链接。如果不是的合法 URI 链接,对于 Chrome 浏览器来说,它会读取 text/plain 类型的数据并以该数据作为关键词进行内容检索。

  • application/json:表示 JSON 类型,该类型对前端开发者来说,应该都比较熟悉了。


介绍完 transmat-source 之后,我们来看一下图 3 自定义目标(transmat-target)的实现代码。


2.2 transmat-target


html


<script src="https://unpkg.com/transmat/lib/index.umd.js"></script>
<div id="target" tabindex="0">放这里哟!</div>

css


body {
text-align: center;
font: 1.2em Helvetia, Arial, sans-serif;
}
#target {
border: dashed 1px rgba(0, 0, 0, 0.5);
border-radius: 8px;
margin: 1em;
padding: 4em;
}
.drag-active {
background: rgba(255, 255, 0, 0.1);
}
.drag-over {
background: rgba(255, 255, 0, 0.5);
}

js


const { Transmat, addListeners, TransmatObserver } = transmat;

const target = document.getElementById("target");

addListeners(target, "receive", (event) => {
const transmat = new Transmat(event);
// 判断是否含有"application/json"类型的数据
// 及事件类型是否为drop或paste事件
if (transmat.hasType("application/json")
&& transmat.accept()
) {
const jsonString = transmat.getData("application/json");
const data = JSON.parse(jsonString);
target.textContent = jsonString;
}
});

在以上代码中,我们利用 transmat 这个库提供的 addListeners 函数为 div#target 元素,添加了 receive 的事件监听。顾名思义,该 receive 事件表示接收消息。在对应的事件处理器中,我们通过 transmat 对象的 hasType 方法过滤了 application/json 的消息,然后通过 JSON.parse 方法进行反序列化获得对应的数据,同时把对应 jsonString 的内容显示在 div#target 元素内。


在图 3 中,当我们把可拖拽的元素,拖拽至自定义的释放目标时,会产生高亮效果,具体如下图所示:



这个效果是利用 transmat 这个库提供的 TransmatObserver 类来实现,该类可以帮助我们响应用户的拖拽行为,具体的使用方式如下所示:


const obs = new TransmatObserver((entries) => {
for (const entry of entries) {
const transmat = new Transmat(entry.event);
if (transmat.hasType("application/json")) {
entry.target.classList.toggle("drag-active", entry.isActive);
entry.target.classList.toggle("drag-over", entry.isTarget);
}
}
});
obs.observe(target);

第一次看到 TransmatObserver 之后,阿宝哥立马想到了 MutationObserver API,因为它们都是观察者且拥有类似的 API。利用 MutationObserver API 我们可以监视 DOM 的变化。DOM 的任何变化,比如节点的增加、减少、属性的变动、文本内容的变动,通过这个 API 我们都可以得到通知。如果你对该 API 感兴趣的话,可以阅读 是谁动了我的 DOM? 这篇文章。


现在我们已经知道 transmat 这个库如何使用,接下来阿宝哥将带大家一起来分析这个库背后的工作原理。



Transmat 使用示例:Transmat Demo


gist.github.com/semlinker/c…



三、Transmat 源码分析


transmat 源码分析环节,因为在前面实战部分,我们使用到了 addListenersTransmatTransmatObserver 这三个 “函数” 来实现核心的功能,所以接下来的源码分析,我们将围绕它们展开。这里我们先来分析 addListeners 函数。


3.1 addListeners 函数


addListeners 函数用于设置监听器,调用该函数后会返回一个用于移除事件监听的函数。在分析函数时,阿宝哥习惯先分析函数的签名:


// src/transmat.ts
function addListeners<T extends Node>(
target: T,
type: TransferEventType,
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void

通过观察以上的函数签名,我们可以很直观的了解该函数的输入和输出。该函数支持以下 4 个参数:



  • target:表示监听的目标,它的类型是 Node 类型。

  • type:表示监听的类型,该参数的类型 TransferEventType 是一个联合类型 —— 'transmit' | 'receive'

  • listener:表示事件监听器,它支持的事件类型为 DataTransferEvent,该类型也是一个联合类型 —— DragEvent | ClipboardEvent,即支持拖拽事件和剪贴板事件。

  • options:表示配置对象,用于设置是否允许拖拽和复制、粘贴操作。


addListeners 函数体中,主要包含以下 3 个步骤:



  • 步骤 ①:根据 isTransmitEventoptions.copyPaste 的值,注册剪贴板相关的事件。

  • 步骤 ②:根据 isTransmitEventoptions.dragDrop 的值,注册拖拽相关的事件。

  • 步骤 ③:返回函数对象,用于移除已注册的事件监听。


// src/transmat.ts
export function addListeners<T extends Node>(
target: T,
type: TransferEventType, // 'transmit' | 'receive'
listener: (event: DataTransferEvent, target: T) => void,
options = {dragDrop: true, copyPaste: true}
): () => void {
const isTransmitEvent = type === 'transmit';
let unlistenCopyPaste: undefined | (() => void);
let unlistenDragDrop: undefined | (() => void);

if (options.copyPaste) {
// ① 可拖拽源监听cut和copy事件,可释放目标监听paste事件
const events = isTransmitEvent ? ['cut', 'copy'] : ['paste'];
const parentElement = target.parentElement!;
unlistenCopyPaste = addEventListeners(parentElement, events, event => {
if (!target.contains(document.activeElement)) {
return;
}
listener(event as DataTransferEvent, target);

if (event.type === 'copy' || event.type === 'cut') {
event.preventDefault();
}
});
}

if (options.dragDrop) {
// ② 可拖拽源监听dragstart事件,可释放目标监听dragover和drop事件
const events = isTransmitEvent ? ['dragstart'] : ['dragover', 'drop'];
unlistenDragDrop = addEventListeners(target, events, event => {
listener(event as DataTransferEvent, target);
});
}

// ③ 返回函数对象,用于移除已注册的事件监听
return () => {
unlistenCopyPaste && unlistenCopyPaste();
unlistenDragDrop && unlistenDragDrop();
};
}

以上代码的事件监听最终是通过调用 addEventListeners 函数来实现,在该函数内部会循环调用 addEventListener 方法来添加事件监听。以前面 Transmat 的使用示例为例,在对应的事件处理回调函数内部,我们会以 event 事件对象为参数,调用 Transmat 构造函数创建 Transmat 实例。那么该实例有什么作用呢?要搞清楚它的作用,我们就需要来了解 Transmat 类。


3.2 Transmat 类


Transmat 类被定义在 src/transmat.ts 文件中,该类的构造函数含有一个类型为 DataTransferEvent 的参数 event


// src/transmat.ts
export class Transmat {
public readonly event: DataTransferEvent;
public readonly dataTransfer: DataTransfer;

// type DataTransferEvent = DragEvent | ClipboardEvent;
constructor(event: DataTransferEvent) {
this.event = event;
this.dataTransfer = getDataTransfer(event);
}
}

Transmat 构造函数内部还会通过 getDataTransfer 函数来获取 DataTransfer 对象并赋值给内部的 dataTransfer 属性。DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。


下面我们来看一下 getDataTransfer 函数的具体实现:


// src/data_transfer.ts
export function getDataTransfer(event: DataTransferEvent): DataTransfer {
const dataTransfer =
(event as ClipboardEvent).clipboardData ??
(event as DragEvent).dataTransfer;
if (!dataTransfer) {
throw new Error('No DataTransfer available at this event.');
}
return dataTransfer;
}

在以上代码中,使用了空值合并运算符 ??。该运算符的特点是:当左侧操作数为 null 或 undefined 时,其返回右侧的操作数,否则返回左侧的操作数。即先判断是否为剪贴板事件,如果是的话就会从 clipboardData 属性获取 DataTransfer 对象。否则,就会从 dataTransfer 属性获取。


对于可拖拽源,在创建完 Transmat 对象之后,我们就可以调用该对象上的 setData 方法保存一项或多项数据。比如,在以下代码中,我们设置了不同类型的多项数据:


transmat.setData({
"text/plain": "大家好,我是阿宝哥!",
"text/html": `
<h1>大家好,我是阿宝哥</h1>
...
`,
"text/uri-list": "https://juejin.cn/user/764915822103079",
"application/json": {
name: "阿宝哥",
wechat: "semlinker",
},
});

了解完 setData 方法的用法之后,我们来看一下它的具体实现:


// src/transmat.ts
setData(
typeOrEntries: string | {[type: string]: unknown},
data?: unknown
): void {
if (typeof typeOrEntries === 'string') {
this.setData({[typeOrEntries]: data});
} else {
// 处理多种类型的数据
for (const [type, data] of Object.entries(typeOrEntries)) {
const stringData =
typeof data === 'object' ? JSON.stringify(data) : `${data}`;
this.dataTransfer.setData(normalizeType(type), stringData);
}
}
}

由以上代码可知,在 setData 方法内部最终会调用 dataTransfer.setData 方法来保存数据。dataTransfer 对象的 setData 方法支持两个字符串类型的参数:formatdata。它们分别表示要保存的数据格式和实际的数据。如果给定数据格式不存在,则将对应的数据保存到末尾。如果给定数据格式已存在,则将使用新的数据替换旧的数据


下图是 dataTransfer.setData 方法的兼容性说明,由图可知主流的现代浏览器都支持该方法。



(图片来源:caniuse.com/mdn-api_dat…


Transmat 类除了拥有 setData 方法之外,它也含有一个 getData 方法,用于获取已保存的数据。getData 方法支持一个字符串类型的参数 type,用于表示数据的类型。在获取数据前,会调用 hasType 方法判断是否含有该类型的数据。如果有包含的话,就会通过 dataTransfer 对象的 getData 方法来获取该类型对应的数据。


// src/transmat.ts
getData(type: string): string | undefined {
return this.hasType(type)
? this.dataTransfer.getData(normalizeType(type))
: undefined;
}

此外,在调用 getData 方法前,还会调用 normalizeType 函数,对传入的 type 类型参数进行标准化操作。具体的如下所示:


// src/data_transfer.ts
export function normalizeType(input: string) {
const result = input.toLowerCase();
switch (result) {
case 'text':
return 'text/plain';
case 'url':
return 'text/uri-list';
default:
return result;
}
}

同样,我们也来看一下 dataTransfer.getData 方法的兼容性:



(图片来源:caniuse.com/mdn-api_dat…


好的,Transmat 类中的 setDatagetData 这两个核心方法就先介绍到这里。接下来我们来介绍另一个类 —— TransmatObserver 。


3.3 TransmatObserver 类


TransmatObserver 类的作用是可以帮助我们响应用户的拖拽行为,可用于在拖拽过程中高亮放置区域。比如,在前面的示例中,我们通过以下方式来实现放置区域的高亮效果:


const obs = new TransmatObserver((entries) => {
for (const entry of entries) {
const transmat = new Transmat(entry.event);
if (transmat.hasType("application/json")) {
entry.target.classList.toggle("drag-active", entry.isActive);
entry.target.classList.toggle("drag-over", entry.isTarget);
}
}
});
obs.observe(target);

同样,我们先来分析一下 TransmatObserver 类的构造函数:


// src/transmat_observer.ts
export class TransmatObserver {
private readonly targets = new Set<Element>(); // 观察的目标集合
private prevRecords: ReadonlyArray<TransmatObserverEntry> = []; // 保存前一次的记录
private removeEventListeners = () => {};

constructor(private readonly callback: TransmatObserverCallback) {}
}

由以上代码可知,TransmatObserver 类的构造函数支持一个类型为 TransmatObserverCallback 的参数 callback,该参数对应的类型定义如下:


// src/transmat_observer.ts
export type TransmatObserverCallback = (
entries: ReadonlyArray<TransmatObserverEntry>,
observer: TransmatObserver
) => void;

TransmatObserverCallback 函数类型接收两个参数:entriesobserver。其中 entries 参数的类型是一个


只读数组(ReadonlyArray),数组中每一项的类型是 TransmatObserverEntry,对应的类型定义如下:


// src/transmat_observer.ts
export interface TransmatObserverEntry {
target: Element;
/** type DataTransferEvent = DragEvent | ClipboardEvent */
event: DataTransferEvent;
/** Whether a transfer operation is active in this window. */
isActive: boolean;
/** Whether the element is the active target (dragover). */
isTarget: boolean;
}

在前面 transmat-target 的示例中,当创建完 TransmatObserver 实例之后,就会调用该实例的 observe 方法并传入待观察的对象。observe 方法的实现并不复杂,具体如下所示:


// src/transmat_observer.ts
observe(target: Element) {
/** private readonly targets = new Set<Element>(); */
this.targets.add(target);
if (this.targets.size === 1) {
this.addEventListeners();
}
}

observe 方法内部,会把需观察的元素保存到 targets Set 集合中。当 targets 集合的大小等于 1 时,就会调用当前实例的 addEventListeners 方法来添加事件监听:


// src/transmat_observer.ts
private addEventListeners() {
const listener = this.onTransferEvent as EventListener;
this.removeEventListeners = addEventListeners(
document,
['dragover', 'dragend', 'dragleave', 'drop'],
listener,
true
);
}

在私有的 addEventListeners 方法内部,会利用我们前面介绍的 addEventListeners 函数来为 document 元素批量添加与拖拽相关的事件监听。而对应的事件说明如下所示:



  • dragover:当元素或选中的文本被拖到一个可释放目标上时触发;

  • dragend:当拖拽操作结束时触发(比如松开鼠标按键);

  • dragleave:当拖拽元素或选中的文本离开一个可释放目标时触发;

  • drop:当元素或选中的文本在可释放目标上被释放时触发。


其实与拖拽相关的事件并不仅仅只有以上四种,如果你对完整的事件感兴趣的话,可以阅读 MDN 上 HTML 拖放 API 这篇文章。下面我们来重点分析 onTransferEvent 事件监听器:


private onTransferEvent = (event: DataTransferEvent) => {
const records: TransmatObserverEntry[] = [];
for (const target of this.targets) {
// 当光标离开浏览器时,对应的事件将会被派发到body或html节点
const isLeavingDrag =
event.type === 'dragleave' &&
(event.target === document.body ||
event.target === document.body.parentElement);

// 页面上是否有拖拽行为发生
// 当拖拽操作结束时触发dragend事件
// 当元素或选中的文本在可释放目标上被释放时触发drop事件
const isActive = event.type !== 'drop'
&& event.type !== 'dragend' && !isLeavingDrag;

// 判断可拖拽的元素是否被拖到target元素上
const isTargetNode = target.contains(event.target as Node);
const isTarget = isActive && isTargetNode
&& event.type === 'dragover';

records.push({
target,
event,
isActive,
isTarget,
});
}

// 仅当记录发生变化的时候,才会调用回调函数
if (!entryStatesEqual(records, this.prevRecords)) {
this.prevRecords = records as ReadonlyArray<TransmatObserverEntry>;
this.callback(records, this);
}
}

在以上代码中,使用了 node.contains(otherNode) 方法来判断可拖拽的元素是否被拖到 target 元素上。当 otherNodenode 的后代节点或者 node 节点本身时,返回 true,否则返回 false。此外,为了避免频繁地触发回调函数,在调用回调函数前会先调用 entryStatesEqual 函数来检测记录是否发生变化。entryStatesEqual 函数的实现比较简单,具体如下所示:


// src/transmat_observer.ts
function entryStatesEqual(
a: ReadonlyArray<TransmatObserverEntry>,
b: ReadonlyArray<TransmatObserverEntry>
): boolean {
if (a.length !== b.length) {
return false;
}
// 如果有一项不匹配,则立即返回false。
return a.every((av, index) => {
const bv = b[index];
return av.isActive === bv.isActive && av.isTarget === bv.isTarget;
});
}

MutationObserver 一样,TransmatObserver 也提供了用于获取最近已触发记录的 takeRecords 方法和用于 “断开” 连接的 disconnect 方法:


// 返回最近已触发记录
takeRecords() {
return this.prevRecords;
}

// 移除所有目标及事件监听器
disconnect() {
this.targets.clear();
this.removeEventListeners();
}

到这里 Transmat 源码分析的相关内容已经介绍完了,如果你对该项目感兴趣的话,可以自行阅读该项目的完整源码。该项目是使用 TypeScript 开发,已入门 TypeScript 的小伙伴可以利用该项目巩固一下所学的 TS 知识及 OOP 面向对象的设计思想。



链接:https://juejin.cn/post/6984587700951056414

收起阅读 »

XVideo 一个能自动进行压缩的小视频录制库

XVideo一个能自动进行压缩的小视频录制库特征支持自定义小视频录制时的视频质量。支持自定义视频录制的界面。支持自定义最大录制时长和最小录制时长。支持自定义属性的视频压缩。演示(请star支持)Demo下载添加Gradle依赖1.在项目根目录的 build.g...
继续阅读 »

XVideo

一个能自动进行压缩的小视频录制库

特征

  • 支持自定义小视频录制时的视频质量。

  • 支持自定义视频录制的界面。

  • 支持自定义最大录制时长和最小录制时长。

  • 支持自定义属性的视频压缩。

演示(请star支持)

Demo下载

Github

添加Gradle依赖

1.在项目根目录的 build.gradle 的 repositories 添加:

allprojects {
repositories {
...
maven { url "https://jitpack.io" }
}
}

2.在主项目的 build.gradle 中增加依赖。

dependencies {
···
implementation 'com.github.xuexiangjys:XVideo:1.0.2'
}

3.进行视频录制存储目录地址的设置。

/**
* 初始化xvideo的存放路径
*/
public static void initVideo() {
XVideo.setVideoCachePath(PathUtils.getExtDcimPath() + "/xvideo/");
// 初始化拍摄
XVideo.initialize(false, null);
}

视频录制

1.视频录制需要CAMERA权限和STORAGE权限。在Android6.0机器上需要动态获取权限,推荐使用XAOP进行权限申请。

2.调用MediaRecorderActivity.startVideoRecorder开始视频录制。

/**
* 开始录制视频
* @param requestCode 请求码
*/
@Permission({PermissionConsts.CAMERA, PermissionConsts.STORAGE})
public void startVideoRecorder(int requestCode) {
MediaRecorderConfig mediaRecorderConfig = MediaRecorderConfig.newInstance();
XVideo.startVideoRecorder(this, mediaRecorderConfig, requestCode);
}

3.MediaRecorderConfig是视频录制的配置对象,可自定义视频的宽、高、时长以及质量等。

MediaRecorderConfig config = new MediaRecorderConfig.Builder()
.fullScreen(needFull) //是否全屏
.videoWidth(needFull ? 0 : Integer.valueOf(width)) //视频的宽
.videoHeight(Integer.valueOf(height)) //视频的高
.recordTimeMax(Integer.valueOf(maxTime)) //最大录制时间
.recordTimeMin(Integer.valueOf(minTime)) //最小录制时间
.maxFrameRate(Integer.valueOf(maxFrameRate)) //最大帧率
.videoBitrate(Integer.valueOf(bitrate)) //视频码率
.captureThumbnailsTime(1)
.build();

视频压缩

使用libx264进行视频压缩。由于手机本身CPU处理能力有限的问题,在手机上进行视频压缩的效率并不是很高,大约压缩的时间需要比视频拍摄本身的时长还要长一些。

LocalMediaConfig.Builder builder = new LocalMediaConfig.Builder();
final LocalMediaConfig config = builder
.setVideoPath(path) //设置需要进行视频压缩的视频路径
.captureThumbnailsTime(1)
.doH264Compress(compressMode) //设置视频压缩的模式
.setFramerate(iRate) //帧率
.setScale(fScale) //压缩比例
.build();
CompressResult compressResult = XVideo.startCompressVideo(config);

混淆配置

-keep class com.xuexiang.xvideo.jniinterface.** { *; }

代码下载:XVideo.zip

收起阅读 »

模版空壳Android工程,快速搭建(集成了XUI、XUtil、XAOP、XPage、XUpdate和XHttp2)

TemplateAppProjectAndroid空壳模板工程,快速搭建(集成了XUI、XUtil、XAOP、XPage、XUpdate、XHttp2、友盟统计和walle多渠道打包)效果使用方式视频教程-如何使用模板工程1.克隆项目git clone htt...
继续阅读 »

TemplateAppProject

Android空壳模板工程,快速搭建(集成了XUI、XUtil、XAOP、XPage、XUpdate、XHttp2、友盟统计和walle多渠道打包)

效果

templateproject_demo.gif


使用方式

视频教程-如何使用模板工程

1.克隆项目

2.修改项目名(文件夹名),并删除目录下的.git文件夹(隐藏文件)

3.使用AS打开项目,然后修改包名applicationIdapp_name

  • 修改包名

templateproject_1.png

templateproject_2.png

  • 修改applicationId

templateproject_3.png

  • 修改app_name

templateproject_5.png

项目打包

1.修改工程根目录的gradle.properties中的isNeedPackage=true

2.添加并配置keystore,在versions.gradle中修改app_release相关参数。

3.如果考虑使用友盟统计的话,在local.properties中设置应用的友盟ID:APP_ID_UMENG

4.使用./gradlew clean assembleReleaseChannels进行多渠道打包。

代码下载:TemplateAppProject-master.zip

收起阅读 »

Android直播间的送礼物动画-GiftSurfaceView

GiftSurfaceViewGiftSurfaceView 最初出自于2014年开发HalloStar项目时所写,主要用于HalloStar项目直播间的送礼物动画。现在想来,那夕阳下的奔跑,是我逝去的青春。因高仿全民TV项目时想起,所以抽空整理了下,以此记录...
继续阅读 »


GiftSurfaceView

GiftSurfaceView 最初出自于2014年开发HalloStar项目时所写,主要用于HalloStar项目直播间的送礼物动画。现在想来,那夕阳下的奔跑,是我逝去的青春。因高仿全民TV项目时想起,所以抽空整理了下,以此记录。

Gif展示


引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>giftsurfaceview</artifactId>
<version>1.1.0</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:giftsurfaceview:1.1.0'

Lvy:

<dependency org='com.king.view' name='giftsurfaceview' rev='1.1.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

    public void updateGiftSurfaceView(int type){

frame.removeAllViews();

GiftSurfaceView giftSurfaceView = new GiftSurfaceView(context);
if(type == RANDOM){
giftSurfaceView.setImageResource(R.drawable.rose);
}else{
giftSurfaceView.setImageBitmap(bitmap,.5f);
}

giftSurfaceView.setPointScale(1,width/10,(int)(height/3.8f));
giftSurfaceView.setRunTime(10000);

try {

switch (type){
case RANDOM:
giftSurfaceView.setRandomPoint(9);
break;
case V:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V),true);
break;
case HEART:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_HEART),true);
break;
case LOVE:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_LOVE));
break;
case SMILE:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_SMILE));
break;
case X:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_X));
break;
case V520:
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V520));
break;
case V1314:
giftSurfaceView.setRunTime(GiftSurfaceView.LONG_TIME);
giftSurfaceView.setListPoint(PointUtils.getListPointByResourceJson(context,ASSET_V1314));
break;

}
frame.addView(giftSurfaceView);
} catch (IOException e) {
e.printStackTrace();
}


}

以上为部分代码使用示例,更多详情请下载查看。

代码下载:GiftSurfaceView.zip

收起阅读 »

HarmonyOS开发者创新大赛作品《智能农场》相关开发技术分享

HarmonyOS开发者创新大赛已于2021年5月24日落幕,在本次赛事中,来自古都西安的开拓者战队凭借《智能农场》这款作品最终获得大赛三等奖,该作品通过HarmonyOS的分布式软总线、分布式数据库技术、分布式任务调度、分布式跨设备数据流转等能力实现了多设备...
继续阅读 »

HarmonyOS开发者创新大赛已于2021年5月24日落幕,在本次赛事中,来自古都西安的开拓者战队凭借《智能农场》这款作品最终获得大赛三等奖,该作品通过HarmonyOS的分布式软总线、分布式数据库技术、分布式任务调度、分布式跨设备数据流转等能力实现了多设备(传感器、智慧屏等)的互联互通、自动控制,实现了农场场景下多设备协同智能养殖体验,令人印象深刻。

以下是“开拓者战队”基于HarmonyOS打造《智能农场》作品的相关思考以及关键技术的简单分享:

1.背景介绍

目前,市面上智慧农业相关的厂商设备(传感器等)相对独立,没有统一的操作系统平台,互联互通困难,且大多数设备部署需要连线,部署成本时间长,成本高,维护复杂度高。随着5G网络的覆盖,下一代全场景操作系统(HarmonyOS)的出现,让万物互联变得更加方便,可以实现一部手机操作所有IoT设备,实现各个IoT设备的互联互通。智能农场系统是基于HarmonyOS实现了多个IoT设备(传感器,电机,大屏等设备)的互联互通、自动控制,并实现全场景化的智慧养殖。智能农场通过各项传感器设备对农场的各项环境指标进行实时检测,并且可以进行自适应调节,让动物一直处于一个良好的生长环境。通过本系统可以实现指标超过阈值预警,智能提醒,智能求助等功能,让农场养殖门槛变低,让农场主轻松成为养殖专家。

2.需求分析

智能农场系统通过对农场的空气温,湿度、光照度等各项环境参数进行实时采集,确保农场主可随时通过智能手机APP了解农场状况。同时,系统可以根据农场内外环境因子的变化进行自适应调解,不仅能保证农场中的动物长期处于良好的生长环境中,还能提升动物的产量和质量。本系统的特色业务功能包括:精细化智能提醒,专家视频求助等。

智能提醒功能体现在多个场景中,如:农场温度过高,降温设备有损坏或者指定时间内温度没有降下来等异常情况出现时,系统会直接给管理者进行电话提醒或者消息推送;不仅如此,系统还会根据动物的年龄,对不同动物的生长周期进行预测,提醒管理者为动物打疫苗。智能求助功能则体现为,当管理者遇到一些养殖常识问题,可以通过智能求助查到相关帮助信息;同时,也提供了养殖专家视频求助功能,帮助管理者及时的解决养殖方面遇到的疑难杂症。

3.解决方案

本解决方案涉及角色:农场主,养殖专家;涉及硬件设备:手机、智慧屏、开发板,各类型传感器(比如:温湿度传感器、可燃气体传感器、光敏传感器、人体红外传感器等)以及各项外设(比如:风扇,加水设备,取暖设备等)。手机、智慧屏、开发板基于HarmonyOS,通过WIFI组网,实现各项设备之间的互联互通。农场主可以通过手机APP对养殖场景中的各项环境指标(温湿度阈值范围、可燃气体浓度范围、光照强度等)进行设置,也可设置定时任务(比如:定时加水、加料,定时播放音乐等),实现智能化提醒和自动化控制。解决方案中养殖技术和案例等信息的查询和分析等服务,由云端的数据服务提供,专家视频求助功能的视频通话服务由云端提供。


4.主要模块介绍

数据采集模块 (以采集湿度为例)

系统启动成功后,数据采集模块会启动定时任务采集温度数据,定时从温度传感器采集一次当前温度数据。如果采集成功,存入分布式数据库(KV方式存储),采集到的温度数据会实时刷新到温度显示界面。温度数据范围:-40~80℃。采集到的数据,可以流转到大屏方便用户查看。(采集流程见下图1)

业务流程


图1采集温度数据流程

自动控制模块(以温度控制为例)

定时获取当前温度数据与用户设置的正常阈值范围或者最大阈值范围(来自:Preferences)进行比较。如果当前温度在正常阈值范围内,不做处理;如果超过正常阈值范围,未超过最大阈值范围(比如:正常范围:5~30℃),包括两种情况:

1.低于5℃,打开加热设备,并调用智能提醒模块通知用户;温度恢复正常范围,关闭设备。

2.高于30℃,打开风扇降温,并调用智能提醒模块通知用户;温度恢复正常范围,关闭设备。

如果超过最大阈值范围(比如:最大阈值范围:<-20℃或>60℃),包括两种情况:

1.低于-20℃,打开多个加热设备,并调用智能提醒模块,发送通知,并拨打电话通知用户;温度恢复正常范围,关闭设备。

2.高于60℃,打开喷水降温,并调用智能提醒模块,发送通知,并拨打电话通知用户;温度恢复正常范围,关闭设备。(控制流程见下图2)


图2 温度控制流程

5.关键技术细节实现

1)分布式多设备发现,实现多设备协同、调度

分布式设备发现关键代码:

List<DeviceInfo> onlineDevices = DeviceManager.getDeviceList(DeviceInfo.FLAG_GET_ONLINE_DEVICE);

分布式设备任务调度关键代码:

Intent intent = new Intent();

Operation operation =

new Intent.OperationBuilder()

.withDeviceId(devicesId)

.withBundleName(getBundleName())

.withAbilityName(Ability.class.getName())

.withFlags(Intent.FLAG_ABILITYSLICE_MULTI_DEVICE)

.build();

intent.setOperation(operation);

2)分布式数据流转

调用continueAbility进行实现数据流转(关键代码)

continueAbility(chooseDevice.getDeviceInfo().getDeviceId());

3)socket通信实现设备间互联互通(如下关键代码)

//调用NetManager.getInstance(Context)获取网络管理的实例对象。

NetManager netManager = NetManager.getInstance(context);

//调用NetManager.getDefaultNet()获取默认的数据网络。

NetHandle netHandle = netManager.getDefaultNet();

//调用NetHandle.bindSocket()绑定网络。

DatagramSocket socket = new DatagramSocket();

netHandle.bindSocket(socket);

//使用socket发送数据

socket.send(request);

4)踩坑(分布式任务调度和分布式数据库技术配合使用)(功能:智能农场手机端采集的数据实时同步到TV端)

当手机端收到采集到的环境数据(如温度、湿度及可燃气体浓度),需要流转到智慧屏上进行显示,团队一开始使用的分布式任务调度,流转到TV端后,发现TV端显示的数据并没有实时刷新,显然不符合现实需求。

为了实现数据的实时刷新,团队发现HarmonyOS有分布式数据服务的能力,可以实现同应用,同网络,同账号在不同设备之间实现数据实时共享,因此最终采用了HarmonyOS的分布式数据库技术,确保了手机端和TV端数据同步刷新的功能。在不依赖云端服务的情况下,实现此功能。

下面是实现的关键代码:

手机端数据存储:

//初始化获取SingleKvStore对象

KvManagerConfig kvManagerConfig = new KvManagerConfig(context);

kvManager = KvManagerFactory.getInstance().createKvManager(kvManagerConfig);

Options options = new Options();

options.setCreateIfMissing(true)

.setEncrypt(false)

.setKvStoreType(KvStoreType.SINGLE_VERSION)

.setAutoSync(true);

SingleKvStore singleKvStore = kvManager.getKvStore(options, storeId);

将采集到的传感器数据,存储在分布式数据库:

singleKvStore.putString("key",

" +…此处省略

"}");

TV端进行数据获取:

//初始化singleKvStore,并为其注册监听器kvStoreObserverClient,观察数据变化:

KvManagerConfig config = new KvManagerConfig(getContext());

KvManager kvManager = KvManagerFactory.getInstance().createKvManager(config);

Options CREATE = new Options();

CREATE.setCreateIfMissing(true).setEncrypt(false).setKvStoreType(KvStoreType.SINGLE_VERSION)

.setAutoSync(true);

singleKvStore = kvManager.getKvStore(CREATE, Constant.KV_STORE_NAME);

kvStoreObserverClient = new KvStoreObserverClient();

singleKvStore.subscribe(SubscribeType.SUBSCRIBE_TYPE_ALL, kvStoreObserverClient);

//实现KvStoreObserver,重新onChange()方法,获取分布式数据,更新UI需要切换到主线程。

private class KvStoreObserverClient implements KvStoreObserver {

@Override

public void onChange(ChangeNotification notification) {

String value = singleKvStore.getString("***");

DataCollectionEntry entry = ZSONObject.stringToClass(value, DataCollectionEntry.class);

getUITaskDispatcher().asyncDispatch(() -> initView(entry));

}

}

从报名HarmonyOS开发者创新大赛开始,团队从一群从来没有配合过的HarmonyOS新手开发者成长为了专业开发者。参加大赛也让团队深刻感受到了HarmonyOS强大的分布式技术以及先进的设计理念,为今后开发更具创意和社会价值的作品打下了坚实的基础。

星光不问赶路人,每一位HarmonyOS开发者都是华为汇聚的星星之火,希望越来越多的开发人才能够加入到HarmonyOS开发者生态,一起创造无限可能!

收起阅读 »

Java静态代理和动态代理

前言 再开始之前我们先不使用任何代理来实现一个网络请求的流程。 定义一个请求的接口: public interface Request { void request(); } 使用OkHttp来实现这个接口 public class ...
继续阅读 »

  • 前言



再开始之前我们先不使用任何代理来实现一个网络请求的流程。


定义一个请求的接口:


public interface Request {
void request();
}

使用OkHttp来实现这个接口


public class OkHttpImpl implements Request {
@Override
public void request() {
System.out.println("OkHttp请求成功");
}
}

现在我们的网络请求已经写好了,我们测试一下:


Request request = new OkHttpImpl();
request.request();

输出: OkHttp请求成功

看起来挺好用的,但是项目经理是个老程序员了,没有用过OkHttp,非要说Volley比OkHttp好用,让你把所有网络请求换成Volley框架


我们使用Volley来实现Request接口


public class VolleyImpl implements Request{
@Override
public void request() {
System.out.println("Volley请求成功");
}
}

重新测试测试一下:


Request request = new VolleyImpl();
request.request();

输出: Volley请求成功

现在项目经理又来了,说:“你网络请求怎么连个加载框都有没?”,这个时候又得去改代码了,但是公司网络框架已经封住好了,不让随便修改,这个时候没有办法了,只能这样写了:


showDialog(); //显示加载进度条

Request request = new VolleyImpl();
request.request();

hideDialog(); //隐藏加载进度条

看起来代码没问题,但是项目中有上百个网络请求,难道每次写网络请求都要手动加上进度条的代码吗?这个时候你去问项目经理,项目经理说:“你去看看Java静态代理和动态代理,或许能找到答案~”。



  • 静态代理



在这里插入图片描述 看起来用户不需要直接访问网络框架了,而是先访问一个代理类,由代理类去执行网络请求,那我们先新建一个代理类:


public class RequestProxy implements Request {

private final Request mRequest;

public RequestProxy(Request request) {
mRequest = request;
}

public void before(){
System.out.println("开始请求");
showDialog(); //显示加载进度条
}

public void after(){
System.out.println("请求完成");
hideDialog(); //隐藏加载进度条
}

@Override
public void request() {
before();
mRequest.request();
after();
}
}

现在我们来测试一下:


Request request = new VolleyImpl();
RequestProxy proxy = new RequestProxy(request);
proxy.request();

输出:
开始请求
Volley请求成功
请求完成

静态代理优点:



  1. 可以在代理类中对目标类进行扩展。

  2. 用户只需要使用代理类的方法,不需要关心真正实现方法。

  3. 用户可以通过代理类实现与真正逻辑的解耦。


静态代理的缺点:



  1. 如果增加一个接口,还需要重新写一个代理类。



  • 动态代理



动态代理不需要写代理类,能很好的弥补静态代理的缺点


我们需要使用Java内部给我们提供好的**Proxy.newProxyInstance()**方法


public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)


newProxyInstance方法需要传入三个参数:



  1. loader: 类加载器

  2. interfaces: 要代理的接口

  3. InvocationHandler: 会回调动态代理的消息


我们先来实现一下动态代理:


Request request = new VolleyImpl();
Object o = Proxy.newProxyInstance(request.getClass().getClassLoader(), new Class[]{Request.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("请求前");
method.invoke(request, args);
System.out.println("请求后");
return null;
}
});

((Request) o).request();

输出:
请求前
Volley请求成功
请求后


  • 动态代理代码解析



我们先把要代理的接口传入到newProxyInstance方法中,并拿到代理对象“o”。


Object o = Proxy.newProxyInstance(request.getClass().getClassLoader(), new Class[]{Request.class}, new InvocationHandler() {})

我们可以把代理类强转成我们要代理的接口,然后直接调用方法


((Request) o).request();

这样代理类的invoke()方法就会被回调,我们看一下invoke()的三个参数:


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

return null;
}


  1. proxy: 代理类的对象

  2. method: 代理类调用的方法

  3. args: 代理类调用方法传的参数


既然回调方法中有method参数了,我们就可以利用反射直接掉用method.invoke(request, args)来调用方法了,同时我们也可以在调用方法前后加上要扩展的代码。


作者:lvkaixuan
链接:https://juejin.cn/post/6983846546844418062
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

优化Android工程中的图片资源

场景 在一些上古工程中,由于年久失修,架构演进跟不上业务发展需要,会衍生出非常多比较明显的性能问题,其中就包括工程中图片资源的问题。 最明显的例子就是,工程中的图片资源未经任何压缩,直接使用来自设计稿中的原图,非常占用安装包体积;其次,显示效果不理想,在对...
继续阅读 »

场景


在一些上古工程中,由于年久失修,架构演进跟不上业务发展需要,会衍生出非常多比较明显的性能问题,其中就包括工程中图片资源的问题。


最明显的例子就是,工程中的图片资源未经任何压缩,直接使用来自设计稿中的原图,非常占用安装包体积;其次,显示效果不理想,在对应分辨率的图片资源文件夹中放入了错误尺寸的图片,导致应用运行时 UI 图片出现模糊、大颗粒等情况。


优化方案


压缩图片资源文件夹的大小


优化工作往往要从业务入手,在业务发展方向明确的前提下,并不是所有的 UI 效果都需要用图片文件的方式进行显示,对于一些简单的 UI,可以考虑使用代码进行绘制。使用代码绘制可以极其明显的减少图片对硬件资源的占用,一来可以减小包体积,二来通常可以减小运行时的内存。


对于一些必须需要通过图片文件来实现的 UI 效果,也需要对图片文件进行相应的压缩后再放入对应分辨率的文件夹,可以考虑无损压缩和有损压缩。


这里重点提下有损压缩,并不是所有的有损压缩都会直接影响 UI 呈现的,如果事先获知应用所运行的设备屏幕硬件本身色彩还原度很差,尺寸较小,分辨率也较低,那么有损压缩往往是更具性价比的选择。


注意这里的压缩不单单指图片质量的压缩,同时也包括图片尺寸的缩放。对于一些特定设备屏幕尺寸,我们可以限定一个最大的图片尺寸作为约束。


检查对应分辨率资源文件夹下的图片


种种原因下,代码工程中往往会存在对于分辨率资源文件夹下放错图片资源的情况。


比如,在 drawable-xxhdpi 下放入了本应该放在 drawable-mdpi 的图片资源,那么最终的 UI 呈现就可能会出现模糊、大颗粒、锯齿感等情况。


image.png


比如下图,在一个 xhdpi 的设备中,实际加载了 mdpi 的图片资源,导致出现 UI 模糊情况。


定义一个 48dp×48dp 的控件,实际控件大小为 96px×96px


<ImageView    
android:id="@+id/iv"   
android:src="@mipmap/ic_launcher"   
app:layout_constraintBottom_toBottomOf="parent"   
app:layout_constraintTop_toTopOf="parent"   
app:layout_constraintRight_toRightOf="parent"   
app:layout_constraintLeft_toLeftOf="parent"   
android:layout_width="48dp"   
android:layout_height="48dp"/>


如果放错了图片资源,则实际加载了 48px×48px 大小的图片。


image.png 将应用进行截图,放大后可以很明显看到模糊情况。


image.png


提供两种方案供参考。


第一种是运行时检查,结合 BitmapCanary 工具,判断应用运行时 UI 控件是否加载了对应尺寸的图片,如果加载的图片资源尺寸小于控件自身的尺寸,那么就需要特别关注,并返回代码工程中进行修改。


第二种是开发时检查,通过脚本工具遍历工程图片资源文件夹中的图片文件,逐一检查图片尺寸,结合我们之前定义过的图片最大尺寸约束,可以剔除并发现放错的图片资源,再针对筛选出的这些特定的图片资源作压缩和缩放。


优化工具


为了让优化工具更加通用,我编写了 ImageRes361Tool 工具,它的工作流程和架构图如下。


架构图


image.png



  • ImageRes361Tool 层:应用层,负责一键执行

  • ImageFinder 层:负责查找工程中不合规的图片资源

  • ImageSaver 层:保存图片

  • Config 层:配置压缩等级、策略以及目标文件夹

  • ImageCompressTool 层:包装图片压缩功能,简化压缩 API

  • PIL、OpenCV 层:负责压缩、处理图片

  • Logger 层:记录日志

  • Thread 层:多线程操作,提升执行效率


工作流程


image.png


使用流程


python 环境


python3 环境要求


输入工程地址


image.png


回车运行


image.png


最终效果


以工程中其中一个 module 为例,清理掉超出图片最大尺寸约束的图片后,图片资源大小可以由 4.4Mb 锐减至 88Kb ;检查并修改对应分辨率的图片资源后,应用运行时不再出现 UI 模糊的情况。


后记


优化类工作往往解决的不仅仅是技术问题,更是管理问题。


制定了开发标准能否顺利执行?架构演进能否跟上业务的不断发展?为了性能指标能否排除万难团结协作?......


管理类问题只能交由管理解决,绝不是某个技术工具就能解决得了的。


看着那些来自大厂的头部 APP,白屏、卡顿、高内存占用等都非常常见,再加上给用户定制的“私人专属”开屏广告,使得启动速度异常地慢。从用户体验的角度来说,不可谓优秀。是它们的技术力不够吗?


应该不是。


作者:彭也
链接:https://juejin.cn/post/6983925947929985061
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

玩会儿Compose,原神主题列表

Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。 整体设计参考DisneyCompose 效果图: 数据源 因为数据比较简单,也就只包含图片、姓名、描述等。...
继续阅读 »

Jetpack Compose出来有一段时间了,一直都没有去尝试,这次有点想法去玩一玩这个声明性界面工具,就以“原神”为主题写个列表吧。


整体设计参考DisneyCompose


效果图:


image.png


image.png


数据源


因为数据比较简单,也就只包含图片、姓名、描述等。所以在后台数据存储上选择的是Bmob后端云,一个方便前端开发的后端服务平台。


主要数据也是从原神各大网站搜集下来的,新建表结构并且将数据填充,我们简单看一下Bmob的后台。


image.png


数据准备好了,那就开始我们的Compose之旅。


首页UI绘制


整体结构


从上面的项目效果图来看,首页总布局属于是一个网格列表,平分两格,列表中的每个Item上方带有头像,头像下面是角色名称以及角色其他信息。


image.png


网格布局


因为整体分成两列,所以选择的是网格布局,Compose提供了一个实现-LazyVerticalGrid


fun LazyVerticalGrid(
cells: GridCells,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
content: LazyGridScope.() -> Unit
)

LazyVerticalGrid中有几个重要参数先说明一下:



  • GridCells :主要控制如何将单元格构建为列,如GridCells.Fixed(2),表示两列平分。

  • Modifier : 主要用来对列表进行额外的修饰。

  • PaddingValues :主要设置围绕整个内容的padding。

  • LazyListState :用来控制或观察列表状态的状态对象


首页布局是平分两列的网格布局,那相应的代码如下:


LazyVerticalGrid(cells = GridCells.Fixed(2)) {}

单个Item


看过了外部框架,那现在来看每个Item的布局。每个Item为卡片式,外边框为圆角,且带有阴影。内部上方是一张图片Image,图片下方是两行文字Text。那Item具体该怎样布局?


我们先来看看在Compose之前,在xml中是怎么写?例如使用ConstraintLayout布局,顶部放一个ImageView,再来一个TextView layout_constraintTop_toBottomOf ImageView,最后在来个TextViewTopToBottomOf第一个TextView


那使用Compose应该怎么写?


其实在Compose里也存在着ConstraintLayout布局并且具体Api的调用思路与在xml中使用也是一致的。我们就来看看具体操作。


ConstraintLayout() {
Image()
Text()
Text()
}

一共两个元素:ImageText,分别代表着xml里的ImageViewTextView



  • Image:


Image(
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
})

Image加载的是网络图片,则使用painter加载图片链接,contentScale与xml中的scaleType相似,modifier主要设置图片的样式,点击事件、宽高等。里面有一个需要注意的点constrainAs(image)


constrainAs(image) {
centerHorizontallyTo(parent)
top.linkTo(parent.top)
}

这段代码主要表示Image在父布局中的位置,例如相对父布局,相对其他子控件等,有点xml中layout_constraintTop_toBottomOf内味。下面Text也是相同的道理。



  • Text


Text(text = item.name,
color = Color.Black,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)
top.linkTo(image.bottom)
}
)

Text的设置主要包含Text内容、文字类型、大小、颜色等。在constrainAs(title)里有一句top.linkTo(image.bottom),这句代码指的就是xml中,TextView layout_constraintTop_toBottomOf ImageView


在Image和Text中发现了一个点,constrainAs(?)中传入了一个值,且设置相对位置时也是以此值为控件的代表。这是在进行相对位置的设定之前,利用createRefs创建多个引用,在ConstraintLayout中作为Modifier.constrainAs的一部分分配给布局。


val (image, title, content) = createRefs()

具体代码:


ConstraintLayout() {
val (image, title, content) = createRefs()
//头像
Image(
//图片地址
painter = rememberCoilPainter(request = item.url),
contentDescription = "",
//图片缩放规则
contentScale = ContentScale.Crop,
modifier = Modifier
.clickable(onClick = {//点击事件
val objectId = item.objectId
navController.navigate("detail/$objectId")
})
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.width(180.dp)
.height(160.dp)
.constrainAs(image) {
centerHorizontallyTo(parent) //水平居中
top.linkTo(parent.top)//位于父布局的顶部
})
//文字
Text(text = item.name,
color = Color.Black,//颜色
style = MaterialTheme.typography.h6,//字体格式
textAlign = TextAlign.Center,
modifier = Modifier
.padding(0.dp, 4.dp, 0.dp, 0.dp)
.constrainAs(title) {
centerHorizontallyTo(parent)//水平居中
top.linkTo(image.bottom)//位于图片的下方
}
)
Text(text = item.from,
color = Color.Black,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(4.dp)
.constrainAs(content) {
centerHorizontallyTo(parent)
top.linkTo(title.bottom)

})
}

image.png


数据填充


UI已经画好了,接下来就是数据展示的事情。还是以ViewModel-LiveData-Repository为整体请求方式。 因为数据都存储到了Bmob后台,就直接使用Bmob的方式查询数据:


private val bmobQuery: BmobQuery<GcDataItem> = BmobQuery()

fun queryRoleData(successLiveData: MutableLiveData<List<GcDataItem>>) {
bmobQuery.findObjects(object : FindListener<GcDataItem>() {
override fun done(list: MutableList<GcDataItem>?, e: BmobException?) {
if (e == null) {
successLiveData.value = list
}
}

})
}

具体的请求方式可参考Bmob的完档,这里就不在赘述。 ViewModel中还是抛出一个LiveData,而UI层相对之前有一些变化。


@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePoster(navController: NavController, model: HomeViewModel
= viewModel()) {
model.queryGcData()
val data: List<GcDataItem> by model.getDataLiveData().observeAsState(listOf())

LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}

}

Compose提供了一个viewModel()方法来获取ViewModel实例,至于怎么拿到数据,Compose提供了LiveData的一个扩展方法 observeAsState(listOf()) 。它的主要作用是用来观察这个LiveData,并通过State表示它的值,每次有新值提交到LiveData时,返回的状态将被更新,从而导致每个状态的重新组合。


拿到List数据后,网格LazyVerticalGrid就开始使用items(data){}添加列表,


 LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(data) {
ItemPoster(navController, item = it)
}
}

而ItemPoster就是我们设置Item布局的地方,将每个Item的数据传递给ItemPoster,利用Image、Text等控件设置imageUrl、text内容等。


@Composable
fun ItemPoster(navController: NavController, item: GcDataItem) {
Surface(
modifier = Modifier
.padding(4.dp),
color = Color.White,
elevation = 8.dp,
shape = RoundedCornerShape(8.dp)
)
{
ConstraintLayout() {
val (image, title, content) = createRefs()

Image(
//设置图片Url-item.url
painter = rememberCoilPainter(request = item.url),
...)

Text(text = item.name
...)

Text(text = item.from
...)
}

}

跳转


样例中还有一个从列表跳转到详情页的功能,Compose提供了一个跳转组件-navigation。这个navigation与之前管理Fragment的navigation思路也是一致的,利用NavHostController进行不同页面的管理。我们先使用 rememberNavController()方法创建一个NavHostController实例。


val navController = rememberNavController()

接着将navController与NavHost相关联,且设置导航图的起始目的地startDestination


 NavHost(navController = navController, startDestination = "Home") {}

我们将起始目的地暂时先标记为“Home”。 那如何对页面进行管理?这就需要在NavHost中使用composable添加页面,例如该项目有两个页面,一个首页列表页,一个详情页。我们就可以这样写:


 NavHost(
navController = navController, startDestination = "Home"
)
{
composable(
route = "Home",
)
{
HomePoster(navController)
}

composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}
}

第一个composable则代表的是列表页,并且将到达目的地的路线route设置为“Home”,其实类似于ARouter框架中在每个Activity上设置Path,做一个标识作用,后面做跳转时也是依据该route进行跳转。


第二个composable则代表的是详情页,同样设置route="detail"


那如何从列表页跳到详情页?只需要在点击事件里使用navController.navigate("detail"),传入想要跳转的route即可。


携带参数跳转


因为详情页需要根据所点击列表Item的Id进行数据查询,点击时要将id传到详情页,这就需要携带参数。 在Compose中,向route添加参数占位符,如"detail/{objectId}",从composable()函数提取 NavArguments。 如下修改详情页:


 composable("detail/{objectId}"){
val objectId = it.arguments?.getString("objectId")
DetailPoster(objectId){
navController.popBackStack()
}
}

跳转时将objectId传到route的占位符中即可。


clickable(onClick = {
val objectId = item.objectId
navController.navigate("detail/$objectId")})

当然,compose navigation还支持launchMode设置、深层链接等,具体可查看官方文档


一点感受


对于用习惯了xml编写UI的我来说,首次上手Compose其实还是蛮不习惯,Compose打破了原有的格局,给了我们一个全新的视角去看待Android,学完后有种“哦,原来UI还可以这么干!!”的感叹。对于Android开发者来说,其实需要这些新的路线去突破自己的固有化思维。


Compose的风格其实和Flutter有点像,估计是出于同一个爸爸的原因。但是Compose没有Flutter的无限套娃,对Android开发者来说还是比较友好的。如果想要学习Flutter,可以用Compose作为过渡。


以上便是本篇内容,感谢阅读,如果对你有帮助,欢迎点赞收藏关注三连走一波?   



项目地址:genshin-compose


收起阅读 »

基于环信MQTT消息云,Web版MQTT客户端快速实现消息收发

本文介绍Web版MQTT 客户端,如何连接环信MQTT消息云快速实现消息的自收自发。一、前提条件1.部署Web开发环境下载安装 IDE。您可以使用VS Code或者WebStorm,本文以VS Code IDEA为例。下载安装浏览器,本文使...
继续阅读 »

本文介绍Web版MQTT 客户端,如何连接环信MQTT消息云快速实现消息的自收自发。

一、前提条件

1.部署Web开发环境

下载安装 IDE。您可以使用VS Code或者WebStorm,本文以VS Code IDEA为例。

下载安装浏览器,本文使用谷歌浏览器

2.导入项目依赖
在VS Code IDEA中创建index.html文件,并在文件中引入Eclipse Paho JavaScript SDK
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>

二、实现流程

1、获取鉴权

     为保障客户安全性需求,环信MQTT消息云服务为客户提供【token+clientID】方式实现鉴权认证,其中AppID(clientID中的关键字段)及token标识获取流程如下:

【登录console】
    欢迎您登录环信云console控制台,在此控制台中,为您提供应用列表、解决方案、DEMO体验以及常见问题等功能。
     在应用列表中,若您未在APP中开通MQTT业务,可参见APP  MQTT开通流程
     若APP已开通MQTT业务,可在应用列表中选中Appname,点击【查看】操作,进入应用详情。


【获取AppID及连接地址】 
      进入【查看】后,点击左侧菜单栏【MQTT】->【服务概览】,在下图红色方框内获取当前AppID及服务器连接地址。


【获取token】
     为实现对用户管控及接入安全性,环信云console提供用户认证功能,支持对用户账户的增、删、改、查以及为每个用户账户分配唯一token标识,获取token标识可选择以下两种形式。
  形式一:console控制台获取(管理员视角)
  * 点击左侧菜单栏【应用概览】->【用户认证】页面,点击【创建IM用户】按钮,增添新的账户信息(包  括用户名及密码)。
  * 创建成功后,在【用户ID】列表中选中账户,点击【查看token】按钮获取当前账户token信息。


  形式二:客户端代码获取(客户端视角)
  * 获取域名:点击左侧菜单栏【即时通讯】->【服务概览】页面,查看下图中token域名、org_name、app_name。


  * 拼接URL:获取token URL格式为:http:/ /token域名/org_name/app_name/token。 
  * 用户名/密码:使用【用户ID】列表中已有账户的用户名及密码,例“用户名:test/密码:test123”。

// 客户端获取token(password)代码示例如下: 
function getAccessToken() {
var grantType = 'password'
var request = new XMLHttpRequest()
// token 域名
var baseUrl = 'a3.easemob.com'
// org_name
var orgName = 'easemob-test'
// app_name
var appName = 'ease-test'
// 拼接token接口
var api = `http://${baseUrl}/${orgName}/${appName}/token`
var token = ''
// Post请求
request.open('post', api)

request.onreadystatechange = function () {
if (request.readyState == 4 && request.status == 200) {
var res = JSON.parse(request.responseText)
// 从响应体中解析出token
token = res.access_token
console.log(token, 'accessToken')
} else {
throw new Error('请求失败,响应码为' + request.status)
}
}

var params = {
grant_type: grantType,
username: 'test',
password: 'test1'
}
// 发送ajax请求
request.send(JSON.stringify(params))
}

//返回结果
{
"access_token": "YWMtN8a0oqV3EeuF0AmiqRgEh-grzF8zZk2Wp8GS3pF-orDW_F-gj3kR6os3h_oz3ROQAwMAAAF5BxhGlwBPGgAvTR8vDrdVsDPNZMQj0fFjv7EaohgZhzMHM9ncVLE30g",
"expires_in": 5184000,
"user": {
"uuid": "d6fc5fa0-8f79-11ea-8b37-87fa33dd1390",
"type": "user",
"created": 1588756404898,
"modified": 1588756404898,
"username": "test",
"activated": true
}
}
access_token即为要获取的token


2、初始化

      在VS CodeIDEA工程中创建index.html,客户端初始配置包括创建clientID,port,连接地址等信息。

<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
var mqtt
// 设置当前用户的接入点域名,进入console控制台获取
var host = '//xxx.xxx.xxx.xxx'
// WebSocket 协议服务端口,如果是走 HTTPS,设置443端口
var port = 80
// 从console控制台获取
var appId = 'TESTAPPID'
// MQTT 用户自定义deviceID
var deviceId = 'deviceId'
// clientId 格式 deviceID@AppID
var clientId = deviceId + '@' + appId

mqtt = new Paho.MQTT.Client(
host,
port,
clientId
)


3、连接服务器

    配置连接密码、cleansession标志、心跳间隔、超时时间等信息,调用connect()函数连接至环信MQTT消息云。

// 是否走加密 HTTPS,如果走 HTTPS,设置为 true
var useTLS = false
// cleansession标志
var cleansession = true
// 超时重连时间
var reconnectTimeout = 2000
// 用户名,在console中注册
var username = 'test'
// 用户密码为第一步中申请的token
var password = 'test123'

var options = {
timeout: 3,
// 连接成功回调
onSuccess: onConnect,
mqttVersion: 4,
cleanSession: cleansession,
// 连接失败回调
onFailure: function (message) {
setTimeout(MQTTconnect, reconnectTimeout)
}
}

options.userName = username
options.password = password
// 如果使用 HTTPS 加密则配置为 true
options.useSSL = useTLS

mqtt.connect(options)


4、订阅【subscribe】

【订阅主题】

当客户端成功连接环信MQTT消息云后,需尽快向服务器发送订阅主题消息。

// 需要订阅或发送消息的topic名称
var topic = 't/t1'
// 订阅消息 QoS参数代表传输质量,可选0,1,2。详细信息,请参见名词解释。
mqtt.subscribe(topic, { qos: 1 })

【取消订阅】

// 取消订阅
mqtt.unsubscribe(topic)

【接收消息】

    配置接收消息回调方法,从环信MQTT消息云接收订阅消息。(tip: 需要在连接之前设置回调方法)

function onMessageArrived(message) {
var topic = message.destinationName
var payload = message.payloadString
console.log("recv msg : " + topic + " " + payload)
}
mqtt.onMessageArrived = onMessageArrived


5、发布【publish】

   环信MQTT消息云中指定topic发送消息。

//set body
message = new Paho.MQTT.Message("Hello mqtt!!")
// set topic
message.destinationName = topic
mqtt.send(message)


6、结果验证

connect success
send msg : t/t1 Hello mqtt!!
recv msg : t/t1 Hello mqtt!!

三、更多信息

  完整demo示例,请参见demo下载或直接下载:MQTTChatDemo-Web.zip

  * 目前MQTT客户端支持多种语言,请参见 SDK下载

  * 如果您在使用MQTT服务中,有任何疑问和建议,欢迎您联系我们

收起阅读 »

JS循环大总结, for, forEach,for in,for of, map区别

map(数组方法): 特性: map不改变原数组但是会 返回新数组 可以使用break中断循环,可以使用return返回到外层函数 实例: let newarr=arr.map(i=>{ return i+=1; console.log(i); })...
继续阅读 »

map(数组方法):


特性:



  1. map不改变原数组但是会 返回新数组

  2. 可以使用break中断循环,可以使用return返回到外层函数


实例:


let newarr=arr.map(i=>{
return i+=1;
console.log(i);
})
console.log(arr)//1,3,4---不会改变原数组
console.log(newarr)//[2,4,5]---返回新数组

forEach(数组方法):


特性:



  1. 便利的时候更加简洁,效率和for循环相同,不用关心集合下标的问题,减少了出错的概率。

  2. 没有返回值

  3. 不能使用break中断循环,不能使用return返回到外层函数


实例:


let newarr=arr.forEach(i=>{
i+=1;
console.log(i);//2,4,5
})
console.log(arr)//[1,3,4]
console.log(newarr)//undefined

注意:



  1. forEach() 对于空数组是不会执行回调函数的。

  2. for可以用continue跳过循环中的一个迭代,forEach用continue会报错。

  3. forEach() 需要用 return 跳过循环中的一个迭代,跳过之后会执行下一个迭代。


for in(大部分用于对象):


用于循环遍历数组或对象属性


特性:


可以遍历数组的键名,遍历对象简洁方便
###实例:


   let person={name:"小白",age:28,city:"北京"}
let text=""
for (let i in person){
text+=person[i]
}
输出结果为:小白28北京
//其次在尝试一些数组
let arry=[1,2,3,4,5]
for (let i in arry){
console.log(arry[i])
}
//能输出出来,证明也是可以的

for of(不能遍历对象):


特性:



  1. (可遍历map,object,array,set string等)用来遍历数据,比如组中的值

  2. 避免了for in的所有缺点,可以使用break,continue和return,不仅支持数组的遍历,还可以遍历类似数组的对象。


   let arr=["nick","freddy","mike","james"];
for (let item of arr){
console.log(item)
}
//暑促结果为nice freddy mike james
//遍历对象
let person={name:"老王",age:23,city:"唐山"}
for (let item of person){
console.log(item)
}
//我们发现它是不可以的
//但是它和forEach有个解决方法,结尾介绍

总结:



  • forEach 遍历列表值,不能使用 break 语句或使用 return 语句

  • for in 遍历对象键值(key),或者数组下标,不推荐循环一个数组

  • for of 遍历列表值,允许遍历 Arrays(数组), Strings(字符串), Maps(映射), Sets(集合)等可迭代的数据结构等.在 ES6 中引入的 for of 循环,以替代 for in 和 forEach() ,并支持新的迭代协议。

  • for in循环出的是key,for of循环出的是value;

  • for of是ES6新引入的特性。修复了ES5的for in的不足;

  • for of不能循环普通的对象,需要通过和Object.keys()搭配使用。


链接:https://juejin.cn/post/6983313955233988644

收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(三):引入Element-plus,解决字体文件404问题

vue
今天我们来看引入大杯Element,其实引入很简单,跟着文档操作就完事了。所以这篇文章重点是看如何修改主题以及在修改主题中我遇到的问题。 废话少说,开整! 引入Element-plus npm install element-plus --save // m...
继续阅读 »

今天我们来看引入大杯Element,其实引入很简单,跟着文档操作就完事了。所以这篇文章重点是看如何修改主题以及在修改主题中我遇到的问题。


image.png


废话少说,开整!


引入Element-plus


npm install element-plus --save

// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus'; // ++
import 'element-plus/lib/theme-chalk/index.css'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus).mount('#app'); // edit

此时在项目中引入Element组件测试发现,已经可以正常使用了。


image.png


修改Element主题


在Element文档中有如何修改主题的教程,我们项目中主要的需求就是修改主题色,因此本文也以修改主题色为例子。


创建文件


首先我新增了两个文件,color.sass 和 element-theme.sass(这里假设你的项目已经引入了sass)。之所以创建两个文件,是因为 color.sass 除了给element主题提供颜色配置,还会引入为全局变量,方便在组件中使用。


image.png


配置主题


// color.sass
$--color-primary: red

// element-theme.sass
@improt "./color.sass" // 引入主题色文件

$--font-path: '~element-plus/lib/theme-chalk/fonts'
@import "~element-plus/packages/theme-chalk/src/index"

// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/lib/theme-chalk/index.css'; // --
import './styles/element-theme.sass'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus).mount('#app');

如上按照element官网给的例子引入以后,在vite项目中会报错。


image.png
这是因为 ~ 这种路径写法是vue-cli中的约定,它使我们可以引用node_modules包内的资源,详见文档:URL转换规则
image.png


所以我们在这里需要把路径 ~element-plus 改成 node_modules/element-plus。也就是文件变成了这个样子:


@import "./color.sass"

$--font-path: 'node_modules/element-plus/lib/theme-chalk/fonts'
@import "node_modules/element-plus/packages/theme-chalk/src/index"

文章写到这里的时候遇到了一个尴尬的问题,在我们的生产项目搭建框架时,路径改成由 node_modules 引入后,主题色修改没有问题,可以生效。但是fonts文件加载请求报404,如下图1。但是文章用的实验项目,同样的方式修改后,一切正常,如图2。


image.png


image.png


经过反复测试后发现,是因为生产项目配置了多入口,启动项目时对应了不同的入口文件,导致引入fonts文件报错。具体原因有待研究,希望了解的兄弟不吝赐教


关于多入口文件配置以及解决element字体引入的问题,后边会有一篇文章单独介绍,这里就先不剧透了。


配置全局变量


前面单独创建了一个color.sass是为了将文件里的颜色变量引入到全局,方便在组件中使用。
为了简化使用,我们可以在文件中为常用颜色额外定义简短变量,但是要注意,不能修改element需要的变量!


$--color-primary: #ff0000

$primary: #ff0000

引入全局变量需要在vite.config.ts文件中配置css预处理器,并将引入的变量文件传给预处理器。配置方式如下


// vite.config.ts
...
export default defineConfig({
...
css: {
preprocessorOptions: {
sass: {
// \n 处理文件中多个引入报换行错误的问题
additionalData: "@import './src/styles/color.sass'\n",
},
},
},
});

引入后我们在组件内进行测试


// HelloWorld.vue
<style scoped lang="sass">
a
color: $primary
</style>

可以看到页面上已经生效了
image.png


因为通过这种方式插入全局变量,会为所有的.sass文件都插入对应的文件引入,所以在前面我们定义的 element-theme.sass 文件中就可以不写 color.sass 文件的引入了。


// element-theme.sass

// @import "./color.sass" // edit

$--font-path: 'node_modules/element-plus/lib/theme-chalk/fonts'
@import "node_modules/element-plus/packages/theme-chalk/src/index"

修改默认语言


可能是为了立足中国,走向世界。使用组件时会发现大杯Element的默认语言变成了英文,我们需要自己引入并修改默认语言为中文。


// main.ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import './styles/element-theme.sass';
import locale from 'element-plus/lib/locale/lang/zh-cn'; // ++
import App from './App.vue';

createApp(App).use(ElementPlus, { locale }).mount('#app'); // edit

修改完成后,再去看看组件,是不是已经变成了中文。


引入大杯Element并修改主题的工作已经完成了,项目中我们就可以使用自定义主题色的Element组件。并且抛出了主题色全局变量,方便我们在组件中使用。


下次我们将一次性引入Vuex和Vue Router,这两项工作完成后,就已经完成了项目框架的雏形,可以开始开发了。不过后续我们仍然会有一些优化以及Vue3开发过程中相较于Vue2有较大变化的方法总结,整理不易,希望大家多多支持。


链接:https://juejin.cn/post/6984004322736472095

收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(二):配置husky和lint-staged

vue
上回我们说到配置ESLint以及添加vue-recommended、airbnb-base、prettier规则,一切都很顺利。唯有一点需要注意的就是 .eslintrc 文件extends配置项中,plugin:prettier/recommended一定要...
继续阅读 »

上回我们说到配置ESLint以及添加vue-recommended、airbnb-base、prettier规则,一切都很顺利。唯有一点需要注意的就是 .eslintrc 文件extends配置项中,plugin:prettier/recommended一定要在airbnb-base之后添加,上篇文章没有看到的童鞋们可以回去看看原因。


上篇文章最后我们提到,在开发阶段进行ESLint校验,效果是一件靠自觉的事。因此我们需要在代码提交前再次执行ESLint,加强校验力度以保证Git上得到的都是优美的代码。


我们本次需要用到的工具有两个:huskylint-staged


husky


它的主要作用就是关联git的钩子函数,在执行相关git hooks时进行自定义操作,比如在提交前执行eslint校验,提交时校验commit message等等。


Install


husky官网推荐使用自动初始化命令,因为我们就按照官网推荐的方式进行安装,以npm为例


// && 连接符在vscode中会报错,建议在windows的powershell执行
npx husky-init && npm install

执行完成后,项目根目录会多出来 .husky 文件夹。


image.png


内部的_文件夹我们在此无需关心,pre-commit文件便是在git提交前会执行的操作,如图。我们可以在当前目录创建钩子文件来完成我们想要的操作。


image.png


需要注意的是,新版husky的配置方式做出了破坏性的改变,如果在使用过程中发现配置完以后没有生效,可以注意查看一下安装版本


升级方式可以查看官方文档:typicode.github.io/husky/#/?id…


配置


我们想要在提交前执行eslint校验代码,因此修改husky的pre-commit文件即可。我们在文件中添加如下代码


#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

eslint . --ext .js,.ts,.vue --fix #++ 校验所有的.js .ts .vue文件,并修复可自动修复的问题
git add . #++ 用于将自动修复后改变的文件添加到暂存区
exit 1 #++ 终止命令,用来测试钩子

此时提交代码执行commit是可以看到已经进入了pre-commit文件执行命令。但是会报错


image.png


这是因为此处执行shell命令,需要我们全局安装eslint。执行 npm install -g eslint。
安装完成后再次执行git commit,可以看到已经可以正常运行了


image.png


错误处理




  • 截图中第一个报错是书写错误,直接改掉就好。




  • 第二个错误,是因为我们的ESLint中没有配置TS的解析器,导致ESLint不能正常识别并校验TS代码。解决它,我们安装 @typescript-eslint/parser,并修改ESLint配置即可。




npm install @typescript-eslint/parser --save-dev

// .eslintrc.js
...
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser', // ++
},
...


  • 第三个错误,它说的是我们引入的vite和@vitejs/plugin-vue两个包在 package.json 中应该是dependencies而不是devDependencies依赖。这个错误是因为airbnb-base规则设置了不允许引入开发依赖包,但是很明显我们不应该修改这两个框架生成的依赖结构。那我们看一下airbnb关于这条规则的定义


image.png
可以看到,airbnb对这条规格做了列外处理,那就很好办了,我们只需要在它的基础上,添加上上面报错的两个包。


在eslint中添加如下规则:


// .eslintrc.js
...
rules: {
...
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
... // 保持airbnb-base中的规则不变
'**vite**', // ++
'**@vitejs**', // ++
],
optionalDependencies: false,
},
],
}
...

修改完上述错误后,我们去掉 .husky/pre-commit 文件中 exit 1 这行代码,再次执行提交操作,可以看到,已经可以提交成功了。


image.png


思考


通过配置husky,我们已经实现了在提交前对代码进行检查。但是eslint配置的是 eslint . --ext .js,.ts,.vue --fix,检查所有的js、ts、vue文件,随着项目代码越来越多,每次提交前校验所有代码显然是不现实的。所以需要一个办法每次只检查新增或修改的文件。


这就需要开头提到的第二个工具来祝我们一臂之力了。


lint-staged


lint-staged的作用就是对暂存区的文件执行lint,可以让我们每次提交时只校验自己修改的文件。


npm install lint-staged --save-dev

配置lint-staged


安装完成后,在package.json文件中添加lint-staged的配置


// package.json
...
"scripts": {
...
"lint-staged": "lint-staged"
},
"lint-staged": {
// 校验暂存区的ts、js、vue文件
"*.{ts,js,vue}": [
"eslint --fix",
"git add ."
]
}

添加scripts里的lint-staged命令,是因为不建议全局安装lint-staged,以防在其他同学电脑上没有全局安装导致运行报错。


修改husky


添加lint-staged配置后,husky就不在需要直接调用eslint了。修改pre-commit文件如下:


#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

# eslint . --ext .js,.ts,.vue --fix
# git add .
# exit 1
npm run lint-staged

lint-staged配置后,我们不再需要配置husky时全局安装的eslint,因为lint-staged可以检测项目里局部安装的脚本。同时,不建议全局安装脚本,原因同上。


测试


到此,提交阶段对代码执行lint需要的配置我们已经完成了。再次提交代码测试,可以看到commit后执行的命令已经变成了lint-staged。


image.png


下一篇踩坑记我们将引入Element-plus,详细介绍其中遇到的问题,并修改element组件主题。


链接:https://juejin.cn/post/6982876819292684318
收起阅读 »

Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(一)

vue
前段时间领导告知公司将开启一个全新的项目。 从零开始,如果不尝试一下最近火热的 Vue3 + Vite 岂不是白白浪费了这么好的吃螃蟹的机会。 说干就干,然后就开始读各种文档,从 0 开始,一步一步搭完这个项目到可以正常开发,这对于我一个第一次搭生产项目的菜鸡...
继续阅读 »

前段时间领导告知公司将开启一个全新的项目。


从零开始,如果不尝试一下最近火热的 Vue3 + Vite 岂不是白白浪费了这么好的吃螃蟹的机会。


说干就干,然后就开始读各种文档,从 0 开始,一步一步搭完这个项目到可以正常开发,这对于我一个第一次搭生产项目的菜鸡来说,着实艰难。


到今天,项目已经进入联调阶段,并且已经在环境上部署成功可以正常访问。这个实验也算是有了阶段性的成功吧,因此来写文章记录此次Vue3项目搭建历险记。


下载.jfif


第一篇文章主要是项目初始化和ESLint导入,废话不多说,开整。


初始化项目


image.png
按照自己需要的框架选择就可以了,我这里用的Vue3+TS。
初始化完成后的目录结构如下:


image.png


启动项目


执行 npm run dev,大概率会直接报错,因为项目默认启动在3000端口,可能会被拒绝。


image.png


解决这个问题,我们需要在根目录下的 vite.config.ts 文件中修改开发服务器的配置,手动指定端口号。


image.png


修改完成后重新启动项目,就可以访问了。


image.png


添加ESLint支持


安装ESLint



  • eslint只有开发阶段需要,因此添加到开发阶段的依赖中即可


npm install eslint --save-dev


  • 在VS Code中安装eslint插件,以在开发中自动进行eslint校验


配置ESLint


创建 .eslintrc.js 文件


添加基础配置


module.exports = {
root: true,
env: {
browser: true, // browser global variables
es2021: true, // adds all ECMAScript 2021 globals and automatically sets the ecmaVersion parser option to 12.
},
parserOptions: {
ecmaVersion: 12,
},
}

引入规则


为了规范团队成员代码格式,以及保持统一的代码风格,项目采用当前业界最火的 Airbnb规范 ,并引入代码风格管理工具 Prettier


eslint-plugin-vue


ESLint官方提供的Vue插件,可以检查 .vue文件中的语法错误


npm install eslint-plugin-vue

// .eslintrc.js
...
extends: [
'plugin:vue/vue3-recommended' // ++
]
...

eslint-config-airbnb-base


Airbnb基础规则的eslint插件


// npm version > 5
npx install-peerdeps --dev eslint-config-airbnb-base

// .eslintrc.js
...
extends: [
'plugin:vue/vue3-recommended',
'airbnb-base', // ++
],
...

这个时候就应该可以看到一些项目原有代码的eslint报错信息了,如果没有的话,可以尝试重启编辑器的eslint服务。


eslint-plugin-prettier


本次项目不单独引入prettier,而是使用eslint插件将prettier作为eslint规则执行。


npm install --save-dev eslint-plugin-prettier
npm install --save-dev --save-exact prettier

// .eslintrc.js
...
plugins: ['prettier'], // ++
rules: {
'prettier/prettier': 'error', // ++
},
...

配置到此时,大概率会遇到 eslint 规则和 prettier 规则冲突的情况,比如下图。eslint告诉我们要使用单引号,但是改为单引号以后,prettier有告诉我们要使用双引号。


image.png


image.png


这时候就需要另一个eslint的插件 eslint-config-prettier,这个插件的作用是禁用所有与格式相关的eslint规则,也就是说把所有格式相关的校验都交给 prettier 处理。


npm install --save-dev eslint-config-prettier

// .eslintrc.js
...
plugins: ['prettier'],
extends: [
'plugin:vue/vue3-recommended',
'airbnb-base',
'plugin:prettier/recommended', // ++
],
rules: {
'prettier/prettier': 'error',
},
...

plugin:prettier/recommended 的配置需要注意的是,一定要放在最后。因为extends中后引入的规则会覆盖前面的规则。


我们还可以在根目录新建 .prettierrc.js 文件自定义 prettier 规则,保存规则后,重启编辑器的eslint服务以更新编辑器读取的配置文件。


// .prettierrc.js
module.exports = {
singleQuote: true, // 使用单引号
}

到此,我们的ESLint基本配置结束了,后续需要时可以对规则进行调整。


这篇文章到这里就结束了,但是只在开发阶段约束代码风格是一件靠自觉性的是,因为我们还需要增强ESLint的约束度。下一篇文章,我们一起研究如果在提交代码前进行ESLint二次校验,保证提交到Git的代码都是符合规定的~


链接:https://juejin.cn/post/6982529246480564238

收起阅读 »

有趣的JS存储

今天给大家分享一下关于JS存储的问题。 建议阅读时间:5-10分钟。 序章 首先看一道经典的关于JS存储的题目,来一场紧张又刺激的脑内吃鸡大战吧: var a = {n:1};a.x = a = {n:2};console.log(a.x);console....
继续阅读 »

今天给大家分享一下关于JS存储的问题。


建议阅读时间:5-10分钟。




序章


首先看一道经典的关于JS存储的题目,来一场紧张又刺激的脑内吃鸡大战吧:


var a = {n:1};
a.x = a = {n:2};
console.log(a.x);
console.log(a);·


问输出?
想必大家心中都有答案了 ...
结果很显然是有趣的,


image.png


到这里有部分现场观众朋友就问了,这特喵咋undefined?不是赋值了吗?别急先别骂人,往下看:




探索时刻


我们先将代码这样修改:


a.x = a = {n:2};   ---- >  a = a.x = {n:2};

image.png


结果显然是一致的,不论是先给 a 赋值还是先给 a.x 赋值结果都是一致的,
查了一些资料后,得知这等式中 . 的优先级别是最高的,


因此这题的思路:


JS会把变量存到栈中,而对象则会存在堆中。


image.png



  1. 第一行代码:变量 a 的指针指向堆栈;

  2. 第二行代码:a.x = a = {n:2}; 堆1中的变量对像X指向堆2 { n:2 }, 接着给a赋值 a={n:2} ,a的指针被改变指向堆2,然后堆1没有被指针指向,被GC回收,因此输出的 a.x 是underfinde 而 a 的值是 {n:2};


理解上述代码只需要稍微理解一下js变量储存:


大家都知道,JavaScript中的变量类型分为两种,一种是基本数据类型,另外一种就是引用类型


两种数据类型的存储方式在JS中也有所不同。


另外,内存分为栈区(stack)和堆区(heap),然后在JS中开发人员并不能直接操作堆区,堆区数据由JS引擎操作完成,那这二者在存储数据上到底有什么区别呢?




揭晓时刻


一幅图告诉你:


image.png


 JS中变量的定义在内存中包括三个部分:



  • 变量标示  (比如上图中的Str,变量标示存储在内存的栈区)

  • 变量值   (比如上面中的Str的值souvenir或者是obj1对象的指向堆区地址,这个值也是存储在栈区)

  • 对象   (比如上图中的对象1或者对象2,对象存储在堆区)


也就是说,对于基本数据类型来说,只使用了内存的栈区。
我们再做一个有趣的改动:


var a = {n:1};
var b=a;
a.x = a = {n:2};
console.log(a.x);
console.log(a);
console.log(b);
console.log(b.x);

可以看到我们并没有对 b 进行操作但是 b.x 等于{n:2},这是一个被操作过的值,就如上述可知 b的指针指向堆1,所以堆没有被回收,而被显示出来了 ~


从这么一个简单例子,你是否对JS存储机制有了新的认识呢 ~


链接:https://juejin.cn/post/6983978244596826142

收起阅读 »

android侧滑菜单SuperSlidingPaneLayout

SuperSlidingPaneLayout     SuperSlidingPaneLayout是在SlidingPaneLayout的基础之上扩展修改,新增几种不同的侧滑效果,基本用法与SlidingPan...
继续阅读 »


SuperSlidingPaneLayout

Download Jitpack API License Blog QQGroup

SuperSlidingPaneLayout是在SlidingPaneLayout的基础之上扩展修改,新增几种不同的侧滑效果,基本用法与SlidingPaneLayout一致。

Image

引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>superslidingpanelayout</artifactId>
<version>1.1.0</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:superslidingpanelayout:1.1.0'

Lvy:

<dependency org='com.king.view' name='superslidingpanelayout' rev='1.1.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

使用布局示例:

<?xml version="1.0" encoding="utf-8"?>
<com.king.view.superslidingpanelayout.SuperSlidingPaneLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/superSlidingPaneLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/menu_bg1"
app:mode="default_"
app:compat_sliding="false">
<include layout="@layout/menu_layout"/>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/top_title_bar"/>
<TextView
android:id="@+id/tvMode"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:gravity="center"
android:text="Default"
android:textSize="24sp"/>
</LinearLayout>

</com.king.view.superslidingpanelayout.SuperSlidingPaneLayout>

代码设置侧滑模式效果:

        superSlidingPaneLayout.setMode(SuperSlidingPaneLayout.Mode.DEFAULT);

superSlidingPaneLayout.setMode(SuperSlidingPaneLayout.Mode.TRANSLATION);

superSlidingPaneLayout.setMode(SuperSlidingPaneLayout.Mode.SCALE_MENU);

superSlidingPaneLayout.setMode(SuperSlidingPaneLayout.Mode.SCALE_PANEL);

superSlidingPaneLayout.setMode(SuperSlidingPaneLayout.Mode.SCALE_BOTH);

更多使用详情请查看demo示例。

相关博文:http://blog.csdn.net/jenly121/article/details/52757409

代码下载:SuperSlidingPaneLayout.zip

收起阅读 »

CounterView for Android 一个数字变化效果的计数器视图控件。

CounterViewCounterView for Android 一个数字变化效果的计数器视图控件。Gif 展示引入Maven:<dependency> <groupId>com.king.view</groupId>...
继续阅读 »


CounterView

CounterView for Android 一个数字变化效果的计数器视图控件。

Gif 展示

Image

引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>counterview</artifactId>
<version>1.1.0</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:counterview:1.1.0'

Lvy:

<dependency org='com.king.view' name='counterview' rev='1.1.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

核心代码

counterView.showAnimation(10000);

代码下载:CounterView.zip

收起阅读 »

NeverCrash for Android 一个全局捕获Crash的库。信NeverCrash,永不Crash。

NeverCrashNeverCrash for Android 一个全局捕获Crash的库。信NeverCrash,永不Crash。Gif 展示引入Maven: com.king.thread nevercrash 1.0.0 pom Gra...
继续阅读 »

NeverCrash

NeverCrash for Android 一个全局捕获Crash的库。信NeverCrash,永不Crash。

Gif 展示

Image

引入

Maven:


com.king.thread
nevercrash
1.0.0
pom

Gradle:

compile 'com.king.thread:nevercrash:1.0.0'

Lvy:



如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)

allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

核心代码(大道至简)

NeverCrash.init(CrashHandler);

代码示例

public class App extends Application {

@Override
public void onCreate() {
super.onCreate();
NeverCrash.init(new NeverCrash.CrashHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
Log.d("Jenly", Log.getStackTraceString(e));
// e.printStackTrace();
showToast(e.getMessage());


}
});
}

private void showToast(final String text){

new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(),text,Toast.LENGTH_SHORT).show();
}
});
}

}

代码下载:NeverCrash.zip

收起阅读 »

SlideBar for Android 一个很好用的联系人快速索引。

SlideBarSlideBar for Android 一个很好用的联系人快速索引。Gif 展示引入Maven:<dependency> <groupId>com.king.view</groupId> <a...
继续阅读 »

SlideBar

SlideBar for Android 一个很好用的联系人快速索引。

Gif 展示


引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>slidebar</artifactId>
<version>1.1.0</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:slidebar:1.1.0'

Lvy:

<dependency org='com.king.view' name='slidebar' rev='1.1.0'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

具体实现详情请戳传送门

代码下载:SlideBar.zip

收起阅读 »

Android码表变化的旋转计数器动画控件

SpinCounterViewSpinCounterView for Android 一个类似码表变化的旋转计数器动画控件。Gif 展示引入Maven:<dependency> <groupId>com.king.view</...
继续阅读 »

SpinCounterView

SpinCounterView for Android 一个类似码表变化的旋转计数器动画控件。

Gif 展示

Image

引入

Maven:

<dependency>
<groupId>com.king.view</groupId>
<artifactId>spincounterview</artifactId>
<version>1.1.1</version>
<type>pom</type>
</dependency>

Gradle:

compile 'com.king.view:spincounterview:1.1.1'

Lvy:

<dependency org='com.king.view' name='spincounterview' rev='1.1.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>

如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)

allprojects {
repositories {
//...
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

示例

布局

    <com.king.view.spincounterview.SpinCounterView
android:id="@+id/scv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:max="100"
app:maxValue="1000"/>

核心动画代码

spinCounterView.showAnimation(80);

代码下载:SpinCounterView.zip

收起阅读 »

Objective-C 消息转发深度理解(2)

4.1.3 forwarding_prep_0伪代码分析Hopper分析完毕后直接搜索forwarding_prep_0查看反汇编伪代码:int ___forwarding_prep_0___(int arg0, int arg1, int arg2, int...
继续阅读 »


4.1.3 forwarding_prep_0伪代码分析

Hopper分析完毕后直接搜索forwarding_prep_0查看反汇编伪代码:

int ___forwarding_prep_0___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
//……
rax = ____forwarding___(&stack[0], 0x0);
if (rax != 0x0) {
rax = *rax;
}
else {
//arg0,arg1
rax = objc_msgSend(stack[0], stack[8]);
}
return rax;
}
  • 可以看到内部是对___forwarding___的调用。
  • ____forwarding___返回值不存在的时候调用的是objc_msgSend参数是arg0
    arg1

4.1.4 __forwarding__伪代码分析


点击进去查看___forwarding___的实现:


int ____forwarding___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
r9 = arg5;
r8 = arg4;
rcx = arg3;
r13 = arg1;
r15 = arg0;
rax = COND_BYTE_SET(NE);
if (arg1 != 0x0) {
r12 = *_objc_msgSend_stret;
}
else {
r12 = *_objc_msgSend;
}
rbx = *(r15 + rax * 0x8);
rsi = *(r15 + rax * 0x8 + 0x8);
var_140 = rax * 0x8;
if (rbx >= 0x0) goto loc_115af7;

loc_115ac0:
//target pointer处理
rax = *_objc_debug_taggedpointer_obfuscator;
rax = *rax;
rcx = (rax ^ rbx) >> 0x3c & 0x7;
rax = ((rax ^ rbx) >> 0x34 & 0xff) + 0x8;
if (rcx != 0x7) {
rax = rcx;
}
if (rax == 0x0) goto loc_115ea6;

loc_115af7:
var_150 = r12;
var_138 = rsi;
var_148 = r15;
rax = object_getClass(rbx);
r15 = rax;
r12 = class_getName(rax);
//是否能响应 forwardingTargetForSelector,不能响应跳转 loc_115bab 否则继续执行 也就是forwardingTargetForSelector方法返回nil或者自身
if (class_respondsToSelector(r15, @selector(forwardingTargetForSelector:)) == 0x0) goto loc_115bab;

loc_115b38:
//rax返回值
rax = [rbx forwardingTargetForSelector:var_138];
//返回值是否存在,返回值是否等于自己 是则跳转 loc_115bab
if ((rax == 0x0) || (rax == rbx)) goto loc_115bab;

loc_115b55:
if (rax >= 0x0) goto loc_115b91;

loc_115b5a:
rcx = *_objc_debug_taggedpointer_obfuscator;
rcx = *rcx;
rdx = (rcx ^ rax) >> 0x3c & 0x7;
rcx = ((rcx ^ rax) >> 0x34 & 0xff) + 0x8;
if (rdx != 0x7) {
rcx = rdx;
}
if (rcx == 0x0) goto loc_115e95;

loc_115b91:
*(var_148 + var_140) = rax;
r15 = 0x0;
goto loc_115ef1;

loc_115ef1:
if (**___stack_chk_guard == **___stack_chk_guard) {
rax = r15;
}
else {
rax = __stack_chk_fail();
}
//返回 forwardingTargetForSelector 为消息的接收者
return rax;

loc_115e95:
rbx = rax;
r15 = var_148;
r12 = var_150;
goto loc_115ea6;

loc_115ea6:
if (dyld_program_sdk_at_least(0x7e30901ffffffff) != 0x0) goto loc_116040;

loc_115ebd:
r14 = _getAtomTarget(rbx);
*(r15 + var_140) = r14;
___invoking___(r12, r15, r15, 0x400, 0x0, r9, var_150, var_148, var_140, var_138, var_130, stack[-304], stack[-296], stack[-288], stack[-280], stack[-272], stack[-264], stack[-256], stack[-248], stack[-240]);
if (*r15 == r14) {
*r15 = rbx;
}
goto loc_115ef1;

loc_116040:
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;

loc_115bab:
var_140 = rbx;
//是否僵尸对象
if (strncmp(r12, "_NSZombie_", 0xa) == 0x0) goto loc_115f30;

loc_115bce:
r14 = var_140;
//是否能够响应 methodSignatureForSelector
if (class_respondsToSelector(r15, @selector(methodSignatureForSelector:)) == 0x0) goto loc_115f46;

loc_115bef:
rbx = var_138;
//调用
rax = [r14 methodSignatureForSelector:rbx];
if (rax == 0x0) goto loc_115fc1;

loc_115c0e:
r15 = rax;
rax = [rax _frameDescriptor];
r12 = rax;
if (((*(int16_t *)(*rax + 0x22) & 0xffff) >> 0x6 & 0x1) != r13) {
rax = sel_getName(rbx);
rcx = "";
if ((*(int16_t *)(*r12 + 0x22) & 0xffff & 0x40) == 0x0) {
rcx = " not";
}
r8 = "";
if (r13 == 0x0) {
r8 = " not";
}
_CFLog(0x4, @"*** NSForwarding: warning: method signature and compiler disagree on struct-return-edness of '%s'. Signature thinks it does%s return a struct, and compiler thinks it does%s.", rax, rcx, r8, r9, var_150);
}
//是否能够响应_forwardStackInvocation
if (class_respondsToSelector(object_getClass(r14), @selector(_forwardStackInvocation:)) == 0x0) goto loc_115d61;

loc_115c9a:
if (*____forwarding___.onceToken != 0xffffffffffffffff) {
dispatch_once(____forwarding___.onceToken, ^ {/* block implemented at ______forwarding____block_invoke */ } });
}
[NSInvocation requiredStackSizeForSignature:r15];
var_138 = r15;
rdx = *____forwarding___.invClassSize;
r13 = &var_150 - (rdx + 0xf & 0xfffffffffffffff0);
memset(r13, 0x0, rdx);
objc_constructInstance(*____forwarding___.invClass, r13);
var_150 = rax;
r15 = var_138;
[r13 _initWithMethodSignature:var_138 frame:var_148 buffer:&stack[-8] - (0xf + rax & 0xfffffffffffffff0) size:rax];
[var_140 _forwardStackInvocation:r13];
rbx = 0x1;
goto loc_115dce;

loc_115dce:
if (*(int8_t *)(r13 + 0x34) != 0x0) {
rax = *r12;
if (*(int8_t *)(rax + 0x22) < 0x0) {
rcx = *(int32_t *)(rax + 0x1c);
rdx = *(int8_t *)(rax + 0x20) & 0xff;
memmove(*(rdx + var_148 + rcx), *(rdx + rcx + *(r13 + 0x8)), *(int32_t *)(*rax + 0x10));
}
}
rax = [r15 methodReturnType];
r14 = rax;
rax = *(int8_t *)rax;
if ((rax != 0x76) && (((rax != 0x56) || (*(int8_t *)(r14 + 0x1) != 0x76)))) {
r15 = *(r13 + 0x10);
if (rbx != 0x0) {
r15 = [[NSData dataWithBytes:r15 length:var_150] bytes];
[r13 release];
rax = *(int8_t *)r14;
}
if (rax == 0x44) {
asm { fld tword [r15] };
}
}
else {
r15 = ____forwarding___.placeholder;
if (rbx != 0x0) {
r15 = ____forwarding___.placeholder;
[r13 release];
}
}
goto loc_115ef1;

loc_115d61:
var_138 = r12;
r12 = r14;
//forwardInvocation的判断,如果没有实现直接跳转loc_115f8e
if (class_respondsToSelector(object_getClass(r14), @selector(forwardInvocation:)) == 0x0) goto loc_115f8e;

loc_115d8d:
rax = [NSInvocation _invocationWithMethodSignature:r15 frame:var_148];
r13 = rax;
[r12 forwardInvocation:rax];
var_150 = 0x0;
rbx = 0x0;
r12 = var_138;
goto loc_115dce;

loc_115f8e:
//错误日志
r14 = @selector(forwardInvocation:);
____forwarding___.cold.4(&var_130, r12);
rcx = r14;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- trouble ahead", var_140, rcx, r8, r9, var_150);
goto loc_115fba;

loc_115fba:
rbx = var_138;
goto loc_115fc1;

loc_115fc1:
rax = sel_getName(rbx);
r14 = rax;
rax = sel_getUid(rax);
if (rax != rbx) {
rcx = r14;
r8 = rax;
_CFLog(0x4, @"*** NSForwarding: warning: selector (%p) for message '%s' does not match selector known to Objective C runtime (%p)-- abort", var_138, rcx, r8, r9, var_150);
}
if (class_respondsToSelector(object_getClass(var_140), @selector(doesNotRecognizeSelector:)) == 0x0) goto loc_116034;

loc_11601b:
[var_140 doesNotRecognizeSelector:rdx];
asm { ud2 };
rax = loc_116034(rdi, rsi, rdx, rcx, r8, r9);
return rax;

loc_116034:
____forwarding___.cold.3(var_140);
goto loc_116040;

loc_115f46:
rbx = class_getSuperclass(r15);
r14 = object_getClassName(r14);
if (rbx == 0x0) {
rax = object_getClassName(var_140);
rcx = r14;
r8 = rax;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- did you forget to declare the superclass of '%s'?", var_140, rcx, r8, r9, var_150);
}
else {
rcx = r14;
_CFLog(0x4, @"*** NSForwarding: warning: object %p of class '%s' does not implement methodSignatureForSelector: -- trouble ahead", var_140, rcx, r8, r9, var_150);
}
goto loc_115fba;

loc_115f30:
r14 = @selector(forwardingTargetForSelector:);
____forwarding___.cold.2(var_140, r12, var_138, rcx, r8);
goto loc_115f46;
}

可以看到汇编伪代码的调用流程与看到的API调用流程差不多。


4.1.5 __forwarding__伪代码还原


还原主要逻辑伪代码如下:


#include <stdio.h>

@interface NSInvocation(additions)

+ (unsigned long long)requiredStackSizeForSignature:(NSMethodSignature *)signature;

-(id)_initWithMethodSignature:(id)arg1 frame:(void*)arg2 buffer:(void*)arg3 size:(unsigned long long)arg4;

+(id)_invocationWithMethodSignature:(id)arg1 frame:(void*)arg2;

@end


@interface NSObject(additions)

- (void)_forwardStackInvocation:(NSInvocation *)invocation;

@end


void forwardingTargetForSelector(Class cls, SEL sel, const char * className, id obj);
void methodSignatureForSelector(Class cls, id obj, SEL sel);
void doesNotRecognizeSelector(id obj, SEL sel);
void _forwardStackInvocation(id obj,NSMethodSignature *signature);
void forwardInvocation(id obj,NSMethodSignature *signature);

int ____forwarding___(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5) {
SEL sel = NULL;
id obj;
Class cls = object_getClass(obj);
const char * className = class_getName(cls);
forwardingTargetForSelector(cls,sel,className,obj);
return 0;
}

void forwardingTargetForSelector(Class cls, SEL sel, const char * className, id obj) {
//是否能响应 forwardingTargetForSelector,不能响应跳转 loc_115bab 否则继续执行 也就是forwardingTargetForSelector方法返回nil或者自身
if (class_respondsToSelector(cls, @selector(forwardingTargetForSelector:))) {
id obj = [cls forwardingTargetForSelector:sel];
if ((obj == nil) || (obj == cls)) {
methodSignatureForSelector(cls,obj,sel);
} else if (obj >= 0x0) {
//返回 forwardingTargetForSelector 备用消息接收者
// return obj;
} else {
//taggedpointer 处理
//返回NSInvocation size数据
}
} else {
//是否僵尸对象
if (strncmp(className, "_NSZombie_", 0xa)) {
methodSignatureForSelector(cls,obj,sel);
} else {
SEL currentSel = @selector(forwardingTargetForSelector:);
doesNotRecognizeSelector(obj,currentSel);
}
}
}


void methodSignatureForSelector(Class cls, id obj, SEL sel) {
if (class_respondsToSelector(cls, @selector(methodSignatureForSelector:))) {
NSMethodSignature *signature = [obj methodSignatureForSelector:sel];
if (signature) {
_forwardStackInvocation(obj,signature);
} else {
doesNotRecognizeSelector(obj,sel);
}
} else {
doesNotRecognizeSelector(obj,sel);
}
}

void _forwardStackInvocation(id obj,NSMethodSignature *signature) {
//是否能够响应_forwardStackInvocation
if (class_respondsToSelector(object_getClass(obj), @selector(_forwardStackInvocation:))) {
//执行dispatch_once相关逻辑
[NSInvocation requiredStackSizeForSignature:signature];
void *bytes;
// objc_constructInstance([NSInvocation class], bytes);
NSInvocation *invocation = [invocation _initWithMethodSignature:signature frame:NULL buffer:NULL size:bytes];
[obj _forwardStackInvocation:invocation];
const char * type = [signature methodReturnType];
//返回signature
} else {
forwardInvocation(obj,signature);
}
}

void forwardInvocation(id obj,NSMethodSignature *signature) {
//forwardInvocation的判断,如果没有实现直接跳转loc_115f8e
if (class_respondsToSelector(object_getClass(obj), @selector(forwardInvocation:))) {
NSInvocation *invocation = [NSInvocation _invocationWithMethodSignature:signature frame:NULL];
[obj forwardInvocation:invocation];
const char * type = [signature methodReturnType];
//返回signature
} else {
SEL sel = @selector(forwardInvocation:);
doesNotRecognizeSelector(obj,sel);
}
}

void doesNotRecognizeSelector(id obj, SEL sel) {
if (class_respondsToSelector(object_getClass(obj), @selector(doesNotRecognizeSelector:))) {
[obj doesNotRecognizeSelector:sel];
/*
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;
*/

} else {
/*
____forwarding___.cold.1();
rax = objc_opt_class(@class(NSInvocation));
*____forwarding___.invClass = rax;
rax = class_getInstanceSize(rax);
*____forwarding___.invClassSize = rax;
return rax;
*/

}
}
为了方便分析我这里class-dumpCoreFoundation头文件。手机端使用cycript进入SpringBoard应用,然后classdumpdyld导出CoreFoudation的头文件,最后拷贝到电脑端,具体操作如下:

cycript -p SpringBoard
@import net.limneos.classdumpdyld;
classdumpdyld.dumpBundle([NSBundle > bundleWithIdentifier:@"com.apple.CoreFoudation"]);
//输出导出头文件路径
@"Wrote all headers to /tmp/CoreFoundation"
//拷贝到电脑的相应目录
scp -r -P 12345 root@localhost:/tmp/CoreFoundation/ ./CoreFoundation_Headers/

伪代码流程图如下



反汇编流程与根据API分析的流程差不多。

  • forwardingTargetForSelector快速转发会对返回值会进行判断,如果是返回的自身或者nil直接进入下一流程(慢速转发)。
  • 如果返回taggedpointer有单独的处理。
  • methodSignatureForSelector慢速转发会先判断有没有实现_forwardStackInvocation(私有方法)。实现_forwardStackInvocation后不会再进入forwardInvocation流程,相当于_forwardStackInvocation是一个私有的前置条件。
  • methodSignatureForSelector如果没有返回签名信息不会继续进行下面的流程。
  • forwardInvocation没有实现就直接走到doesNotRecognizeSelector流程了。

4.2 流程分析


上篇文章分析resolveInstanceMethod在消息转发后还会调用一次resolveInstanceMethod(在日志文件中看到是在doesNotRecognizeSelector之前,methodSignatureForSelector之后)。那么实现对应的方法做下验证:

HPObject resolveInstanceMethod: HPObject-0x100008290-instanceMethod
-[HPObject forwardingTargetForSelector:] - instanceMethod
-[HPObject methodSignatureForSelector:] - instanceMethod
HPObject resolveInstanceMethod: HPObject-0x100008290-instanceMethod
-[HPObject doesNotRecognizeSelector:] - instanceMethod

证实是在methodSignatureForSelector之后,doesNotRecognizeSelector之前有一次进行了方法动态决议。那么为什么要这么处理呢?因为消息转发的过程中可能已经加入了对应的sel-imp,所以再给一次机会进行方法动态决议。这次决议后不会再进行消息转发。

但是在反汇编分析中并没有明确的再次进行动态方法决议的逻辑。


4.2.1 反汇编以及源码探究

那么在第二次调用resolveInstanceMethod前打断点查看下堆栈信息
macOS堆栈如下:

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 5.1
frame #0: 0x0000000100300f53 libobjc.A.dylib`resolveMethod_locked(inst=0x0000000000000000, sel="instanceMethod", cls=HPObject, behavior=0) at objc-runtime-new.mm:6339:13
frame #1: 0x00000001002ffbd5 libobjc.A.dylib`lookUpImpOrForward(inst=0x0000000000000000, sel="instanceMethod", cls=HPObject, behavior=0) at objc-runtime-new.mm:6601:16
frame #2: 0x00000001002d6df9 libobjc.A.dylib`class_getInstanceMethod(cls=HPObject, sel="instanceMethod") at objc-runtime-new.mm:6210:5
* frame #3: 0x00007fff2e33fc68 CoreFoundation`__methodDescriptionForSelector + 282
frame #4: 0x00007fff2e35b57c CoreFoundation`-[NSObject(NSObject) methodSignatureForSelector:] + 38
frame #5: 0x0000000100003a21 HPObjcTest`-[HPObject methodSignatureForSelector:](self=0x0000000100706a30, _cmd="methodSignatureForSelector:", aSelector="instanceMethod") at HPObject.m:29:12 [opt]
frame #6: 0x00007fff2e327fc0 CoreFoundation`___forwarding___ + 408
frame #7: 0x00007fff2e327d98 CoreFoundation`__forwarding_prep_0___ + 120
frame #8: 0x0000000100003c79 HPObjcTest`main + 153
frame #9: 0x00007fff683fecc9 libdyld.dylib`start + 1
frame #10: 0x00007fff683fecc9 libdyld.dylib`start + 1
可以看到methodSignatureForSelector调用后进入了__methodDescriptionForSelector随后调用了class_getInstanceMethod。查看汇编确实在__methodDescriptionForSelector中调用了class_getInstanceMethod


那么系统是如何从methodSignatureForSelector调用到__methodDescriptionForSelector的?
当前的methodSignatureForSelector的实现是:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSLog(@"%s - %@",__func__,NSStringFromSelector(aSelector));
return [super methodSignatureForSelector:aSelector];
}

如果改为返回nil呢?

HPObject resolveInstanceMethod: HPObject-0x100008288-instanceMethod
-[HPObject forwardingTargetForSelector:] - instanceMethod
-[HPObject methodSignatureForSelector:] - instanceMethod
-[HPObject doesNotRecognizeSelector:] - instanceMethod
这个时候发现没有第二次调用了,那也就是说核心逻辑在[super methodSignatureForSelector:aSelector]的实现中。
查看源码:

// Replaced by CF (returns an NSMethodSignature)
+ (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
_objc_fatal("+[NSObject methodSignatureForSelector:] "
"not available without CoreFoundation");
}

// Replaced by CF (returns an NSMethodSignature)
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
_objc_fatal("-[NSObject methodSignatureForSelector:] "
"not available without CoreFoundation");
}

注释说的已经很明显了实现在CoreFoundation中,直接搜索methodSignatureForSelector的反汇编实现:


/* @class NSObject */
-(void *)methodSignatureForSelector:(void *)arg2 {
rdx = arg2;
if ((rdx != 0x0) && (___methodDescriptionForSelector(objc_opt_class(), rdx) != 0x0)) {
rax = [NSMethodSignature signatureWithObjCTypes:rdx];
}
else {
rax = 0x0;
}
return rax;
}
  • sel不为nil的时候会调用___methodDescriptionForSelector。这样就串联起来了。

class_getInstanceMethod的实现如下:


Method class_getInstanceMethod(Class cls, SEL sel)
{
if (!cls || !sel) return nil;
lookUpImpOrForward(nil, sel, cls, LOOKUP_RESOLVER);
return _class_getMethod(cls, sel);
}

4.2.2 断点调试验证

既然上面已经清楚了resolveInstanceMethod第二次调用是methodSignatureForSelector之后调用的,那么不妨打个符号断点跟踪下methodSignatureForSelector:




显然只需要关心调用的函数以及跳转逻辑。

跟进去__methodDescriptionForSelector


这样通过断点也从methodSignatureForSelector定位到了resolveInstanceMethod

结论:

  • 实例方法 - methodSignatureForSelector-> ___methodDescriptionForSelector -> class_getInstanceMethod-> lookUpImpOrForward->resolveMethod_locked-> resolveInstanceMethod
  • 类方法 + methodSignatureForSelector -> ___methodDescriptionForSelector(传递的是元类) -> class_getInstanceMethod- lookUpImpOrForward->resolveMethod_locked-> resolveClassMethod

⚠️总结:

  1. 在methodSignatureForSelector内部调用了class_getInstanceMethod进行lookUpImpOrForward随后进入方法动态决议。这也就是class_getInstanceMethod调用第二次的来源入口。
  2. methodSignatureForSelector后第二次调用class_getInstanceMethod是为了再给一次进行消息查找和动态决议流程,因为消息转发流程过程中有可能实现了对应的sel-imp

动态方法决议以及消息转发整个流程如下:




五、消息发送查找总结

前面已经通过objc_msgSend分析整个消息缓存、查找、决议、转发整个流程。

  • 通过CacheLookup进行消息快速查找
    • 整个cache查找过程相当于是insert过程的逆过程,找到imp就解码跳转,否则进入慢速查找流程。
  • 通过lookUpImpOrForward进行消息慢速查找
    • 慢速查找涉及到递归查找,查找过程分为二分查找/循环查找。
    • 找到imp直接跳转,否则查找父类缓存。父类缓存依然找不到则在父类方法列表中查找,直到找到nil。查找到父类方法/缓存方法直接插入自己的缓存中。
  • imp找不到的时候进行方法动态决议
    • 当快速和慢速消息查找都没有找到imp的时候就进入了方法动态决议流程,在这个流程中主要是添加imp后再次进行快速慢速消息查找。
  • 之后进入本篇的消息转发流程,消息转发分为快速以及慢速。
    • 在动态方法决议没有返回imp的时候就进入到了消息转发阶段。
    • 快速消息转发提供一个备用消息接收者,返回值不能为nil与自身。这个过程不能修改参数和返回值。
    • 慢速消息转发需要提供消息签名,只要提供有效签名就可以解决消息发送错误问题。同时要实现forwardInvocation配合处理消息。
    • forwardInvocation配合处理消息,使target生效起作用。
    • 在慢速消息转发后系统会再进行一次慢速消息查找流程。这次不会再进行消息转发。
    • 消息转发仍然没有解决问题会进入doesNotRecognizeSelector,这个方法并不能处理错误,实现它仍然会报错。只是能拿到错误信息而已。

⚠️慢速消息转发后系统仍然给了一次机会进行 慢速消息查找!!!(并不仅仅是动态方法决议)。

整个流程如下:







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







收起阅读 »

iOS Hook原理 - 反hook& MonkeyDev

一、 反 hook 初探我们Hook别人的代码一般使用OC的MethodSwizzle,如果我们用fishhook将MethodSwizzle hook了,别人是不是就hook不了我们的代码了?1.1 创建主工程 AntiHookDemo创建一个工程AntiH...
继续阅读 »

一、 反 hook 初探

我们Hook别人的代码一般使用OCMethodSwizzle,如果我们用
fishhookMethodSwizzle hook了,别人是不是就hook不了我们的代码了?

1.1 创建主工程 AntiHookDemo

创建一个工程AntiHookDemo,页面中有两个按钮btn1btn2:



对应两个事件:

- (IBAction)btn1Click:(id)sender {
NSLog(@"click btn1");
}

- (IBAction)btn2Click:(id)sender {
NSLog(@"click btn2");
}

1.2 创建防护 HookManager (FrameWork 动态库)

这个时候要使用fishhook防护,在FrameWork中写防护代码。基于两点:

  1. Framework在主工程+ load执行之前执行+ load
  2. 别人注入的Framework也在防护代码之后。

创建一个HookManager Framework,文件结构下:




AntiHookManager.h

#import <Foundation/Foundation.h>
#import <objc/message.h>

//暴露给外界使用
CF_EXPORT void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

@interface AntiHookManager : NSObject

@end

AntiHookManager.m:

#import "AntiHookManager.h"
#import "fishhook.h"

@implementation AntiHookManager

+ (void)load {
//基本防护
struct rebinding exchange;
exchange.name = "method_exchangeImplementations";
exchange.replacement = hp_exchange;
exchange.replaced = (void *)&exchange_p;
struct rebinding bds[] = {exchange};
rebind_symbols(bds, 1);
}


//指回原方法
void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

void hp_exchange(Method _Nonnull m1, Method _Nonnull m2) {
//可以在这里进行上报后端等操作
NSLog(@"find Hook");
}

@end

HookManager.h中导出头文件:

#import <HookManager/AntiHookManager.h>

然后将AntiHookManager.h放入public Headers

修改主工程的ViewController.m如下:


#import <HookManager/HookManager.h>

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
exchange_p(class_getInstanceMethod(self.class, @selector(btn2Click:)),class_getInstanceMethod(self.class, @selector(test)));
}

- (void)test {
NSLog(@"self Hook Success");
}

- (IBAction)btn1Click:(id)sender {
NSLog(@"click btn1");
}

- (IBAction)btn2Click:(id)sender {
NSLog(@"click btn2");
}

@end

在工程中Hook自己的方法,这个时候运行主工程:


AntiHookDemo[1432:149145] click btn1
AntiHookDemo[1432:149145] self Hook Success

btn2能够被自己正常Hook


1.3 创建注入工程 HookDemo

  1. 在根目录创建APP文件夹以及Payload文件夹,拷贝AntiHookDemo.appAPP/Payload目录,压缩zip -ry AntiHookDemo.ipa Payload/生成.ipa文件
  2. 拷贝appResign.sh重签名脚本以及yololib注入工具到根目录。
  3. 创建HPHook注入Framework

HPHook代码如下:


#import "HPInject.h"
#import <objc/message.h>

@implementation HPInject

+ (void)load {
method_exchangeImplementations(class_getInstanceMethod(objc_getClass("ViewController"), @selector(btn1Click:)), class_getInstanceMethod(self, @selector(my_click)));
}

- (void)my_click {
NSLog(@"inject Success");
}

@end

编译运行:

AntiHookDemo[1437:149999] find  Hook
AntiHookDemo[1437:149999] click btn1
AntiHookDemo[1437:149999] self Hook Success

首先是检测到了Hook,其次自己内部btn2 hook成功了,btn1 hook没有注入成功。到这里暴露给自己用和防止别人Hook都已经成功了。对于三方库中正常使用到的Hook可以在防护代码中做逻辑判断可以加白名单等调用回原来的方法。如果自己的库在image list最后一个那么三方库其实已经Hook完了。

当然只Hook method_exchangeImplementations不能完全防护,还需要Hook class_replaceMethod以及method_setImplementation

这种防护方式破解很容易,一般不这么处理:
1.在Hopper中可以找到method_exchangeImplementations,直接在MachO中修改这个字符串HookManager中就Hook不到了(这里会直接crash,因为viewDidLoad中调用了exchange_p,对于有保护逻辑的就可以绕过了,并且method_exchangeImplementations没法做混淆)


2.可以很容易定位到防护代码,直接在防护代码之前Hook,或者将fishhook中的一些系统函数Hook也能破解。本质上是不执行防护代码。


二、MonkeyDev

MonkeyDev是逆向开发中一个常用的工具 MonkeyDev。能够帮助我们进行重签名和代码注入。


2.1 安装 MonkeyDev

theos安装(Cydia Substrate就是 theos中的工具)

sudo git clone --recursive https://github.com/theos/theos.git /opt/theos

配置环境变量

#逆向相关配置
#export THEOS=/opt/theos

#写入环境变量
#export PATH=$THEOS/bin:$PATH

运行nic.pl查看theos信息。



[error] Cowardly refusing to make a project inside $THEOS (/opt/theos/)出现这个错误则是export配置有问题。

指定Xcode

sudo xcode-select -s /Applications/Xcode.app

安装命令

这里是安装Xcode插件。安装完成后重启XcodeXcode中会出现MonkeyDev对应的功能:



  • MonkeyApp:自动给第三方应用集成RevealCycript和注入dylib的模块,支持调试dylib和第三方应用,支持Pod给第三放应用集成SDK,只需要准备一个砸壳后的ipa或者app文件即可。
  • MonkeyPod:提供了创建Pod的项目。
  • CaptainHook Tweak:使用CaptainHook提供的头文件进行OC函数的Hook以及属性的获取。
  • Command-line Tool:可以直接创建运行于越狱设备的命令行工具。
  • Logos Tweak:使用theos提供的logify.pl工具将.xm文件转成.mm文件进行编译,集成了CydiaSubstrate,可以使用MSHookMessageExMSHookFunctionHook OC函数和指定地址。


错误处理
1.MonkeyDev 安装出现:Types.xcspec not found
添加一个软连接:
sudo ln -s /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Xcode/PrivatePlugIns/IDEOSXSupportCore.ideplugin/Contents/Resources /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Xcode/Specifications

2.2 重签名

创建一个MonkeyDemo工程:


工程目录如下:



在工程目录下有一个TargetApp目录,直接将微信8.0.2版本拖进去:


编译运行工程:

这个时候就重签名成功了。相比用脚本自己跑方便很多,也能避免很多异常。

2.3 MonkeyDev 代码注入



工程配置

MonkeyDemo注入一下AntiHookDemo,将AntiHookDemo编译生成的App加入MonkeyDemoTargetApp中:


代码注入

MonkeyDemo工程MonkeyDemoDylib->Logos目录,.xm文件可以写OCC++C




MonkeyDemoDylib.xmtype改为Objective-C++ Preprocessed Source

这里面的默认代码就是Logos语法:




.xm默认打开方式修改为Xcode后重启Xcode就能识别代码了,否则就还是默认文本文件。将默认的代码删除,写Hook btn1Click的代码:

#import <UIKit/UIKit.h>

//要hook的类
%hook ViewController

//要hook的方法
- (void)btn1Click:(id)sender {
NSLog(@"Monkey Hook Success");
//调用原来的方法
%orig;
}

%end

直接运行工程后点击btn1

AntiHookDemo[9306:5972601] find  Hook
AntiHookDemo[9306:5972601] find Hook
AntiHookDemo[9309:5973617] Monkey Hook Success
AntiHookDemo[9350:5987306] click btn1




这个时候就Hook成功了,并且检测到了Hook。这里没有防护住是因为Monkey中用的是getImpsetImp
AntiHookManager做下改进:
AntiHookManager .h:

#import <Foundation/Foundation.h>
#import <objc/message.h>

//暴露给外界使用
CF_EXPORT void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

CF_EXPORT IMP _Nonnull (*getImp_p)(Method _Nonnull m);

CF_EXPORT IMP _Nonnull(*setImp_p)(Method _Nonnull m, IMP _Nonnull imp);

@interface AntiHookManager : NSObject

@end

AntiHookManager .m:

#import "AntiHookManager.h"
#import "fishhook.h"

@implementation AntiHookManager

+ (void)load {
//基本防护
struct rebinding exchange;
exchange.name = "method_exchangeImplementations";
exchange.replacement = hp_exchange;
exchange.replaced = (void *)&exchange_p;

struct rebinding setIMP;
setIMP.name = "method_setImplementation";
setIMP.replacement = hp_setImp;
setIMP.replaced = (void *)&setImp_p;


struct rebinding getIMP;
getIMP.name = "method_getImplementation";
getIMP.replacement = hp_getImp;
getIMP.replaced = (void *)&getImp_p;

struct rebinding bds[] = {exchange,setIMP,getIMP};
rebind_symbols(bds, 3);
}


//指回原方法
void (*exchange_p)(Method _Nonnull m1, Method _Nonnull m2);

IMP _Nonnull (*getImp_p)(Method _Nonnull m);

IMP _Nonnull(*setImp_p)(Method _Nonnull m, IMP _Nonnull imp);

void hp_exchange(Method _Nonnull m1, Method _Nonnull m2) {
//可以在这里进行上报后端等操作
NSLog(@"find Hook");
}

void (hp_getImp)(Method _Nonnull m) {
NSLog(@"find Hook getImp");
}

void (hp_setImp)(Method _Nonnull m, IMP _Nonnull imp) {
NSLog(@"find Hook setImp");
}

@end

这个时候控制台输出:


AntiHookDemo[1488:207119] find  Hook getImp
AntiHookDemo[1488:207119] find Hook
AntiHookDemo[1488:207119] find Hook getImp
AntiHookDemo[1488:207119] find Hook
AntiHookDemo[1488:207119] click btn1

点击btn1也没有Hook到了。在这里运行时有可能CrashJSEvaluateScript的时候,直接删除App重新跑一次就可以了。
libsubstrate.dylib解析的,
其实这里.xm文件是被libsubstrate.dylib解析成MonkeyDemoDylib.mm中的内容(.xm代码是不参与编译的):



MSHookMessageEx底层用的是setImpgetImpOC进行Hook的。

错误问题
1.Signing for "MonkeyDemoDylib" requires a development team. Select a development team in the Signing & Capabilities editor.

直接在该targetbuild settings 中添加CODE_SIGNING_ALLOWED=NO





2.Failed to locate Logos Processor. Is Theos installed? If not, see https://github.com/theos/theos/wiki/Inst allation.
出现这个错误一般是theos没有安装好。或者路径配置的有问题。

3.library not found for -libstdc++
需要下载对应的库到XCode目录中。参考:https://github.com/longyoung/libstdc.6.0.9-if-help-you-give-a-star

4.The WatchKit app’s Info.plist must have a WKCompanionAppBundleIdentifier key set to the bundle identifier of the companion app.
删除DerivedData重新运行。

5.This application or a bundle it contains has the same bundle identifier as this application or another bundle that it contains. Bundle identifiers must be unique.
这种情况大概率是手机上之前安装过相同bundleIdApp安装不同版本导致,需要删除重新安装。还有问题的话删除DerivedDatabundleId

6.This app contains a WatchKit app with one or more Siri Intents app extensions that declare IntentsSupported that are not declared in any of the companion app's Siri Intents app extensions. WatchKit Siri Intents extensions' IntentsSupported values must be a subset of the companion app's Siri Intents extensions' IntentsSupported values.
需要删除com.apple.WatchPlaceholder(在/opt/MonkeyDev/Tools目录中修改pack.sh):


rm -rf "${TARGET_APP_PATH}/com.apple.WatchPlaceholder" || true

然后删除DerivedData重新运行。

  1. LLVM Profile Error: Failed to write file "default.profraw": Operation not permitted
    这个说明App内部做了反调试防护。直接在Monkey中开启sysctl
rebind_symbols((struct rebinding[1]){{"sysctl", my_sysctl, (void*)&orig_sysctl}},1);
8.Attempted to load Reveal Library twice. Are you trying to load dynamic library with Reveal Framework already linked?
直接删除dylibOther Linker Flags的设置即可(可能的原因是手机端已经导入了这个库):



⚠️遇见莫名其妙的错误建议删除DerivedData重启Xcode重新运行。


总结

  • Hook
    • 使用fishhook Hookmethod_exchangeImplementationsclass_replaceMethodmethod_setImplementation
    • 需要在动态库中添加防护代码。
    • 本地导出原函数IMP供自己项目使用,配合白名单。
    • 这种防护很容易破解,一般不推荐这么使用。
  • MonkeyDev:逆向开发中一个常用的工具。
    • 重签名:很容易,直接拖进去.ipa或者.app运行工程就可以了。
    • 代码注入:Logos主要是编写.xm文件。底层依然是getImpsetImp的调用。



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

收起阅读 »

iOS逆向 - fishhook

一、Hook概述HOOK中文译为挂钩或钩子。在iOS逆向中是指改变程序运行流程的一种技术。通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术。只有了解其原理才能够对恶意代码进行有效的防护。比如很久之前的微信自动抢红包插件:1.1Hook的...
继续阅读 »

一、Hook概述

HOOK中文译为挂钩钩子。在iOS逆向中是指改变程序运行流程的一种技术。通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术。只有了解其原理才能够对恶意代码进行有效的防护。

比如很久之前的微信自动抢红包插件:


1.1Hook的几种方式

iOSHOOK技术的大致上分为5种:Method SwizzlefishhookCydia Substratelibffiinlinehook

1.1.1 Method Swizzle (OC)

利用OCRuntime特性,动态改变SEL(方法编号)和IMP(方法实现)的对应关系,达到OC方法调用流程改变的目的。主要用于OC方法。

可以将SEL 和 IMP 之间的关系理解为一本书的目录SEL 就像标题IMP就像页码。他们是一一对应的关系。(书的目录不一定一一对应,可能页码相同,理解就行。)。

Runtime提供了交换两个SELIMP对应关系的函数:

OBJC_EXPORT void
method_exchangeImplementations(Method _Nonnull m1, Method _Nonnull m2)
OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);

通过这个函数交换两个SELIMP对应关系的技术,称之为Method Swizzle(方法欺骗)


runtime中有3种方式实现方法交换:

  • method_exchangeImplementations:在分类中直接交换就可以了,如果不在分类需要配合class_addMethod实现原方法的回调。
  • class_replaceMethod:直接替换原方法。
  • method_setImplementation:重新赋值原方法,通过getImpsetImp配合。

1.1.2 fishhook (外部函数)

Facebook提供的一个动态修改链接mach-O文件的工具。利用MachO文件加载原理,通过修改懒加载非懒加载两个表的指针达到C(系统C函数)函数HOOK的目的。fishhook

总结下来是:dyld 更新 Mach-O 二进制的 __DATA segment 的 __la_symbol_str 中的指针,使用 rebind_symbol方法更新两个符号位置来进行符号的重新绑定。


1.1.3 Cydia Substrate

Cydia Substrate 原名为 Mobile Substrate ,主要作用是针对OC方法C函数以及函数地址进行HOOK操作。并不仅仅针对iOS而设计,安卓一样可以用。Cydia Substrate官方

Cydia Substrate主要分为3部分:Mobile HookerMobileLoadersafe mode

Mobile Hooker

它定义了一系列的宏和函数,底层调用objcruntimefishhook来替换系统或者目标应用的函数。其中有两个函数:


void MSHookMessageEx(Class class, SEL selector, IMP replacement, IMP result) 
MSHookFunction :(inline hook)主要作用于CC++函数 MSHookFunction。 Logos语法的%hook就是对这个函数做了一层封装。

void MSHookFunction(voidfunction,void* replacement,void** p_original)

MobileLoader

MobileLoader用于加载第三方dylib在运行的应用程序。启动时MobileLoader会根据规则把指定目录的第三方的动态库加载进去,第三方的动态库也就是我们写的破解程序。

safe mode

破解程序本质是dylib寄生在别人进程里。 系统进程一旦出错,可能导致整个进程崩溃,崩溃后就会造成iOS瘫痪。所以CydiaSubstrate引入了安全模式,在安全模式下所有基于CydiaSubstratede的三方dylib都会被禁用,便于查错与修复。

1.1.4 libffi

基于libbfi动态调用C函数。使用libffi中的ffi_closure_alloc构造与原方法参数一致的"函数" (stingerIMP),以替换原方法函数指针;此外,生成了原方法和Block的调用的参数模板cifblockCif。方法调用时,最终会调用到void _st_ffi_function(ffi_cif *cif, void *ret, void **args, void *userdata), 在该函数内,可获取到方法调用的所有参数、返回值位置,主要通过ffi_call根据cif调用原方法实现和切面blockAOP库 StingerBlockHook就是使用libbfi做的。

1.1.5 inlinehook 内联钩子 (静态)

Inline Hook 就是在运行的流程中插入跳转指令来抢夺运行流程的一个方法。大体分为三步:

  • 将原函数的前 N 个字节搬运到 Hook 函数的前 N 个字节;
  • 然后将原函数的前 N 个字节填充跳转到 Hook 函数的跳转指令;
  • 在 Hook 函数末尾几个字节填充跳转回原函数 +N 的跳转指令;


MSHookFunction就是inline hook

基于 Dobby 的 Inline HookDobby 是通过插入 __zDATA 段和__zTEXT 段到 Mach-O 中。

  • __zDATA 用来记录 Hook 信息(Hook 数量、每个 Hook 方法的地址)、每个 Hook 方法的信息(函数地址、跳转指令地址、写 Hook 函数的接口地址)、每个 Hook 的接口(指针)。
  • __zText 用来记录每个 Hook 函数的跳转指令。

dobby
Dobby(原名:HOOKZz)是一个全平台的inlineHook框架,它用起来就和fishhook一样。
Dobby 通过 mmap 把整个 Mach-O 文件映射到用户的内存空间,写入完成保存本地。所以 Dobby 并不是在原 Mach-O上进行操作,而是重新生成并替换。



二 fishHook

2.1 fishhook的使用

fishhook源码.h文件中只提供了两个函数和一个结构体rebinding

rebind_symbols、rebind_symbols_image


FISHHOOK_VISIBILITY
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);

FISHHOOK_VISIBILITY
int rebind_symbols_image(void *header,
intptr_t slide,
struct rebinding rebindings[],
size_t rebindings_nel)
;

  • rebindings[]:存放rebinding结构体的数组(可以同时交换多个函数)。
  • rebindings_nelrebindings数组的长度。
  • slideASLR
  • headerimageHeader

只有两个函数重新绑定符号,两个函数的区别是一个指定image一个不指定。按照我们一般的理解放在前面的接口更常用,参数少的更简单。

rebinding

struct rebinding {
const char *name;//需要HOOK的函数名称,C字符串
void *replacement;//新函数的地址
void **replaced;//原始函数地址的指针!
};

  • name:要HOOK的函数名称,C字符串。
  • replacement:新函数的地址。(函数指针,也就是函数名称)。
  • replaced:原始函数地址的指针。(二级指针)。

2.1.1 Hook NSLog

现在有个需求,Hook系统的NSLog函数。
Hook代码:

- (void)hook_NSLog {
struct rebinding rebindNSLog;
rebindNSLog.name = "NSLog";
rebindNSLog.replacement = HP_NSLog;
rebindNSLog.replaced = (void *)&sys_NSLog;

struct rebinding rebinds[] = {rebindNSLog};

rebind_symbols(rebinds, 1);
}

//原函数,函数指针
static void (*sys_NSLog)(NSString *format, ...);

//新函数
void HP_NSLog(NSString *format, ...) {
format = [format stringByAppendingFormat:@"\n Hook"];
//调用系统NSLog
sys_NSLog(format);
}
调用:

    [self hook_NSLog];
NSLog(@"hook_NSLog");
输出:

hook_NSLog
Hook

这个时候就已经HookNSLog,走到了HP_NSLog中。
Hook代码调用完毕,sys_NSLog保存系统NSLog原地址,NSLog指向HP_NSLog

2.1.2 Hook 自定义 C 函数

Hook一下自己的C函数:

void func(const char * str) {
NSLog(@"%s",str);
}

- (void)hook_func {
struct rebinding rebindFunc;
rebindFunc.name = "func";
rebindFunc.replacement = HP_func;
rebindFunc.replaced = (void *)&original_func;

struct rebinding rebinds[] = {rebindFunc};

rebind_symbols(rebinds, 1);
}
//原函数,函数指针
static void (*original_func)(const char * str);

//新函数
void HP_func(const char * str) {
NSLog(@"Hook func");
original_func(str);
}
调用:

 [self hook_func];
func("HotPotCat");
输出:
HotPotCat

这个时候可以看到没有Hookfunc

结论:自定义的函数fishhook hook 不了,系统的可以hook

2.2 fishhook原理

fishHOOK可以HOOK C函数,但是我们知道函数是静态的,也就是说在编译的时候,编译器就知道了它的实现地址,这也是为什么C函数只写函数声明调用时会报错。那么为什么fishhook还能够改变C函数的调用呢?难道函数也有动态的特性存在?

是否意味着C Hook就必须修改调用地址?那意味着要修改二进制。(原理上使用汇编可以实现。fishhook不是这么处理的)

那么系统函数和本地函数区别到底在哪里?

2.2.1 符号 & 符号绑定 & 符号表 & 重绑定符号

NSLog函数的地址在编译的那一刻并不知道NSLog的真实地址。NSLogFoundation框架中。在运行时NSLog的地址在 共享缓存 中。在整个手机中只有dyld知道NSLog的真实地址。

LLVM编译器生成MachO文件时,如果让我们做就先空着系统函数的地址,等运行起来再替换。我们知道MachO中分为Text(只读)和Data(可读可写),那么显然这种方式行不通。

那么可行的方案是在Data段放一个 占位符(8字节)让代码编译的时候直接bl 占位符。在运行的时候dyld加载应用的时候将Data段的地址修改为NSLog真实地址,代码bl 占位符没有变 。这个技术就叫做 PIC(position independent code`)位置无关代码。(实际不是这么简单)

  • 占位符 就叫做 符号
  • dylddata段符号进行修改的这个过程叫做 符号绑定
  • 一个又一个的符号放在一起形成了一个列表,叫做 符号表

对于外部的C函数通过 符号 找 地址 也就给了我们机会动态的Hook外部C函数。OC是修改SELIMP对应的关系,符号 也是修改符号所对应的地址。这个动作叫做 重新绑定符号表。这也就是fishhook``hook的原理。

2.2.2验证

Hook NSLog前后分别调用NSLog:

    NSLog(@"before");
[self hook_NSLog];
NSLog(@"after");




MachO中我们能看到懒加载和非懒加载符号表,dyld绑定过程中绑定的是非懒加载符号和弱符号的。NSLog是懒加载符号,只有调用的时候才去绑定。

MachO中可以看到_NSLogData(值)是0000000100006960offset为:0x8010
在第一个NSLog处打个断点 运行查看:
主程序开始0x0000000100b24000ASLR0xb24000

0x0000000100b24000 + 0x8010中存储的内容为0x0100b2a960
0000000100006960 + 0xb24000 (ASLR) = 0x100B2A960
所以这里就对应上了。0x0100b2a960这个地址就是(符号表中的值其实是一个代码的地址,指向本地代码。)。



执行完第一个NSLog后(hook前):



符号表指向了HP_NSLog

这也就是fishhook能够Hook的真正原因(修改懒加载符号表)。

2.3 符号绑定过程(间接)

刚才在上面NSLog第一次执行之前我们拿到的地址0x0100b2a960实际上指向一段本地代码,加上ASLR后执行对应地址的代码然后就修改了懒加载符号表。

那么这个过程究竟是怎么做的呢?

先说明一些符号的情况:

  • 本地符号:只能本MachO用。
  • 全局符号:暴露给外面用。
  • 间接符号:当我们要调用外部函数/方法时,在编译时期地址是不知道的。比如系统的NSLog

间接符号专门有个符号表Indirect Symbols





比首地址大0x0000000100e0c000,所以这个地址在本MachO中。
0x100e12998 - 0x0000000100e0c000 = 0x6998

6998MachOSymbol Stubs中:





这个时候就对应上了:



这段代码的意思是执行桩中的代码找到符号表中代码跳转执行(0000000100006A28)。

6A28这段代码在__stub_helper中:



对应上了。实际上执行的是dyld_stub_binder。也就是说懒加载符号表里面的初始值都是执行符号绑定的函数

dyld_stub_binder是外部函数,那么怎么得到的dyld_stub_binder函数呢?



所以dyld_stub_binder是通过去非懒加载表中查找。
验证 :




验证确认,No-Lazy Symbol Pointers表中默认值是0

符号绑定过程:

  • 程序一运行,先绑定No-Lazy Symbol Pointers表中dyld_stub_binder的值。
  • 调用NSLog先找桩,执行桩中的代码。桩中的代码是找懒加载符号表中的代码去执行。
  • 懒加载符号表中的初始值是本地的源代码,这个代码去NoLazy表中找绑定函数地址。
  • 进入dyldbinder函数进行绑定。

binder函数执行完毕后就调用第一次的NSLog了。这个时候再看一下懒加载符号表中的符号:




符号已经变了。这个时候符号就已经绑定成功了。

接着执行第二次NSLog,这个时候依然是去找桩中的代码执行:



这个时候通过桩直接跳到了真实地址(还是虚拟的)。这个做的原因是符号表中保存地址执行代码,代码是保存在代码段的(桩)。





  • 外部函数调用时执行桩中的代码(__TEXT,__stubs)。
  • 桩中的代码去懒加载符号表中找地址执行(__DATA,__la_symbo_ptrl)。
    • 通过懒加载符号表中的地址去执行。要么直接调用函数地址(绑定过了),要么去__TEXT,__stubhelper中找绑定函数进行绑定。懒加载符号表中默认保存的是寻找binder的代码。
  • 懒加载中的代码去__TEXT,__stubhelper中执行绑定代码(binder函数)。
  • 绑定函数在非懒加载符号表中(__DATA._got),程序运行就绑定好了dyld

2.4 通过符号找字符串

上面使用fishhook的时候我们是通过rebindNSLog.name = "NSLog";告诉fishhookhook NSLog。那么fishhook通过NSLog怎么找到的符号的呢?

首先,我们清楚在绑定的时候是去Lazy Symbol中去找的NSLog对应的绑定代码:




找的是0x00008008这个地址,在Lazy SymbolNSLog排在第一个。

Indirect Symbols中可以看到顺序和Lazy Symbols中相同,也就是要找Lazy Symbols中的符号,只要找到Indirect Symbols中对应的第几个就可以了。




那么怎么确认Indirect Symbols中的第几个呢?
Indirect Symbolsdata对应值(十六进制)这里NSLog101,这个代表着NSLog在总的符号表(Symbols)中的角标:



在这里我们可以看到NSLogString Table中偏移为0x98(十六进制)。


通过偏移值计算得到0xCC38就确认到了_NSLog(长度+首地址)。

这里通过.隔开,函数名前面有_

这样我们就从Lazy Symbols -> Indirect Symbols -> Symbols - > String Table 通过符号找到了字符串。那么fishhook的过程就是这么处理的,通过遍历所有符号和要hook的数组中的字符串做对比。

fishhook中有一张图说明这个关系:




这里是通过符号查找close字符串。

  1. Lazy Symbol Pointer Tableclose index1061
  2. Indirect Symbol Table 1061 对应的角标为0X00003fd7(十进制16343)。
  3. Symbol Table找角标16343对应的字符串表中的偏移值70026
  4. String Table中找首地址+偏移值(70026)就找到了close
    字符串。

实际的原理还是通过传递的字符串找到符号进行替换:通过字符串找符号过程:

  1. String Table中找到字符串计算偏移值。
  2. 通过偏移值在Symbols中找到角标。
  3. 通过角标在Indirect Symbols中找到对应的符号。这个时候就能拿到这个符号的index了。
  4. 通过找到的indexLazy Symbols中找到对应index的符号。

2.5 去掉符号&恢复符号

符号本身在MachO文件中,占用包体积大小 ,在我们分析别人的App时符号是去掉的。

2.5.1 去除符号

符号基本分为:全局符号、间接符号(导出&导入)、本地符号。
对于App来说会去掉所有符号(间接符号除外)。对于动态库来说要保留全局符号(外部要调用)。

去掉符号在Build setting中设置:




  • Deployment Postprocessing:设置为YES则在编译阶段去符号,否则在打包阶段去符号。
  • Strip StyleAll Symbols去掉所有符号(间接除外),Non-Global Symbols去掉除全局符号外的符号。Debugging Symbols去掉调试符号。

设置Deployment PostprocessingYESStrip StyleAll Symbols。编译查看多了一个.bcsymbolmap文件,这个文件就是bitcode


这个时候的MachO文件中Symbols就只剩下间接符号表中的符号了:


其中
value为函数的实现地址(imp)。间接符号不会找到符号表中地址执行,是找Lazy Symbol Table中的地址。

代码中打断点就断不住了:




先计算出偏移值,下次直接ASLR+偏移值直接断点。这个也就是动态调试常用的方法。


2.5.2 恢复符号

前面动态调试下断点比较麻烦,如果能恢复符号的话就方便很多了。
在上面的例子中去掉所有符号后Symbol Table中只有间接符号了。虽然符号表中没有了,但是类列表和方法列表中依然存在。

这也就为我们提供了创建Symbol Table的机会。
可以通过restore-symbol工具恢复符号(只能恢复oc的,runtime机制导致):./restore-symbol 原始Macho文件 -o 恢复后文件

./restore-symbol FishHookDemo -o recoverDemo



这个时候就可以重签名后进行动态调试了。

2.6 fishhook源码解析

rebind_symbols
rebind_symbols的实现:

//第一次是拿dyld的回调,之后是手动拿到所有image去调用。这里因为没有指定image所以需要拿到所有的。
int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
//prepend_rebindings的函数会将整个 rebindings 数组添加到 _rebindings_head 这个链表的头部
//Fishhook采用链表的方式来存储每一次调用rebind_symbols传入的参数,每次调用,就会在链表的头部插入一个节点,链表的头部是:_rebindings_head
int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
//根据上面的prepend_rebinding来做判断,如果小于0的话,直接返回一个错误码回去
if (retval < 0) {
return retval;
}
//根据_rebindings_head->next是否为空判断是不是第一次调用。
if (!_rebindings_head->next) {
//第一次调用的话,调用_dyld_register_func_for_add_image注册监听方法.
//已经被dyld加载的image会立刻进入回调。之后的image会在dyld装载的时候触发回调。这里相当于注册了一个回调到 _rebind_symbols_for_image 函数。
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
//不是第一次调用,遍历已经加载的image,进行的hook
uint32_t c = _dyld_image_count();//这个相当于 image list count
for (uint32_t i = 0; i < c; i++) {
//遍历重新绑定image header aslr
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
return retval;
}
  • 首先通过prepend_rebindings函数生成链表,存放所有要Hook的函数。
  • 根据_rebindings_head->next是否为空判断是不是第一次调用,第一次调用走系统的回调,第二次则自己获取所有的image list进行遍历。
  • 最后都会走_rebind_symbols_for_image函数。

  • _rebind_symbols_for_image

    //两个参数 header  和 ASLR
    static void _rebind_symbols_for_image(const struct mach_header *header,
    intptr_t slide) {
    //_rebindings_head 参数是要交换的数据,head的头
    rebind_symbols_for_image(_rebindings_head, header, slide);
    }

    这里直接调用了rebind_symbols_for_image,传递了head链表地址。

    rebind_symbols_image

    int rebind_symbols_image(void *header,
    intptr_t slide,
    struct rebinding rebindings[],
    size_t rebindings_nel) {
    struct rebindings_entry *rebindings_head = NULL;
    int retval = prepend_rebindings(&rebindings_head, rebindings, rebindings_nel);
    //如果指定image就直接调用了 rebind_symbols_for_image,没有遍历了。
    rebind_symbols_for_image(rebindings_head, (const struct mach_header *) header, slide);
    if (rebindings_head) {
    free(rebindings_head->rebindings);
    }
    free(rebindings_head);
    return retval;
    }

    底层和rebind_symbols都调用到了rebind_symbols_for_image,由于给定了image所以不需要循环遍历。

    rebind_symbols_for_image

    //回调的最终就是这个函数! 三个参数:要交换的数组  、 image的头 、 ASLR的偏移
    static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
    const struct mach_header *header,
    intptr_t slide) {

    /*dladdr() 可确定指定的address 是否位于构成进程的进址空间的其中一个加载模块(可执行库或共享库)内,如果某个地址位于在其上面映射加载模块的基址和为该加载模块映射的最高虚拟地址之间(包括两端),则认为该地址在加载模块的范围内。如果某个加载模块符合这个条件,则会搜索其动态符号表,以查找与指定的address 最接近的符号。最接近的符号是指其值等于,或最为接近但小于指定的address 的符号。
    */

    /*
    如果指定的address 不在其中一个加载模块的范围内,则返回0 ;且不修改Dl_info 结构的内容。否则,将返回一个非零值,同时设置Dl_info 结构的字段。
    如果在包含address 的加载模块内,找不到其值小于或等于address 的符号,则dli_sname 、dli_saddr 和dli_size字段将设置为0 ; dli_bind 字段设置为STB_LOCAL , dli_type 字段设置为STT_NOTYPE 。
    */


    // typedef struct dl_info {
    // const char *dli_fname; //image 镜像路径
    // void *dli_fbase; //镜像基地址
    // const char *dli_sname; //函数名字
    // void *dli_saddr; //函数地址
    // } Dl_info;

    Dl_info info;//拿到image的信息
    //dladdr函数就是在程序里面找header
    if (dladdr(header, &info) == 0) {
    return;
    }
    //准备从MachO里面去找!
    segment_command_t *cur_seg_cmd;//临时变量
    //这里与MachOView中看到的对应
    segment_command_t *linkedit_segment = NULL;//SEG_LINKEDIT
    struct symtab_command* symtab_cmd = NULL;//LC_SYMTAB 符号表地址
    struct dysymtab_command* dysymtab_cmd = NULL;//LC_DYSYMTAB 动态符号表地址
    //cur为了跳过header的大小,找loadCommands cur = 首地址 + mach_header大小
    uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
    //循环load commands找对应的 SEG_LINKEDIT LC_SYMTAB LC_DYSYMTAB
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    cur_seg_cmd = (segment_command_t *)cur;
    //这里`SEG_LINKEDIT`获取和`LC_SYMTAB`与`LC_DYSYMTAB`不同是因为在`MachO`中分别对应`LC_SEGMENT_64(__LINKEDIT)`、`LC_SYMTAB`、`LC_DYSYMTAB`
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
    if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
    linkedit_segment = cur_seg_cmd;
    }
    } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
    symtab_cmd = (struct symtab_command*)cur_seg_cmd;
    } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
    dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
    }
    }
    //有任何一项为空就直接返回,nindirectsyms表示间接符号表中符号数量,没有则直接返回
    if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
    !dysymtab_cmd->nindirectsyms) {
    return;
    }

    // Find base symbol/string table addresses
    //符号表和字符串表都属于data段中的linkedit,所以以linkedit基址+偏移量去获取地址(这里的偏移量不是整个macho的偏移量,是相对基址的偏移量)
    //链接时程序的基址 = __LINKEDIT.VM_Address -__LINKEDIT.File_Offset + silde的改变值
    uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    //printf("地址:%p\n",linkedit_base);
    //符号表的地址 = 基址 + 符号表偏移量
    nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
    //字符串表的地址 = 基址 + 字符串表偏移量
    char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);

    // Get indirect symbol table (array of uint32_t indices into symbol table)
    //动态(间接)符号表地址 = 基址 + 动态符号表偏移量
    uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

    cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
    cur_seg_cmd = (segment_command_t *)cur;
    if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
    //寻找到load command 中的data【LC_SEGEMNT_64(__DATA)】,相当于拿到data段的首地址
    if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
    strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
    continue;
    }

    for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
    section_t *sect =
    (section_t *)(cur + sizeof(segment_command_t)) + j;
    //找懒加载表(lazy symbol table)
    if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
    //找到直接调用函数 perform_rebinding_with_section,这里4张表就都已经找到了。传入要hook的数组、ASLR、以及4张表
    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
    }
    //非懒加载表(Non-Lazy symbol table)
    if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
    }
    }
    }
    }
    }
    • 找到SEG_LINKEDITLC_SYMTABLC_DYSYMTABload commans

    SEG_LINKEDIT获取和LC_SYMTABLC_DYSYMTAB不同是因为在Load Commands中本来就不同,我们解析其它字段也要做类似操作
    • 根据linkedit和偏移值分别找到符号表的地址字符串表的地址以及间接符号表地址
    • 遍历load commandsdata段找到懒加载符号表非懒加载符号表
    • 找到表的同时就直接调用perform_rebinding_with_section进行hook替换函数符号。

    perform_rebinding_with_section

    //rebindings:要hook的函数链表,可以理解为数组
    //section:懒加载/非懒加载符号表地址
    //slide:ASLR
    //symtab:符号表地址
    //strtab:字符串标地址
    //indirect_symtab:动态(间接)符号表地址
    static void perform_rebinding_with_section(struct rebindings_entry *rebindings,
    section_t *section,
    intptr_t slide,
    nlist_t *symtab,
    char *strtab,
    uint32_t *indirect_symtab) {
    //nl_symbol_ptr和la_symbol_ptrsection中的reserved1字段指明对应的indirect symbol table起始的index。也就是第几个这里是和间接符号表中相对应的
    //这里就拿到了index
    uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
    //slide+section->addr 就是符号对应的存放函数实现的数组也就是我相应的__nl_symbol_ptr和__la_symbol_ptr相应的函数指针都在这里面了,所以可以去寻找到函数的地址。
    //indirect_symbol_bindings中是数组,数组中是函数指针。相当于lazy和non-lazy中的data
    void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
    //遍历section里面的每一个符号(懒加载/非懒加载)
    for (uint i = 0; i < section->size / sizeof(void *); i++) {
    //找到符号在Indrect Symbol Table表中的值
    //读取indirect table中的数据
    uint32_t symtab_index = indirect_symbol_indices[i];
    if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
    symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
    continue;
    }
    //以symtab_index作为下标,访问symbol table,拿到string table 的偏移值
    uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
    //获取到symbol_name 首地址 + 偏移值。(char* 字符的地址)
    char *symbol_name = strtab + strtab_offset;
    //判断是否函数的名称是否有两个字符,因为函数前面有个_,所以方法的名称最少要1个
    bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
    //遍历最初的链表,来判断名字进行hook
    struct rebindings_entry *cur = rebindings;
    while (cur) {
    for (uint j = 0; j < cur->rebindings_nel; j++) {
    //这里if的条件就是判断从symbol_name[1]两个函数的名字是否都是一致的,以及判断字符长度是否大于1
    if (symbol_name_longer_than_1 &&
    strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
    //判断replaced的地址不为NULL 要替换的自己实现的方法和rebindings[j].replacement的方法不一致。
    if (cur->rebindings[j].replaced != NULL &&
    indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
    //让rebindings[j].replaced保存indirect_symbol_bindings[i]的函数地址,相当于将原函数地址给到你定义的指针的指针。
    *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
    }
    //替换内容为自己自定义函数地址,这里就相当于替换了内存中的地址,下次桩直接找到lazy/non-lazy表的时候直接就走这个替换的地址了。
    indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
    //替换完成跳转外层循环,到(懒加载/非懒加载)数组的下一个数据。
    goto symbol_loop;
    }
    }
    //没有找到就找自己要替换的函数数组的下一个函数。
    cur = cur->next;
    }
    symbol_loop:;
    }
    }
    • 首先通过懒加载/非懒加载符号表和间接符号表找到所有的index
    • 将懒加载/非懒加载符号表的data放入indirect_symbol_bindings数组中。
    indirect_symbol_bindings就是存放lazynon-lazy表中的data数组:
    • 遍历懒加载/非懒加载符号表。
      • 读取indirect_symbol_indices找到符号在Indrect Symbol Table表中的值放入symtab_index
      • symtab_index作为下标,访问symbol table,拿到string table的偏移值。
      • 根据 strtab_offset偏移值获取字符地址symbol_name,也就相当于字符名称。
      • 循环遍历rebindings也就是链表(自定义的Hook数据)
      • 判断&symbol_name[1]rebindings[j].name两个函数的名字是否都是一致的,以及判断字符长度是否大于1
      • 相同则先保存原地址到自定义函数指针(如果replaced传值的话,没有传则不保存)。并且用要Hook的目标函数replacement替换indirect_symbol_bindings,这里就完成了Hook
    • reserved1确认了懒加载和非懒加载符号在间接符号表中的index值。

    疑问点:懒加载和非懒加载怎么和间接符号表index对应的呢?
    直接Hook dyld_stub_binder以及NSLog看下index对应的值:




    在间接符号表中非懒加载符号从20开始供两个,懒加载从22开始,这也就对应上了。这也就验证了懒加载和非懒加载符号都在间接符号表中能对应上。

    总结


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



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

    收起阅读 »

    petite-vue源码分析:无虚拟DOM的极简版Vue

    vue
    最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码...
    继续阅读 »

    最近发现Vue增加了一个petite-vue的仓库,大概看了一下,这是一个无虚拟DOM的mini版Vue,前身貌似是vue-lite(瞎猜的~),主要用于在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。颇有意思,于是看了下源码(v0.2.3),整理了本文。



    起步


    开发调试环境


    整个项目的开发环境非常简单


    git clone git@github.com:vuejs/petite-vue.git

    yarn

    # 使用vite启动
    npm run dev

    # 访问http://localhost:3000/

    (不得不说,用vite来搭开发环境还是挺爽的~


    新建一个测试文件exmaples/demo.html,写点代码


    <script type="module">
    import { createApp, reactive } from '../src'

    createApp({
    msg: "hello"
    }).mount("#app")
    </script>

    <div id="app">
    <h1>{{msg}}</h1>
    </div>

    然后访问http://localhost:3000/demo.html即可


    目录结构


    从readme可以看见项目与标准vue的一些差异



    • Only ~5.8kb,体积很小

    • Vue-compatible template syntax,与Vue兼容的模板语法

    • DOM-based, mutates in place,基于DOM驱动,就地转换

    • Driven by @vue/reactivity,使用@vue/reactivity驱动


    目录结构也比较简单,使用ts编写,外部依赖基本上只有@vue/reactivity



    核心实现


    createContext


    从上面的demo代码可以看出,整个项目从createApp开始。


    export const createApp = (initialData?: any) => {
    // root context
    const ctx = createContext()
    if (initialData) {
    ctx.scope = reactive(initialData) // 将初始化数据代理成响应式
    }
    // app的一些接口
    return {
    directive(name: string, def?: Directive) {},
    mount(el?: string | Element | null) {},
    unmount() {}
    }
    }

    关于Vue3中的reactive,可以参考之前整理的:Vue3中的数据侦测reactive,这里就不再展开了。


    createApp中主要是通过createContext创建根context,这个上下文现在基本不陌生了,来看看createContext


    export const createContext = (parent?: Context): Context => {
    const ctx: Context = {
    ...parent,
    scope: parent ? parent.scope : reactive({}),
    dirs: parent ? parent.dirs : {}, // 支持的指令
    effects: [],
    blocks: [],
    cleanups: [],
    // 提供注册effect回调的接口,主要使用调度器来控制什么时候调用
    effect: (fn) => {
    if (inOnce) {
    queueJob(fn)
    return fn as any
    }
    // @vue/reactivity中的effect方法
    const e: ReactiveEffect = rawEffect(fn, {
    scheduler: () => queueJob(e)
    })
    ctx.effects.push(e)
    return e
    }
    }
    return ctx
    }

    稍微看一下queueJob就可以发现,还是Vue中熟悉的nextTick实现,



    • 通过一个全局变量queue队列保存回调

    • 在下一个微任务处理阶段,依次执行queue中的每一个回调,然后清空queue


    mount


    基本使用


    createApp().mount("#app")

    mount方法最主要的作用就是处理el参数,找到应用挂载的根DOM节点,然后执行初始化流程


    mount(el?: string | Element | null) {
    let roots: Element[]
    // ...根据el参数初始化roots
    // 根据el创建Block实例
    rootBlocks = roots.map((el) => new Block(el, ctx, true))
    return this
    }

    Block是一个抽象的概念,用于统一DOM节点渲染、插入、移除和销毁等操作。


    下图是依赖这个Block的地方,可以看见主要在初始化、iffor这三个地方使用



    看一下Block的实现


    // src/block.ts
    export class Block {
    template: Element | DocumentFragment
    ctx: Context
    key?: any
    parentCtx?: Context

    isFragment: boolean
    start?: Text
    end?: Text

    get el() {
    return this.start || (this.template as Element)
    }

    constructor(template: Element, parentCtx: Context, isRoot = false) {
    // 初始化this.template
    // 初始化this.ctx

    // 构建应用
    walk(this.template, this.ctx)
    }
    // 主要在新增或移除时使用,可以先不用关心实现
    insert(parent: Element, anchor: Node | null = null) {}
    remove() {}
    teardown() {}
    }

    这个walk方法,主要的作用是递归节点和子节点,如果之前了解过递归diff,这里应该比较熟悉。但petite-vue中并没有虚拟DOM,因此在walk中会直接操作更新DOM。


    export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
    const type = node.nodeType
    if (type === 1) {
    // 元素节点
    const el = node as Element
    // ...处理 如v-if、v-for
    // ...检测属性执行对应的指令处理 applyDirective,如v-scoped、ref等

    // 先处理子节点,在处理节点自身的属性
    walkChildren(el, ctx)

    // 处理节点属性相关的自定,包括内置指令和自定义指令
    } else if (type === 3) {
    // 文本节点
    const data = (node as Text).data
    if (data.includes('{{')) {
    // 正则匹配需要替换的文本,然后 applyDirective(text)
    applyDirective(node, text, segments.join('+'), ctx)
    }
    } else if (type === 11) {
    walkChildren(node as DocumentFragment, ctx)
    }
    }

    const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
    let child = node.firstChild
    while (child) {
    child = walk(child, ctx) || child.nextSibling
    }
    }

    可以看见会根据node.nodeType区分处理处理



    • 对于元素节点,先处理了节点上的一些指令,然后通过walkChildren处理子节点。

      • v-if,会根据表达式决定是否需要创建Block然后执行插入或移除

      • v-for,循环构建Block,然后执行插入



    • 对于文本节点,替换{{}}表达式,然后替换文本内容


    v-if


    来看看if的实现,通过branches保存所有的分支判断,activeBranchIndex通过闭包保存当前位于的分支索引值。


    在初始化或更新时,如果某个分支表达式结算结果正确且与上一次的activeBranchIndex不一致,就会创建新的Block,然后走Block构造函数里面的walk。


    export const _if = (el: Element, exp: string, ctx: Context) => {
    const parent = el.parentElement!
    const anchor = new Comment('v-if')
    parent.insertBefore(anchor, el)

    // 存放条件判断的各种分支
    const branches: Branch[] = [{ exp,el }]

    // 定位if...else if ... else 等分支,放在branches数组中

    let block: Block | undefined
    let activeBranchIndex: number = -1 // 通过闭包保存当前位于的分支索引值

    const removeActiveBlock = () => {
    if (block) {
    parent.insertBefore(anchor, block.el)
    block.remove()
    block = undefined
    }
    }

    // 收集依赖
    ctx.effect(() => {
    for (let i = 0; i < branches.length; i++) {
    const { exp, el } = branches[i]
    if (!exp || evaluate(ctx.scope, exp)) {
    // 当判断分支切换时,会生成新的block
    if (i !== activeBranchIndex) {
    removeActiveBlock()
    block = new Block(el, ctx)
    block.insert(parent, anchor)
    parent.removeChild(anchor)
    activeBranchIndex = i
    }
    return
    }
    }
    // no matched branch.
    activeBranchIndex = -1
    removeActiveBlock()
    })

    return nextNode
    }

    v-for


    for指令的主要作用是循环创建多个节点,这里还根据key实现了类似于diff算法来复用Block的功能


    export const _for = (el: Element, exp: string, ctx: Context) => {
    // ...一些工具方法如createChildContexts、mountBlock

    ctx.effect(() => {
    const source = evaluate(ctx.scope, sourceExp)
    const prevKeyToIndexMap = keyToIndexMap
    // 根据循环项创建多个子节点的context
    ;[childCtxs, keyToIndexMap] = createChildContexts(source)
    if (!mounted) {
    // 首次渲染,创建新的Block然后insert
    blocks = childCtxs.map((s) => mountBlock(s, anchor))
    mounted = true
    } else {
    // 更新时
    const nextBlocks: Block[] = []
    // 移除不存在的block
    for (let i = 0; i < blocks.length; i++) {
    if (!keyToIndexMap.has(blocks[i].key)) {
    blocks[i].remove()
    }
    }
    // 根据key进行处理
    let i = childCtxs.length
    while (i--) {
    const childCtx = childCtxs[i]
    const oldIndex = prevKeyToIndexMap.get(childCtx.key)
    const next = childCtxs[i + 1]
    const nextBlockOldIndex = next && prevKeyToIndexMap.get(next.key)
    const nextBlock =
    nextBlockOldIndex == null ? undefined : blocks[nextBlockOldIndex]
    // 不存在旧的block,直接创建
    if (oldIndex == null) {
    // new
    nextBlocks[i] = mountBlock(
    childCtx,
    nextBlock ? nextBlock.el : anchor
    )
    } else {
    // 存在旧的block,复用,检测是否需要移动位置
    const block = (nextBlocks[i] = blocks[oldIndex])
    Object.assign(block.ctx.scope, childCtx.scope)
    if (oldIndex !== i) {
    if (blocks[oldIndex + 1] !== nextBlock) {
    block.insert(parent, nextBlock ? nextBlock.el : anchor)
    }
    }
    }
    }
    blocks = nextBlocks
    }
    })

    return nextNode
    }

    处理指令


    所有的指令都是通过applyDirectiveprocessDirective来处理的,后者是基于前者的二次封装,主要处理一些内置的指令快捷方式builtInDirectives


    export const builtInDirectives: Record<string, Directive<any>> = {
    bind,
    on,
    show,
    text,
    html,
    model,
    effect
    }

    每种指令都是基于ctx和el等来实现快速实现某些逻辑,具体实现可以参考对应源码。


    当调用app.directive注册自定义指令时,


    directive(name: string, def?: Directive) {
    if (def) {
    ctx.dirs[name] = def
    return this
    } else {
    return ctx.dirs[name]
    }
    },

    实际上是向contenx的dirs添加一个属性,当调用applyDirective时,就可以得到对应的处理函数


    const applyDirective = (el: Node,dir: Directive<any>,exp: string,ctx: Context,arg?: string,modifiers?: Record<string, true>) => {
    const get = (e = exp) => evaluate(ctx.scope, e, el)
    // 执行指令方法
    const cleanup = dir({
    el,
    get,
    effect: ctx.effect,
    ctx,
    exp,
    arg,
    modifiers
    })
    // 收集那些需要在卸载时清除的副作用
    if (cleanup) {
    ctx.cleanups.push(cleanup)
    }
    }

    因此,可以利用上面传入的这些参数来构建自定义指令


    app.directive("auto-focus", ({el})=>{
    el.focus()
    })

    小结


    整个代码看起来,确实非常精简



    • 没有虚拟DOM,就无需通过template构建render函数,直接递归遍历DOM节点,通过正则处理各种指令就行了

    • 借助@vue/reactivity,整个响应式系统实现的十分自然,除了在解析指令的使用通过ctx.effect()收集依赖,基本无需再关心数据变化的逻辑


    文章开头提到,petite-vue的主要作用是:在服务端渲染的HTML页面中上"sprinkling"(点缀)一些Vue式的交互。


    就我目前接触到的大部分服务端渲染HTML的项目,如果要实现一些DOM交互,一般使用



    • jQuery操作DOM,yyds

    • 当然Vue也是可以通过script + template的方式编写的,但为了一个div的交互接入Vue,又有点杀鸡焉用牛刀的感觉

    • 其他如React框架等同上


    petite-vue使用了与Vue基本一致的模板语法和响应式功能,开发体验上应该很不错。且其无需考虑虚拟DOM跨平台的功能,在源码中直接使用浏览器相关API操作DOM,减少了框架runtime运行时的成本,性能方面应该也不错。


    总结一下,感觉petite-vue结合了Vue标准版本的开发体验,以非常小的代码体积、良好的开发体验和还不错的运行性能,也许可以用来替代jQuery,用更现代的方式来操作DOM。


    该项目是6月30号提交的第一个版本,目前相关的功能和接口应该不是特别稳定,可能会有调整。但就exmples目录中的示例而言,应该能满足一些简单的需求场景了,也许可以尝试在一些比较小型的历史项目中使用。


    链接:https://juejin.cn/post/6983688046843527181

    收起阅读 »

    【学不动了就回家喂猪】尤大大新活 petite-vue 尝鲜

    vue
    前言 打开尤大大的GitHub,发现多了个叫 petite-vue 的东西,好家伙,Vue3 和 Vite 还没学完呢,又开始整新东西了?本着学不死就往死里学的态度,咱还是来瞅瞅这到底是个啥东西吧,谁让他是咱的祖师爷呢! 简介 从名字来看可以知道 peti...
    继续阅读 »


    前言


    image.png


    打开尤大大的GitHub,发现多了个叫 petite-vue 的东西,好家伙,Vue3 和 Vite 还没学完呢,又开始整新东西了?本着学不死就往死里学的态度,咱还是来瞅瞅这到底是个啥东西吧,谁让他是咱的祖师爷呢!


    简介


    image.png


    从名字来看可以知道 petite-vue 是一个 mini 版的vue,大小只有5.8kb,可以说是非常小了。据尤大大介绍,petite-vue 是 Vue 的可替代发行版,针对渐进式增强进行了优化。它提供了与标准 Vue 相同的模板语法和响应式模型:



    • 大小只有5.8kb

    • Vue 兼容模版语法

    • 基于DOM,就地转换

    • 响应式驱动


    上活


    下面对 petite-vue 的使用做一些介绍。


    简单使用


    <body>
    <script src="https://unpkg.com/petite-vue" defer init></script>
    <div v-scope="{ count: 0 }">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
    </div>
    </body>

    通过 script 标签引入同时添加 init ,接着就可以使用 v-scope 绑定数据,这样一个简单的计数器就实现了。



    了解过 Alpine.js 这个框架的同学看到这里可能有点眼熟了,两者语法之间是很像的。



    <!--  Alpine.js  -->
    <div x-data="{ open: false }">
    <button @click="open = true">Open Dropdown</button>
    <ul x-show="open" @click.away="open = false">
    Dropdown Body
    </ul>
    </div>

    除了用 init 的方式之外,也可以用下面的方式:


    <body>
    <div v-scope="{ count: 0 }">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
    </div>
    <!-- 放在body底部 -->
    <script src="https://unpkg.com/petite-vue"></script>
    <script>
    PetiteVue.createApp().mount()
    </script>
    </body>

    或使用 ES module 的方式:


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'
    createApp().mount()
    </script>

    <div v-scope="{ count: 0 }">
    <button @click="count--">-</button>
    <span>{{ count }}</span>
    <button @click="count++">+</button>
    </div>
    </body>

    根作用域


    createApp 函数可以接受一个对象,类似于我们平时使用 data 和 methods 一样,这时 v-scope 不需要绑定值。


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'
    createApp({
    count: 0,
    increment() {
    this.count++
    },
    decrement() {
    this.count--
    }
    }).mount()
    </script>

    <div v-scope>
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
    </div>
    </body>

    指定挂载元素


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'
    createApp({
    count: 0
    }).mount('#app')
    </script>

    <div id="app">
    {{ count }}
    </div>
    </body>

    生命周期


    可以监听每个元素的生命周期事件。


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'
    createApp({
    onMounted1(el) {
    console.log(el) // <span>1</span>
    },
    onMounted2(el) {
    console.log(el) // <span>2</span>
    }
    }).mount('#app')
    </script>

    <div id="app">
    <span @mounted="onMounted1($el)">1</span>
    <span @mounted="onMounted2($el)">2</span>
    </div>
    </body>

    组件


    在 petite-vue 里,组件可以使用函数的方式创建,通过template可以实现复用。


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'

    function Counter(props) {
    return {
    $template: '#counter-template',
    count: props.initialCount,
    increment() {
    this.count++
    },
    decrement() {
    this.count++
    }
    }
    }

    createApp({
    Counter
    }).mount()
    </script>

    <template id="counter-template">
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
    </template>

    <!-- 复用 -->
    <div v-scope="Counter({ initialCount: 1 })"></div>
    <div v-scope="Counter({ initialCount: 2 })"></div>
    </body>

    全局状态管理


    借助 reactive 响应式 API 可以很轻松的创建全局状态管理


    <body>
    <script type="module">
    import { createApp, reactive } from 'https://unpkg.com/petite-vue?module'

    const store = reactive({
    count: 0,
    increment() {
    this.count++
    }
    })
    // 将count加1
    store.increment()
    createApp({
    store
    }).mount()
    </script>

    <div v-scope>
    <!-- 输出1 -->
    <span>{{ store.count }}</span>
    </div>
    <div v-scope>
    <button @click="store.increment">+</button>
    </div>
    </body>

    自定义指令


    这里来简单实现一个输入框自动聚焦的指令。


    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'

    const autoFocus = (ctx) => {
    ctx.el.focus()
    }

    createApp().directive('auto-focus', autoFocus).mount()
    </script>

    <div v-scope>
    <input v-auto-focus />
    </div>
    </body>

    内置指令



    • v-model

    • v-if / v-else / v-else-if

    • v-for

    • v-show

    • v-html

    • v-text

    • v-pre

    • v-once

    • v-cloak



    注意:v-for 不需要key,另外 v-for 不支持 深度解构



    <body>
    <script type="module">
    import { createApp } from 'https://unpkg.com/petite-vue?module'

    createApp({
    userList: [
    { name: '张三', age: { a: 23, b: 24 } },
    { name: '李四', age: { a: 23, b: 24 } },
    { name: '王五', age: { a: 23, b: 24 } }
    ]
    }).mount()
    </script>

    <div v-scope>
    <!-- 支持 -->
    <li v-for="{ age } in userList">
    {{ age.a }}
    </li>
    <!-- 不支持 -->
    <li v-for="{ age: { a } } in userList">
    {{ a }}
    </li>
    </div>
    </body>

    不支持


    为了更轻量小巧,petite-vue 不支持以下特性:



    • ref()、computed

    • render函数,因为petite-vue 没有虚拟DOM

    • 不支持Map、Set等响应类型

    • Transition, KeepAlive, Teleport, Suspense

    • v-on="object"

    • v-is &

    • v-bind:style auto-prefixing


    总结


    以上就是对 petite-vue 的一些简单介绍和使用,抛砖引玉,更多新的探索就由你们去发现了。


    总的来说,prtite-vue 保留了 Vue 的一些基础特性,这使得 Vue 开发者可以无成本使用,在以往,当我们在开发一些小而简单的页面想要引用 Vue 但又常常因为包体积带来的考虑而放弃,现在,petite-vue 的出现或许可以拯救这种情况了,毕竟它真的很小,大小只有 5.8kb,大约只是 Alpine.js 的一半。


    链接:https://juejin.cn/post/6983328034443132935
    收起阅读 »

    10张脑图带你快速入门Vue3 | 附高清原图

    vue
    前言 这个月重新开始学习Vue3 目前已经完结第一部分:基础部分 我将所有内容吸收整理成10张脑图,一来快速入门Vue3,二来方便以后查看 脑图 应用实例和组件实例 模板语法 配置选项 计算属性和监听器 绑定class和style 条件渲染 列表渲...
    继续阅读 »

    前言


    这个月重新开始学习Vue3


    目前已经完结第一部分:基础部分


    我将所有内容吸收整理成10张脑图,一来快速入门Vue3,二来方便以后查看


    脑图


    应用实例和组件实例


    1应用实例和组件实例.png


    模板语法


    2模板语法.png


    配置选项


    3配置选项.png


    计算属性和监听器


    4计算属性和监听器.png


    绑定class和style


    5绑定class和style.png


    条件渲染


    6条件渲染.png


    列表渲染


    7列表渲染v-for.png


    事件处理


    8事件处理.png


    v-model及其修饰符


    9v-model及其修饰符.png


    组件的基本使用


    10组件的基本使用.png


    温馨小贴士



    1. 由于图片较多,为了避免一张张保存的麻烦


    我已将上述原图已上传githubgithub.com/jCodeLife/m…



    1. 如果需要更改图片,为了方便你按照自己的习惯进行修改


    我已将原始文件xmind上传github
    github.com/jCodeLife/m…



    链接:https://juejin.cn/post/6983867993805553671

    收起阅读 »

    面试官问我CORS跨域,我直接一套操作斩杀!

    前言 我们都知道由于同源策略的存在,导致我们在跨域请求数据的时候非常的麻烦。首先阻挡我们的所谓同源到底是什么呢?,所谓同源就是浏览器的一个安全机制,不同源的客户端脚本没有在明确授权的情况下,不能读写对方资源。由于存在同源策略的限制,而又有需要跨域的业务,所以就...
    继续阅读 »

    前言


    我们都知道由于同源策略的存在,导致我们在跨域请求数据的时候非常的麻烦。首先阻挡我们的所谓同源到底是什么呢?,所谓同源就是浏览器的一个安全机制,不同源的客户端脚本没有在明确授权的情况下,不能读写对方资源。由于存在同源策略的限制,而又有需要跨域的业务,所以就有了CORS的出现。


    我们都知道,jsonp也可以跨域,那为什么还要使用CORS



    • jsonp只可以使用 GET 方式提交

    • 不好调试,在调用失败的时候不会返回任何状态码

    • 安全性,万一假如提供jsonp的服务存在页面注入漏洞,即它返回的javascript的内容被人控制的。那么结果是什么?所有调用这个jsonp的网站都会存在漏洞。于是无法把危险控制在一个域名下…所以在使用jsonp的时候必须要保证使用的jsonp服务必须是安全可信的。


    开始CORS


    CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing),他允许浏览器向跨源服务器发送XMLHttpRequest请求,从而克服啦 AJAX 只能同源使用的限制


    CORS需要浏览器和服务器同时支持,整个 CORS通信过程,都是浏览器自动完成不需要用户参与,对于开发者来说,CORS的代码和正常的 ajax 没有什么差别,浏览器一旦发现跨域请求,就会添加一些附加的头信息,


    CORS这么好吗,难道就没有缺点嘛?


    答案肯定是NO,目前所有最新浏览器都支持该功能,但是万恶的IE不能低于10


    简单请求和非简单请求


    浏览器将CORS请求分成两类:简单请求和非简单请求


    简单请求


    凡是同时满足以下两种情况的就是简单请求,反之则非简单请求,浏览器对这两种请求的处理不一样



    • 请求方法是以下方三种方法之一

      • HEAD

      • GET

      • POST



    • HTTP的头信息不超出以下几种字段

      • Accept

      • Accept-Language

      • Content-Language

      • Last-Event-ID

      • Content-Type:只限于三个值 application/x-www-form-urlencodedmultipart/form-datatext/plain




    对于简单请求来说,浏览器之间发送CORS请求,具体来说就是在头信息中,增加一个origin字段,来看一下例子


    GET /cors? HTTP/1.1
    Host: localhost:2333
    Connection: keep-alive
    Origin: http://localhost:2332
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
    Accept: */*
    Referer: http://localhost:2332/CORS.html
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9
    If-None-Match: W/"1-NWoZK3kTsExUV00Ywo1G5jlUKKs"

    上面的头信息中,Origin字段用来说名本次请求来自哪个源,服务器根据这个值,决定是否同意这次请求。


    如果Origin指定的源不在允许范围之内,服务器就会返回一个正常的HTTP回应,然后浏览器发现头信息中没有包含Access-Control-Allow-Origin 字段,就知道出错啦,然后抛出错误,反之则会出现这个字段(实例如下)


    Access-Control-Allow-Origin: http://api.bob.com
    Access-Control-Allow-Credentials: true
    Access-Control-Expose-Headers: FooBar
    Content-Type: text/html; charset=utf-8



    • Access-Control-Allow-Origin 这个字段是必须的,表示接受那些域名的请求(*为所有)




    • Access-Control-Allow-Credentials 该字段可选, 表示是否可以发送cookie




    • Access-Control-Expose-Headers 该字段可选,XHMHttpRequest对象的方法只能够拿到六种字段: Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma ,如果想拿到其他的需要使用该字段指定。




    如果你想要连带Cookie一起发送,是需要服务端和客户端配合的


    // 服务端
    Access-Control-Allow-Credentials: true
    // 客户端
    var xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    // 但是如果省略withCredentials属性的设置,有的浏览器还是会发送cookie的
    xhr.withCredentials = false;

    非简单请求


    非简单请求则是不满足上边的两种情况之一,比如请求的方式为 PUT,或者请求头包含其他的字段


    非简单请求的CORS请求是会在正式通信之前进行一次预检请求


    浏览器先询问服务器,当前网页所在的域名是否可以请求您的服务器,以及可以使用那些HTTP动词和头信息,只有得到正确的答复,才会进行正式的请求


    // 前端代码
    var url = 'http://localhost:2333/cors';
    var xhr = new XMLHttpRequest();
    xhr.open('PUT', url, true);
    xhr.setRequestHeader('X-Custom-Header', 'value');
    xhr.send();

    由于上面的代码使用的是 PUT 方法,并且发送了一个自定义头信息.所以是一个非简单请求,当浏览器发现这是一个非简单请求的时候,会自动发出预检请求,看看服务器可不可以接收这种请求,下面是"预检"HTTP 头信息


    OPTIONS /cors HTTP/1.1
    Origin: localhost:2333
    Access-Control-Request-Method: PUT // 表示使用的什么HTTP请求方法
    Access-Control-Request-Headers: X-Custom-Header // 表示浏览器发送的自定义字段
    Host: localhost:2332
    Accept-Language: zh-CN,zh;q=0.9
    Connection: keep-alive
    User-Agent: Mozilla/5.0...

    "预检"使用的请求方法是 OPTIONS , 表示这个请求使用来询问的,


    预检请求后的回应,服务器收到"预检"请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。


    预检的响应头:


    HTTP/1.1 200 OK
    Date: Mon, 01 Dec 2008 01:15:39 GMT
    Server: Apache/2.0.61 (Unix)
    Access-Control-Allow-Origin: http://localhost:2332 // 表示http://localhost:2332可以访问数据
    Access-Control-Allow-Methods: GET, POST, PUT
    Access-Control-Allow-Headers: X-Custom-Header
    Content-Type: text/html; charset=utf-8
    Content-Encoding: gzip
    Content-Length: 0
    Keep-Alive: timeout=2, max=100
    Connection: Keep-Alive
    Content-Type: text/plain

    如果浏览器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS的头相关信息,这是浏览器就认定,服务器不允许此次访问,从而抛出错误


    预检之后的请求


    当预检请求通过之后发出正经的HTTP请求,还有一个就是一旦通过了预检请求就会,请求的时候就会跟简单请求,会有一个Origin头信息字段。


    通过预检之后的,浏览器发出发请求


    PUT /cors HTTP/1.1
    Origin: http://api.bob.com // 通过预检之后的请求,会自动带上Origin字段
    Host: api.alice.com
    X-Custom-Header: value
    Accept-Language: en-US
    Connection: keep-alive
    User-Agent: Mozilla/5.0...

    感谢


    谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。




    链接:https://juejin.cn/post/6983852288091619342

    收起阅读 »

    「百毒不侵」面试官最喜欢问的13种Vue修饰符

    1.lazy lazy修饰符作用是,改变输入框的值时value不会改变,当光标离开输入框时,v-model绑定的值value才会改变 <input type="text" v-model.lazy="value"> <div>{{val...
    继续阅读 »

    image.png


    1.lazy


    lazy修饰符作用是,改变输入框的值时value不会改变,当光标离开输入框时,v-model绑定的值value才会改变


    <input type="text" v-model.lazy="value">
    <div>{{value}}</div>

    data() {
    return {
    value: '222'
    }
    }

    lazy1.gif


    2.trim


    trim修饰符的作用类似于JavaScript中的trim()方法,作用是把v-model绑定的值的首尾空格给过滤掉。


    <input type="text" v-model.trim="value">
    <div>{{value}}</div>

    data() {
    return {
    value: '222'
    }
    }

    number.gif


    3.number


    number修饰符的作用是将值转成数字,但是先输入字符串和先输入数字,是两种情况


    <input type="text" v-model.number="value">
    <div>{{value}}</div>

    data() {
    return {
    value: '222'
    }
    }


    先输入数字的话,只取前面数字部分



    trim.gif



    先输入字母的话,number修饰符无效



    number2.gif


    4.stop


    stop修饰符的作用是阻止冒泡


    <div @click="clickEvent(2)" style="width:300px;height:100px;background:red">
    <button @click.stop="clickEvent(1)">点击</button>
    </div>

    methods: {
    clickEvent(num) {
    不加 stop 点击按钮输出 1 2
    加了 stop 点击按钮输出 1
    console.log(num)
    }
    }

    5.capture


    事件默认是由里往外冒泡capture修饰符的作用是反过来,由外网内捕获


    <div @click.capture="clickEvent(2)" style="width:300px;height:100px;background:red">
    <button @click="clickEvent(1)">点击</button>
    </div>

    methods: {
    clickEvent(num) {
    不加 capture 点击按钮输出 1 2
    加了 capture 点击按钮输出 2 1
    console.log(num)
    }
    }

    6.self


    self修饰符作用是,只有点击事件绑定的本身才会触发事件


    <div @click.self="clickEvent(2)" style="width:300px;height:100px;background:red">
    <button @click="clickEvent(1)">点击</button>
    </div>

    methods: {
    clickEvent(num) {
    不加 self 点击按钮输出 1 2
    加了 self 点击按钮输出 1 点击div才会输出 2
    console.log(num)
    }
    }

    7.once


    once修饰符的作用是,事件只执行一次


    <div @click.once="clickEvent(2)" style="width:300px;height:100px;background:red">
    <button @click="clickEvent(1)">点击</button>
    </div>

    methods: {
    clickEvent(num) {
    不加 once 多次点击按钮输出 1
    加了 once 多次点击按钮只会输出一次 1
    console.log(num)
    }
    }

    8.prevent


    prevent修饰符的作用是阻止默认事件(例如a标签的跳转)


    <a href="#" @click.prevent="clickEvent(1)">点我</a>

    methods: {
    clickEvent(num) {
    不加 prevent 点击a标签 先跳转然后输出 1
    加了 prevent 点击a标签 不会跳转只会输出 1
    console.log(num)
    }
    }

    9.native


    native修饰符是加在自定义组件的事件上,保证事件能执行


    执行不了
    <My-component @click="shout(3)"></My-component>

    可以执行
    <My-component @click.native="shout(3)"></My-component>

    10.left,right,middle


    这三个修饰符是鼠标的左中右按键触发的事件


    <button @click.middle="clickEvent(1)"  @click.left="clickEvent(2)"  @click.right="clickEvent(3)">点我</button>

    methods: {
    点击中键输出1
    点击左键输出2
    点击右键输出3
    clickEvent(num) {
    console.log(num)
    }
    }

    11.passive


    当我们在监听元素滚动事件的时候,会一直触发onscroll事件,在pc端是没啥问题的,但是在移动端,会让我们的网页变卡,因此我们使用这个修饰符的时候,相当于给onscroll事件整了一个.lazy修饰符


    <div @scroll.passive="onScroll">...</div>

    12.camel


    不加camel viewBox会被识别成viewbox
    <svg :viewBox="viewBox"></svg>

    加了canmel viewBox才会被识别成viewBox
    <svg :viewBox.camel="viewBox"></svg>

    12.sync


    父组件传值进子组件,子组件想要改变这个值时,可以这么做


    父组件里
    <children :foo="bar" @update:foo="val => bar = val"></children>

    子组件里
    this.$emit('update:foo', newValue)

    sync修饰符的作用就是,可以简写:


    父组件里
    <children :foo.sync="bar"></children>

    子组件里
    this.$emit('update:foo', newValue)

    13.keyCode


    当我们这么写事件的时候,无论按什么按钮都会触发事件


    <input type="text" @keyup="shout(4)">

    那么想要限制成某个按键触发怎么办?这时候keyCode修饰符就派上用场了


    <input type="text" @keyup.keyCode="shout(4)">

    Vue提供的keyCode:


    //普通键
    .enter
    .tab
    .delete //(捕获“删除”和“退格”键)
    .space
    .esc
    .up
    .down
    .left
    .right
    //系统修饰键
    .ctrl
    .alt
    .meta
    .shift

    例如(具体的键码请看键码对应表


    按 ctrl 才会触发
    <input type="text" @keyup.ctrl="shout(4)">

    也可以鼠标事件+按键
    <input type="text" @mousedown.ctrl.="shout(4)">

    可以多按键触发 例如 ctrl + 67
    <input type="text" @keyup.ctrl.67="shout(4)">

    链接:https://juejin.cn/post/6981628129089421326

    收起阅读 »

    iOS 自定义键盘

    很多项目中都使用自定义键盘,实现自定义键盘有很多方法,本文讲的是修改UITextField/UITextView的inputView来实现自定义键盘。如何修改已经知道了,但是怎么修改。有两种思路:自定义CustomTextField/CustomTextVie...
    继续阅读 »

    很多项目中都使用自定义键盘,实现自定义键盘有很多方法,本文讲的是修改UITextField/UITextView的inputView来实现自定义键盘。
    如何修改已经知道了,但是怎么修改。有两种思路:

    1. 自定义CustomTextField/CustomTextView,直接实现如下代码
    textField.inputView = customView;   
    textView.inputView = customView;

    但是这样写有个弊端,就是通用性不强。比如项目中可能要实现某个具体业务逻辑,这个textField/textView是继承ATextField/ATextView,其他地方又有用到的是继承BTextField/BTextView,那我们再写代码时候,可能需要写n个自定义textField/textView,用起来就非常麻烦了,所以这种方法不推荐。

    1. 使用分类来实现自定义键盘
      思路就是在分类中增加一个枚举,这个枚举定义了不同类型的键盘
    typedef NS_ENUM(NSUInteger, SJKeyboardType)
    {
    SJKeyboardTypeDefault, // 使用默认键盘
    SJKeyboardTypeNumber // 使用自定义数字键盘
    // 还可以根据需求 自定义其他样式...
    };

    写一个属性,来标记键盘类型

    @property (nonatomic, assign) SJKeyboardType sjKeyboardType;
    在.m文件中实现getter和setter方法

    static NSString *sjKeyboardTypeKey = @"sjKeyboardTypeKey";
    - (SJKeyboardType)sjKeyboardType
    {
    return [objc_getAssociatedObject(self, &sjKeyboardTypeKey) integerValue];
    }

    - (void)setSjKeyboardType:(SJKeyboardType)sjKeyboardType
    {
    objc_setAssociatedObject(self, &sjKeyboardTypeKey, @(sjKeyboardType), OBJC_ASSOCIATION_ASSIGN);
    [self setupKeyboard:sjKeyboardType];
    }

    在set方法中来实现自定义键盘视图设置及对应点击方法实现

    - (void)setupKeyboard:(SJKeyboardType)sjKeyboardType
    {

    switch (sjKeyboardType) {
    case SJKeyboardTypeDefault:
    break;
    case SJKeyboardTypeNumber: {
    SJCustomKeyboardView *numberInputView = [[[NSBundle mainBundle] loadNibNamed:@"SJCustomKeyboardView" owner:self options:nil] lastObject];
    numberInputView.frame = CGRectMake(0, 0, SJSCREEN_WIDTH, SJNumberKeyboardHeight + SJCustomKeyboardBottomMargin);
    self.inputView = numberInputView;
    numberInputView.textFieldReplacementString = ^(NSString * _Nonnull string) {
    BOOL canEditor = YES;
    if ([self.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)]) {
    canEditor = [self.delegate textField:self shouldChangeCharactersInRange:NSMakeRange(self.text.length, 0) replacementString:string];
    }

    if (canEditor) {
    [self replaceRange:self.selectedTextRange withText:string];
    }
    };
    numberInputView.textFieldShouldDelete = ^{
    BOOL canEditor = YES;
    if ([self.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)] && self.text.length) {
    canEditor = [self.delegate textField:self shouldChangeCharactersInRange:NSMakeRange(self.text.length - 1, 1) replacementString:@""];
    }
    if (canEditor) {
    [self deleteBackward];
    }
    };
    numberInputView.textFieldShouldClear = ^{
    BOOL canClear = YES;
    if ([self.delegate respondsToSelector:@selector(textFieldShouldClear:)]) {
    canClear = [self.delegate textFieldShouldClear:self];
    }
    if (canClear) {
    [self setText:@""];
    }
    };
    numberInputView.textFieldShouldReturn = ^{
    if ([self.delegate respondsToSelector:@selector(textFieldShouldReturn:)]) {
    [self.delegate textFieldShouldReturn:self];
    }
    };
    break;
    }
    }
    }
    之后就需要实现自定义键盘视图,这里需要注意一点,就是如果使用新建子类实现自定义键盘,个人感觉按钮响应用代理实现会看起来逻辑更清晰

    /* 用代理看的更清楚 但是分类不能实现代理 所以只能用block实现回调 如果自定义textField可以用代理 @protocol SJCustomKeyboardViewDelegate - (void)textFieldReplacementString:(NSString *_Nullable)string; - (BOOL)textFieldShouldDelete; - (BOOL)textFieldShouldClear; - (BOOL)textFieldShouldReturn; @end */

    但是分类不能实现代理,所以只能用block来实现回调


    @property (nonatomic, copy) void (^textFieldReplacementString)(NSString *string);
    @property (nonatomic, copy) void (^textFieldShouldDelete)(void);
    @property (nonatomic, copy) void (^textFieldShouldClear)(void);
    @property (nonatomic, copy) void (^textFieldShouldReturn)(void);

    .m中只需要实现按钮的点击方法和对应的回调方法即可。
    这样好处是只需要引入头文件,修改一个属性即可实现自定义键盘,不会影响项目中其他的业务逻辑。

    self.textField = [[UITextField alloc] initWithFrame:CGRectMake(20, 100, SJSCREEN_WIDTH - 40, 40)];  
    self.textField.placeholder = @"input";
    self.textField.borderStyle = UITextBorderStyleBezel;
    self.textField.delegate = self;
    [self.view addSubview:self.textField];

    self.textField.sjKeyboardType = SJKeyboardTypeNumber;





    收起阅读 »

    回顾 | Jetpack WindowManager 更新

    在今年年初,我们发布了 Jetpack WindowManager 库 alpha02 版本,这是一个较为重大的版本更新,并且包含部分已弃用的 API (目前已经发布到 1.0.0-alpha09 版),本文将为您回顾这次版本更新的内容。 Jetpack W...
    继续阅读 »

    在今年年初,我们发布了 Jetpack WindowManager 库 alpha02 版本,这是一个较为重大的版本更新,并且包含部分已弃用的 API (目前已经发布到 1.0.0-alpha09 版),本文将为您回顾这次版本更新的内容。


    Jetpack WindowManager 库可帮助您构建能够感知折叠和铰链等新设备功能的应用,使用以前不存在的新功能。在开发 Jetpack WindowManager 库时,我们结合了开发者的反馈意见,并且在 Alpha 版本中持续迭代 API,以提供一个更干净完整的 API 界面。我们一直在关注 WindowManager 空间中的不同领域以提供更多的功能,我们引入了 WindowMetrics,以便您可以在 Android 4.1 (API 级别 16) 及以上版本使用这些在 Android 11 加入的新 API


    首版发布后,我们用了大量时间来分析开发者反馈,并在 alpha02 版本中进行了大量的更新,接下来我们来看在 alpha02 版本中更新的具体内容!


    新建一个 WindowManager


    Alpha02 版本提供了一个简单的构造函数,这个构造函数只有一个参数,参数指向一个可见实体 (比如当前显示的 Activity) 的 Context:


    val windowManager = WindowManager(context: Context)

    原有的构造函数 仍可使用,但已被标记为废弃:


    @Deprecated
    val windowManager = WindowManager(context: Context, windowBackend: WindowBackend?)

    当您想在一个常见的设备或模拟器上使用一个自定义的 WindowBackend 模拟一个可折叠设备时,可使用原有的构造函数进行测试。这个 样例工程 中的实现可以供您参考。


    在 alpha02 版本,您仍可给参数 WindowBackend 传参为 null,我们计划在未来的版本中将 WindowBackend 设置为必填参数,移除 deprecation 标志,以推动此接口在测试时使用。


    添加 DisplayFeature 弃用 DeviceState


    另一个重大变化是弃用了 DeviceState 类,同时也弃用了使用它通知您应用的回调。之所以这样做,是因为我们希望提供更加通用的 API,这些通用的 API 允许系统向您的应用返回所有可用的 DisplayFeature 实例,而不是定义全局的设备状态。我们在 alpha06 的版本中已经将 DeviceState 从公共 API 中移除,请改用 FoldingFeature。


    alpha02 版本引入了带有更新了回调协议的新 DisplayFeature 类,以在 DisplayFeature 更改时通知您的应用。您可以注册、反注册回调来使用这些方法:


    registerLayoutChangeCallback(@NonNull Executor executor, @NonNull Consumer<WindowLayoutInfo> callback)

    unregisterLayoutChangeCallback(@NonNull Consumer<WindowLayoutInfo> callback)

    WindowLayoutInfo 包含了位于 window 内的 DisplayFeature 实例列表。


    FoldingFeature 类实现了 DisplayFeature 接口,其中包含了有关下列类型功能的信息:


    TYPE_FOLD(折叠类型)

    TYPE_HINGE(铰链类型)

    设备可能的折叠状态如下:


    △ DisplayFeature 可能的状态: 完全展开、半开、翻转


    △ DisplayFeature 可能的状态: 完全展开、半开、翻转


    需要注意的是这里没有与 DeviceState 中 POSTURE_UNKNOWN 和 POSTURE_CLOSED 姿态对应的状态。


    要获取最新的状态信息,您可以使用已注册回调返回的 FoldingFeature 信息:


    class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
    override fun accept(newLayoutInfo: WindowLayoutInfo) {
    // 检查 newLayoutInfo. getDisplayFeatures() 的返回值,
    // 看它是否为 FoldingFeature 实例,并获取其中的信息。
    }
    }

    如何使用这些信息,请参阅: github.com/android/use…


    更好的回调注册


    上述示例代码的回调 API 也更加健壮了。在之前版本中,如果应用在 window 可用之前注册回调,将会抛出异常。


    在 aplha02 版本中我们修改了上述的行为。您可在对您应用设计有用的任何时候,注册这些回调,库会在 window 可用时发送初始 WindowLayoutInfo。


    R8 规则


    我们在库中添加了 R8 的 "keep" 规则,以保留那些因为内部模块的组织架构而可能被删除的方法或类。这些规则会自动合并到应用最终的 R8 规则中,这样可以防止应用出现如 alpha01 版本上的崩溃。


    WindowMetrics


    由于历史的命名习惯和各种可能的 Window Manager 状态,在 Android 上获取当前 window 的尺寸信息比较困难。Android 11 中一些被废弃的方法 (例如 Display#getSize 和 Display#getMetrics) 和在 window 尺寸新的 API 的使用,都凸显了可折叠设备从全屏到多窗口和自适应窗口这一上升的趋势。为了简化这一过渡过程,我们在 Android 11 中增加了 WindowMetrics API


    在第一次布局完成之前,WindowMetrics 可以让您轻松获取当前 window 状态信息,和系统当前状态下最大 Window 尺寸信息。例如像 Surface Duo 这样的设备,设备会有一个默认的配置决定应用从哪一个屏幕启动,但是也可以跨过设备的铰链扩展到两块屏幕上。在默认的状态,'getMaximumWindowMetrics' 方法返回应用当前所在屏幕的边界信息。当应用被移动到处于跨屏状态,'getMaximumWindowMetrics' 方法返回反映新状态的边界信息。这些信息最早在 onCreate 期间就会提供,您的 Activity 可以利用这些信息进行计算或者尽早做出决定,以便在第一时间选择正确的布局。


    API 返回的结果不包括系统 inset 信息,比如状态栏或导航栏,这是由于目前支持的所有 Android 版本中,在第一次布局完成之前,这些值对应的区域都不可用。关于使用 ViewCompat 去获取系统可用 inset 信息,Chris Banes 的文章 - 处理视觉冲突|手势导航 (二) 是非常好的资源。API 返回的边界信息也不会对布局填充时可能发生变化的布局参数作出响应。


    要访问这些 API,您需要像上文说明的那样先获取一个 WindowManager 对象:


    val windowManager = WindowManager(context: Context)

    现在您就可以访问 WindowMetrics API,并可轻松获取当前 window 的尺寸以及最大尺寸信息。


    windowManager.currentWindowMetrics

    windowManager.maximumWindowMetrics

    例如,如果您的应用在手机和平板电脑上的布局或导航模式截然不同,那么可以在视图填充之前依赖此信息对布局做出选择。如果您认为用户会对布局的明显变化感到疑惑,您可以忽略当前 window 尺寸信息的变化,选择部分信息作为常量。在选择填充哪些之前,您可以使用 window 最大尺寸信息。


    尽管 Android 11 平台已经包含了在 onCreate 期间获取 inset 信息的 API,但是我们还没有将这个 API 添加到 WindowManager 库中,这是因为我们想了解这些功能中哪些对开发者有用。您可以积极反馈,以便我们了解在您第一次布局之前,需要知道哪些能够使编写布局更为简便的值或抽象。


    我们希望这些可以用在 Android 低版本上的 API 能够帮助您构建响应 window 尺寸变化的应用,同时帮助您替换上文提到的已废弃 API。


    联系我们


    我们非常希望得到您对这些 API 的反馈,尤其是您认为缺少的那些,或者可让您开发变得更轻松的那些反馈。有一些使用场景我们可能没有考虑到,所以希望您在 public tracker 上向我们提交 bug 或功能需求。


    作者:Android_开发者
    链接:https://juejin.cn/post/6983867552841596942
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android so文件的加载原理

    so
    先说说so的编译类型 Android只支持3种cpu架构分为:arm,mips,x86,目前用的最多的是arm体系cpu,x86和mips体系的很少用到了。 arm体系中,又分32位和64位: armeabi/armeabi-v7a:这个架构是arm类型的,主...
    继续阅读 »



    1. 先说说so的编译类型
      Android只支持3种cpu架构分为:arm,mips,x86,目前用的最多的是arm体系cpu,x86和mips体系的很少用到了。
      arm体系中,又分32位和64位:

      armeabi/armeabi-v7a:这个架构是arm类型的,主要用于Android 4.0之后的,cpu是32位的,其中armeabi是相当老旧的一个版本, 缺少对浮点数的硬件支持,基本已经淘汰,可以不用考虑了。

      arm64-v8a:这个架构是arm类型的,主要是用于Android 5.0之后,cpu是64位的。平时项目中引入第三方的so文件时,第三方会根据cpu的架构编译成不同类型的so文件,项目引入这些so文件时,会将这些文件分别放入jniLibs目录下的arm64-v8a,armeabi-v7a等这些目录下,其实对于arm体系的so文件,没这个必要,因为arm体系是向下兼容的,比如32位的so文件是可以在64位的系统上运行的。Android上每启动一个app都会创建一个虚拟机,Android 64位的系统加载32位的so文件时,会创建一个64位的虚拟机的同时,还会创建一个32位的虚拟机,这样就能兼容32位的app应用了。鉴于兼容的原理,在app中,可以只保留armeabi-v7a版本的so文件就足够了。64位的操作系统会在32位的虚拟机上加载这个它。这样就极大的精简了app打包后的体积。虽然这样可以精简apk的体积,但是,在64位平台上运行32位版本的ART和Android组件,将丢失专为64位优化过的性能(ART,webview,media等等)所以,更好的方法是,为相应的abi打对应的apk包,这样就可以为不同abi版本生成不同的apk包。具体在build.gradle中的配置如下:



    android {

    ...

    splits {
    abi {
    enable true
    reset()
    include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' //select ABIs to build APKs for
    universalApk true //generate an additional APK that contains all the ABIs
    }
    }

    // map for the version code
    project.ext.versionCodes = ['armeabi': 1, 'armeabi-v7a': 2, 'arm64-v8a': 3, 'mips': 5, 'mips64': 6, 'x86': 8, 'x86_64': 9]

    android.applicationVariants.all { variant ->
    // assign different version code for each output
    variant.outputs.each { output ->
    output.versionCodeOverride =
    project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 0) * 1000000 + android.defaultConfig.versionCode
    }
    }
    }


    1. so的加载流程
      可以通过以下命令来查看手机的cpu型号(以OPPO R7手机为例),在AS中的Terminal窗口中,输入如下命令


      C:\Users\xg\Desktop\AndroidSkill>adb shell
    shell@hwmt7:/ $ getprop ro.product.cpu.abilist
    arm64-v8a,armeabi-v7a,armeabi

    手机支持的种类存在一个abiList 的集合中,有个前后顺序,比如我的手机,支持三种类型, abiList 的集合中就有三个元素,第一个元素是arm64-v8a ,第二个元素是armeabi-v7a,第三个元素是armeabi 。按照这个先后顺序,我们遍历jniLib 目录,如果这个目录下有arm64-v8a子目录并且里面有so文件,那么接下来将加载arm64-v8a下的所有so文件,就不再去看其他子目录(比如armeabi-v7a)了,以此类推。在我的手机上,如果arm64-v8a 下有a.so,armeabi-v7a下有a.so和b.so那么我的手机只会加载arm64-v8a下的a.so,而永远不会加载到b.so,这时候就会抛出找不到b.so的异常,这是由Android 中的so加载算法导致的。因此,为了节省apk的体积,我们只能保存一份so文件,那就是armeabi-v7a下的so文件。32位的arm手机,肯定能加载到armeabi-v7a下的so文件。64位的arm手机,想要加载32位的so文件,千万不要在arm64 -v8a目录下放置任何so文件。把so文件都放在armeabi-v7a目录下就可以加载到了。


    下面举个例子来说明上面so的加载过程:
    32位的arm手机,如果项目的jniLibs目录下存在如下的so文件,
    jniLibs/arm64-v8a/libmsc.so
    jniLibs/armeabi-v7a/libmsc.so
    当要加载msc这个so文件时,就会直接到areabi-v7a目录下找。找到就加载, 找不到就报 couldn’t find “libmsc.so”
    如果armeabi-v7a这个目录都不存在时,也报 couldn’t find “libmsc.so”


    64位的arm手机,如果项目的jniLibs目录下存在如下的so文件,
    jniLibs/arm64-v8a/libmsc.so
    jniLibs/armeabi-v7a/libmsc.so
    当要加载msc.so文件时,就先到arm64-v8a目录下找,找到后,就不会去其他目录下找了。
    如果arm64-v8a目录下未找到,则到armeabi-v7a目录下找,找到就使用,找不到就去其他目录找,依次类推,如果都找到不到就报 couldn’t find “libmsc.so”。
    这个查找过程可以看下图:
    在这里插入图片描述



    1. so的加载方式
      方式一:System.loadLibrary方法,加载jniLibs目录下的so文件。例如,jniLibs目录下的arm64-v8a目录下有一个libHello.so文件,那么加载这个so文件是:


         System.loadLibray("Hello");//注意,没有lib前缀

    方式二:使用System.load方法,加载任意路径下的so文件,需要传入一个参数,这个参数就是so文件所在的完整路径。这两种方式最终都是调用的底层的dlopen方法加载so文件。但是方式二,由于可以传入so的路径,这样就可以实现动态加载so文件。so的插件化,就是使用的这种方式。动态加载so文件时,有时会出现 dlopen failed:libXXX.so is 32-bit instead of 64 bit 的异常。出现这个异常的原因是,手机的操作系统是64位的,这样加载这个32位的so文件时,会默认使用64位的虚拟机去加载,这样就报了这个异常。解决这个问题的方式,可以先在jniLibs目录下armeabi-v7a目录下,放入一个很简单的32位的libStub.so文件,在动态加载插件的so文件时,先去加载这个jniLibs/armeabi-v7a目录下的libStub.so文件,这样就会创建一个32位的虚拟机,当加载插件的32位的so文件时,就会使用这个32位的虚拟机来加载插件的so文件,这样也就不会报错了。


    注意,每个abi目录下的so文件数量要相同,因为,如果,在arm64-v8a目录下,存在a.so文件,在armeabi-v7a目录下,存在a.so和b.so文件,如果是在64位的arm系统的手机上加载a.so和b.so文件,由于先找a.so文件会先到arm64-v8a目录下找,找到后,后续的其他so文件就会都在这个目录下找了,有arm64-v8a目录下没有b.so文件,这样就会报couldn’t find "b.so"文件异常。所以,要保持每个abi目录下的so文件个数一致。


    关于加载插件中的so文件,是通过先创建加载插件的DexClassLoader,将插件中的so文件的路径传递给DecClassLoader的构造函数的第三个参数,这样,后续使用这个DexClassLoader去加载插件中的类或方法,插件中这些类或者方法中去加载插件的so文件。


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

    收起阅读 »

    探索 Android 消息机制

    1. Android 消息机制概述 Android 消息机制是由 Handler、Looper 和 MessageQueue 三者合作完成的,消息机制可以分为消息机制初始化、消息轮询、消息发送和消息处理 4 个过程来理解,消息机制是基于 Linux 的事...
    继续阅读 »

    1. Android 消息机制概述


    Android 消息机制.png


    Android 消息机制是由 HandlerLooperMessageQueue 三者合作完成的,消息机制可以分为消息机制初始化消息轮询消息发送消息处理 4 个过程来理解,消息机制是基于 Linux 的事件轮询机制 epoll 和用来通知事件的文件描述符 eventfd 来实现的 。


    消息机制初始化过程是从消息轮询器 Looper 的 prepare() 方法开始的,当线程调用 Looper 的 prepare() 方法时,prepare() 方法会调用 Looper 的构造函数创建一个 Looper ,并放到线程私有变量 ThreadLocal 中。Looper 的构造函数中会创建一个消息队列 MessageQueue ,而消息队列的构造方法会调用 nativeInit() JNI 方法初始化 Native 层的消息队列,在 Native 层消息队列的构造方法中,会调用 Native 层 Looper 的构造函数初始化 Native 层的 Looper ,而在 Native 层 Looper 的构造函数中会调用 rebuildEpollLocked() 方法,在 rebuildEpollLocked() 方法中会调用 epoll_create1() 系统调用创建一个 epoll 实例,然后再调用 epoll_ctl() 系统调用给 epoll 实例添加一个唤醒事件文件描述符,到这里消息机制的初始化就完成了。


    epoll 、select 和 poll 都是 Linux 中的一种 I/O 多路复用机制, poll 和 select 在每次调用时,都必须遍历所有被监视的文件描述符,文件描述符列表越大,性能就越差。而 epoll 则把监听注册从监听中分离了出来,这样就不需要每次调用时都遍历文件描述符列表了。创建 epoll 实例时,Linux 会创建一个 evnetpoll 结构体,这个结构体中有 rbrrdlist 两个成员,rbr 是红黑树的根节点,epoll 会用红黑树存储所有需要监控的事件 ,rdlist 则是存放着要通过 epoll_wait() 返回给用户的事件。


    唤醒事件文件描述符是一个 eventfd 对象,是 Linux 中的一个用来通知事件的文件描述符,与 pipe 相比,pipe 只能在进程/线程间使用,而 eventfd 是广播式的通知,可以多对多。eventfd 的结构体 eventfd_ctx 中有 wqhcount 两个成员,wqh 是一个等待队列的头结点,类型为 __wait_queue_head ,是一个自带自旋锁双向链表的节点,而 count 则是一个计数器


    消息轮询过程是从 Looper 的 loop() 方法开始的,当线程调用 Looper 的 loop() 方法后,loop() 方法中会调用 MessageQueuenext() 方法获取下一条要处理的消息,next() 方法中会通过 nativePollOnce() JNI 方法调检查当前消息队列中是否有新的消息要处理,nativePollOnce() 方法会调用 NativeMessageQueuepollOnce() 方法,NativeMessageQueue 的 pollOnce() 方法会调用 Native 层 Looper 的 pollOnce() 方法, Native 层 Looper 的 pollOnce() 方法中会把 timeout 参数传到 epoll_wait() 系统调用中,epoll_wait() 调用后会等待事件的产生,当 MessageQueue 中没有更多消息时,传到 epoll_wait() 中的 timeout 的值就是 -1 ,这时线程会一直被阻塞,直到有新的消息进来,这就是为什么 Looper 的死循环不会导致 CPU 飙高,因为主线程处于阻塞状态。当调用完 nativePollOnce() 方法后,MessageQueue 就会看下当前消息是不是同步屏障,是的话就找出并返回异步消息给 Looper ,不是的话则找出下一条到了发送时间的返回非异步消息。


    消息发送过程一般是从 Handler 的 sendMessage() 方法开始的,当我们调用 Handler 的 sendMessage() 或 sendEmptyMessage() 等方法时,Handler 会调用 MessageQueue 的 enqueueMessage() 方法把消息加入到消息队列中。消息 Message 并不是真正的队列结构,而是链表结构。MessageQueue 的enqueueMessage() 方法首先会判断消息的延时时间是否晚于当前链表中最后一个结点的发送时间,是的话则把该消息作为链表的最后一个结点。然后 enqueueMessage() 方法会判断是否需要唤醒消息轮询线程,是的话则通过 nativeWake() JNI 方法调用 NativeMessageQueue 的 wake() 方法。NativeMessageQueue 的 wake() 方法又会调用 Native 层 Looper 的 wake() 方法,在 Native 层 Looper 的 wake() 方法中,会通过 write() 系统调用写入一个 W 字符到唤醒事件文件描述符中,这时监听这个唤醒事件文件描述符的消息轮询线程就会被唤醒


    消息处理过程也是从 Looper 的 loop() 方法开始的,当 Looper 的 loop() 方法从 MessageQueue 的 next() 中获取到消息时,就会调用 Message 的 targetdispatchMessage() 的方法,Message 的 target 就是发送消息时用的 Handler ,Handler 的 dispatchMessage() 方法首先会判断 Message 是否设置了 callback 回调 ,比如用 post() 方法发送消息时,传入 post() 方法中的 Runnable 就是 Message 的 callback 回调,如果 Message 没有设置 callback ,则 dispatchMessage() 方法会调用 Handler 的 handleMessage() 方法,到这里消息处理过程就结束了。


    另外在使用消息 Message 的时候,建议使用 Message 的 obtain() 方法复用全局消息池中的消息。


    2. 消息机制初始化流程


    消息机制初始化流程就是 Handler、Looper 和 MessageQueue 三者的初始化流程,Handler 的初始化流程比较简单,而 Looper 的初始化流程则是从 prepare() 方法开始的,当 Looper 的 prepare() 方法被调用后,Looper 会创建一个消息队列 MessageQueue ,在 MessageQueue 的构造方法中会调用 nativeInit() JNI 方法初始化 Native 层的消息队列,在 NativeMessageQueue 的构造方法中会创建 Native 层的 Looper 实例,而在 Native 层的 Looper 的构造函数中,则会把唤醒事件的文件描述符监控请求的文件描述符添加到 epoll 的兴趣列表中。


    消息机制初始化流程.png


    1.1 Handler 初始化流程


    Handler 的初始化过程比较简单,这个过程中比较特别的两个点分别是不能在没有调用 Looper.prepare() 的线程创建 Handler以及异步 Handler


    Handler 中有好几个构造函数,其中不传 Looper 的构造函数在高版本的 SDK 中已经被声明为弃用了,也就是我们要创建主线程消息处理器的话,就要把 Looper.getMainLooper() 传到 Handler 的构造函数中。


    Handler 的构造函数有一个比较特别的一个 async 参数,async 为 true 时表示该 Handler 是一个异步消息处理器,使用这个 Handler 发送的消息会是异步消息,但是这个构造函数没有开放给我们使用,是系统组件自己用的。


    HandlerCode.png


    1.2 Looper 初始化流程


    之所以我们能在 Activity 中直接用 Handler 给主线程发消息 ,是因为 ActivityThread 的主函数 main() 中初始化了一个主线程专用的 Looper ,也正是这个 Looper 一直在轮询主线程要处理的消息。


    ActivityThread.png


    Looper 的 prepareMainLooper() 方法会调用 prepare() 方法创建一个新的 Looper , prepare() 是一个公共静态方法,如果我们也要开一个新的线程执行一个任务,这个任务也需要放在死循环中执行并等待消息,而我们又不想浪费 CPU 资源的话,就可以通过 Looper.prepare() 来创建线程的 Looper ,也可以直接使用 Android SDK 中 的 HandlerThread ,HandlerThread 内部也维护了一个 Looper。prepare() 方法会把创建好的 Looper 会放在线程局部变量 ThreadLocal 中。


    prepare() 方法可以传入一个 quitAllowed 参数,这个参数默认为 true ,用于指定是否允许退出,假如 quitAllowed 为 false 的话,那在 MessageQueue 的 quit() 方法被调用时就会抛出一个非法状态异常。


    Looper.png


    Looper 的构造函数中创建了 MessageQueue ,下面来看下 MessageQueue 的初始化流程。


    1.3 MessageQueue 初始化流程


    在 MessageQueue 的构造函数中调用了一个 JNI 方法 nativeInit() ,并且把初始化后的 NativeMessageQueue 的指针保存在 mPtr 中,发送消息的时候要用这个指针来唤醒消息轮询线程。


    MessageQueue.png


    nativeInit() 方法中调用了 NativeMessageQueue 的构造函数,在 NativeMessageQueue 的构造函数中创建了一个新的 Native 层的 Looper ,这个 Looper 跟 Java 层的 Looper 没有任何关系,只是在 Native 层实现了一套类似功能的逻辑。


    NativeMessageQueue 的构造函数中创建完 Looper 后,会通过 setForThread() 方法把它设置给当前线程,这个操作类似于把 Looper 放到 ThreadLocal 中。


    NativeMessageQueue.png


    在 Native 层的 Looper 的构造函数中,创建了一个新的唤醒事件文件描述符(eventfd)并赋值给 mWakeEventFd 变量,这个变量是一个唤醒事件描述符,然后再调用 rebuildEpollLocked() 方法重建 epoll 实例,新的事件文件描述符的初始值为 0 ,标志为 EFD_NONBLOCKEFD_CLOEXEC ,关于什么是文件描述符和这两个标志的作用在后面会讲到。


    NativeLooper.png


    rebuildEpollLocked() 方法的实现如下,关于什么是 epoll 后面会讲到,在 rebuildEpollLocked() 方法的最后会遍历请求列表,这个请求列表中的请求有很多地方会添加,比如输入分发器 InputDispatcherregisterInputChannel() 方法中也会添加一个请求到 Native 层 Looper 的请求列表中。


    rebuildEpollLocked().png


    1.4 Unix/Linux 体系架构


    由于 eventfd 和文件描述符都是 Linux 中的概念,所以下面来看一些 Linux 相关的知识。


    Linux 体系架构.png


    Linux 操作系统的体系架构分为用户态内核态(用户空间和内核空间),内核本质上看是一种软件,控制着计算机的硬件资源,并提供上层应用程序运行的环境。


    而用户态就是上层应用程序的活动空间,应用程序的执行,比如依托于内核提供的资源,包括 CPU 资源、存储资源、I/O 资源等,为了让上层应用能够访问这些资源,内核必须为上层应用提供访问的接口,也就是系统调用


    系统调用是受控的内核入口,借助这一机制,进程可以请求内核以自己的名义去执行某些动作,以 API 的形式,内核提供有一系列服务供程序访问,包括创建进程、执行 I/O 以及为进程间通信创建管道等。


    1.5 文件描述符


    Linux 继承了 UNIX 一切皆文件 的思想,在 Linux 中,所有执行 I/O 操作的系统调用都以文件描述符指代已打开的文件,包括管道(pipe)、FIFO、Socket、终端、设备和普通文件,文件描述符往往是数值很小的非负整数,获取文件描述符一般是通过系统调用 open() ,在参数中指定 I/O 操作目标文件的路径名。


    通常由 shell 启动的进程会继承 3 个已打开的文件描述符:



    • 描述符 0 :标准输入,指代为进程提供输入的文件


    • 描述符 1 :标准输出,指代供进程写入输出的文件


    • 描述符 2 :标准错误,指代进程写入错误消息或异常通告的文件



    文件描述符(File Descriptor) 是 Linux 中的一个索引值,系统在运行时有大量的文件操作,内核为了高效管理已被打开的文件会创建索引,用于指向被打开的文件,这个索引就是文件描述符


    1.6 事件文件描述符 eventfd


    eventfd 可以用于线程或父子进程间通信,内核通过 eventfd 也可以向用户空间发送消息,其核心实现是在内核空间维护一个计数器,向用户空间暴露一个与之关联的匿名文件描述符,不同线程通过读写该文件描述符通知或等待对方,内核则通过该文件描述符通知用户程序。


    在 Linux 中,很多程序都是事件驱动的,也就是通过 select/poll/epoll 等系统调用在一组文件描述符上进行监听,当文件描述符的状态发生变化时,应用程序就调用对应的事件处理函数,有的时候需要的只是一个事件通知,没有对应具体的实体,这时就可以使用 eventfd


    与管道(pipe)相比,管道是半双工的传统 IPC 方式,两个线程就需要两个 pipe 文件,而 eventfd 只要打开一个文件,而文件描述符又是非常宝贵的资源,linux 的默认值也只有 1024 个。eventfd 非常节省内存,可以说就是一个计数器,是自旋锁 + 唤醒队列来实现的,而管道一来一回在用户空间有多达 4 次的复制,内核还要为每个 pipe 至少分配 4K 的虚拟内存页,就算传输的数据长度为 0 也一样。这就是为什么只需要通知机制的时候优先考虑使用 eventfd 。


    eventfd 提供了一种非标准的同步机制,eventfd() 系统调用会创建一个 eventfd 对象,该对象拥有一个相关的由内核维护的 8 字节无符号整数,它返回一个指向该对象的文件描述符,向这个文件描述符中写入一个整数会把该整数加到对象值上,当对象值为 0 时,对该文件描述符的 read() 操作将会被阻塞,如果对象的值不是 0 ,那么 read() 会返回该值,并将对象值重置为 0 。


    struct eventfd_ctx {
    struct kref kref;
    wait_queue_head_t wqh;
    __u64 count;
    unsigned int flags;
    int id;
    };

    eventfd_ctx 结构体是 eventfd 实现的核心,其中 wqhcountflags 的作用如下。


    wqh 是等待队列头,所有阻塞在 eventfd 上的读进程挂在该等待队列上。


    count 是 eventfd 计数器,当用户程序在一个 eventfd 上执行 write 系统调用时,内核会把该值加在计数器上,用户程序执行 read 系统调用后,内核会把该值清 0 ,当计数器为 0 时,内核会把 read 进程挂在等待队列头 wqh 指向的队列上。


    有两种方式可以唤醒等待在 eventfd 上的进程,一个是用户态 write ,另一个是内核态的 eventfd_signal ,也就是 eventfd 不仅可以用于用户进程相互通信,还可以用作内核通知用户进程的手段。


    在一个 eventfd 上执行 write 系统调用,会向 count 加上被写入的值,并唤醒等待队列中输入的元素,内核中的 eventfd_signal 函数也会增加 count 的值并唤醒等待队列中的元素。


    flags 是决定用户 read 后内核的处理方式的标志,取值有EFD_SEMAPHOREEFD_CLOEXECEFD_NONBLOCK三个。


    EFD_SEMAPHORE表示把 eventfd 作为一个信号量来使用。


    EFD_NONBLOCK 表示该文件描述符是非阻塞的,在调用文件描述符的 read() 方法时,有该标志的文件描述符会直接返回 -1 ,在调用文件描述符的 write() 方法时,如果写入的值的和大于 0xFFFFFFFFFFFFFFFE ,则直接返回 -1 ,否则就会一直阻塞直到执行 read() 操作。


    EFD_CLOEXEC 表示子进程执行 exec 时会清理掉父进程的文件描述符。


    3. 事件轮询 epoll


    selectpollepoll都是 I/O 多路复用模型,可以同时监控多个文件描述符,当某个文件描述符就绪,比如读就绪或写就绪时,则立刻通知对应程序进行读或写操作,select/poll/epoll 都是同步 I/O ,也就是读写是阻塞的。


    1. epoll 简介

    epoll 是 Linux 中的事件轮询(event poll)机制,是为了同时监听多个文件描述符的 I/O 读写事件而设计的,epoll API 的优点有能高效检查大量文件描述符支持水平和边缘触发避免复杂的信号处理流程灵活性高四个。


    当检查大量的文件描述符时,epoll 的性能延展性比 select() 和 poll() 高很多


    epoll API 支持水平触发边缘触发,而 select() 和 poll() 只支持水平触发,信号驱动 I/O 则只支持边缘触发。


    epoll 可以避免复杂的信号处理流程,比如信号队列溢出时的处理。


    epoll 灵活性高,可以指定我们想检查的事件类型,比如检查套接字文件描述符的读就绪、写就绪或两者同时指定。


    2. 水平触发与边缘触发

    Linux 中的文件描述符准备就绪的通知有水平触发边缘触发两种模式。


    水平触发通知就是文件描述符上可以非阻塞地执行 I/O 调用,这时就认为它已经就绪。


    边缘触发通知就是文件描述符自上次状态检查以来有了新的 I/O 活动,比如新的输入,这时就要触发通知。


    3. epoll 实例

    epoll API 的核心数据结构称为 epoll 实例,它与一个打开的文件描述符关联,这个文件描述符不是用来做 I/O 操作的,而是内核数据结构的句柄,这些内核数据结构实现了记录兴趣列表维护就绪列表两个目的。


    这些内核数据结构记录了进程中声明过的感兴趣的文件描述符列表,也就是兴趣列表(interest list)


    这些内核数据结构维护了处于 I/O 就绪状态的文件描述符列表,也就是就绪列表(ready list),ready list 中的成员是兴趣列表的子集。


    4 epoll API 的 4 个系统调用

    epoll API 由以下 4 个系统调用组成。


    epoll_create() 创建一个 epoll 实例,返回代表该实例的文件描述符,有一个 size 参数,该参数指定了我们想通过 epoll 实例检查的文件描述符个数。


    epoll_creaet1() 的作用与 epoll_create() 一样,但是去掉了无用的 size 参数,因为 size 参数在 Linux 2.6.8 后就被忽略了,而 epoll_create1() 把 size 参数换成了 flag 标志,该参数目前只支持 EPOLL_CLOEXEC 一个标志。


    epoll_ctl() 操作与 epoll 实例相关联的列表,通过 epoll_ctl() ,我们可以增加新的描述符到列表中,把已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的掩码。


    epoll_wait()用于获取 epoll 实例中处于就绪状态的文件描述符。


    5. epoll_ctl()

    epoll_ctl.png


    epoll_ctl() 用于操作与 epoll 实例相关联的列表,成功返回 0 ,失败返回 -1,的 fd 参数指明了要修改兴趣列表中的哪一个文件描述符的设定,该参数可以是代表管道、FIFO、套接字等,甚至可以是另一个 epoll 实例的文件描述符。


    op 参数用于指定要执行的操作,可以选择的值如下。


    EPOLL_CTL_ADD 表示把描述符添加到 epoll 实例 epfd 的兴趣列表中。


    EPOLL_CTL_MOD 表示修改描述符上设定的事件。


    EPOLL_CTL_DEL 表示把文件描述符从 epfd 的兴趣列表中移除。


    6. epoll_wait()

    epoll_wait.png


    epoll_wait() 方法用于获取 epoll 实例中处于就绪状态的文件描述符,其中参数 timeout 就是 MessageQueue 的 next() 方法中的 nextPollTimeoutMillis ,timeout 参数用于确定 epoll_wait() 的阻塞行为,阻塞行为有如下几种。



    • -1 :调用将一直阻塞,直到兴趣列表中的文件描述符有事件产生,或者直到捕捉到一个信号为止

    • 0 :执行一次非阻塞式检查,看兴趣列表中的文件描述符上产生了哪个事件

    • 大于 0 :调用将阻塞至 timeout 毫秒,直到文件描述符上有事件发生,或者捕捉到一个信号为止


    7. epoll 事件

    下面是几个调用 epoll_ctl() 时可以在 ev.events 中指定的位掩码,以及由 epoll_wait() 返回的 evlist[].events 中的值。



    • EPOLLIN:可读取非高优先级的数据

    • EPOLLPRI:可读取高优先级的数据

    • EPOLLRDHUP:套接字对端关闭

    • EPOLLOUT:普通数据可写

    • EPOLLET:采用边缘触发事件通知

    • EPOLLONESHOT:在完成事件通知后禁用检查

    • EPOLLERR:在错误时发生

    • EPOLLHUP:出现挂断


    4. 消息轮询过程


    1. 消息轮询过程概述

    消息循环过程主要是由 Looper 的 loop() 方法、MessageQueue 的 next() 方法、Native 层 Looper 的 pollOnce() 这三个方法组成。


    消息轮询过程是从 Looper 的 loop() 方法开始的,loop() 方法中有一个死循环,死循环中会调用 MessageQueue 的 next() 方法,获取到消息后,loop() 方法就会调用 Message 的 target 的 dispatchMessage() 方法分发消息,target 其实就是最初发送 Message 的 Handler 。loop() 方法最后会调用 recycleUnchecked() 方法回收处理完的消息。


    在 MessageQueue 的 next() 方法中,首先会调用 nativePollOnce() JNI 方法检查队列中是否有新的消息要处理,没有时线程就会被阻塞。有的话就会尝试找出需要优先执行的异步线程,没有异步消息的话,就会判断消息是否到了要执行的时间,是的话就返回给 Looper 处理,否则重新计算消息的执行时间。


    2. Looper.loop()

    前面讲到了在 ActivityThread 的 main() 函数中会调用 Looper 的 loop() 方法让 Looper 开始轮询消息,loop() 方法中有一个死循环,死循环中会调用 MessageQueue 的 next() 方法获取下一条消息,获取到消息后,loop() 方法就会调用 Message 的 target 的 dispatchMessage() 方法,target 其实就是发送 Message 的 Handler 。最后就会调用 Message 的 recycleUnchecked() 方法回收处理完的消息。


    loop().png


    3. MessageQueue.next()

    在 MessageQueue 的 next() 方法中,首先会调用 nativePollOnce() JNI方法检查队列中是否有新的消息要处理,如果没有,那么当前线程就会在执行到 Native 层的 epoll_wait() 时阻塞。如果有消息,而且消息是同步屏障,那就会找出或等待需要优先执行的异步消息。调用完 nativePollOnce() 后,如果没有异步消息,就会判断当前消息是否到了要执行的时间,是的话则返回消息给 Looper 处理,不是的话就重新计算消息的执行时间(when)。在把消息返回给 Looper 后,下一次执行 nativePollOnce() 的 timeout 参数的值是默认的 0 ,所以进入 next() 方法时,如果没有消息要处理,next() 方法中还可以执行 IdleHandler。在处理完消息后,next() 方法最后会遍历 IdleHandler 数组,逐个调用 IdleHandler 的 queueIdle() 方法。


    下图是 MessageQueue 中找出异步消息后的链表变化。


    MessageQueue 异步消息处理机制.png


    光看 next() 方法的代码的话会觉得有点绕。ViewRootImpl 的 scheduleTraversals() 方法在很多地方都会被调用,当 scheduleTraversals() 方法被调用时,ViewRootImpl 就会调用 MessageQueuepostSyncBarrier() 方法插入一个同步屏障到消息链表中,然后再调用 ChoreographerpostCallback() 方法执行一个 View 遍历任务 ,然后再调用 MessageQueue 的 removeSyncBarrier() 方法移除同步屏障。Choreographer 的 postCallback() 方法会调用 postCallbackDelayedInternal() 方法,postCallbackDelayedInternal() 方法会调用 scheduleFrameLocked() 方法,scheduleFrameLock() 方法会从消息池中获取一条消息,并调用 Message 的 setAsynchronous() 方法把这条消息的标志 flags 设为异步标志 FLAG_ASYNCHRONOUS,然后调用内部类 FrameHandlersendMessageAtFrontOfQueue() 方法把异步消息添加到队列中。


    scheduleFrameLocked().png


    下面是 MessageQueue 的 next() 方法的具体实现代码。


    MessageQueue.next().png


    IdleHandler 可以用来做一些在主线程空闲的时候才做的事情,通过 Looper.myQueue().addIdleHandler() 就能添加一个 IdleHandler 到 MessageQueue 中,比如下面这样。


    addIdleHandler().png


    当 IdleHandler 的 queueIdle() 方法返回 false 时,那 MessageQueue 就会在执行完 queueIdle() 方法后把这个 IdleHandler 从数组中删除,下次不再执行。


    4. Looper.pollOnce()(Native 层)

    继续往下看。在 NativeMessageQueuepollOnce() 方法中,会调用 Native 层的 Looper 的 pollOnce() 方法。


    NativeMessageQueuePollOnce.png


    在 Looper 的 pollOnce() 方法中,首先会遍历了响应列表,如果响应的标识符(identifier)ident 值大于等 0 ,则返回标识符,响应是在 pollInner() 方法中添加的。


    NativeLooperPollOnce.png


    6. Looper.pollInner() (Native 层)

    在 pollInner() 方法中,首先会调用 epoll_wait() 获取可用事件,获取不到就阻塞当前线程,否则遍历可用事件数组 eventItems ,如果遍历到的事件的文件描述符是唤醒事件文件描述符 mWakeEventFd ,则调用 awoken()方法 唤醒当前线程。然后还会遍历响应数组信封数组,这两个数组是在 Native 层消息机制里用的,和我们上层用的关系不大,这里就不展开讲了。


    LooperPollInner.png


    awoken() 方法的实现很简单,只是调用了 read() 方法把 mWakeEventFd 的数据读取出来,mWakeEventFd 是一个 eventfd ,eventfd 的特点就是在读的时候它的 counter 的值会重置为 0


    awoken().png


    4. 消息发送机制



    当我们用 Handler 的 sendMessage()sendEmptyMessage()post() 等方法发送消息时, 最终都会走到 Handler 的 enqueueMessage() 方法。Handler 的 enqueueMessage() 又会调用 MessageQueue 的 enqueueMessage() 方法。


    ![sendMessage()](/Users/oushaoze/Documents/Projects/giteePages/assets/images/Android 消息机制/sendMessage().png)


    MessageQueue 的 enqueueQueue() 方法的实现如下。enqueueMessage() 首先会判断,当没有更多消息消息不是延时消息消息的发送时间早于上一条消息这三个条件其中一个成立时,就会把当前消息作为链表的头节点,然后如果 IdleHandler 都执行完的话,就会调用 nativeWake() JNI 方法唤醒消息轮询线程。


    如果把当前消息作为链表的头结点的条件不成立,就会遍历消息链表,当遍历到最后一个节点,或者发现了一条早于当前消息的发送时间的消息,就会结束遍历,然后把遍历结束的最后一个节点插入到链表中。如果在遍历链表的过程中发现了一条异步消息,就不会再调用 nativeWake() JNI 方法唤醒消息轮询线程。


    ![enqueueMessage()](/Users/oushaoze/Documents/Projects/giteePages/assets/images/Android 消息机制/enqueueMessage().png)


    nativeWake() 的实现如下,只是简单调用了 Native 层 Looper 的 wake() 方法。


    nativeWake().png


    Native 层 Looper 的 wake() 方法的实现如下,TEMP_FAILURE_RETRY 是一个用于重试,能返回 EINTR 的函数 ,write() 方法会向唤醒事件文件描述符写入一个 W 字符,这个操作唤醒被阻塞的消息循环线程 。


    LooperWake.png


    5. 消息处理过程


    消息处理过程是从 Looper 的 loop() 方法开始的,当 Looper 从 MessageQueue 中获取下一条要处理的消息后,就会调用 Message 的 target 的 dispatchMessage() 方法,而 target 其实就是发送消息的 Handler 。


    LooperLoop().png


    设置 Message 的 target 的地方就是在 HandlerenqueueMessage() 方法中。


    HandlerEnqueueMessage.png


    在 Handler 的 dispatchMessage() 方法中,如果消息是通过 post() 方法发送,那么 post() 传入的 Runnable 就会作为 msg 的 callback 字段。如果 callback 字段不为空,dispatchMessage() 方法就会调用 callback 的 run() 方法 ,否则调用 Handler 的 callback 或 Handler 本身的 handleMessage() 方法,Handler 的 callback 指的是在创建 Handler 时传入构造函数的 Callback


    dispatchMessage.png


    6. 消息 Message


    下面我们来看下 Message 的实现。Message 中的 what消息的标识符。而 arg1arg2objdata 分别是可以放在消息中的整型数据Object 类型数据Bundle 类型数据when 则是消息的发送时间


    sPool全局消息池,最多能存放 50 条消息,一般建议用 Message 的 obtain() 方法复用消息池中的消息,而不是自己创建一个新消息。如果在创建完消息后,消息没有被使用,想回收消息占用的内存,可以调用 recycle() 方法回收消息占用的资源。如果消息在 Looper 的 loop() 方法中处理了的话,Looper 就会调用 recycleUnchecked() 方法回收 Message 。


    Message.png


    参考资料




    作者:灯不利多
    链接:https://juejin.cn/post/6983598752837664781
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    比浏览器 F12 更好用的免费调试抓包工具 Fiddler 介绍

    身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我...
    继续阅读 »

    身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我司 TE 同学用 Fiddler 进行抓包测试,一问这软件还是免费的,遂进行了一番学习了解,发现可以直接解决刚刚提到的这两个问题,所以在这里做个分享。


    简介



    • Fiddler 是位于客户端和服务器端的 HTTP 代理

    • 目前最常用的 HTTP 抓包工具之一

    • 功能非常强大,是 web 调试的利器

      • 监控浏览器所有的 HTTP/HTTPS 流量

      • 查看、分析请求内容细节

      • 伪造客户端请求和服务器响应

      • 解密 HTTPS 的 web 会话

      • 全局、局部断点功能

      • 第三方插件



    • 使用场景

      • 接口的测试与调试

      • 线上环境调试

      • web 性能分析




    下载


    直接去官网下载 Fiddler Classic 即可:


    image.png


    原理


    学习一件新事物,最好是知其然亦知其所以然,这样遇到问题心里有底,才不容易慌,下面就介绍下 Fiddler 抓包的原理。


    Fiddler 是位于客户端和服务器端之间的 HTTP 代理。一旦启动 Fiddler,其会自动将代理服务器设置成本机,默认端口为 8888,并设置成系统代理(Act as system proxy on startup)。可以在 Fiddler 通过 'Tools -> Options -> Connections' 查看, 图示如下:


    image.png

    在 Fiddler 运行的情况下,以 Chrome 浏览器为例,可以在其 '设置 -> 高级 -> 系统 -> 打开您计算机的代理设置 -> 连接 -> 局域网(LAN)设置' 里看到,'代理服务器' 下的 '为 LAN 使用代理服务器' 选项被勾选了(如果没有运行 Fiddler,默认情况下是不会被勾选的),如下图:


    image (1).png

    点开 '高级',会发现 '要使用的代理服务器地址' 就是本机 ip,端口为 8888。如下图:


    image (2).png

    也就是说浏览器的 HTTP 请求/响应都被代理到了系统的 8888 端口,被 Fiddler 拦截了。


    界面介绍


    下面开始对整个 Fiddler 的界面进行一个庖丁解牛


    工具栏


    image.png
    主要介绍上图中几个标了号的我认为比较常用的功能:



    1. Replay:重放选中的那条请求,同时按下 shift + R 键,可以输入重复发送请求的次数(这些请求是串行发送的)。可以用来做重放攻击的测试。

    2. 删除会话(sessions)

    3. 继续打了断点的请求:打断点后请求会被拦截在 Fiddler,点击这个 Go 继续发送。打断点的方式是点击界面底部的空格,具体位置如下图所示:


    image (1).png



    1. 这个类似瞄准器的工具时用于选择抓取请求的应用:按住不放将鼠标拖放到目标应用即可

    2. 可用于查找某条请求,比如你知道请求参数里的某个字段,可以直接输入进行查找

    3. 编码解码工具,可以进行多种编码的转换,是个人觉得挺好用的一个工具,能够编码的格式包括但不限于 base64、md5 和 URLEncode 等

    4. 可以查看一些诸如本机 ip(包括 IPv4,IPv6) 等信息,就用不着去 cmd 里 输入ipconfig 查看了,如下图:


    image (2).png


    会话列表(Session List)


    位于软件界面的左半部的就是会话列表了,抓取到的每条 http 请求都列在这,每一条被称为一个 session,如下图所示:

    image (3).png


    每条会话默认包含的信息



    • 请求的状态码(result)

    • 协议(protocol)

    • 主机名(host)

    • URL

    • 请求大小(body,以字节为单位)

    • 缓存信息(caching)

    • 响应类型(content-type)

    • 发出请求的 Windows 进程及进程 ID(process)


    自定义列


    除了以上这些,我们还可以添加自定义列,比如想添加一列请求方法信息:



    1. 点击菜单栏 -> Rules -> Customize Rules 调出 Fiddler ScriptEditor 窗口

    2. 按下 ctrl + f 输入 static function Main() 进行查找

    3. 然后在找到的函数 Main 里添加:


    FiddlerObject.UI.lvSessions.AddBoundColumn("Method",60,getHTTPMethod );
    static function getHTTPMethod(oS: Session){
    if (null != oS.oRequest) return oS.oRequest.headers.HTTPMethod;
    else return String.Empty;
    }

    图示如下:


    image (4).png
    4. 按下 ctrl + s 保存。然后就可以在会话列表里看到多出了名为 Method 的一列,内容为请求方法。


    排序和移动



    1. 点击每一列的列表头,可以反向排序

    2. 按住列表头不放进行拖动,可以改变列表位置


    QuickExec 与状态栏


    位于软件界面底部的那条黑色的是 QuickExec,可用于快速执行输入的一些命令,具体命令可输入 help 跳转到官方的帮助页面查看。图示如下:


    image (5).png

    在 QuickExec 下面的就是状态栏,



    1. Capturing:代表目前 Fiddler 的代理功能是开启的,也就是是否进行请求响应的拦截,如果想关闭代理,只需要点击一下 Capturing 图标即可

    2. All Processes:选择抓取的进程,可以只选浏览器进程或是非浏览器进程等

    3. 断点:按一次是请求前断点,也就是请求从浏览器发出到 Fiddler 这停住;再按一次是响应后的断点,也就是响应从服务器发出,到Fiddler 这停住;再按一次就是不打断点

    4. 当前选中的会话 / 总会话数

    5. 附加信息


    辅助标签 + 工具


    位于软件界面右边的这一大块面板,即为辅助标签 + 工具,如下图所示,它拥有 10 个小标签,我们先从 Statistics 讲起,btw,这单词的发音是 [stəˈtɪstɪks],第 3 个字母 a 发 'ə' 的音,而不是 'æ'~


    image (6).png


    Statistics(统计)


    这个 tab 里都是些 http 请求的性能数据分析,如 DNS Lookup(DNS 解析时间)、 TCP/IP Connect(TCP/IP 连接时间)等。


    Inspectors(检查器)


    image.png

    以多种不同的方式查看请求的请求报文和响应报文,比如可以只看头部信息(Headers)、或者是查看请求的原始信息(Raw),再比如请求的参数是 x-www-form-urlencoded 的话,就能在 WebForms 里查看...


    AutoResponder(自动响应器)


    image (1).png

    这是一个我认为比较有用的功能了,它可以篡改从服务器返回的数据,达到欺骗浏览器的目的。


    实战案例


    我在做一个后台项目的时候,因为前台还没弄好,数据库都是没有数据的,在获取列表时,请求得到的都是如下图所示的空数组:


    image.png

    那么在页面上显示的也就是“暂无数据”,这就影响了之后一些删改数据的接口的对接。


    image (2).png

    此时,我们就可以通过 AutoResponder ,按照接口文档的返回实例,对返回的数据进行编辑,具体步骤如下:



    1. 勾选上 Enable rules(激活自动响应器) 和 Unmatched requests passthrough(放行所有不匹配的请求)


    image (3).png

    2. 在左侧会话列表里选中要修改响应的那条请求,按住鼠标直接拖动到 AutoResponder 的面板里,如下图红框所示:


    image (4).png

    3. 选中上图红框里的请求单机鼠标右键,选择 Edit Response...


    image (5).png

    4. 进入编辑面板选择 Raw 标签就可以直接进行编辑了,这里我按照接口文档的返回示例,给 items 数组添加了数据,如下图所示:


    image (6).png

    这样,浏览器接收到数据,页面就如下图所示有了内容,方便进行之后的操作


    image (7).png


    Composer(设计者)


    说完了对响应的篡改,现在介绍的 composer 就是用于对请求的篡改。这个单词的翻译是作曲家,按照我们的想法去修改一个请求,宛如作曲家谱一首乐曲一般。


    image.png

    用法与 AutoResponder 类似,也是可以从会话列表里直接拖拽一个请求到上图红框中,然后对请求的内容进行修改即可。应用场景之一就是可以绕过一些前端用 js 写的限制与验证,直接发送请求,通过返回的数据可以判断后端是否有做相关限制,测试系统的健壮性。


    Filters(过滤器)


    在默认情况下,Filters 会抓取一切能够抓取到的请求,统统列在左侧的会话列表里,如果我们是有目的对某些接口进行测试,就会觉得请求列表很杂乱,这时可以点开 Filters 标签,勾选 Use Filters,启动过滤工具,如下图:


    image.png

    接着就可以根据我们需要对左侧列表里展示的所抓取的接口进行过滤,比如根据 Hosts 进行过滤,只显示 Hosts 为 api.juejin.cn 的请求,就可以如下图在 Hosts 那选择 'Show only the following Hosts',然后点击右上角 Actions 里的 'Run Filterset now' 执行过滤。


    image.png

    过滤的筛选条件还有很多,比如据请求头字段里 URL 是否包含某个单词等,都很简单,一看便知,这里不再一一细说。


    HTTPS 抓包


    默认情况下,Fiddler 没办法显示 HTTPS 的请求,需要进行证书的安装:



    1. 点击 'Tools -> Options...' ,勾选上 'Decrypt HTTPS traffic' (解密HTTPS流量)


    image.png



    1. 点击 Actions 按钮,点击 'Reset All Certicicates' (重置所有证书),之后遇到弹出的窗口,就一直点击 '确定' 或 'yes' 就行了。


    image (1).png



    1. 查看证书是否安装成功:点击 'Open Windows Certificate Manager' 打开 Windows 证书管理器窗口


    image (2).png

    点击 '操作' 选择 '查找证书',在 '包含' 输入框输入 fiddler 进行查找


    image (3).png

    查找结果类似下图即安装证书成功


    image (4).png

    现在会话列表就能成功显示 https 协议的请求了。


    断点应用


    全局断点


    通过 'Rules -> Automatic Breakpoints' 可以给请求打断点,也就是中断请求,断点分为两种:



    1. Before Requests(请求前断点):请求发送给服务器之前进行中断

    2. After Responses(响应后断点):响应返回给客户端之前进行中断


    image.png

    打上断点之后,选中想要修改传输参数的那一条请求,按 R 进行重发,这条请求就会按要求在请求前或响应后被拦截,我们就可以根据需要进行修改,然后点击工具栏的 'Go',或者点击如下图所示的绿色按钮 'Run to Completion',继续完成请求。


    image (1).png

    这样打断点是全局断点,即所有请求都会被拦截,下面介绍局部断点。


    局部断点


    如果只想对某一条请求打断点,则可以在 QuickExec 输入相应的命令执行。



    • 请求前断点



    1. 在 QuickExec 输入 bpu query_adverts 。注意:query_adverts 为请求的 url 的一部分,这样就只有 url 中包含 query_adverts 的请求会被打上断点。


    image (2).png



    1. 按下 Enter 键,可以看到红框中显示 query_adverts 已经被 breakpoint 了,而且是 RequestURI


    image (3).png



    1. 选中 url 中带 query_adverts 的这条请求,按 R 再次发送,在发给服务器前就会被中断(原谅我又拿掘金的请求做例子~)


    image (4).png



    1. 取消断点:在 QuickExec 输入 bpu 按下 Enter 即可



    • 响应后断点


    与请求前断点步骤基本一致,区别在于输入的命令是 bpafter get_today_status
    按下 Enter 后在 'Composer' 标签下点击 'Execute' 执行,再次发送该请求则服务器的响应在发送给浏览器之前被截断,注意下红色的图标,跟之前的请求前断点的区别在于一个是向上的箭头,一个是向下的箭头。


    image (5).png

    取消拦截则是输入 bpafter 后回车,可以看到状态栏显示 'ResponseURI breakpoint cleared'


    image (6).png


    弱网测试


    Fiddler 还可以用于弱网测试,'Rules -> Performance -> 勾选 Simulate Modem Speeds' 即可


    image (7).png

    再次刷新网页会感觉回到了拨号上网的年代,可以测试网站在网速很低的情况下的表现。


    修改网速


    网速还可以修改,点击 'FiddlerScript' 标签,在下图绿框中搜索 simulateM,按几下回车找到 if (m_SimulateModem) 这段代码,可以修改上下传输的速度:


    image (8).png


    安卓手机抓包


    最后一部分主要内容是关于手机抓包的,我用的是小米手机 9,MIUI 12.5.1 稳定版,安卓版本为 11。



    1. 首先保证安装了 Fiddler 的电脑和手机连的是同一个 wifi

    2. 在 Fiddler 中,点击 'Tools -> Options...' ,在弹出的 Options 窗口选择 Connections 标签,勾选 'Allow remote computers to connect'


    image (9).png



    1. 手机打开 '设置 -> WLAN -> 连接的那个 WLAN 的设置' 进入如下图所示的页面


    image (10).png



    1. '代理' 选择 '手动','主机名' 填写电脑的主机名,端口则是 Fiddler 默认监听的 8888,然后点击左上角的 '打钩图标' 进行保存

    2. 下载证书:打开手机浏览器,输入 'http://192.168.1.1:8888' (注意:192.168.1.1 要替换成你电脑的 ip 地址),会出现如下页面


    image (11).png

    点击红框中链接进行证书的下载



    1. 安装证书:打开 '设置 -> 密码与安全 -> 系统安全 -> 加密与凭据 -> 安装证书(从存储设备安装证书)-> 证书 ' 找到刚刚下载的证书进行安装


    image (12).png



    1. 安装完成可以在 '加密与凭据 -> 信任的凭据' 下查看


    image (13).png



    1. 现在 Fiddler 就可以抓到手机里 app 发送的请求了

    2. 最后注意:测试完毕需要关闭手机的 WLAN 代理,否则手机就上不了网了~


    One More Thing


    几个常用快捷键



    • 双击某一条请求:打开该请求的 Inspectors 面板

    • ctrl + X:清除请求列表

    • R:选中某一条请求,按 R 键可重新发送该请求

    • shift+delete:删除除了选中那一条之外的请求



    链接:https://juejin.cn/post/6983282278277316615

    收起阅读 »

    小程序自动化测试入门到实践

    背景 随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。 今天要带来的是: 小程序自动化测试入门教程。 环境 系统 :macOS 微信开发者工具版本: 1.05.2106300 什么是小程序自动化 ...
    继续阅读 »

    背景


    随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。


    今天要带来的是: 小程序自动化测试入门教程


    环境


    系统 :macOS

    微信开发者工具版本: 1.05.2106300


    什么是小程序自动化


    微信官方文档:小程序自动化


    使用小程序自动化 SDK miniprogram-automator,可以在帮助我们在小程序中完成一些事情,比如:控制小程序跳转到指定页面,获取小程序页面数据,获取小程序页面元素状态等。


    配合 jest 就可以实现小程序端自动化测试了。
    话不多说,我们开始吧


    准备




    1. 项目根目录 mini-auto-test-demo 里面准备两个目录 miniprogram 放小程序代码,和 test-e2e 放测试用例代码




     |— mini-auto-test-demo/  // 根目录
    |— miniprogram/ // 小程序代码
    |— pages/
    |— index/ // 测试文件
    |— test-e2e/ // 测试用例代码
    |— index.spec.js // 启动文件
    |— package.json

    index 文件夹下准备用于测试的页面

    <!--index.wxml-->
    <view class="userinfo">
    <view class="userinfo-avatar" bindtap="bindViewTap">
    <open-data type="userAvatarUrl"></open-data>
    </view>
    <open-data type="userNickName"></open-data>
    </view>

    /**index.wxss**/
    .userinfo {
    margin-top: 50px;
    display: flex;
    flex-direction: column;
    align-items: center;
    color: #aaa;
    }
    .userinfo-avatar {
    overflow: hidden;
    width: 128rpx;
    height: 128rpx;
    margin: 20rpx;
    border-radius: 50%;
    }

    // index.js
    // 获取应用实例
    const app = getApp()
    Page({
    data: {
    userInfo: {},
    },
    // 事件处理函数
    bindViewTap() {
    wx.navigateTo({
    url: '../logs/logs'
    })
    }
    })


    1. 微信开发者工具->设置-> 安全设置 -> 打卡服务端口


    image.png



    1. 安装npm包


    如果根目录没有 package.json 文件,先执行


    npm init

    如果根目录已经有 package.json 文件 ,执行以下命令:


    npm install miniprogram-automator jest --save-dev
    npm i jest -g

    安装需要的依赖



    1. 在根目录下新建index.spec.js 文件
    const automator = require('miniprogram-automator')

    automator.launch({
    cliPath: '/Applications/wechatwebdevtools.app/Contents/MacOS/cli', // 工具 cli 位置
    projectPath: '/Users/SONG/Documents/github/mini-auto-test-demo/miniprogram', // 项目文件地址
    }).then(async miniProgram => {
    const page = await miniProgram.reLaunch('/pages/index/index')
    await page.waitFor(500)
    const element = await page.$('.userinfo-avatar')
    console.log(await element.attribute('class'))
    await element.tap()
    await miniProgram.close()
    })

    这里要注意修改为自己的cli位置和项目文件地址:



    1. cliPath:


    可以在应用程序中找到微信开发者工具,点击右键点击"显示包内容"


    image.png


    找到cli后,快捷键 :command+option+c 复制路径, 就拿到了


    image.png



    1. projectPath:


    注意!!项目路径填写的是小程序文件夹miniprogram而不是mini-auto-test-demo


    启动


    写好路径后,在mac终端进入mini-auto-test-demo根目录或 vscode 终端根目录执行命令:


    node index.spec.js

    image.png


    你会发现微信开发者工具被自动打开,并执行了点击事件进入了log页面,终端输出了class的值。
    到此你已经感受到了自动化,接下来你要问了,自动化测试呢?别急,接着往下看。


    自动化测试


    在一开始准备的test-e2e 文件夹下新建integration.test.js文件,


    引入'miniprogram-automator, 连接自动化操作端口,把刚刚index.spec.js中的测试代码,放到 jest it 里,jest相关内容我们这里就不赘述了,大家可以自行学习(其实我也才入门 ̄□ ̄||)。

    const automator = require('miniprogram-automator');

    describe('index', () => {
    let miniProgram;
    let page;
    const wsEndpoint = 'ws://127.0.0.1:9420';
    beforeAll(async() => {
    miniProgram = await automator.connect({
    wsEndpoint: wsEndpoint
    });
    }, 30000);

    it('test index', async() => {
    page = await miniProgram.reLaunch('/pages/index/index')
    await page.waitFor(500)
    const element = await page.$('.userinfo-avatar')
    console.log(await element.attribute('class'))
    await element.tap()
    });
    });

    package.json scripts 添加命令


    "e2e": "jest ./test-e2e integration.test.js --runInBand"

    测试代码写好了,接下来如何运行呢?这里我们提另外一个方法。


    cli 命令行调用


    官方文档:命令行调用

    你一定会问,刚刚我们不是学习了启动运行,这么还要学另外一种方法 o(╥﹏╥)o
    大家都知道,一般团队里都是多人合作的,大家的项目路径都不一样,难道每次还要改projectPath吗?太麻烦了,使用cli就不需要考虑在哪里启动,项目地址在哪里,话不多说,干!


    打开终端进入放微信开发者工具cli文件夹(路径仅供参考):


    cd /Applications/wechatwebdevtools.app/Contents/MacOS 

    执行命令(如果你的微信开发者工具开着项目,先关掉)


    ./cli --auto  /Users/SONG/Documents/github/mini-auto-test-demo/miniprogram  --auto-port 9420

    微信开发者工具通过命令行启动


    image.png


    启动后在项目根目录下执行,可以看到测试通过


    npm run e2e

    image.png


    到此,我们已经可以写测试用例了。这只是入门系列,后续会持续更文,感谢大家的耐心阅读,如果你有任何问题都可以留言给我,摸摸哒



    链接:https://juejin.cn/post/6983294039852318728
    收起阅读 »

    面试官:能不能手写几道链表的基本操作

    反转链表 示例: 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL 循环解决方案 这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 ,...
    继续阅读 »

    反转链表


    示例:


    输入: 1->2->3->4->5->NULL
    输出: 5->4->3->2->1->NULL


    • 循环解决方案


    这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 , 但是 实现上 并没有那么简单的特点。


    那在实现上应该注意一些什么问题呢?


    保存后续节点。作为新手来说,很容易将当前节点的 next 指针直接指向前一个节点,但其实当前节点下一个节点 的指针也就丢失了。因此,需要在遍历的过程当中,先将下一个节点保存,然后再操作 next指向。


    链表结构声定义如下:


    function ListNode(val) {
    this.val = val;
    this.next = null;
    }

    实现如下:

    /**
    * @param {ListNode} head
    * @return {ListNode}
    */
    let reverseList = (head) => {
    if (!head)
    return null;
    let pre = null,
    cur = head;
    while (cur) {
    // 关键: 保存下一个节点的值
    let next = cur.next;
    cur.next = pre;
    pre = cur;
    cur = next;
    }
    return pre;
    };


    • 递归解决方案


    let reverseList = (head) =>{
    let reverse = (pre, cur) => {
    if(!cur) return pre;
    // 保存 next 节点
    let next = cur.next;
    cur.next = pre;
    return reverse(cur, next);
    }
    return reverse(null, head);
    }

    2.区间反转


    反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。


    说明: 1 ≤ m ≤ n ≤ 链表长度。


    示例:


    输入: 1->2->3->4->5->NULL, m = 2, n = 4
    输出: 1->4->3->2->5->NULL

    思路
    这一题相比上一个整个链表反转的题,其实是换汤不换药。我们依然有两种类型的解法:循环解法递归解法


    image.png
    关于前节点和后节点的定义,大家在图上应该能看的比较清楚了,后面会经常用到。


    反转操作上一题已经拆解过,这里不再赘述。值得注意的是反转后的工作,那么对于整个区间反转后的工作,其实就是一个移花接木的过程,首先将前节点的 next 指向区间终点,然后将区间起点的 next 指向后节点。因此这一题中有四个需要重视的节点: 前节点 、 后节点 、 区间起点 和 区间终点 。



    • 循环解法
    /**
    * @param {ListNode} head
    * @param {number} m
    * @param {number} n
    递归解法
    对于递归解法,唯一的不同就在于对于区间的处理,采用递归程序进行处理,大家也可以趁着复习一下
    递归反转的实现。
    * @return {ListNode}
    */
    var reverseBetween = function(head, m, n) {
    let count = n - m;
    let p = dummyHead = new ListNode();
    let pre, cur, start, tail;
    p.next = head;
    for(let i = 0; i < m - 1; i ++) {
    p = p.next;
    }
    // 保存前节点
    front = p;
    // 同时保存区间首节点
    pre = tail = p.next;
    cur = pre.next;
    // 区间反转
    for(let i = 0; i < count; i++) {
    let next = cur.next;
    cur.next = pre;
    pre = cur;
    cur = next;
    }
    // 前节点的 next 指向区间末尾
    front.next = pre;
    // 区间首节点的 next 指向后节点(循环完后的cur就是区间后面第一个节点,即后节点)
    tail.next = cur;
    return dummyHead.next;
    };


    • 递归解法
    var reverseBetween = function(head, m, n) {
    // 递归反转函数
    let reverse = (pre, cur) => {
    if(!cur) return pre;
    // 保存 next 节点
    let next = cur.next;
    cur.next = pre;
    return reverse(cur, next);
    }
    let p = dummyHead = new ListNode();
    dummyHead.next = head;
    let start, end; //区间首尾节点
    let front, tail; //前节点和后节点
    for(let i = 0; i < m - 1; i++) {
    p = p.next;
    }
    front = p; //保存前节点
    start = front.next;
    for(let i = m - 1; i < n; i++) {
    p = p.next;
    }
    end = p;
    tail = end.next; //保存后节点
    end.next = null;
    // 开始穿针引线啦,前节点指向区间首,区间首指向后节点
    front.next = reverse(null, start);
    start.next = tail;
    return dummyHead.next;
    }

    3.两个一组翻转链表


    给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。


    你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


    示例


    给定 1->2->3->4, 你应该返回 2->1->4->3

    思路


    如图所示,我们首先建立一个虚拟头节点(dummyHead),辅助我们分析。


    image.png


    首先让 p 处在 dummyHead 的位置,记录下 p.next 和 p.next.next 的节点,也就是 node1 和
    node2。


    随后让 node1.next = node2.next, 效果:


    image.png


    然后让 node2.next = node1, 效果:


    image.png
    最后,dummyHead.next = node2,本次翻转完成。同时 p 指针指向node1, 效果如下:


    image.png
    依此循环,如果 p.next 或者 p.next.next 为空,也就是 找不到新的一组节点 了,循环结束。



    • 循环解决
    var swapPairs = function(head) {
    if(head == null || head.next == null)
    return head;
    let dummyHead = p = new ListNode();
    let node1, node2;
    dummyHead.next = head;
    while((node1 = p.next) && (node2 = p.next.next)) {
    node1.next = node2.next;
    node2.next = node1;
    p.next = node2;
    p = node1;
    }
    return dummyHead.next;
    };


    • 递归方式


    var swapPairs = function(head) {
    if(head == null || head.next == null)
    return head;
    let node1 = head, node2 = head.next;
    node1.next = swapPairs(node2.next);
    node2.next = node1;
    return node2;
    };

    4.K个一组翻转


    给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。


    k 是一个正整数,它的值小于或等于链表的长度。


    如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。


    示例


    给定这个链表:1->2->3->4->5
    当 k = 2 时,应当返回: 2->1->4->3->5
    当 k = 3 时,应当返回: 3->2->1->4->5

    说明 :


    你的算法只能使用常数的额外空间。


    你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


    思路
    思路类似No.3中的两个一组翻转。唯一的不同在于两个一组的情况下每一组只需要反转两个节点,而在K 个一组的情况下对应的操作是将 K 个元素 的链表进行反转。



    • 递归解法
    /**
    * @param {ListNode} head
    * @param {number} k
    * @return {ListNode}
    */
    var reverseKGroup = function(head, k) {
    let pre = null, cur = head;
    let p = head;
    // 下面的循环用来检查后面的元素是否能组成一组
    for(let i = 0; i < k; i++) {
    if(p == null) return head;
    p = p.next;
    }
    for(let i = 0; i < k; i++){
    let next = cur.next;
    cur.next = pre;
    pre = cur;
    cur = next;
    }
    // pre为本组最后一个节点,cur为下一组的起点
    head.next = reverseKGroup(cur, k);
    return pre;
    };


    • 循环解法
    var reverseKGroup = function(head, k) {
    let count = 0;
    // 看是否能构成一组,同时统计链表元素个数
    for(let p = head; p != null; p = p.next) {
    if(p == null && i < k) return head;
    count++;
    }
    let loopCount = Math.floor(count / k);
    let p = dummyHead = new ListNode();
    dummyHead.next = head;
    // 分成了 loopCount 组,对每一个组进行反转
    for(let i = 0; i < loopCount; i++) {
    let pre = null, cur = p.next;
    for(let j = 0; j < k; j++) {
    let next = cur.next;
    cur.next = pre;
    pre = cur;
    cur = next;
    }
    // 当前 pre 为该组的尾结点,cur 为下一组首节点
    let start = p.next;// start 是该组首节点
    // 开始穿针引线!思路和2个一组的情况一模一样
    p.next = pre;
    start.next = cur;
    p = start;
    }
    return dummyHead.next;
    }


    链接:https://juejin.cn/post/6983580875842093092

    收起阅读 »

    前端工程化实战 - 企业级 CLI 开发

    背景 先罗列一些小团队会大概率会遇到的问题: 规范 代码没有规范,每个人的风格随心所欲,代码交付质量不可控 提交 commit 没有规范,无法从 commit 知晓提交开发内容 流程 研发没有流程,没有 prd,没有迭代的需求管理,这个项目到底做了...
    继续阅读 »

    背景


    image.png


    先罗列一些小团队会大概率会遇到的问题:



    1. 规范

      • 代码没有规范,每个人的风格随心所欲代码交付质量不可控

      • 提交 commit 没有规范,无法从 commit 知晓提交开发内容



    2. 流程

      • 研发没有流程,没有 prd,没有迭代的需求管理,这个项目到底做了点啥也不知道



    3. 效率

      • 不断的重复工作,没有技术积累与沉淀



    4. 项目质量

      • 项目没有规范就一定没有质量

      • 测试功能全部靠人工发现与回归,费时费力



    5. 部署

      • 人工构建、部署,刀耕火种般的操作

      • 依赖不统一、人为不可控

      • 没有版本追踪、回滚等功能




    除了上述比较常见的几点外,其余的一些人为环境因素就不一一列举了,总结出来其实就是混乱 + 不舒服


    同时处在这样的一个团队中,团队自身的规划就不明确,个人就更难对未来有一个清晰的规划与目标,容易全部陷于业务不可自拔、无限循环。


    当你处在一个混乱的环境,遇事不要慌(乱世出英雄,为什么不能是你呢),先把事情捋顺,然后定个目标与规划,一步步走。


    工程化


    上述列举的这些问题可以通过引入工程化体系来解决,那么什么是工程化呢?


    广义上,一切以提高效率、降低成本、保障质量为目的的手段,都属于工程化的范畴。


    通过一系列的规范、流程、工具达到研发提效、自动化、保障质量、服务稳定、预警监控等等。


    对前端而言,在 Node 出现之后,可以借助于 Node 渗透到传统界面开发之外的领域,将研发链路延伸到整个 DevOps 中去,从而脱离“切图仔”成为前端工程师。


    image.png


    上图是一套简单的 DevOps 流程,技术难度与成本都比较适中,作为小型团队搭建工程化的起点,性价比极高。


    在团队没有制定规则,也没有基础建设的时候,通常可以先从最基础的 CLI 工具开始然后切入到整个工程化的搭建。


    所以先定一个小目标,完成一个团队、项目通用的 CLI 工具。


    CLI 工具分析


    小团队里面的业务一般迭代比较快,能抽出来提供开发基建的时间与机会都比较少,为了避免后期的重复工作,在做基础建设之前,一定要做好规划,思考一下当前最欠缺的核心与未来可能需要用到的功能是什么?



    Coding 永远不是最难的,最难的是不知道能使用 code 去做些什么有价值的事情。



    image.png


    参考上述的 DevOps 流程,本系列先简单规划出 CLI 的四个大模块,后续如果有需求变动再说。



    可以根据自己项目的实际情况去设计 CLI 工具,本系列仅提供一个技术架构参考。



    构建


    通常在小团队中,构建流程都是在一套或者多套模板里面准备多环境配置文件,再使用 Webpack Or Rollup 之类的构建工具,通过 Shell 脚本或者其他操作去使用模板中预设的配置来构建项目,最后再进行部署之类的。


    这的确是一个简单、通用的 CI/CD 流程,但问题来了,只要最后一步的发布配置不在可控之内,任意团队的开发成员都可以对发布的配置项做修改。


    即使构建成功,也有可能会有一些不可预见的问题,比如 Webpack 的 mode 选择的是 dev 模式、没有对构建代码压缩混淆、没有注入一些全局统一方法等等,此时对生产环境而言是存在一定隐患的


    所以需要将构建配置、过程从项目模板中抽离出来,统一使用 CLI 来接管构建流程,不再读取项目中的配置,而通过 CLI 使用统一配置(每一类项目都可以自定义一套标准构建配置)进行构建。


    避免出现业务开发同学因为修改了错误配置而导致的生产问题。


    质量


    与构建是一样的场景,业务开发的时候为了方便,很多时候一些通用的自动化测试以及一些常规的格式校验都会被忽略。比如每个人开发的习惯不同也会导致使用的 ESLINT 校验规则不同,会对 ESLINT 的配置做一些额外的修改,这也是不可控的一个点。一个团队还是使用同一套代码校验规则最好。


    所以也可以将自动化测试、校验从项目中剥离,使用 CLI 接管,从而保证整个团队的某一类项目代码格式的统一性。


    模板


    至于模板,基本上目前出现的博客中,只要是关于 CLI 的,就必然会有模板功能。


    因为这个一个对团队来说,快速、便捷初始化一个项目或者拉取代码片段是非常重要的,也是作为 CLI 工具来说产出最高、收益最明显的功能模块,但本章就不做过多的介绍,放在后面模板的博文统一写。


    工具合集


    既然是工具合集,那么可以放一些通用的工具类在里面,比如



    1. 图片压缩(png 压缩的更小的那种)、上传 CDN 等

    2. 项目升级(比如通用配置更新了,CLI 提供一键升级模板的功能)

    3. 项目部署、发布 npm 包等操作。

    4. 等等其他一些重复性的操作,也都可以放在工具合集里面


    CLI 开发


    前面介绍了 CLI 的几个模块功能设计,接下来可以正式进入开发对应的 CLI 工具的环节。


    搭建基础架构


    CLI 工具开发将使用 TS 作为开发语言,如果此时还没有接触过 TS 的同学,刚好可以借此项目来熟悉一下 TS 的开发模式。


    mkdir cli && cd cli // 创建仓库目录
    npm init // 初始化 package.json
    npm install -g typescript // 安装全局 TypeScript
    tsc --init // 初始化 tsconfig.json

    全局安装完 TypeScript 之后,初始化 tsconfig.json 之后再进行修改配置,添加编译的文件夹与输出目录。

    {
    "compilerOptions": {
    "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
    "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    "outDir": "./lib", /* Redirect output structure to the directory. */
    "strict": true, /* Enable all strict type-checking options. */
    "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    "skipLibCheck": true, /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
    },
    "include": [
    "./src",
    ]
    }

    上述是一份已经简化过的配置,但应对当前的开发已经足够了,后续有需要可以修改 TypeScript 的配置项。


    ESLINT


    因为是从 0 开发 CLI 工具,可以先从简单的功能入手,例如开发一个 Eslint 校验模块。


    npm install eslint --save-dev // 安装 eslint 依赖
    npx eslint --init // 初始化 eslint 配置

    直接使用 eslint --init 可以快速定制出适合自己项目的 ESlint 配置文件 .eslintrc.json

    {
    "env": {
    "browser": true,
    "es2021": true
    },
    "extends": [
    "plugin:react/recommended",
    "standard"
    ],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
    "ecmaFeatures": {
    "jsx": true
    },
    "ecmaVersion": 12,
    "sourceType": "module"
    },
    "plugins": [
    "react",
    "@typescript-eslint"
    ],
    "rules": {
    }
    }


    如果项目中已经有定义好的 ESlint,可以直接使用自己的配置文件,或者根据项目需求对初始化的配置进行增改。


    创建 ESlint 工具类


    第一步,对照文档 ESlint Node.js API,使用提供的 Node Api 直接调用 ESlint。


    将前面生成的 .eslintrc.json 的配置项按需加入,同时使用 useEslintrc:fase 禁止使用项目本身的 .eslintrc 配置,仅使用 CLI 提供的规则去校验项目代码。

    import { ESLint } from 'eslint'
    import { getCwdPath, countTime } from '../util'

    // 1. Create an instance.
    const eslint = new ESLint({
    fix: true,
    extensions: [".js", ".ts"],
    useEslintrc: false,
    overrideConfig: {
    "env": {
    "browser": true,
    "es2021": true
    },
    "parser": getRePath("@typescript-eslint/parser"),
    "parserOptions": {
    "ecmaFeatures": {
    "jsx": true
    },
    "ecmaVersion": 12,
    "sourceType": "module"
    },
    "plugins": [
    "react",
    "@typescript-eslint",
    ],
    },
    resolvePluginsRelativeTo: getDirPath('../../node_modules') // 指定 loader 加载路径
    });


    export const getEslint = async (path: string = 'src') => {
    try {
    countTime('Eslint 校验');
    // 2. Lint files.
    const results = await eslint.lintFiles([`${getCwdPath()}/${path}`]);

    // 3. Modify the files with the fixed code.
    await ESLint.outputFixes(results);

    // 4. Format the results.
    const formatter = await eslint.loadFormatter("stylish");

    const resultText = formatter.format(results);

    // 5. Output it.
    if (resultText) {
    console.log('请检查===》', resultText);
    }
    else {
    console.log('完美!');
    }
    } catch (error) {

    process.exitCode = 1;
    console.error('error===>', error);
    } finally {
    countTime('Eslint 校验', false);
    }
    }

    创建测试项目


    npm install -g create-react-app // 全局安装 create-react-app
    create-react-app test-cli // 创建测试 react 项目

    测试项目使用的是 create-react-app,当然你也可以选择其他框架或者已有项目都行,这里只是作为一个 demo,并且后期也还会再用到这个项目做测试。


    测试 CLI


    新建 src/bin/index.ts, demo 中使用 commander 来开发命令行工具。

    #!/usr/bin/env node // 这个必须添加,指定 node 运行环境
    import { Command } from 'commander';
    const program = new Command();

    import { getEslint } from '../eslint'

    program
    .version('0.1.0')
    .description('start eslint and fix code')
    .command('eslint')
    .action((value) => {
    getEslint()
    })
    program.parse(process.argv);

    修改 pageage.json,指定 bin 的运行 js(每个命令所对应的可执行文件的位置)


     "bin": {
    "fe-cli": "/lib/bin/index.js"
    },

    先运行 tsc 将 TS 代码编译成 js,再使用 npm link 挂载到全局,即可正常使用。



    commander 的具体用法就不详细介绍了,基本上市面大部分的 CLI 工具都使用 commander 作为命令行工具开发,也都有这方面的介绍。



    命令行进入刚刚的测试项目,直接输入命令 fe-cli eslint,就可以正常使用 Eslint 插件,输出结果如下:


    image.png


    美化输出


    可以看出这个时候,提示并没有那么显眼,可以使用 chalk 插件来美化一下输出。


    先将测试工程故意改错一个地方,再运行命令 fe-cli eslint


    image.png


    至此,已经完成了一个简单的 CLI 工具,对于 ESlint 的模块,可以根据自己的想法与规划定制更多的功能。


    构建模块


    配置通用 Webpack


    通常开发业务的时候,用的是 webpack 作为构建工具,那么 demo 也将使用 webpack 进行封装。


    先命令行进入测试项目中执行命令 npm run eject,暴露 webpack 配置项。


    image.png


    从上图暴露出来的配置项可以看出,CRA 的 webpack 配置还是非常复杂的,毕竟是通用型的脚手架,针对各种优化配置都做了兼容,但目前 CRA 使用的还是 webpack 4 来构建。作为一个新的开发项目,CLI 可以不背技术债务,直接选择 webpack 5 来构建项目。



    一般来说,构建工具替换不会影响业务代码,如果业务代码被构建工具绑架,建议还是需要去优化一下代码了。


    import path from "path"

    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const postcssNormalize = require('postcss-normalize');
    import { getCwdPath, getDirPath } from '../../util'

    interface IWebpack {
    mode?: "development" | "production" | "none";
    entry: any
    output: any
    template: string
    }

    export default ({
    mode,
    entry,
    output,
    template
    }: IWebpack) => {
    return {
    mode,
    entry,
    target: 'web',
    output,
    module: {
    rules: [{
    test: /\.(js|jsx)$/,
    use: {
    loader: getRePath('babel-loader'),
    options: {
    presets: [
    ''@babel/preset-env',
    ],
    },
    },
    exclude: [
    getCwdPath('./node_modules') // 由于 node_modules 都是编译过的文件,这里做过滤处理
    ]
    },
    {
    test: /\.css$/,
    use: [
    'style-loader',
    {
    loader: 'css-loader',
    options: {
    importLoaders: 1,
    },
    },
    {
    loader: 'postcss-loader',
    options: {
    postcssOptions: {
    plugins: [
    [
    'postcss-preset-env',
    {
    ident: "postcss"
    },
    ],
    ],
    },
    }
    }
    ],
    },
    {
    test: /\.(woff(2)?|eot|ttf|otf|svg|)$/,
    type: 'asset/inline',
    },
    {
    test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
    loader: 'url-loader',
    options: {
    limit: 10000,
    name: 'static/media/[name].[hash:8].[ext]',
    },
    },
    ]
    },
    plugins: [
    new HtmlWebpackPlugin({
    template,
    filename: 'index.html',
    }),
    ],
    resolve: {
    extensions: [
    '',
    '.js',
    '.json',
    '.sass'
    ]
    },
    }
    }

    上述是一份简化版本的 webpack 5 配置,再添加对应的 commander 命令。


    program
    .version('0.1.0')
    .description('start eslint and fix code')
    .command('webpack')
    .action((value) => {
    buildWebpack()
    })

    现在可以命令行进入测试工程执行 fe-cli webpack 即可得到下述构建产物


    image.png


    image.png


    下图是使用 CRA 构建出来的产物,跟上图的构建产物对一下,能明显看出使用简化版本的 webpack 5 配置还有很多可优化的地方,那么感兴趣的同学可以再自行优化一下,作为 demo 已经完成初步的技术预研,达到了预期目标。


    image.png


    此时,如果熟悉构建这块的同学应该会想到,除了 webpack 的配置项外,构建中绝大部分的依赖都是来自测试工程里面的,那么如何确定 React 版本或者其他的依赖统一呢?


    常规操作还是通过模板来锁定版本,但是业务同学依然可以自行调整版本依赖导致不一致,并不能保证依赖一致性。


    既然整个构建都由 CLI 接管,只需要考虑将全部的依赖转移到 CLI 所在的项目依赖即可。


    解决依赖


    Webpack 配置项新增下述两项,指定依赖跟 loader 的加载路径,不从项目所在 node_modules 读取,而是读取 CLI 所在的 node_modules。


    resolveLoader: {
    modules: [getDirPath('../../node_modules')]
    }, // 修改 loader 依赖路径
    resolve: {
    modules: [getDirPath('../../node_modules')],
    }, // 修改正常模块依赖路径

    同时将 babel 的 presets 模块路径修改为绝对路径,指向 CLI 的 node_modules(presets 会默认从启动路劲读取依赖)。

    {
    test: /\.(js|jsx)$/,
    use: {
    loader: getRePath('babel-loader'),
    options: {
    presets: [
    getRePath('@babel/preset-env'),
    [
    getRePath("@babel/preset-react"),
    {
    "runtime": "automatic"
    }
    ],
    ],
    },
    },
    exclude: [
    [getDirPath('../../node_modules')]
    ]
    }

    完成依赖修改之后,一起测试一下效果,先将测试工程的依赖 node_modules 全部删除


    image.png


    再执行 fe-cli webpack,使用 CLI 依赖来构建此项目。


    image.png


    image.png


    可以看出,已经可以在项目不安装任何依赖的情况,使用 CLI 也可以正常构建项目了。


    那么目前所有项目的依赖、构建已经全部由 CLI 接管,可以统一管理依赖与构建流程,如果需要升级依赖的话可以使用 CLI 统一进行升级,同时业务开发同学也无法对版本依赖进行改动。



    这个解决方案要根据自身的实际需求来实施,所有的依赖都来源于 CLI 工具的话,版本升级影响会非常大也会非常被动,要做好兼容措施。比如哪些依赖可以取自项目,哪些依赖需要强制通用,做好取舍。



    写给迷茫 Coder 们的一段话


    如果遇到最开始提到那些问题的同学们,应该会经常陷入到业务中无法自拔,而且写这种基础项目,是真的很花时间也很枯燥。容易对工作厌烦,对 coding 感觉无趣。


    这是很正常的,绝大多数人都有这段经历与类似的想法,但还是希望你能去多想想,在枯燥、无味、重复的工作中去发现痛点、机会。只有接近业务、熟悉业务,才有机会去优化、革新、创造。


    所有的基建都是要依托业务才能发挥最大的作用


    每天抽个半小时思考一下今天的工作还能在哪些方面有所提高,提高效率的不仅仅是你的代码也可以是其他的工具或者是引入新的流程。


    同时也不要仅仅限制在思考阶段,有想法就争取落地,再多抽半小时进行 coding 或者找工具什么的,但凡能够提高个几分钟的效率,即使是个小工具、多几行代码、换个流程这种也值得去尝试一下。


    等你把这些零碎的小东西、想法一点点全部积累起来,到最后整合到一个体系中去,那么此时你会发现已经可以站在更高一层的台阶去思考、规划下一阶段需要做的事情,而这其中所有的经历都是你未来成长的基石。


    一直相信一句话:努力不会被辜负,付出终将有回报。此时敲下去的每一行代码在未来都将是你登高的一步步台阶。



    链接:https://juejin.cn/post/6982215543017193502

    收起阅读 »

    完了,又火一个前端项目

    今天逛 GitHub 的时候,在趋势榜上看到一个项目,竟然短短一天的时间内,涨了 1000 多个星星! 就是这个名为 solid 的项目: 要知道日增上千 star 可是非常难得的,我不禁感到好奇,点进去看看这个项目到底有啥牛逼的? 啥是 Solid? 这是...
    继续阅读 »

    今天逛 GitHub 的时候,在趋势榜上看到一个项目,竟然短短一天的时间内,涨了 1000 多个星星!


    就是这个名为 solid 的项目:



    要知道日增上千 star 可是非常难得的,我不禁感到好奇,点进去看看这个项目到底有啥牛逼的?


    啥是 Solid?


    这是一个国外的前端项目,截止到发文前,已经收获了 8400 个 star。


    我总觉得这个项目很眼熟,好像之前也看到过,于是去 Star History 上搜了一下这个项目的 star 增长历史。好家伙,这几天的增速曲线几乎接近垂直,已经连续好几天增长近千了!


    项目 Star 增长曲线


    看到这个曲线,我想起来了,solid 是一个 JavaScript 框架,此前在一次 JavaScript 框架的性能测试中看到过它。


    要知道,现在的 JavaScript 开发框架基本就是 React、Vue、Angular 三分天下,还有就是新兴的 Svelte 框架潜力无限(近 5w star),其他框架想分蛋糕还是很难的。那么 Solid 到底有什么本事,能让他连续几天 star 数暴涨呢?


    描述


    打开官网,官方对 Solid 的描述是:一个用于构建用户界面的 声明性 JavaScript 库,特点是高效灵活。


    顺着官网往下看,Solid 有很多特点,比如压缩后的代码体积只有 6 kb;而且天然支持 TypeScript 以及 React 框架中经常编写的 JSX(JavaScript XML)。


    来看看官网给的示例代码:


    Solid 语法


    怎么样,他的语法是不是和 React 神似?


    性能


    但是,这些并不能帮助 Solid 框架脱颖而出,真正牛逼的一点是它 非常快


    有多快呢?第一够不够 !


    JS 框架性能测试对比


    有同学说了,你这不睁着眼睛说瞎话么?Solid 明明是第二,第一是 Vanilla 好吧!


    哈哈,但事实上,Vanilla 其实就是不使用任何框架的纯粹的原生 JavaScript,通常作为一个性能比较的基准。


    那么 Solid 为什么能做到这么快呢?甚至超越了我们引以为神的 Vue 和 React。


    这是因为 Solid 没有采用其他主流前端框架中的 Virtual DOM,而是直接被静态编译为真实的原生 DOM 节点,并且将更新控制在细粒度的局部范围内。从而让 runtime(运行时)更加轻小,也不需要所谓的脏检查和摘要循环带来的额外消耗,使得性能和原生 JavaScript 几乎无异。换句话说,编译后的 Solid 其实就是 JavaScript!



    其实 Solid 的原理和新兴框架 Svelte 的原理非常类似,都是编译成原生 DOM,但为啥他更快一点呢?


    为了搞清楚这个问题,我打开了百度来搜这玩意,但发现在国内根本搜不到几条和 Solid.js 有关的内容,基本全是一些乱七八糟的东西。后来还是在 Google 上搜索,才找到了答案,结果答案竟然还是来自于某乎的大神伊撒尔。。。


    要搞清楚为什么 Solid 比 Svelte 更快,就要看看同一段代码经过它们编译后,有什么区别。


    大神很贴心地举了个例子,比如这句代码:


    <div>{aaa}</div>

    经 Svelte 编译后的代码:

    let a1, a2
    a1 = document.creatElement('div')
    a2 = docment.createTextNode('')
    a2.nodeValue = ctx[0] // aaa
    a1.appendChild(a2)

    经 Solid 编译后的代码:

    let a1, a2
    let fragment = document.createElement('template')
    fragment.innerHTML = `<div>aaa</div>`
    a1 = fragment.firstChild
    a2 = a1.fristChild
    a2.nodeValue = data.aaa

    可以看到,在创建 DOM 节点时,原来 Solid 耍了一点小把戏,利用了 innerHTML 代替 createElement 来创建,从而进一步提升了性能。


    当然,抛去 Virtual DOM 不意味着就是 “银弹” 了,毕竟十年前各种框架出现前大家也都是写原生 JavaScript,轻 runtime 也有缺点,这里就不展开说了。


    除了快之外,Solid 还有一些其他的特点,比如语法精简、WebComponent 友好(可自定义元素)等。




    总的来说, 我个人还是非常看好这项技术的,日后说不定能和 Svelte 一起动摇一下三巨头的地位,给大家更多的选择呢?这也是技术选型好玩的地方,没有绝对最好的技术,只有最适合的技术。


    不禁感叹道:唉,技术发展太快了,一辈子学不完啊!(不过前端初学者不用关心那么多,老老实实学基础三件套 + Vue / React 就行了)


    链接:https://juejin.cn/post/6983177757219897352

    收起阅读 »

    判断是否完全二叉树

    Hello: ? 今天又和小伙伴们见面啦,最近一直做二叉树相关的题目今天再和大家分享一道相关的题目《判断是不是完全二叉树》 判断是否是完全二叉树 查看全部源码:点击查看全部源码 介绍-什么是完全二叉树? 先看如下这一张图: ...
    继续阅读 »

    Hello:


    ? 今天又和小伙伴们见面啦,最近一直做二叉树相关的题目今天再和大家分享一道相关的题目《判断是不是完全二叉树》


    判断是否是完全二叉树


    查看全部源码:点击查看全部源码


    介绍-什么是完全二叉树?


    先看如下这一张图:










    这个一颗二叉树,如何区分该树是不是完全二叉树呢?



    • 当一个节点存在右子节点但是不存在左子节点这颗树视为非完全二叉树

    • 当一个节点的左子节点存在但是右子节点不存在视为完全二叉树

    • 如果没有子节点,那也是要在左侧开始到右侧依次没有子节点才视为完全二叉树,就像上图2中



    而上面第一张图这颗二叉树很明显是一颗非完全二叉树,因为在第三层也就是在节点2它并没有右子节点。在6和4节点中隔开了一个节点(2节点没有右子节点),所以不是完全二叉树


    再看第二张图,这颗树就是一个完全二叉树,虽然在这个颗节点3没有右子节点,但是6 4 5节点之间并没有空缺的子节点,这里就解释了上面说的第三条(如何没有子节点,那也是在左侧开始到右侧依次没有子节点才视为完全二叉树)



    流程


    这道题可以使用按层遍历的方式来解决:



    • 首先准备一个队列,按层遍历使用队列是最好的一种解决方法

    • 首先将头节点加入到队列里面(如果头节点为空,你可以认为它是一个非完全二叉树也可以认为它是完全二叉树)

    • 遍历该队列跳出遍历的条件是直到这个队列为空时

    • 这个时候需要准备一个Bool的变量,如果当一个节点的左子节点或者右子节点不存在时将其置成true

    • 当Bool变量为true并且剩余节点的左或右子节点不为空该树就是非完全二叉树

    • 当一树的左子节点不存在并且右子节点存在,该树也是非完全二叉树


    代码


    树节点


    type TreeNode struct {
    val string
    left *TreeNode
    right *TreeNode
    }

    测试代码


    func main() {
    root := &TreeNode{val: "1"}
    root.left = &TreeNode{val: "2"}
    root.left.left = &TreeNode{val: "4"}
    root.left.right = &TreeNode{val: "10"}
    root.left.left.left = &TreeNode{val: "7"}
    root.right = &TreeNode{val: "3"}
    root.right.left = &TreeNode{val: "5"}
    root.right.right = &TreeNode{val: "6"}
    if IsCompleteBt(root) {
    fmt.Println("是完全二叉树")
    } else {
    fmt.Println("不是完全二叉树")
    }
    }

    判断树是否为完全二叉树代码


    // IsCompleteBt 这里默认根节点为空属于完全二叉树,这个可以自已定义是否为完全二叉树/***/
    func IsCompleteBt(root *TreeNode) bool {
    if root == nil {
    return true
    }

    /**
    * 条件:
    * 1.当一个节点存在右子节点但是不存在左子节点这颗树视为非完全二叉树
    * 2.当一个节点的左子节点存在但是右子节点不存在视为完全二叉树
    */

    var tempNodeQueue []*TreeNode

    tempNodeQueue = append(tempNodeQueue, root)

    var tempNode *TreeNode
    isSingleNode := false
    for len(tempNodeQueue) != 0 {
    tempNode = tempNodeQueue[0]
    tempNodeQueue = tempNodeQueue[1:]

    if (isSingleNode && (tempNode.left != nil || tempNode.right != nil)) || (tempNode.left == nil && tempNode.right != nil){
    return false
    }

    if tempNode.left != nil{
    tempNodeQueue = append(tempNodeQueue,tempNode.left)
    }else{
    isSingleNode = true
    }

    if tempNode.right != nil {
    tempNodeQueue = append(tempNodeQueue, tempNode.right)
    }else{
    isSingleNode = true
    }
    }
    return true
    }

    代码解读


    这段代码里面没有多少好说的,就说下for里面第一个if判断叭


    这里看下上面流程中最后两个条件,当满足最后两个条件的时候才可以判断出来这颗树是否是完全二叉树.



    同样因为实现判断是否是完全二叉树是通过对树的按层遍历来处理的,因为对树的按层遍历通过队列是可以间单的实现的。所以这里使用到了队列



    至于这里为什么要单独创建一个isSingleNode变量:



    • 因为当有一个节点左侧节点或者是右侧的节点没有的时候,在这同一层后面如果还有不为空的节点时,那么这颗树便不是完全二叉树,看下图


    image-20210707163759637


    在这颗树的最后一层绿色涂鸭处是少一个节点的,所以我用了一个变量我标识当前节点(在上图表示节点2)的子节点是不是少一个,如果少了当前节点(在上图表示节点2)的下一个节点(在上图表示节点3)的子节点(在上图表示4和5)如果存在则不是完全二叉树,所以这就是创建了一个isSingleNode变量的作用


    运行结果


    image-20210707150308392


    作者:我与晚风同行
    链接:https://juejin.cn/post/6982109128395063304
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android 依赖注入 hilt 库的使用

    hilt官网 1-什么是控制反转和依赖注入? IOC(控制反转):全称是 Inverse of Control , 是一种思想.指的是让第3方去控制去创建对象. DI(依赖注入):全称是 Dependency Injection , 对象的创建是通过注入...
    继续阅读 »

    hilt官网


    1-什么是控制反转和依赖注入?


    IOC(控制反转):全称是 Inverse of Control , 是一种思想.指的是让第3方去控制去创建对象.


    DI(依赖注入):全称是 Dependency Injection , 对象的创建是通过注入的方式实现. 是IOC的一种具体实现.


    2- 为啥要用依赖注入?


    在java中我们创建对象都是通过new Object(), 或者是使用反射泛型进行创建, 需要指定泛型, 需要继承或者实现某接口, 不够灵活, 举个例子: 比如在使用MVVM模式进行网络请求时,我们通常在ViewModel定义Repository层,然后把Api传递给Repository层. 最后在ViewModel中发起接口请求


    // 定义网络接口
    interface MainApi {
    default void requestList() {}
    }

    // 仓库抽象类
    abstract class BaseRepo{}

    // 首页仓库
    class MainRepo extends BaseRepo {
    private MainApi api;
    public MainRepo(MainApi api) {
    this.api = api;
    }
    void requestList() {
    // 具体调用接口
    api.requestList();
    }
    }

    // 抽象ViewModel层
    abstract class BaseViewModel {}

    // ViewModel层
    class MainViewModel extends BaseViewModel {
    MainRepo repo = new MainRepo(new MainApi() {});

    void requestList(){
    // 通过repo请求接口
    repo.requestList();
    }
    }

    问题: 每次都要在Model层创建Repository对象和Api对象,这是重复且冗余的.


    解决方案: 通过在ViewModel层和Repo层指定泛型,然后反射创建


    // 定义网络接口
    interface MainApi {
    default void requestList() {
    }
    }

    // 仓库抽象类
    abstract class BaseRepo<Api> {
    private Api api;

    public Api getApi() {
    return api;
    }

    public void setApi(Api api) {
    this.api = api;
    }
    }

    // 首页仓库
    class MainRepo extends BaseRepo<MainApi> {
    void requestList() {
    // 具体调用接口
    getApi().requestList();
    }
    }

    // 抽象ViewModel层
    abstract class BaseViewModel<R extends BaseRepo> {
    private R repo;

    public BaseViewModel() {
    try {
    repo = crateRepoAndApi(this);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    public R getRepo() {
    return repo;
    }
    // 反射创建Repo和Api
    public R crateRepoAndApi(BaseViewModel<R> model) throws Exception {
    Type repoType = ((ParameterizedType) model.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    R repo = (R) repoType.getClass().newInstance();
    Type apiType = ((ParameterizedType) repoType.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    String apiClassPath = apiType.getClass().toString().replace("class ", "").replace("interface ", "");
    repo.setApi(Class.forName(apiClassPath));
    return repo;
    }
    }

    // ViewModel层
    class MainViewModel extends BaseViewModel<MainRepo> {
    void requestList() {
    // 通过repo请求接口
    getRepo().requestList();
    }
    }

    通过反射可以避免在ViewModel里写new Repo()和new api()的代码. 除了反射还有没有更好的实现方式呢?


    image.png


    3-jectpack 中 hilt库的使用方法


    1-引入包


    1-在项目最外层build.gralde引入
    classpath 'com.google.dagger:hilt-android-gradle-plugin:2.37'

    2-在app模块顶部
    plugin "dagger.hilt.android.plugin"
    plugin "kotlin-kapt"

    3-在app模块内,最外层添加纠正错误类型
    kapt {
    correctErrorTypes true
    }

    4-添加依赖
    implementation 'com.google.dagger:hilt-android:2.37'
    kapt 'com.google.dagger:hilt-compiler:2.37'

    2-必须在Application子类上添加注解@HiltAndroidApp


    @HiltAndroidApp
    class MyApp : Application() {
    override fun onCreate() {
    super.onCreate()
    }
    }

    @HiltAndroidApp 创建一个容器.该容器遵循 Android 的生命周期类,目前支持的类型是: Activity, Fragment, View, Service, BroadcastReceiver @Inject


    使用 @Inject 来告诉 Hilt 如何提供该类的实例,常用于构造方法,非私有字段,方法中。


    Hilt 有关如何提供不同类型的实例信息也称之为绑定


    @Module


    module 是用来提供一些无法用 构造@Inject 的依赖,如第三方库,接口,build 模式的构造等。


    使用 @Module 注解的类,需要使用 @InstallIn 注解指定 module 的范围


    增加了 @Module 注解的类,其实代表的就是一个模块,并通过指定的组件来告诉在那个容器中可以使用绑定安装。


    @InstallIn


    使用 @Module 注入的类,需要使用 @InstallIn 注解指定 module 的范围。


    例如使用 @InstallIn(ActivityComponent::class) 注解的 module 会绑定到 activity 的生命周期上。


    @Provides


    常用于被 @Module 注解标记类的内部方法上。并提供依赖项对象。


    @EntryPoint Hilt 支持最常见的 Android 类 Application、Activity、Fragment、View、Service、BroadcastReceiver 等等,但是您可能需要在Hilt 不支持的类中执行依赖注入,在这种情况下可以使用 @EntryPoint 注解进行创建,Hilt 会提供相应的依赖。

    收起阅读 »

    用了postman,接口测试不用愁了

    Postman是一个功能强大的接口测试工具,不仅可以调用http接口也可以发送https请求,满足日常测试工作的需求。 一、下载 官网:https://www.postman.com 1.选择需要下载的版本号 2.双击下载的安装包,进入到用户登录和...
    继续阅读 »


    Postman是一个功能强大的接口测试工具,不仅可以调用http接口也可以发送https请求,满足日常测试工作的需求。


    一、下载


    官网:https://www.postman.com


    1.选择需要下载的版本号



    2.双击下载的安装包,进入到用户登录和注册的页面


    若个人使用,点击下方Skip and go to the app进入到postman的主页面。


    若企业或团队使用,可以先注册账号加入到团队工作区



    二、postman界面


    1.界面导航说明



    2.请求体选择


    form-data:是post请求当中常用的一种,将表单数据处理为一条消息,以标签为单元,用分隔符分开。既可以单独上传键值对,也可以直接上传文件(当上传字段是文件时,会有Content-Type来说明文件类型,但该文件不会作为历史保存,只能在每次需要发送请求的时候,重新添加文件)


    x-www-form-urlencoded:对应信息头
    application/x-www-form-urlencoded,将所表单中的数据转换成键值对的形式。


    raw:可以上传任意类型的文本,比如text、JavaScript、json、HTML、XML。一般输出为json格式,请求头为Content-Type:application/json 。使用时要用花括号{}将数据包裹起来,才能够正常转化成json格式。


    binary:对应请求头Content-Type:application/octet-stream,只能上传二进制文件且没有键值对,一次只能上传一个文件。



    三、请求方法


    GET:用于从API访问数据用于从API访问数据


    POST:创建新的数据


    PUT:更新数据,全部更新


    PATCH:更新数据,部分更新


    DELETE:删除现有数据



    四、发送一个http请求


    1.get请求


    在URL处填写请求的地址信息,有请求参数的填写在Params中,点击Send,就可以在下面的窗口中查看到响应的json数据。



    2.post请求


    在URL处填写请求的地址信息,选择请求体格式,输入json格式的数据,点击Send发送请求


    在这里插入图片描述


    3.接口响应数据解析


    其中Body和Status是做接口测试的重点,一般来说接口测试都会验证响应体中的数据和响应状态码是否正确。


    Test Results是在编写断言后,可以查看断言的执行结果。


    Time和Size在做性能测试时,可以根据这两个参数来对所测接口的性能做一个简单的判断。


    在这里插入图片描述


    Pretty:在postman中响应结果默认展示的是pretty,数据经过格式化后看起来更加直观,并且显示行号。


    Raw:返回的数据是文本格式,也就是未经处理过的原始数据。


    Preview:一般对返回HTML的页面效果比较明显,如请求百度后返回中可以直接看到页面。



    五、发送https请求设置


    主界面的右上面点击工具标志–选择“Setting”,进入到设置页面。



    在General选项中将SSL certificate verification设为ON,即打开https请求开关。



    在Certificate选项中将CA Certificate开关设置为ON,然后点击Add Certificate,进入到证书添加页面。



    填写请求的地址加端口号,上传CA证书秘钥,设置完成后回到主页面可以发起https请求了。



    六、接口管理(Collection)


    日常工作中接口测试涉及到一个或多个系统中的很多用例需要维护,那么就需要对用例进行分类管理。postman中的Collection可以实现这个功能。


    用例分类管理,方便后期维护


    可以批量执行用例,实现接口自动化测试


    1.创建集合目录


    在Collection选项中,点击“+”号,即可添加一个集合目录,右键可以对目录进行重命名、添加子目录或添加请求等。或者点击集合后面的“…”号,也可查看到更多操作。




    创建好的用例管理效果,如图显示:



    2.批量执行用例


    选中一个Collection,点击右上角的RUN,进入到Collection Runner界面,默认会把所有的用例选中,点击底部的Run Collection按钮执行用例。


    用了postman,接口测试不用愁了



    断言统计:左上角Passed和Failed都为0,表示当前Collection中断言执行的成功数和失败数,如果没有断言默认都为0。


    View Summary:运行结果总览,点击可以看到每个请求的具体断言详细信息。


    Run Again:将Collection中的用例重新运行一次


    New:返回到Runner界面,重新选择用例集合


    Export Results:导出运行结果,默认为json格式


    七、日志查看


    接口测试过程中报错时少不了去查看请求的日志信息,postman中提供了这个功能,可以方便定位问题。


    方法一:点击主菜单View–>Show Postman Console


    方法二:主界面左下角的“Console”按钮



    点击Show Postman Console,进入到日志界面,可以在搜索栏中输入搜索的URL,也可以过滤日志级别



    搜索框:通过输入URL或者请求的关键字进行查找。


    ALL Logs:这里有Log、Info、Warning、Error级别的日志。


    Show raw log:点开可以查看到原始请求的报文信息


    Show timestamps:勾选后展示请求的时间


    Hide network:把请求都隐藏掉,只查看输出日志


    八、断言


    断言是做自动化测试的核心,没有断言,那么只能叫做接口的功能测试,postman中提供的断言功能很强大,内置断言很多也很方便使用。


    点击主界面Tests,在右侧显示框中展示了所有内置断言。按接口响应的组成划分,有状态行、响应头、响应体。


    状态行断言:


    断言状态码:Status code: code is 200


    断言状态信息:Status code:code name has string


    响应头断言:


    断言响应头中包含:Response headers:Content-Type header check


    响应体断言:


    断言响应体中包含XXX字符串:Response body:Contains string


    断言响应体等于XXX字符串:Response body : is equal to a string


    断言响应体(json)中某个键名对应的值:Response body : JSON value check


    响应时间断言:


    断言响应时间:Response time is less than 200ms


    用了postman,接口测试不用愁了


    例如:


    点击右侧的状态码断言,显示在Tests下面的窗口中,点击send发送请求后,在返回的Test Results中可以查看到断言结果。




    以上是整理的postman中常用方法,掌握后对于接口测试来说非常方便,也有利于用例的维护。



    收起阅读 »

    Android集成开发google登录

    这是我参与新手入门的第2篇文章 背景 项目在要Google Play上架,并支持全球下载,加了google登录 一.准备 google登录集成地址 在google登录中创建并配置项目:console.developers.google...
    继续阅读 »

    这是我参与新手入门的第2篇文章


    背景



    项目在要Google Play上架,并支持全球下载,加了google登录



    一.准备


    google登录集成地址



    1. 在google登录中创建并配置项目:console.developers.google.com


    在控制面板选择Credentials → New Project,会提示创建项目名称和组织名称,如下图


    WX20210708-135551.png 2. 创建项目成功后开始创建OAuth client ID image.png 应用类型选择为Android


    image.png 根据系统提示,名称, packageName以及SHA-1值 获取SHA-1值的方式: keytool -keystore path-to-debug-or-production-keystore -list -v


    image.png


    创建成功后会生成一个Client ID 一定要保存好,集成的时候要用



    PS: 如果是通过集成文档创建成功的,会提示下载credentials.json文件,一定要下,不然可能会坑



    image.png


    二.集成开发



    PS: google登录需要运行在Android 4.1及以上且Google Play 服务 15.0.0及以上版本




    • 把刚才下载的credentials.json文件放入app路径的根目录

    • 检查项目顶级build.gradle中包含Maven库


    allprojects {
    repositories {
    google()

    // If you're using a version of Gradle lower than 4.1, you must instead use:
    // maven {
    // url 'https://maven.google.com'
    // }
    }
    }

    在app的build.gradle中引用google play服务


    dependencies {
    implementation 'com.google.android.gms:play-services-auth:19.0.0'
    }

    添加登录



    • 配置 Google Sign-in 和 GoogleSignInClient 对象


    var mGoogleSignInClient: GoogleSignInClient? = null
    private fun initGoogle() {
    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
    .requestIdToken(CommonConstants.GOOGLE_CLIENT_ID)
    .requestEmail()
    .build()
    mGoogleSignInClient = GoogleSignIn.getClient(requireActivity(), gso)
    }


    CommonConstants.GOOGLE_CLIENT_ID 为创建项目成功后的Client ID




    • 调起登录


    private fun signIn() {
    val signInIntent: Intent = mGoogleSignInClient?.signInIntent!!
    startActivityForResult(signInIntent, RC_SIGN_IN)
    }


    • onActivityResult中接收消息


        private val RC_SIGN_IN: Int = 3000
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == RC_SIGN_IN) {
    // The Task returned from this call is always completed, no need to attach
    // a listener.
    val task: Task<GoogleSignInAccount> = GoogleSignIn.getSignedInAccountFromIntent(data)
    handleSignInResult(task)
    }
    super.onActivityResult(requestCode, resultCode, data)
    }

    private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
    try {
    val account = completedTask.getResult(ApiException::class.java)
    // Signed in successfully, show authenticated UI.
    Log.e("handleSignInResult", account.toString())
    Log.e("handleSignInResult_displayName", account?.displayName!!)
    Log.e("handleSignInResult_email", account?.email!!)
    Log.e("handleSignInResult_familyName", account?.familyName!!)
    Log.e("handleSignInResult_givenName", account?.givenName!!)
    Log.e("handleSignInResult_id", account?.id!!)
    Log.e("handleSignInResult_idToken", account?.idToken!!)
    Log.e("handleSignInResult_isExpired", account?.isExpired.toString())
    Log.e("handleSignInResult_photoUrl", account?.photoUrl.toString())
    } catch (e: ApiException) {
    // The ApiException status code indicates the detailed failure reason.
    // Please refer to the GoogleSignInStatusCodes class reference for more information.
    Log.e("handleSignInResult", "signInResult:failed code=" + e.statusCode)
    }
    }

    检查现有用户是否登录


     val account = GoogleSignIn.getLastSignedInAccount(activity)
    updateUI(account);

    退出登录


    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
    .requestIdToken(CommonConstants.GOOGLE_CLIENT_ID)
    .requestEmail()
    .build()
    val mGoogleSignInClient = GoogleSignIn.getClient(activity, gso)
    mGoogleSignInClient.signOut().addOnCompleteListener(activity) { }

    拿到token信息后发送至自己的服务进行校验,至此google登录完成


    作者:是芝麻吖
    链接:https://juejin.cn/post/6982454523621015589
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    政策工具类-谷歌AndroidAppBundle(aab)政策海外发行

    作者 大家好,我是怡寶; 本人于18年毕业于湖南农业大学,于2021年6月加入37手游安卓团队; 目前负责于海外游戏发行安卓开发。 背景 根据Google Play的政策要求,自 2021 年 8 月起,Google Play 将开始要求新应用使用 ...
    继续阅读 »

    作者


    大家好,我是怡寶;


    本人于18年毕业于湖南农业大学,于2021年6月加入37手游安卓团队;


    目前负责于海外游戏发行安卓开发。


    背景


    根据Google Play的政策要求,自 2021 年 8 月起,Google Play 将开始要求新应用使用 Android App Bundle(以下简称aab) 进行发布。该格式将取代 APK 作为标准发布格式。


    想了解更多关于aab的介绍可以直接阅读android官方文档,有详细的说明developer.android.com/guide/app-b…


    juejin1


    正常情况:直接Android Studio上面点击打包或者用Gradle命令直接生成一个aab,交给运营提包到Google Play商店上面去,任务完成,下班~ 。


    存在问题:我没有工程,也没有源码,到我手上的就只有一个apk,走google提供的方案就不行了。


    思 考:我们常做的事情是把apk拿过来,反编译一下,修改修改代码,换换参数,然后重新打成新apk。 apk和aab都是同一个项目生成的,代码资源都一样,那么可不可以相互转化?


    查资料ing.....


    本文向大家介绍如何从apk一步步转化成aab,文末提供本文所使用到的工具&python脚本源码


    需要工具



    apk生成aab


    Android Studio打包可选Android App Bundle(aab),并提供详细教程,本文不再说明。


    解压apk


    通过apktool去解压apk包


    java -jar apktool_2.5.0.jar d test.apk -s -o decode_apk_dir

    解压apk后 decode_apk_dir 目录结构:


    ./decode_apk_dir
    ├── AndroidManifest.xml
    ├── apktool.yml
    ├── assets
    ├── classes2.dex
    ├── classes.dex
    ├── lib
    ├── original
    ├── res
    └── unknown

    编译资源


    编译资源使用aapt2编译生成 *.flat文件集合


    aapt2 compile --dir decode_apk_dir\res -o compiled_resources.zip

    生成compiled_resources.zip文件


    为什么要加.zip的后缀,不和谷歌官方文档一样直接生成compiled_resources文件,或者compiled_resources文件夹。此处为了windows能正常的编译打包,linux和mac随意~


    关联资源


    aapt2 link --proto-format -o base.apk -I android_30.jar \
    --min-sdk-version 19 --target-sdk-version 29 \
    --version-code 1 --version-name 1.0 \
    --manifest decode_apk_dir\AndroidManifest.xml \
    -R compiled_resources.zip --auto-add-overlay

    生成base.apk


    解压base.apk


    通过unzip解压到base文件夹,目录结构:


    ./base
    ├── AndroidManifest.xml
    ├── res
    └── resources.pb

    拷贝资源


    以base文件夹为根目录


    创建 base/manifest 将 base/AndroidManifest.xml 剪切过来


    拷贝assets , 将 ./temp/decode_apk_dir/assets 拷贝到 ./temp/base/assets


    拷贝lib, 将 ./temp/decode_apk_dir/lib 拷贝到 ./temp/base/lib


    拷贝unknown, 将 ./temp/decode_apk_dir/unknown 拷贝到 ./temp/base/root


    拷贝kotlin, 将 ./temp/decode_apk_dir/kotlin拷贝到 ./temp/base/root/kotlin


    拷贝META-INF,将./temp/decode_apk_dir/original/META-INF 拷贝到 ./temp/base/root/META-INF (删除签名信息***.RSA**、.SF.MF)


    创建./base/dex 文件夹,将 ./decode_apk_dir/*.dex(多个dex都要一起拷贝过来)


    base/manifest                        ============> base/AndroidManifest.xml
    decode_apk_dir/assets ============> base/assets
    decode_apk_dir/lib ============> base/lib
    decode_apk_dir/unknown ============> base/root
    decode_apk_dir/kotlin ============> base/root/kotlin
    decode_apk_dir/original/META-INF ============> base/root/META-INF
    decode_apk_dir/*.dex ============> base/dex/*.dex

    最终的目录结构


    base/
    ├── assets
    ├── dex
    ├── lib
    ├── manifest
    ├── res
    ├── resources.pb
    └── root

    压缩资源


    将base文件夹,压缩成base.zip 一定要zip格式


    编译aab


    打包app bundle需要使用bundletool


    java -jar bundletool-all-1.6.1.jar build-bundle \
    --modules=base.zip --output=base.aab

    aab签名


    jarsigner -digestalg SHA1 -sigalg SHA1withRSA \
    -keystore luojian37.jks \
    -storepass ****** \
    -keypass ****** \
    base.aab \
    ******

    注意:您不能使用 apksigner 为 aab 签名。签名aab的时候不需要使用v2签名,使用JDK的普通签名就行。


    测试


    此时我们已经拿到了一个aab的包,符合Google Play的上架要求,那么我们要确保这个aab的包是否正常呢?作为一个严谨的程序员还是得自己测一下。


    上传Google Play


    上传Google Play的内部测试,通过添加测试用户从Google Play去下载到手机测试。更加能模拟真实的用户环境。


    bundletool安装aab(推荐)


    每次都上传到Google Play上面去测试,成本太高了,程序员一般没上传权限,运营也不在就没法测试了。此时我们可以使用bundletool模拟aab的安装。


    连接好手机,调好adb,执行bundletool命令进行安装


    1.从 aab 生成一组 APK


    java -jar bundletool-all-1.6.1.jar build-apks \
    --bundle=base.aab \
    --output=base.apks \
    --ks=luojian37.jks \
    --ks-pass=pass:****** \
    --ks-key-alias=****** \
    --key-pass=pass:******

    2.将 APK 部署到连接的设备


    java -jar bundletool-all-1.6.1.jar install-apks --apks=base.apks

    还原成apk


    竟然apk可以转化成aab,同样aab也可以生成apk,而且更加简单


    java -jar bundletool-all-1.6.1.jar build-apks \
    --mode=universal \
    --bundle=base.aab \
    --output=test.apks \
    --ks=luojian37.jks \
    --ks-pass=pass:****** \
    --ks-key-alias=****** \
    --key-pass=pass:******

    此时就可以或得一个test.apks的压缩包,解压这个压缩包就有一个universal.apk,和开始转化的apk几乎一样。


    获取工具&源码


    github.com/37sy/build_…


    作者:37手游安卓团队
    链接:https://juejin.cn/post/6982111395621896229
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »