当前位置: 首页 > 编程日记 > 正文

从 Android 静音看正确的查bug的姿势?

0、写在前面

没抢到小马哥的红包,无心回家了,回公司写篇文章安慰下自己TT。。话说年关难过,bug多多,时间久了难免头昏脑热,不辨朝暮,难识乾坤。。。艾玛,扯远了,话说谁没踩过坑,可视大家都是如何从坑里爬出来的呢?

1、实现个静音的功能

话说,有那么一天,

PM:『我这里有个需求,很简单很简单那种』

RD:『哦,需要做三天』

PM:『真的很简单很简单那种』

RD:『哦,你又说了一遍很简单,那么现在需要做六天了』

对呀,静音功能多简单,点一下,欸,静音了;再点一下,欸,不静音了;再点一下,欸。。。

我一看API,是挺简单的:

private void setMuteEnabled(boolean enabled){AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);mAudioManager.setStreamMute(AudioManager.STREAM_MUSIC, enabled);
}

是吧,多简单,三分钟搞定。不过说真的,这并不是什么好兆头,太简单了,简单到令人窒息啊!

2、『您好,我是京东快递,您有一个bug签收一下』

话说,过了几天,

QA:『如果我先开启静音,然后退出我们的app再进来,尽管页面显示静音状态,但我无法取消静音啊』

RD:『一定是你的用法有问题!』

当然,我也挺心虚的啊,因为这段代码我总共花了三分钟,说有bug,我也不敢不信呐。我们再来细细把刚才的场景理一遍:

  1. 打开app,开启静音
  2. 点击返回键,直到app进入后台运行
  3. 重新点击app的icon,启动app,此时期望app中的静音按钮显示为静音开启的状态,并且点击可以取消静音。当然,实际上并不是这样 (|_|)

有个问题需要交代一下,Android api并没有提供获取当前音频通道是否静音的api(为什么没有?你。。你居然问我为什么?你为什么这么着急?往后看就知道啦),所以我在进入app加载view时,要根据本地存储的静音状态来初始化view的状态:

boolean persistedMute = mute.getContext().getSharedPreferences("volume", Context.MODE_PRIVATE).getBoolean("Volume.Mute", false);
muteButton.setChecked(persistedMute);

而这个字段是在用户点击了muteButton之后被存入SharedPreference当中的。

不可能啊,到这里毫无悬念可言啊,肯定是没有问题的呀。

接着看,这时候我们要取消静音了,调用的代码就是下面这段代码:

private void setMuteEnabled(boolean enabled){AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);mAudioManager.setStreamMute(AudioManager.STREAM_MUSIC, enabled);
}

然后,app一脸不屑的看都不看洒家一眼,依旧不吱声。

坑爹呢吧!!自行脑补我摔手机的场景

正是:自古bug多简单,惹得骚年尽难眠。?

3、『你可以告诉我该静音或者不静音,但听不听那是我的事儿』

我这么无辜,寥寥几行代码,能犯什么错误呢?所以问题一定出在官方的API上。

AudioManager.java

/*** Mute or unmute an audio stream.* <p>* The mute command is protected against client process death: if a process* with an active mute request on a stream dies, this stream will be unmuted* automatically.* <p>* The mute requests for a given stream are cumulative: the AudioManager* can receive several mute requests from one or more clients and the stream* will be unmuted only when the same number of unmute requests are received.* <p>* For a better user experience, applications MUST unmute a muted stream* in onPause() and mute is again in onResume() if appropriate.* <p>* This method should only be used by applications that replace the platform-wide* management of audio settings or the main telephony application.* <p>This method has no effect if the device implements a fixed volume policy* as indicated by {@link #isVolumeFixed()}.** @param streamType The stream to be muted/unmuted.* @param state The required mute state: true for mute ON, false for mute OFF** @see #isVolumeFixed()*/public void setStreamMute(int streamType, boolean state) {IAudioService service = getService();try {service.setStreamMute(streamType, state, mICallBack);} catch (RemoteException e) {Log.e(TAG, "Dead object in setStreamMute", e);}}

我们摘出最关键的一句,大家一起来乐呵乐呵。。。。

The mute requests for a given stream are cumulative: the AudioManager
can receive several mute requests from one or more clients and the stream
will be unmuted only when the same number of unmute requests are received.

