android 判断主线程_面试官:Android 子线程更新UI了解吗?

前言

今天一个朋友去面试,被问到为什么Loop 死循环而不阻塞UI线程?

为什么子线程不能更新UI?是不是子线程一定不可以更新UI?

SurfaceView是为什么可以直接子线程绘制呢?

用SurfaceView 做一个小游戏,别踩百块,so easy!

今天我们来一起讨论一下这些问题,在看下面讨论时,你需要掌握Android Handler,View 线程等基础知识。第一次看我文章的小伙伴可以关注一下我,顺便关注一下我的专栏:Android高级开发架构,每天更新各种技术干货,分享更多最热程序员圈内事。Android高级开发架构​zhuanlan.zhihu.com

单线程 异步消息的原理

我们刚开始学习移动端开发的时候,不管是Android,还是IOS,经常会听到一句话,网络请求是耗时操作,需要开一个单独的线程请求网络。

而如果最近接触过Flutter的同学,可能知道网络请求只是一个异步操作,不需要开单独的线程或者进程进行耗时请求,那这种机制是什么样的原理呢?

这里先解释一下,网络请求是一个耗时操作的确是没问题的,但是他不是一个耗CPU的操作,他仅仅是一个异步操作。那异步操作是不是可以用单线程就实现了呢?(因为他不耗CPU)

我们看一下异步消息的模型(生产者消费者模型),如下:

那么单线程的话,怎么搞呢?其实只要一个消息不断的去读队列,如果没有消息,那就只等待状态,只要有消息进来,比如点击事件,滑动事件等,就可以直接取出消息执行。

下面我们来看一下Android里面的异步消息实现机制 Handler,主线程在APP启动(ActivityThread)的时候,就会启动消息循环,如下:

//ActivityThread 省略部分代码

public static void main(String[] args) {

AndroidOs.install();

Process.setArgV0("");

Looper.prepareMainLooper(); //Handler启动机制: Looper.prepare()

ActivityThread thread = new ActivityThread();

thread.attach(false, startSeq);

if (sMainThreadHandler == null) {

sMainThreadHandler = thread.getHandler();

}

Looper.loop();Handler启动原理: Looper.loop()

}

为什么Loop 死循环而不阻塞UI线程?

//Looper

public static void loop() {

final Looper me = myLooper();

for (;;) {

Message msg = queue.next(); // might block

if (msg == null) {

// No message indicates that the message queue is quitting.

return;

}

...

}

}

....

这个从上面的单线程异步消息模型,我们就可以知道,他不是阻塞线程了,而是只要有消息插入MessageQueue队列,就可以直接执行。

UI更新被设计成单线程(主线程或者说是UI线程)的原因

我们知道UI刷新,需要在规定时间内完成,以此带来流畅的体验。如果刷新频率是60HZ的话,需要在16ms内完成一帧的绘制,除了一些人为原因,怎么做才能达到UI刷新高效呢?

事实就是UI线程被设计成单线程访问?这样有什么好处呢?单线程访问,是不需要加锁的。

如果多个线程访问那就需要加锁,耗时会比较多,如果多线程访问不加锁,多个线程共同访问更新操作同一个UI控件时容易发生不可控的错误。

所以UI线程被设计成单线才能程访问,也是这样设计的一个伪锁。

是不是子线程一定不可以更新UI

答案是否定的,有些人可能认为SurfaceView的画布就可以在子线程中访问,这个本来就是另外的一个范畴,我们下一节讨论。

从上面一节,我们知道,UI线程被设计成单线程访问的,但是看代码,他设计只是在访问UI的时候检测线程是否是主线程。如下:

//ViewRootImpl

void checkThread() {

if (mThread != Thread.currentThread()) {

throw new CalledFromWrongThreadException(

"Only the original thread that created a view hierarchy can touch its views.");

}

}

那我们可不可以绕过这个checkThread方法呢?来达到子线程访问UI,我们先看一段代码:

public class MainActivity extends AppCompatActivity {

private TextView tvTest;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

tvTest = findViewById(R.id.tvTest);

new Thread(new Runnable() {

@Override

public void run() {

tvTest.setText("测试子线程加载");

}

}).start();

}

}

