• PaintView 绘图控件解析


      博客地址:博客园,版权所有,转载须联系作者。

      GitHub地址:JustWeTools 

      最近做了个绘图的控件,实现了一些有趣的功能。

      先上效果图:

     

     PaintView画图工具:

    1.可直接使用设定按钮来实现已拥有的方法,且拓展性强
    2.基础功能:更换颜色、更换橡皮、以及更换橡皮和笔的粗细、清屏、倒入图片
    3.特殊功能:保存画笔轨迹帧动画、帧动画导入导出、ReDo和UnDo

    GitHub地址:JustWeTools 

    如何使用该控件可以在GitHub的README中找到,此处不再赘述。

    原理分析:

    1.绘图控件继承于View,使用canvas做画板,在canvas上设置一个空白的Bitmap作为画布,以保存画下的轨迹。

            mPaint = new Paint();
            mEraserPaint = new Paint();
            Init_Paint(UserInfo.PaintColor,UserInfo.PaintWidth);
            Init_Eraser(UserInfo.EraserWidth);
            WindowManager manager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
            width = manager.getDefaultDisplay().getWidth();
            height = manager.getDefaultDisplay().getHeight();
            mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mBitmap);
            mPath = new Path();
            mBitmapPaint = new Paint(Paint.DITHER_FLAG);

    mPaint作为画笔, mEraserPaint 作为橡皮,使用两个在onDraw的刷新的时候就会容易一点,接着获取了屏幕的宽和高,使之为Bitmap的宽和高。 新建canvas,路径Path,

    和往bitmap上画的画笔mBitmapPaint。

    2.橡皮和铅笔的配置:

     1     // init paint
     2     private void Init_Paint(int color ,int width){
     3         mPaint.setAntiAlias(true);
     4         mPaint.setDither(true);
     5         mPaint.setColor(color);
     6         mPaint.setStyle(Paint.Style.STROKE);
     7         mPaint.setStrokeJoin(Paint.Join.ROUND);
     8         mPaint.setStrokeCap(Paint.Cap.ROUND);
     9         mPaint.setStrokeWidth(width);
    10     }
    11 
    12 
    13     // init eraser
    14     private void Init_Eraser(int width){
    15         mEraserPaint.setAntiAlias(true);
    16         mEraserPaint.setDither(true);
    17         mEraserPaint.setColor(0xFF000000);
    18         mEraserPaint.setStrokeWidth(width);
    19         mEraserPaint.setStyle(Paint.Style.STROKE);
    20         mEraserPaint.setStrokeJoin(Paint.Join.ROUND);
    21         mEraserPaint.setStrokeCap(Paint.Cap.SQUARE);
    22         // The most important
    23         mEraserPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
    24     }

    铅笔的属性不用说,查看一下源码就知道了,橡皮的颜色随便设置应该都可以, 重点在最后一句。

            mEraserPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));

     意思是设定了层叠的方式,当橡皮擦上去的时候,即新加上的一层(橡皮)和原有层有重叠部分时,取原有层去掉重叠部分的剩余部分,这也就达到了橡皮的功能。

    3.PaintView重点在于对于按下、移动、抬起的监听:

     1     private void Touch_Down(float x, float y) {
     2         mPath.reset();
     3         mPath.moveTo(x, y);
     4         mX = x;
     5         mY = y;
     6          if(IsRecordPath) {
     7              listener.AddNodeToPath(x, y, MotionEvent.ACTION_DOWN, IsPaint);
     8          }
     9     }
    10 
    11 
    12     private void Touch_Move(float x, float y) {
    13         float dx = Math.abs(x - mX);
    14         float dy = Math.abs(y - mY);
    15         if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) {
    16             mPath.quadTo(mX, mY, (x + mX) / 2, (y + mY) / 2);
    17             mX = x;
    18             mY = y;
    19             if(IsRecordPath) {
    20                 listener.AddNodeToPath(x, y, MotionEvent.ACTION_MOVE, IsPaint);
    21             }
    22         }
    23     }
    24     private void Touch_Up(Paint paint){
    25         mPath.lineTo(mX, mY);
    26         mCanvas.drawPath(mPath, paint);
    27         mPath.reset();
    28         if(IsRecordPath) {
    29             listener.AddNodeToPath(mX, mY, MotionEvent.ACTION_UP, IsPaint);
    30         }
    31     }
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            float x = event.getX();
            float y = event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    Touch_Down(x, y);
                    invalidate();
                    break;
    
                case MotionEvent.ACTION_MOVE:
                    Touch_Move(x, y);
                    invalidate();
                    break;
    
                case MotionEvent.ACTION_UP:
                    if(IsPaint){
                        Touch_Up(mPaint);
                    }else {
                        Touch_Up(mEraserPaint);
                    }
                    invalidate();
                    break;
            }
            return true;
        }

    Down的时候移动点过去,Move的时候利用塞贝尔曲线将至连成一条线,Up的时候降至画在mCanvas上,并将path重置,并且每一次操作完都调用invalidate();以实现刷新。

    另外clean方法:

     1     public void clean() {
     2         mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
     3         mCanvas.setBitmap(mBitmap);
     4         try {
     5             Message msg = new Message();
     6             msg.obj = PaintView.this;
     7             msg.what = INDIVIDE;
     8             handler.sendMessage(msg);
     9             Thread.sleep(0);
    10         } catch (InterruptedException e) {
    11             // TODO Auto-generated catch block
    12             e.printStackTrace();
    13         }
    14     }
    15     private Handler handler=new Handler(){
    16 
    17         @Override
    18         public void handleMessage(Message msg) {
    19             switch (msg.what){
    20                 case INDIVIDE:
    21                     ((View) msg.obj).invalidate();
    22                     break;
    23                 case CHOOSEPATH:
    24                     JsonToPathNode(msg.obj.toString());
    25                     break;
    26             }
    27             super.handleMessage(msg);
    28         }
    29         
    30     };

    clean方法就是重设Bitmap并且刷新界面,达到清空的效果。

    还有一些set的方法:

     1     public void setColor(int color) {
     2         showCustomToast("已选择颜色" + colorToHexString(color));
     3         mPaint.setColor(color);
     4     }
     5 
     6 
     7     public void setPenWidth(int width) {
     8         showCustomToast("设定笔粗为:" + width);
     9         mPaint.setStrokeWidth(width);
    10     }
    11 
    12     public void setIsPaint(boolean isPaint) {
    13         IsPaint = isPaint;
    14     }
    15 
    16     public void setOnPathListener(OnPathListener listener) {
    17         this.listener = listener;
    18     }
    19 
    20     public void setmEraserPaint(int width){
    21         showCustomToast("设定橡皮粗为:"+width);
    22         mEraserPaint.setStrokeWidth(width);
    23     }
    24 
    25     public void setIsRecordPath(boolean isRecordPath,PathNode pathNode) {
    26         this.pathNode = pathNode;
    27         IsRecordPath = isRecordPath;
    28     }
    29 
    30     public void setIsRecordPath(boolean isRecordPath) {
    31         IsRecordPath = isRecordPath;
    32     }
    33     public boolean isShowing() {
    34         return IsShowing;
    35     }
    36 
    37 
    38     private static String colorToHexString(int color) {
    39         return String.format("#%06X", 0xFFFFFFFF & color);
    40     }
    41 
    42     // switch eraser/paint
    43     public void Eraser(){
    44         showCustomToast("切换为橡皮");
    45         IsPaint = false;
    46         Init_Eraser(UserInfo.EraserWidth);
    47     }
    48 
    49     public void Paint(){
    50         showCustomToast("切换为铅笔");
    51         IsPaint = true;
    52         Init_Paint(UserInfo.PaintColor, UserInfo.PaintWidth);
    53     }
    54 
    55     public Paint getmEraserPaint() {
    56         return mEraserPaint;
    57     }
    58 
    59     public Paint getmPaint() {
    60         return mPaint;
    61     }

    这些都不是很主要的东西。

    4.设定图片:

     1     /**
     2      *  @author lfk_dsk@hotmail.com
     3      *  @param uri get the uri of a picture
     4      * */
     5     public void setmBitmap(Uri uri){
     6         Log.e("图片路径", String.valueOf(uri));
     7         ContentResolver cr = context.getContentResolver();
     8         try {
     9             mBitmapBackGround = BitmapFactory.decodeStream(cr.openInputStream(uri));
    10 //            RectF rectF = new RectF(0,0,width,height);
    11             mCanvas.drawBitmap(mBitmapBackGround, 0, 0, mBitmapPaint);
    12         } catch (FileNotFoundException e) {
    13             e.printStackTrace();
    14         }
    15         invalidate();
    16     }
    17 
    18     /**
    19      *  @author lfk_dsk@hotmail.com
    20      *  @param file Pictures' file
    21      * */
    22     public void BitmapToPicture(File file){
    23         FileOutputStream fileOutputStream = null;
    24         try {
    25             SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss");
    26             Date now = new Date();
    27             File tempfile = new File(file+"/"+formatter.format(now)+".jpg");
    28             fileOutputStream = new FileOutputStream(tempfile);
    29             mBitmap.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream);
    30             showCustomToast(tempfile.getName() + "已保存");
    31         } catch (FileNotFoundException e) {
    32             e.printStackTrace();
    33         }
    34     }

    加入图片和将之保存为图片。

    5.重点:纪录帧动画

      其实说是帧动画,我其实是把每个onTouchEvent的动作的坐标、笔的颜色、等等记录了下来,再清空了在子线程重绘以实现第一幅效果图里点击一键重绘的效果,

    但从原理上说仍可归于逐帧动画。

      首先设置一个Linstener监听存储。

    package com.lfk.drawapictiure;
    
    /**
     * Created by liufengkai on 15/8/26.
     */
    public interface OnPathListener {
    
        void AddNodeToPath(float x, float y ,int event,boolean Ispaint);
    
    }

      再在监听里进行存储:

     1         paintView.setOnPathListener(new OnPathListener() {
     2             @Override
     3             public void AddNodeToPath(float x, float y, int event, boolean IsPaint) {
     4                 PathNode.Node tempnode = pathNode.new Node();
     5                 tempnode.x = x;
     6                 tempnode.y = y;
     7                 if (IsPaint) {
     8                     tempnode.PenColor = UserInfo.PaintColor;
     9                     tempnode.PenWidth = UserInfo.PaintWidth;
    10                 } else {
    11                     tempnode.EraserWidth = UserInfo.EraserWidth;
    12                 }
    13                 tempnode.IsPaint = IsPaint;
    14                 Log.e(tempnode.PenColor + ":" + tempnode.PenWidth + ":" + tempnode.EraserWidth, tempnode.IsPaint + "");
    15                 tempnode.TouchEvent = event;
    16                 tempnode.time = System.currentTimeMillis();
    17                 pathNode.AddNode(tempnode);
    18             }
    19         });

      其中PathNode是一个application类,用于存储存下来的arraylist:

     1 package com.lfk.drawapictiure;
     2 import android.app.Application;
     3 
     4 import java.util.ArrayList;
     5 
     6 /**
     7  * Created by liufengkai on 15/8/25.
     8  */
     9 public class PathNode extends Application{
    10     public class Node{
    11         public Node() {}
    12         public float x;
    13         public float y;
    14         public int PenColor;
    15         public int TouchEvent;
    16         public int PenWidth;
    17         public boolean IsPaint;
    18         public long time;
    19         public int EraserWidth;
    20 
    21     }
    22     private ArrayList<Node> PathList;
    23 
    24 
    25     public ArrayList<Node> getPathList() {
    26         return PathList;
    27     }
    28 
    29     public void AddNode(Node node){
    30         PathList.add(node);
    31     }
    32 
    33     public Node NewAnode(){
    34         return new Node();
    35     }
    36 
    37 
    38     public void ClearList(){
    39         PathList.clear();
    40     }
    41 
    42     @Override
    43     public void onCreate() {
    44         super.onCreate();
    45         PathList = new ArrayList<Node>();
    46     }
    47 
    48     public void setPathList(ArrayList<Node> pathList) {
    49         PathList = pathList;
    50     }
    51 
    52     public Node getTheLastNote(){
    53         return PathList.get(PathList.size()-1);
    54     }
    55 
    56     public void deleteTheLastNote(){
    57         PathList.remove(PathList.size()-1);
    58     }
    59 
    60     public PathNode() {
    61         PathList = new ArrayList<Node>();
    62     }
    63 
    64 }

    存入之后,再放到子线程里面逐帧的载入播放:

     1 class PreviewThread implements Runnable{
     2         private long time;
     3         private ArrayList<PathNode.Node> nodes;
     4         private View view;
     5         public PreviewThread(View view, ArrayList<PathNode.Node> arrayList) {
     6             this.view = view;
     7             this.nodes = arrayList;
     8         }
     9         public void run() {
    10             time = 0;
    11             IsShowing = true;
    12             clean();
    13             for(int i = 0 ;i < nodes.size();i++) {
    14                 PathNode.Node node=nodes.get(i);
    15                 Log.e(node.PenColor+":"+node.PenWidth+":"+node.EraserWidth,node.IsPaint+"");
    16                 float x = node.x;
    17                 float y = node.y;
    18                 if(i<nodes.size()-1) {
    19                     time=nodes.get(i+1).time-node.time;
    20                 }
    21                 IsPaint = node.IsPaint;
    22                 if(node.IsPaint){
    23                     UserInfo.PaintColor = node.PenColor;
    24                     UserInfo.PaintWidth = node.PenWidth;
    25                     Init_Paint(node.PenColor,node.PenWidth);
    26                 }else {
    27                     UserInfo.EraserWidth = node.EraserWidth;
    28                     Init_Eraser(node.EraserWidth);
    29                 }
    30                 switch (node.TouchEvent) {
    31                     case MotionEvent.ACTION_DOWN:
    32                         Touch_Down(x,y);
    33                         break;
    34                     case MotionEvent.ACTION_MOVE:
    35                         Touch_Move(x,y);
    36                         break;
    37                     case MotionEvent.ACTION_UP:
    38                         if(node.IsPaint){
    39                             Touch_Up(mPaint);
    40                         }else {
    41                             Touch_Up(mEraserPaint);
    42                         }
    43                         break;
    44                 }
    45                     Message msg=new Message();
    46                     msg.obj = view;
    47                     msg.what = INDIVIDE;
    48                     handler.sendMessage(msg);
    49                 if(!ReDoOrUnDoFlag) {
    50                     try {
    51                         Thread.sleep(time);
    52                     } catch (InterruptedException e) {
    53                         e.printStackTrace();
    54                     }
    55                 }
    56             }
    57             ReDoOrUnDoFlag = false;
    58             IsShowing = false;
    59             IsRecordPath = true;
    60         }
    61     }
    1     public void preview(ArrayList<PathNode.Node> arrayList) {
    2         IsRecordPath = false;
    3         PreviewThread previewThread = new PreviewThread(this, arrayList);
    4         Thread thread = new Thread(previewThread);
    5         thread.start();
    6     }

    这是播放的帧动画,接下来说保存帧动画,我将之输出成json并输出到文件中去。

     1     public void PathNodeToJson(PathNode pathNode,File file){
     2         ArrayList<PathNode.Node> arrayList = pathNode.getPathList();
     3         String json = "[";
     4         for(int i = 0;i < arrayList.size();i++){
     5             PathNode.Node node = arrayList.get(i);
     6             json += "{"+"""+"x"+"""+":"+px2dip(node.x)+"," +
     7                     """+"y"+"""+":"+px2dip(node.y)+","+
     8                     """+"PenColor"+"""+":"+node.PenColor+","+
     9                     """+"PenWidth"+"""+":"+node.PenWidth+","+
    10                     """+"EraserWidth"+"""+":"+node.EraserWidth+","+
    11                     """+"TouchEvent"+"""+":"+node.TouchEvent+","+
    12                     """+"IsPaint"+"""+":"+"""+node.IsPaint+"""+","+
    13                     """+"time"+"""+":"+node.time+
    14                     "},";
    15         }
    16         json = json.substring(0,json.length()-1);
    17         json += "]";
    18         try {
    19             json = enCrypto(json, "lfk_dsk@hotmail.com");
    20         } catch (InvalidKeySpecException e) {
    21             e.printStackTrace();
    22         } catch (InvalidKeyException e) {
    23             e.printStackTrace();
    24         } catch (NoSuchPaddingException e) {
    25             e.printStackTrace();
    26         } catch (IllegalBlockSizeException e) {
    27             e.printStackTrace();
    28         } catch (BadPaddingException e) {
    29             e.printStackTrace();
    30         }
    31         SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss");
    32         Date now = new Date();
    33         File tempfile = new File(file+"/"+formatter.format(now)+".lfk");
    34         try {
    35             FileOutputStream fileOutputStream = new FileOutputStream(tempfile);
    36             byte[] bytes = json.getBytes();
    37             fileOutputStream.write(bytes);
    38             fileOutputStream.close();
    39             showCustomToast(tempfile.getName() + "已保存");
    40         } catch (FileNotFoundException e) {
    41             e.printStackTrace();
    42         } catch (IOException e) {
    43             e.printStackTrace();
    44         }
    45     

    另外还可将文件从json中提取出来:

     1     private void JsonToPathNode(String file){
     2         String res = "";
     3         ArrayList<PathNode.Node> arrayList = new ArrayList<>();
     4         try {
     5             Log.e("绝对路径1",file);
     6             FileInputStream in = new FileInputStream(file);
     7             ByteArrayOutputStream bufferOut = new ByteArrayOutputStream();
     8             byte[] buffer = new byte[1024];
     9             for(int i = in.read(buffer, 0, buffer.length); i > 0 ; i = in.read(buffer, 0, buffer.length)) {
    10                 bufferOut.write(buffer, 0, i);
    11             }
    12             res = new String(bufferOut.toByteArray(), Charset.forName("utf-8"));
    13             Log.e("字符串文件",res);
    14         } catch (FileNotFoundException e) {
    15             e.printStackTrace();
    16         } catch (IOException e) {
    17             e.printStackTrace();
    18         }
    19         try {
    20             res = deCrypto(res, "lfk_dsk@hotmail.com");
    21         } catch (InvalidKeyException e) {
    22             e.printStackTrace();
    23         } catch (InvalidKeySpecException e) {
    24             e.printStackTrace();
    25         } catch (NoSuchPaddingException e) {
    26             e.printStackTrace();
    27         } catch (IllegalBlockSizeException e) {
    28             e.printStackTrace();
    29         } catch (BadPaddingException e) {
    30             e.printStackTrace();
    31         }
    32         try {
    33             JSONArray jsonArray = new JSONArray(res);
    34             for(int i = 0;i < jsonArray.length();i++){
    35                 JSONObject jsonObject = new JSONObject(jsonArray.getString(i));
    36                 PathNode.Node node = new PathNode().NewAnode();
    37                 node.x = dip2px(jsonObject.getInt("x"));
    38                 node.y = dip2px(jsonObject.getInt("y"));
    39                 node.TouchEvent = jsonObject.getInt("TouchEvent");
    40                 node.PenWidth = jsonObject.getInt("PenWidth");
    41                 node.PenColor = jsonObject.getInt("PenColor");
    42                 node.EraserWidth = jsonObject.getInt("EraserWidth");
    43                 node.IsPaint = jsonObject.getBoolean("IsPaint");
    44                 node.time = jsonObject.getLong("time");
    45                 arrayList.add(node);
    46             }
    47         } catch (JSONException e) {
    48             e.printStackTrace();
    49         }
    50         pathNode.setPathList(arrayList);
    51     }

    另外如果不想让别人看出输出的是json的话可以使用des加密算法:

     1         /**
     2          * 加密(使用DES算法)
     3          *
     4          * @param txt
     5          *            需要加密的文本
     6          * @param key
     7          *            密钥
     8          * @return 成功加密的文本
     9          * @throws InvalidKeySpecException
    10          * @throws InvalidKeyException
    11          * @throws NoSuchPaddingException
    12          * @throws IllegalBlockSizeException
    13          * @throws BadPaddingException
    14          */
    15     private static String enCrypto(String txt, String key)
    16                 throws InvalidKeySpecException, InvalidKeyException,
    17                 NoSuchPaddingException, IllegalBlockSizeException,
    18                 BadPaddingException {
    19         StringBuffer sb = new StringBuffer();
    20         DESKeySpec desKeySpec = new DESKeySpec(key.getBytes());
    21         SecretKeyFactory skeyFactory = null;
    22         Cipher cipher = null;
    23         try {
    24             skeyFactory = SecretKeyFactory.getInstance("DES");
    25             cipher = Cipher.getInstance("DES");
    26         } catch (NoSuchAlgorithmException e) {
    27             e.printStackTrace();
    28         }
    29         SecretKey deskey = skeyFactory != null ? skeyFactory.generateSecret(desKeySpec) : null;
    30         if (cipher != null) {
    31             cipher.init(Cipher.ENCRYPT_MODE, deskey);
    32         }
    33         byte[] cipherText = cipher != null ? cipher.doFinal(txt.getBytes()) : new byte[0];
    34         for (int n = 0; n < cipherText.length; n++) {
    35             String stmp = (java.lang.Integer.toHexString(cipherText[n] & 0XFF));
    36 
    37             if (stmp.length() == 1) {
    38                 sb.append("0" + stmp);
    39             } else {
    40                 sb.append(stmp);
    41             }
    42         }
    43         return sb.toString().toUpperCase();
    44     }
    45 
    46         /**
    47          * 解密(使用DES算法)
    48          *
    49          * @param txt
    50          *            需要解密的文本
    51          * @param key
    52          *            密钥
    53          * @return 成功解密的文本
    54          * @throws InvalidKeyException
    55          * @throws InvalidKeySpecException
    56          * @throws NoSuchPaddingException
    57          * @throws IllegalBlockSizeException
    58          * @throws BadPaddingException
    59          */
    60     private static String deCrypto(String txt, String key)
    61                 throws InvalidKeyException, InvalidKeySpecException,
    62                 NoSuchPaddingException, IllegalBlockSizeException,
    63                 BadPaddingException {
    64         DESKeySpec desKeySpec = new DESKeySpec(key.getBytes());
    65         SecretKeyFactory skeyFactory = null;
    66         Cipher cipher = null;
    67         try {
    68             skeyFactory = SecretKeyFactory.getInstance("DES");
    69             cipher = Cipher.getInstance("DES");
    70         } catch (NoSuchAlgorithmException e) {
    71             e.printStackTrace();
    72         }
    73         SecretKey deskey = skeyFactory != null ? skeyFactory.generateSecret(desKeySpec) : null;
    74         if (cipher != null) {
    75             cipher.init(Cipher.DECRYPT_MODE, deskey);
    76         }
    77         byte[] btxts = new byte[txt.length() / 2];
    78         for (int i = 0, count = txt.length(); i < count; i += 2) {
    79             btxts[i / 2] = (byte) Integer.parseInt(txt.substring(i, i + 2), 16);
    80         }
    81         return (new String(cipher.doFinal(btxts)));
    82     }

    6.Redo 和 Undo:

    绘图时撤销和前进的功能也是十分有用的。

        public void ReDoORUndo(boolean flag){
            if(!IsShowing) {
                ReDoOrUnDoFlag = true;
                try {
                    if (flag) {
                        ReDoNodes.add(pathNode.getTheLastNote());
                        pathNode.deleteTheLastNote();
                        preview(pathNode.getPathList());
                    } else {
                        pathNode.AddNode(ReDoNodes.get(ReDoNodes.size() - 1));
                        ReDoNodes.remove(ReDoNodes.size() - 1);
                        preview(pathNode.getPathList());
                    }
    
                } catch (ArrayIndexOutOfBoundsException e) {
                    e.printStackTrace();
                    showCustomToast("无法操作=-=");
                }
            }
        }

    其实就是把PathNode的尾节点转移到一个新的链表中,根据需要再处理,然后调用重绘,区别是中间不加sleep的线程休眠,这样看上去不会有重绘的过程,只会一闪就少了一节。

    把它绑定在音量键上就能轻松使用两个音量键来调节Redo OR Undo。

      博客地址:博客园,版权所有,转载须联系作者。

      GitHub地址:JustWeTools 

      如果觉得对您有帮助请点赞。

  • 相关阅读:
    Pytest框架之命令行参数2
    Pytest框架之命令行参数1
    [编程题] 二维数组中的查找
    [编程题]二叉树镜像
    补充基础:栈与队列模型
    6641. 【GDOI20205.20模拟】Sequence
    瞎讲:任意模数MTT
    瞎讲:FFT三次变二次优化
    小米oj 重拍数组求最大和
    小米oj 有多少个公差为2的等差数列
  • 原文地址:https://www.cnblogs.com/lfk-dsk/p/4768850.html
Copyright © 2020-2023  润新知