就是说,我们可以发送任意次静音请求,而想要取消静音,还得发出同样次数的取消静音请求才可以真正取消静音。

好像找到答案了。不对呀,我以你的人格担保,我只发了一次静音请求啊,怎么取消静音就这么费劲呢!

4、『这是我的名片』

突然,嗯,就是在这时,我想起前几天我那本被茶水泡了的《深入理解Android》卷③提到,其实每个app都可以发送静音请求,而且各自都是单独计数的。那么问题来了,每个app发静音请求的唯一身份标识是啥嘞?

还是要看设置静音的接口方法:

AudioManager.java

   public void setStreamMute(int streamType, boolean state) {IAudioService service = getService();try {service.setStreamMute(streamType, state, mICallBack);} catch (RemoteException e) {Log.e(TAG, "Dead object in setStreamMute", e);}}

这个service其实是AudioService的一个实例,当然,其实AudioManager本身所有操作都是转发给AudioService的。

AudioService.java

    /** @see AudioManager#setStreamMute(int, boolean) */public void setStreamMute(int streamType, boolean state, IBinder cb) {if (mUseFixedVolume) {return;}if (isStreamAffectedByMute(streamType)) {if (mHdmiManager != null) {synchronized (mHdmiManager) {if (streamType == AudioSystem.STREAM_MUSIC && mHdmiTvClient != null) {synchronized (mHdmiTvClient) {if (mHdmiSystemAudioSupported) {mHdmiTvClient.setSystemAudioMute(state);}}}}}mStreamStates[streamType].mute(cb, state);}}

最后一行我们看到实际上设置静音需要传入cb也就是AudioManager传入的mICallBack,以及是静音还是取消静音的操作state,而这个mute方法本质上也是调用了VolumeDeathHandler的mute方法,我们直接看这个方法的源码:

AudioService.VolumeDeathHandler