这段代码是可以直接运行成功的,并且没有任何问题,那这是是为什么呢?可能你已经猜想到这是为什么了—— 绕过了checkThread方法。

下面来分析一下原因: 访问及刷新UI,最后都会调用到ViewRootImpl,如果对ViewRootImpl还很陌生,可以现场Google。

那么直接在onCreate 启动时,ViewRootImpl肯定还没启动起来啊,不然,那刷新肯定失败,我们可以验证一下。把上面Thread 里面加一个延迟,变成这样

public class MainActivity extends AppCompatActivity {

private TextView tvTest;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

tvTest = findViewById(R.id.tvTest);

new Thread(new Runnable() {

@Override

public void run() {

try {

Thread.sleep(500);

} catch (InterruptedException e) {

e.printStackTrace();

}

tvTest.setText("测试子线程加载");

}

}).start();

}

}

运行起来直接崩溃

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:7753)

at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1225)

at android.view.View.requestLayout(View.java:23093)

at android.view.View.requestLayout(View.java:23093)

at android.view.View.requestLayout(View.java:23093)

at android.view.View.requestLayout(View.java:23093)

at android.view.View.requestLayout(View.java:23093)

at android.view.View.requestLayout(View.java:23093)

at androidx.constraintlayout.widget.ConstraintLayout.requestLayout(ConstraintLayout.java:3172)

at android.view.View.requestLayout(View.java:23093)

at android.widget.TextView.checkForRelayout(TextView.java:8908)

at android.widget.TextView.setText(TextView.java:5730)

at android.widget.TextView.setText(TextView.java:5571)

at android.widget.TextView.setText(TextView.java:5528)

at com.ding.carshdemo.MainActivity$1.run(MainActivity.java:27)

和猜想一致,那么ViewRootImpl是什么时候被启动起来的呢?

当Activity准备好后,最终会调用到Activity中的makeVisible,并通过WindowManager添加View,代码如下

//Activity

void makeVisible() {

if (!mWindowAdded) {

ViewManager wm = getWindowManager();

wm.addView(mDecor, getWindow().getAttributes());

mWindowAdded = true;

}

mDecor.setVisibility(View.VISIBLE);

}

看一下wm addView方法

//WindowManagerImpl

public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {

applyDefaultToken(params);

mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);

}

在看一下mGlobal.addView方法

//WindowManagerGlobal

public void addView(View view, ViewGroup.LayoutParams params,

Display display, Window parentWindow) {

ViewRootImpl root;

.....

View panelParentView = null;

synchronized (mLock) {

root = new ViewRootImpl(view.getContext(), display);

view.setLayoutParams(wparams);

mViews.add(view);

mRoots.add(root);

}

...

}

终于找到了ViewRootImpl的创建。那么回到上面makeVisible是什么时候被调用到的呢? 看Activity启动流程时,我们知道,Ativity的启动和AMS交互的代码在ActivityThread中,搜索makeVisible方法,可以看到调用地方为

//ActivityThrea

public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,

String reason) {

...

if (r.activity.mVisibleFromClient) {

r.activity.makeVisible();

}

...

}