public void mute(boolean state) {boolean updateVolume = false;if (state) {if (mMuteCount == 0) {// Register for client death notificationtry {// mICallback can be 0 if muted by AudioServiceif (mICallback != null) {mICallback.linkToDeath(this, 0);}VolumeStreamState.this.mDeathHandlers.add(this);// If the stream is not yet muted by any client, set level to 0if (!VolumeStreamState.this.isMuted()) {updateVolume = true;}} catch (RemoteException e) {// Client has died!binderDied();return;}} else {Log.w(TAG, "stream: "+mStreamType+" was already muted by this client");}mMuteCount++;} else {if (mMuteCount == 0) {Log.e(TAG, "unexpected unmute for stream: "+mStreamType);} else {mMuteCount--;if (mMuteCount == 0) {// Unregister from client death notificationVolumeStreamState.this.mDeathHandlers.remove(this);// mICallback can be 0 if muted by AudioServiceif (mICallback != null) {mICallback.unlinkToDeath(this, 0);}if (!VolumeStreamState.this.isMuted()) {updateVolume = true;}}}}if (updateVolume) {sendMsg(mAudioHandler,MSG_SET_ALL_VOLUMES,SENDMSG_QUEUE,0,0,VolumeStreamState.this, 0);}
}

其实这个方法的逻辑比较简单,如果静音,那么mMuteCount++,否则—。这里面还有一个逻辑处理了发送了静音请求的app因为crash而无法发出取消静音的请求的情形,如果出现这样的情况,系统会直接清除这个app发出的所有静音请求来使系统音频正常工作。

那么,mMuteCount是VolumeDeathHandler的成员,而VolumeDeathHandler的唯一性主要体现在传入的IBinder实例cb上。

AudioService.VolumeDeathHandler

private class VolumeDeathHandler implements IBinder.DeathRecipient {private IBinder mICallback; // To be notified of client's deathprivate int mMuteCount; // Number of active mutes for this clientVolumeDeathHandler(IBinder cb) {mICallback = cb;}……
}

结论就是:AudioManager的mICallBack是静音计数当中发起请求一方的唯一身份标识。

5、『其实,刚才不是我』

对呀,有名片啊,问题是我这是同一个app啊,同一个啊……问题出在哪里了呢。

刚才我们知道了,其实静音请求计数是以AudioManager当中的一个叫mICallBack的家伙为唯一标识的,这个家伙是哪里来的呢?

AudioManager.java

private final IBinder mICallBack = new Binder();

我们发现,其实对于同一个AudioManager来说,这个mICallBack一定是同一个。反过来说,我们在操作静音和取消静音时没有效果,应该就是因为我们的mICallBack不一样,如果是这样的话,那么说明AudioManager也不一样。。。

操曰:『天下英雄,唯使君与操耳』

玄德大惊曰:『操耳是哪个嘛?』

正当我收起我惊呆了的下巴的时候,我回过神来,准备对AudioManager的身世一探究竟。且说,AudioManager是怎么来的?

AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);

那么这个getSystemService又是什么来头??经过一番查证,我们发现,其实这个方法最终是在ContextImpl这个类当中得以实现:

ContextImpl.java

    @Overridepublic Object getSystemService(String name) {ServiceFetcher fetcher = SYSTEM_SERVICE_MAP.get(name);return fetcher == null ? null : fetcher.getService(this);}

那么问题的关键就在与我们拿到的这个ServiceFetcher实例了。且看它的get方法实现:

ContextImpl.ServiceFetcher

        public Object getService(ContextImpl ctx) {ArrayList<Object> cache = ctx.mServiceCache;Object service;synchronized (cache) {if (cache.size() == 0) {// Initialize the cache vector on first access.// At this point sNextPerContextServiceCacheIndex// is the number of potential services that are// cached per-Context.for (int i = 0; i < sNextPerContextServiceCacheIndex; i++) {cache.add(null);}} else {service = cache.get(mContextCacheIndex);if (service != null) {return service;}}service = createService(ctx);cache.set(mContextCacheIndex, service);return service;}}

如果有缓存的Service实例,就直接取出来返回;如果没有,调用createService返回一个。再看看下面的片段,这个问题就很清楚了:

        registerService(AUDIO_SERVICE, new ServiceFetcher() {public Object createService(ContextImpl ctx) {return new AudioManager(ctx);}});

这一句就实际上往SYSTEM_SERVICE_MAP.get当中添加了一个与AudioService有关的ServiceFetcher实例,而这个实例里面居然直接new了一个AudioManager。

等会儿让我想会儿静静。它在这里new了一个AudioManager。它怎么能new了一个AudioManager呢。

按照我们刚才的推断,前后两次操作AudioManager是不一样的,而同一个Context返回的AudioManager只能是一个实例,换句话说,只要我们每次获取AudioManager时使用的Context不是同一个实例,那么AudioManager就不是同一个实例,继而mICallBack也不是同一个,所以音频服务会以为是两个毫不相干的静音和取消静音的请求。

再来看看我们用的Context会有什么问题。

AudioManager mAudioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);

这段代码是在View当中的,换句话说,getContext返回的是初始化View时传入的Context。初始化这个View传入的Context是我们唯一的Activity。这时,我不说,大家也会猜到下面的内容了:

静音时的Activity实例和第二次进入引用时取消静音时的Activity根本不可能是同一个实例,因此这两个操作是不相干的。由于系统只要收到任意的静音请求都会使对应的音频通道进入静音状态,因此即使我们用另一个AudioManager发出了取消静音的请求,不过然并卵。

6、『这事儿还是交给同一个人办比较靠谱』

有了前面的分析,解决方法其实也就浮水而出了:

AudioManager mAudioManager = (AudioManager) getContext().getApplicationContext().getSystemService(Context.AUDIO_SERVICE);

我们只要使用Application全局Context去获取AudioManager不就没有那么多事儿了么?其实尽可能地引用Application而不是Activity,在很多场合甚至会避免内存泄露。有朋友问起什么时候应该用Application,什么时候应该用Activity,答案很明显,只要是Application可以做到的,就一律不要用Activity,除非引用方的生命周期跟Activity的生命周期一致。

再来回答,为什么系统没有提供获取是否静音的Api这个问题。如果系统确实提供了这个Api,它应该为你提供哪些信息呢?是告诉你系统当前是否静音吗?它告诉你这个有啥意义呢,反正那些别人操作的结果,如果已经静音,你也单方面做不到取消静音;是告诉你你这个应用是否已经发送过静音请求?请求数量你自己完全可以自己记录,为什么还要官方Api提供给你?所以,获取是否处于静音状态这个接口其实意义并不见得有多大。当然,实际上这个api是写在代码中的,只不过被@hide了,我们就当做没有看待好了。

7、 小结

静音的故事讲完了,这个小故事告诉我们一个道理:代码从来都不会骗我们

侯捷先生在《STL源码剖析》一书的扉页上面写道『源码之前,了无秘密』。写程序的时候,我经常会因为运行结果与预期不一致而感到不悦,甚至抱怨这就是『命』,想想也是挺逗的。计算机总是会忠实地执行我们提供的程序,如果你发现它『不听』指挥,显然是你的指令有问题;除此之外,我们的指令还需要经过层层传递,才会成为计算机可以执行的机器码,如果你对系统api的工作原理不熟悉,对系统的工作原理不熟悉,你在组织自己的代码的时候就难免一厢情愿。

至于官方API文档,每次看到它都有看到『课本』一样的感觉。中学的时候,老师最爱说的一句话就是,『课本要多读,常读常新』。官方API呢,显然也是这样。没有头绪的时候,它就是我们救星啊。

作为Android开发者,尽管我不需要做Framework开发,但这并不能说明我不需要对Framework有一定的认识和了解。我们应该在平时的开发和学习当中经常翻阅这些系统的源码,了解它们的工作机制有助于我们更好的思考系统api的应用场景。

关于Android系统源码,如果不是为了深入的研究,我比较建议直接在网上直接浏览:

  • Androidxref,该站点提供了一定程度上的代码跳转支持,以及非常强大的检索功能,是我们查询系统源码的首选。
  • Grepcode也可以检索Android系统源码,与前者不同的是,它只包含Java代码,不过也是尺有所长,grepcode在Java代码跳转方面的支持已经非常厉害了。

    想了解更多干货,请搜索关注公众号:腾讯Bulgy,或搜索微信号:weixinBugly,关注我们


腾讯Bugly

Bugly是腾讯内部产品质量监控平台的外发版本,支持iOS和Android两大主流平台,其主要功能是App发布以后,对用户侧发生的crash以及卡顿现象进行监控并上报,让开发同学可以第一时间了解到app的质量情况,及时修改。目前腾讯内部所有的产品,均在使用其进行线上产品的崩溃监控。

腾讯Bugly经过内部团队4年打磨,目前腾讯内部所有的产品都在使用,基本覆盖了中国市场的移动设备以及网络环境,可靠性有保证。使用Bugly,你就使用了和手机QQ、QQ空间、手机管家相同的质量保障手段。

转载于:https://www.cnblogs.com/bugly/p/5210624.html

相关文章:

SecureCRT中sqlplus,使用Backspace删除时 ^H^H

在“Session Options” - "Terminal" - "Mapped Keys" - "Other mappings"&#xff0c;选择“Backspace sends delete”。转载于:https://www.cnblogs.com/Clark-cloud-database/p/7813867.html

在vue中使用vuex,修改state的值示例

1、 安装 vuex npm install vuex -S 2、在目录下创建store文件 3、 在store.js编辑一个修改state的方法 然后在mian.js中全局引入 最后在组件中使用 这个的功能是运用mutations 修改state中的值

软件开发 自由职业_自由职业? 这里有7个可以出售软件开发服务的地方

软件开发 自由职业Web developers need clients. This is true whether you are a full-time freelancer or you freelance on the side.Web开发人员需要客户。 无论您是全职自由职业者&#xff0c;还是身旁的自由职业者&#xff0c;都是如此。 So if youve ever asked: "…

生成SSH key

1、打开终端&#xff0c;输入下面的代码 &#xff0c;注意&#xff1a;$your_email为占位符&#xff0c;此处输入你自己的email ssh-keygen -t rsa -C "$your_email" 2、查看生成的公钥&#xff0c;输入下面的代码 cat ~/.ssh/id_rsa.pub 3、复制公钥&#xff08;公钥…

[转载]二叉树(BST,AVT,RBT)

二叉查找树(Binary Search Tree)是满足如下性质的二叉树&#xff1a;①若它的左子树非空&#xff0c;则左子树上所有结点的值均小于根结点的值&#xff1b;②若它的右子树非空&#xff0c;则右子树上所有结点的值均大于根结点的值&#xff1b;③左、右子树本身又各是一棵二叉查…

Invalid Host header 问题解决

出现该问的原因&#xff1a; 因为新版的 webpack-dev-server 出于安全考虑&#xff0c;默认检查 hostname&#xff0c;如果hostname不是配置内的就不能访问。 解决办法&#xff1a;设置跳过host检查 打开你的项目全局搜索 devServer &#xff0c;在 devServer 里面添加 &quo…

react hooks使用_为什么要使用React Hooks?

react hooks使用The first thing you should do whenever youre about to learn something new is ask yourself two questions -每当您要学习新东西时&#xff0c;应该做的第一件事就是问自己两个问题- Why does this thing exist? 为什么这东西存在&#xff1f; What probl…

算法 - 字符串匹配

http://blog.csdn.net/linhuanmars/article/details/20276833 转载于:https://www.cnblogs.com/qlky/p/7817471.html

工作流入门链接

百度百科-工作流 http://baike.baidu.com/link?urlZjElBNByyZz_ItLtd_Uqt3Sadcwv0-4CDO806vKQWJDuUOFybbkzpg8GOB1EU71w8bT4x64RoRXBrFXa7o_dK 企业应用工作流的好处http://jingyan.baidu.com/article/90895e0fe9c56164ec6b0b24.html工作流管理的好处http://blog.sina.com.cn/…

uniapph5配置index.html模板路径不生效解决办法

很简单&#xff0c;关闭应用再重新启动试试&#xff0c;还不行的话&#xff0c;重启IDE

终端软件升级功能开发_5个很棒的终端技巧可帮助您升级为开发人员

终端软件升级功能开发There are plenty of beginner tutorials around that help you learn command line basics, such as cd, ls, pwd and so on...but what about that fancy magic youve seen more experienced developers use?周围有很多初学者教程可以帮助您学习命令行基…

自定义左右侧滑菜单

实现效果&#xff1a; 左右侧滑菜单&#xff0c;侧滑栏占主屏比为60%监听触控&#xff0c;自定义滑动动画&#xff0c;当侧边栏滑动超过50%松开触控将自动滑动到60%&#xff0c;未超过50%松开触控回归侧边栏隐藏为主屏设置蒙版效果&#xff0c;根据侧滑菜单的占屏比设置主屏蒙版…

uniapp设置模板路径页面样式混乱解决办法

乱了就在html里面加上下面这行代码试试 <link rel"stylesheet" href"<% BASE_URL %>static/index.css" /> <meta name"viewport" content"widthdevice-width, initial-scale1.0, user-scalableno, minimum-scale1.0, maxim…

JavaScript获取当前日期,昨天,今天日期以及任意天数间隔日期

<script language"JavaScript" type"text/javascript"> function GetDateStr(AddDayCount) { var dd new Date(); dd.setDate(dd.getDate()AddDayCount);//获取AddDayCount天后的日期 var y dd.getYear(); var m dd.getMonth()1;//获取当前月份…

snapd_snapd使管理Nextcloud变得轻而易举

snapdAs I’ve described in both my Linux in Action book and Linux in Motion course, Nextcloud is a powerful way to build a file sharing and collaboration service using only open source software running on your own secure infrastructure. It’s DropBox, Skyp…

atitit.跨架构 bs cs解决方案. 自定义web服务器的实现方案 java .net jetty  HttpListener...

atitit.跨架构 bs cs解决方案. 自定义web服务器的实现方案 java .net jetty HttpListener 1. 自定义web服务器的实现方案&#xff0c;基于原始socket vs 基于tcpListener vs 基于HttpListener1 2. download1 3. Lib3 4. Code3 5. HttpListener类4 6. Reef5 1. 自定义web服务器…

Python高级函数--map/reduce

名字开头大写 后面小写&#xff1b;练习&#xff1a; 1 def normalize(name): 2 return name[0].upper() name[1:].lower() 3 L1 [adam, LISA, barT] 4 L2 list(map(normalize, L1)) 5 print(L2) reduce求积&#xff1a; 1 from functools import reduce 2 3 def prod(…

样式集(9) - 切换Tab菜单

先上效果图 下面是以vue实现的示例代码&#xff1a; 代码解析&#xff1a; 很简单的代码&#xff0c;直接复制粘贴使用吧~ 别忘记一键三连哦&#xff0c;点赞&#xff0c;收藏&#xff0c;关注&#xff0c;谢谢~ 后续持续更新更多干货哦&#xff01;&#xff01; <temp…

python字典{:4}_Python字典101:详细的视觉介绍

python字典{&#xff1a;>4}欢迎 (Welcome) In this article, you will learn how to work with Python dictionaries, an incredibly helpful built-in data type that you will definitely use in your projects.在本文中&#xff0c;您将学习如何使用Python字典&#xff…

asp.net提交危险字符处理方法之一

在form表单提交前&#xff0c;可以在web页面&#xff0c;submit按钮的click事件中&#xff0c;使用js函数对&#xff0c;可能有危险字符的内容进行编码。 有3个函数可用&#xff1a; encodeURI() 函数可把字符串作为 URI 进行编码。 escape() 函数可对字符串进行编码&#xff0…

mysql帐号,权限管理

-> use mysql; //选择数据库 -> select host,user,password from user; //查询已有用户 -> insert into user (host,user,password) values(localhost,kiscms,password(kiscms)); //插入一个用户 -> select host,user,password from user; //再次查询用户 -> fl…

样式集(10) - 滑动删除功能实现,VUE完整源码附效果图

先看效果图 实现方式&#xff1a; 使用 scroll-view 标签&#xff0c;进行横向滑动&#xff0c;达到左滑出现删除按钮&#xff0c; 注&#xff1a;如果不是使用uni-app或者小程序框架&#xff0c;没有 scroll-view 组件的话可以通过CSS实现哦 下面看uni-app的实现代码&#…

机器学习关键的几门课程_互联网上每门机器学习课程,均按您的评论排名

机器学习关键的几门课程by David Venturi大卫文图里(David Venturi) 互联网上每门机器学习课程&#xff0c;均按您的评论排名 (Every single Machine Learning course on the internet, ranked by your reviews) A year and a half ago, I dropped out of one of the best com…

[POJ2104]K-th Number(区间第k值 记录初始状态)

题目链接&#xff1a;http://poj.org/problem?id2104 给n个数和m个查询&#xff0c;查询[i, j]内第k小的数是多少。&#xff08;主席树、划分树那种高大上的姿势叒不会啊QAQ 可以在维护这n个数的同时维护刚刚输入的时候他们的下标&#xff0c;之后预处理排序一次&#xff0c;查…

Geant4采用make和cmake编译运行geant4自带例子的方法

该教程介绍如何将geant4中自带的例子通过camke编译成可执行文件&#xff0c;并运行程序。 1 在linux主目录下创建一个geant4_workdir目录&#xff0c;并将geant4自带的例子B1复制到该目录下&#xff0c;如图1所示&#xff0c;geant4自带的B1源文件所在目录为geant4安装目录&…

GitLab设置中文

第一步&#xff0c;点击右上角的头像 点击Preferences&#xff0c;选择语言 选择简体中文然后保存

java python算法_用Python,Java和C ++示例解释的排序算法

java python算法什么是排序算法&#xff1f; (What is a Sorting Algorithm?) Sorting algorithms are a set of instructions that take an array or list as an input and arrange the items into a particular order.排序算法是一组指令&#xff0c;这些指令采用数组或列表…

c++运算符重载总结

c的一大特性就是重载(overload)&#xff0c;通过重载可以把功能相似的几个函数合为一个&#xff0c;使得程序更加简洁、高效。在c中不止函数可以重载&#xff0c;运算符也可以重载。由于一般数据类型间的运算符没有重载的必要&#xff0c;所以运算符重载主要是面向对象之间的。…

一道面试题:js返回函数, 函数名后带多个括号的用法及join()的注意事项

博客搬迁&#xff0c;给你带来的不便&#xff0c;敬请谅解&#xff01; http://www.suanliutudousi.com/2017/11/13/js%E8%BF%94%E5%9B%9E%E5%87%BD%E6%95%B0%E4%B8%AD%EF%BC%8C%E5%87%BD%E6%95%B0%E5%90%8D%E5%90%8E%E5%B8%A6%E5%A4%9A%E4%B8%AA%E6%8B%AC%E5%8F%B7%E7%9A%84%E…

小程序画布画海报保存成图片可以保存实现完整代码

老规矩先来个效果图&#xff1a; 因为是截图所以会有些模糊&#xff0c;在真机上会比较清晰 下面针对效果图来看看里面都画了什么元素&#xff0c;代码在文章的最后&#xff0c;大家想直接拷代码可以略过这&#xff0c;这里是方便大家理解代码。 首先&#xff0c;咱们的海报有…