private void updateVisibility(ActivityClientRecord r, boolean show) {

....

if (show) {

if (!r.activity.mVisibleFromServer) {

if (r.activity.mVisibleFromClient) {

r.activity.makeVisible();

}

...

}

//调用updateVisibility地方为

handleStopActivity() handleWindowVisibility() handleSendResult()

这里我们只关注ViewRootImpl创建的第一个地方,从Acitivity声明周期handleResumeActivity会被优先调用到,也就是说在handleResumeActivity启动后(OnResume),ViewRootImpl就被创建了,这个时候,就无法在在子线程中访问UI了,上面子线程延迟了一会,handleResumeActivity已经被调用了,所以发生了崩溃。Android学习PDF+架构视频+面试文档+源码笔记​shimo.im

SurfaceView是为什么可以直接子线程绘制呢?

我们一般的View有一个Surface,并且对应SurfaceFlinger的一块内存区域。这个本地Surface和View是绑定的,他的绘制操作,最终都会调用到ViewRootImpl,那么这个就会被检查是否主线程了,所以只要在ViewRootImpl启动后,访问UI的所有操作都不可以在子线程中进行。

那SurfaceView为什么可以子线程访问他的画布呢?如下

public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback {

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

SurfaceView surfaceView = findViewById(R.id.sv);

surfaceView.getHolder().addCallback(this);

}

@Override

public void surfaceCreated(final SurfaceHolder holder) {

new Thread(new Runnable() {

@Override

public void run() {

while (true){

Canvas canvas = holder.lockCanvas();

canvas.drawColor(Color.RED);

holder.unlockCanvasAndPost(canvas);

try {

Thread.sleep(100);

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

}).start();

}

@Override

public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

}

@Override

public void surfaceDestroyed(SurfaceHolder holder) {

}

}

其实查看SurfaceView的代码,可以发现他自带一个Surface

public class SurfaceView extends View implements ViewRootImpl.WindowStoppedCallback {

...

final Surface mSurface = new Surface();

...

}

在SurfaceView的updateSurface()中

protected void updateSurface() {

....

if (creating) {

//View自带Surface的创建

mSurfaceSession = new SurfaceSession(viewRoot.mSurface);

mDeferredDestroySurfaceControl = mSurfaceControl;

updateOpaqueFlag();

final String name = "SurfaceView - " + viewRoot.getTitle().toString();

mSurfaceControl = new SurfaceControlWithBackground(

name,

(mSurfaceFlags & SurfaceControl.OPAQUE) != 0,

new SurfaceControl.Builder(mSurfaceSession)

.setSize(mSurfaceWidth, mSurfaceHeight)

.setFormat(mFormat)

.setFlags(mSurfaceFlags));

}

//SurfaceView 中自带的Surface

if (creating) {

mSurface.copyFrom(mSurfaceControl);

}

....

}

SurfaceView中的mSurface也有在SurfaceFlinger对应的内存区域,这样就很容易实现子线程访问画布了。

这样设计有什么不好的地方吗?

因为这个 mSurface 不在 View 体系中,它的显示也不受 View 的属性控制,所以不能进行平移,缩放等变换,也不能放在其它 ViewGroup 中,一些 View 中的特性也无法使用。

别踩百块

我们知道SurfaceView可以在子线程中刷新画布(所称的离屏刷新),那做一些刷新频率高的游戏,就很适合.下面我们开始撸一个前些年比较火的小游戏。

看游戏分为几个步骤,这里主要讲一下原理和关键代码(下面有完整代码地址)绘制一帧

动起来

手势交互

判断游戏是否结束

优化内存

绘制一帧

我们把一行都成一个图像,那么他有一个黑色块,和多个白色块组成. 那就可以简单抽象为:

public class Block {

private int height;

private int top;

private int random = 0; //第几个是黑色块

}

绘制逻辑

public void draw(Canvas canvas,int random){

this.random=random;

canvas.save();

for(int i=0;i

if(random == i){

blackRect=new Rect(left+i*width,top,width+width*i,top+height);

canvas.drawRect(left+i*width,top,width+width*i,top+height,mPaint);

}else if(error == i){

canvas.drawRect(left+i*width,top,width+width*i,top+height, errorPaint);

}else{

canvas.drawRect(left+i*width,top,width+width*i,top+height,mDefaultPaint);

}

}

canvas.restore();

}

那么一行的数据有了,我只需要一个List就可以绘制一屏幕的数据

//List list;

private void drawBg() {

synchronized (list) {

mCanvas.drawColor(Color.WHITE);

if (list.size() == 0) {

for (int i = 0; i <= DEAULT_HEIGHT_NUM; i++) {

addBlock(i);

}

} else {

......

}

}

}

private void addBlock(int i) {

Block blok = new Block(mContext);

blok.setTop(mHeight - (mHeight / DEAULT_HEIGHT_NUM) * i);

int random = (int) (Math.random() * DEAFAUL_LINE_NUME);

blok.draw(mCanvas, random);

list.add(blok);

}

要让其动起来

SurfaceView在不断的刷新,那么只要让List里面的数据每一行的top不断增加,下面没有数据了,直接添加到上面

//SurfaceView 新开的子线程Thread

@Override

public void run() {

isRunning=true;

while (isRunning){

draw();

}

}

private void draw() {

try {

mCanvas = mHolder.lockCanvas();

if(mCanvas !=null) {

drawBg();

// removeNotBg();

// checkGameover(-1,-1);

}

}catch (Exception e){

}finally {

mHolder.unlockCanvasAndPost(mCanvas);

}

}

private void drawBg() {

synchronized (list) {

mCanvas.drawColor(Color.WHITE);

if (list.size() == 0) {

....

} else {

for (Block block : list) {

//top 不断添加

block.setTop(block.getTop() + mSpeend);

block.draw(mCanvas, block.getRandom());

}

if (list.get(list.size() - 1).getTop() >= 0) {

Block block = new Block(mContext);

block.setTop(list.get(list.size() - 1).getTop() - (mHeight / DEAULT_HEIGHT_NUM));

int random = (int) (Math.random() * DEAFAUL_LINE_NUME);

block.draw(mCanvas, random);

//如果上面的top出去了,那下面在加一个block

list.add(block);

}

}

mCanvas.drawText(String.valueOf(count),350,mHeight/8,textPaint);

}

}

手势交互

如果用户黑块点击了,就开始游戏,如果已经开始,那么点击了正确的黑块,就绘制成灰色并加速,并检查游戏是否结束了

@Override

public boolean onTouchEvent(MotionEvent event) {

switch (event.getAction()) {

case MotionEvent.ACTION_DOWN:

if(isRunning) {

checkGameover((int) event.getX(), (int) event.getY());

}else{

count=0;

list.clear();

mSpeend=0;

thread = new Thread(this);

thread.start();

}

break;

}

return super.onTouchEvent(event);

}

绘制灰色代码见下面

判断游戏是否结束了下面到屏幕底端了,还未点击

点击错误

private boolean checkGameover(int x,int y){

synchronized (list) {

for (Block block : list) {

if(x !=-1 && y !=-1) {

if (block.getBlackRect().contains(x, y)) {

count++;

if(mSpeend == 0){

mSpeend=DensityUtils.dp2px(getContext(),10);

}else if(mSpeend <=10){

mSpeend+=DensityUtils.dp2px(getContext(),2);

}else if(count == 60){

mSpeend+=DensityUtils.dp2px(getContext(),2);

} else if(count == 100){

mSpeend+=DensityUtils.dp2px(getContext(),2);

}else if(count == 200){

mSpeend+=DensityUtils.dp2px(getContext(),1);

} else if(count == 300){

mSpeend+=DensityUtils.dp2px(getContext(),1);

} else if(count == 400){

mSpeend+=DensityUtils.dp2px(getContext(),1);

}

block.setBlcakPaint();

} else if (y > block.getTop() && y < block.getTop() + block.getHeight()) {

isRunning = false;

block.setError(x / block.getWidth());

}

}else{

if(block.getTop()+block.getHeight()-50 >=mHeight && !block.isChick()){

isRunning=false;

block.setError(block.getRandom());

}

}

}

}

return false;

}

最后优化一下内存

因为我们在不断的添加block,玩一会内存就爆了,可以学习ListView,划出屏幕后上方就移除.

private void removeNotBg() {

synchronized (list) {

for (Block block : list) {

if (block.getTop() >= mHeight) {

needRemoveList.add(block);

}

}

if(needRemoveList.size() !=0){

list.removeAll(needRemoveList);

needRemoveList.clear();

}

}

}

由于代码量比较小,直接上传到了百度云网盘,地址: http://pan.baidu.com/s/1-pSwF34O… 提取码: 2j3aAndroid学习PDF+架构视频+面试文档+源码笔记​shimo.im

总结

在Android/IOS/Flutter/Window中,都有消息循环这套机制,保证了UI高效,安全。我们作为Android开发程序员,有必要掌握。在这里还分享一份我自己收录整理的Android学习PDF+架构视频+面试文档+源码笔记,还有高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习

如果你有需要的话,可以点赞+转发,关注我,然后私信我【学习】我分享给你

如果文章对你有帮助,帮忙点一下赞,非常感谢。作者:北斗星_And

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/471797.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

记事本linux命令换行符,Windows 10版记事本应用终于支持Linux/Mac换行符 排版不再辣眼睛...

记事本(Notepad)是微软 Windows 操作系统中相当经典的一款工具&#xff0c;其在最新的 Windows 10 操作系统中也得到了保留&#xff0c;命运比被 Photos 和 Paint 3D 取代的画图(MsPaint)程序要好得多。不过最近&#xff0c;Windows10 版记事本应用迎来了一项技能更新&#xff…

LeetCode 1885. Count Pairs in Two Arrays(二分查找)

文章目录1. 题目2. 解题1. 题目 Given two integer arrays nums1 and nums2 of length n, count the pairs of indices (i, j) such that i < j and nums1[i] nums1[j] > nums2[i] nums2[j]. Return the number of pairs satisfying the condition. Example 1: Inpu…

hal库开启中断关中断_STM32对HAL库的定时器中断

从串口中断&#xff0c;到外部中断再到现在的定时器中断&#xff0c;越来越感觉HAL库应用比较好用&#xff0c;虽然一开始理解上面有点怪&#xff0c;但是网上查询一下就能够学会。定时器中断分为两个重点&#xff0c;1、在应用CUBEMX时配置定时器的分频数以及时钟树设置 2、生…

How to Avoid Producing Legacy Code at the Speed of Typing

英语不好翻译很烂。英语好的去看原文。 About the Author I am a software architect/developer/programmer.I have a rather pragmatic approach towards programming, but I have realized that it takes a lot of discipline to be agile. I try to practice good craftsman…

c语言程序做成可执行文件,windows环境下C程序生成可执行文件

windows环境下&#xff0c;编写C程序&#xff0c;生成.exe&#xff0c;用于操作某个文件。包含三部分&#xff1a;搭建环境、程序实现、程序分析。1、搭建程序编写和编译环境在windows下安装Git Bash(下载页面)。安装完成后&#xff0c;可以在windows的任意文件夹下&#xff0c…

LeetCode MySQL 1890. 2020年最后一次登录(year)

文章目录1. 题目2. 解题1. 题目 表: Logins -------------------------- | 列名 | 类型 | -------------------------- | user_id | int | | time_stamp | datetime | --------------------------(user_id, time_stamp) 是这个表的主键。 每一…

大锅菜机器人_炒菜机或者炒菜机器人有好用的吗 - 玩主论坛

好像公司还在&#xff0c;出新型号了。智能烹饪机炒菜视频&#xff1a;http://videotudoucom/v/XMTk3MDkwNDU5Nghtml智能烹饪机将主(菜、肉、鱼……)副料一次性投入&#xff0c;选定菜系&#xff0c;轻触按钮&#xff0c;一般家常菜肴仅三分钟即可出锅食用。本机具有自动翻炒功…

高效开发--crm开发有悟

今天我计划许久的回访单功能模块&#xff0c;还是没有实现&#xff0c;没有搞定。心中很是苦闷啊&#xff01;&#xff01;&#xff01;原因&#xff1a;自己想去吧。以后怎么做&#xff1a;什么也是纸老虎&#xff0c;程序研发太简单&#xff0c;只要弄清了需求&#xff0c;其…

LeetCode MySQL 1873. 计算特殊奖金(case when then else end)

文章目录1. 题目2. 解题1. 题目 表: Employees ---------------------- | 列名 | 类型 | ---------------------- | employee_id | int | | name | varchar | | salary | int | ----------------------employee_id 是这个表的主键。 此表的每…

href 带参数 打开exe_js调用winform程序(带参数)

我们会发现,我们点击迅雷下载的时候 网页可以调用应用程序,而且连接会传入迅雷,这个是怎么做到的呢?原理: 先注册表中添加软件的具体信息,然后通过 href 可以直接调用1.写入注册表信息,注册,如果不需要参数 只要第一个HKEY_CLASSES_ROOT\test 段落Windows Registry Editor Ve…

c语言variant是什么变量,介绍一些常用数据类型的使用。先定义一些常见类型变量借以.doc...

介绍一些常用数据类型的使用。先定义一些常见类型变量借以说明int i 100;long l 2001;float f300.2;double d12345.119;char username[]"张三";char temp[200];char *buf;CString str;_variant_t v1;_bstr_t v2;一、其它数据类型转换为字符串短整型(int)itoa(i,tem…

What code you will get when you create a wcf library

创建wcf服务库的时候&#xff0c;系统自动生成的代码 // 注意: 使用“重构”菜单上的“重命名”命令&#xff0c;可以同时更改代码和配置文件中的接口名“IService1”。[ServiceContract]public interface IService1{[OperationContract]string GetData(int value);[OperationC…

LeetCode 1868. 两个行程编码数组的积(双指针)

文章目录1. 题目2. 解题2.1 模拟超时2.2 优化1. 题目 行程编码&#xff08;Run-length encoding&#xff09;是一种压缩算法&#xff0c;能让一个含有许多段连续重复数字的整数类型数组 nums 以一个&#xff08;通常更小的&#xff09;二维数组 encoded 表示。 每个 encoded[…

结巴分词有前空格_jieba英文空格分词问题

1.对于关键词存在空格或者特殊符号的情况下&#xff0c;jieba无法分出该词2.在github上找到了一个解决方案&#xff0c;修改jieba源码__init__.py免费分享&#xff0c;造损免责。打开默认词典(根目录)或自定义词典&#xff0c;把所有用来间隔词频和词性的空格间隔符改成(选用是…

linux socket 面试题,面试题

1、网络TCP/IP协议2、三层路由架构&#xff0c;搭建局域网3、自动化测试工具robotframework的使用&#xff0c;怎么管理测试脚本4、shell脚本可用过&#xff1f;怎么替换一个文本中指定的内容&#xff1f;5、python的多线程怎么使用的&#xff1f;正则表达式中serach和match的区…

Core Location :⽤用于地理定位

Core Location :⽤用于地理定位 在移动互联⽹网时代,移动app能解决⽤用户的很多⽣生活琐事,⽐比如 导航:去任意陌⽣生的地⽅方 周边:找餐馆、找酒店、找银⾏行、找电影院 在上述应⽤用中,都⽤用到了地图和定位功能,在iOS开发中,要想加⼊入这2⼤大功 能,必须基于2个框架进⾏…

LeetCode MySQL 1587. 银行账户概要 II

文章目录1. 题目2. 解题1. 题目 表: Users ----------------------- | Column Name | Type | ----------------------- | account | int | | name | varchar | -----------------------account 是该表的主键. 表中的每一行包含银行里中每一个用户的账号…

取多补少C语言,leetcode题目: 数字的补数 的C语言解法

题目链接题目内容给定一个正整数&#xff0c;输出它的补数。补数是对该数的二进制表示取反。注意:给定的整数保证在32位带符号整数的范围内。你可以假定二进制数不包含前导零位。示例 1:输入: 5输出: 2解释: 5的二进制表示为101(没有前导零位)&#xff0c;其补数为010。所以你需…

wpf mvvm MenuItem的Command事件

这是一个事件的辅助类&#xff0c;可以通过它实现MenuItem的Command事件 public class MyCommands : Freezable, ICommand, ICommandSource{public MyCommands() {}public static readonly DependencyProperty CommandParameterProperty DependencyProperty.Register("Com…

python写字板_pywinauto简单操作写字板的例子

前段时间写了做web程序界面自动化的简单例子&#xff0c;今天写一下windows gui程序界面自动化测例子吧。def openwordpad():app application.Application()wordpadapp.start(r"C:\Program Files\Windows NT\Accessories\wordpad.exe")wordpad.wordpadclass.RICHEDI…