iis服务器助手广告广告
返回顶部
首页 > 资讯 > 移动开发 >SurfaceView开发[捉小猪]手机游戏 (一)
  • 222
分享到

SurfaceView开发[捉小猪]手机游戏 (一)

2024-04-02 19:04:59 222人浏览 独家记忆
摘要

先上效果图:   哈哈, 说下实现思路: 我们可以把每一个树桩, 小猪, 车厢都看成是一个Drawable, 这个Drawable里面保存了x, y坐标, 我们的Surf

先上效果图:

这里写图片描述这里写图片描述 

哈哈, 说下实现思路:
我们可以把每一个树桩, 小猪, 车厢都看成是一个Drawable, 这个Drawable里面保存了x, y坐标, 我们的SurfaceView在draw的时候, 就把这些Drawable draw出来.

那可能有的小伙伴就会问了:
1. 那小猪是怎么让它跑起来, 并且腿部还不断地在动呢?
2. 还有小猪是怎么找到出路的呢?

刚刚我们讲过小猪是Drawable, 其实我们自定义的这个Drawable就是一个帧动画, 它里面有一个Bitmap数组, 一个currentIndex(这个用来记录当前帧), 我们在子线程里面不断更新这个currentIndex, 当Drawable被调用draw的时候, 就根据currentIndex来从Bitmap数组里面取对应的bitmap出来. 刚刚还讲过Drawable里面保存了当前x, y坐标, 我们的路径动画在播放的时候, 就不断的更新里面的坐标, 另外, SurfaceView那边也不断的调用这些Drawable的draw方法, 把他们画出来, 这样小猪就可以边移动, 边播放奔跑的动画了, 哈哈.

小猪找出路的话, 我们先看看这个:

这里写图片描述 

哈哈哈, 这样思路是不是清晰了好多.
其实我们的SurfaceView里面有一个Rect二维数组, 用来存放这些矩形, 小猪离开手指之后, 就开始从小猪当前所在的矩形,
用广度优先遍历, 找到一条最短的路径(比如: [5,5 5,4 5,3 5,2 5,1 5,0]这样的), 然后再根据这条路径在Rect数组中找到对应的矩形, 最后根据这些对应的矩形的坐标来确定出Path.
哈哈, 有了Path小猪就可以跑了.

下面我们先来看看那个自定义的Drawable怎么写 (下面的那个ThreadPool类就是我们自己封装的一个单例的线程池):



public class MyDrawable extends Drawable implements Cloneable {

    private final int mDelay;//帧延时
    private final byte[] mLock;//控制线程暂停的
    private Semaphore mSemaphore;//来用控制线程更新问题
    private Bitmap[] mBitmaps;//帧
    private Paint mPaint;
    private int mCurrentIndex;//当前帧索引
    private float x, y;//当前坐标
    private Future mTask;//帧动画播放的任务
    private volatile boolean isPaused;//已暂停

    public MyDrawable(int delay, Bitmap... bitmaps) {
        mSemaphore = new Semaphore(1);
        mBitmaps = bitmaps;
        mDelay = delay;
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mLock = new byte[0];
    }

    public void start() {
        stop();
        mTask = ThreadPool.getInstance().execute(() -> {
            while (true) {
                synchronized (mLock) {
                    while (isPaused) {
                        try {
                            mLock.wait();
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                }
                try {
                    Thread.sleep(mDelay);
                } catch (InterruptedException e) {
                    return;
                }
                try {
                    mSemaphore.acquire();
                } catch (InterruptedException e) {
                    return;
                }
                mCurrentIndex++;
                if (mCurrentIndex == mBitmaps.length) {
                    mCurrentIndex = 0;
                }
                mSemaphore.release();
            }
        });
    }

    void pause() {
        isPaused = true;
    }

    void resume() {
        isPaused = false;
        synchronized (mLock) {
            mLock.notifyAll();
        }
    }

    private void stop() {
        if (mTask != null) {
            mTask.cancel(true);
            mTask = null;
            mCurrentIndex = 0;
        }
    }

    @Override
    public void draw(@NonNull canvas canvas) {
        try {
            mSemaphore.acquire();
        } catch (InterruptedException e) {
            return;
        }
        canvas.drawBitmap(mBitmaps[mCurrentIndex], x, y, mPaint);
        mSemaphore.release();
    }

    public void release() {
        stop();
        if (mBitmaps != null) {
            for (Bitmap bitmap : mBitmaps) {
                if (bitmap != null && !bitmap.isRecycled()) {
                    bitmap.recycle();
                }
            }
        }
        mBitmaps = null;
        mPaint = null;
        mTask = null;
    }

    public float getX() {
        return x;
    }

    public void setX(float x) {
        this.x = x;
    }

    public float getY() {
        return y;
    }

    public void setY(float y) {
        this.y = y;
    }

    public Bitmap getBitmap() {
        Bitmap result = null;
        if (mBitmaps != null && mBitmaps.length > 0) {
            result = mBitmaps[0];
        }
        return result;
    }

    @Override
    public int getIntrinsicWidth() {
        if (mBitmaps.length == 0) {
            return 0;
        }
        return mBitmaps[0].getWidth();
    }

    @Override
    public int getIntrinsicHeight() {
        if (mBitmaps.length == 0) {
            return 0;
        }
        return mBitmaps[0].getHeight();
    }

    @Override
    public void setAlpha(int alpha) {
        mPaint.setAlpha(alpha);
    }

    @Override
    public void setColorFilter(ColorFilter cf) {
        mPaint.setColorFilter(cf);
    }

    @Override
    public int getOpacity() {
        return PixelFORMat.TRANSLUCENT;
    }

    @SuppressWarnings("MethodDoesntCallSuperMethod")
    public MyDrawable clone() {
        return new MyDrawable(0, mBitmaps[0]);
    }
}

start方法大概就是开启一个子线程, 每次指定延时过后就更新currentIndex, currentIndex超出范围就置0, 这样就可以一直循环播放下去了, 哈哈.
mSemaphore是当执行draw的时候, 用来锁定currentIndex不让更新的.

好了, 现在有了Drawable, 我们再来看看Path是怎么播放的:
我们可以先获取到Path上面的点, 有了这些点接下来就非常简单了.
获取Path上面的点坐标的方法大家应该也很熟悉了吧, 代码就不贴出来了,5.0及以上系统用Path的approximate方法, 5.0系统以下的用PathMeasure类. 具体代码在SDK里面也可以找到.
播放Path的话, 我们可以自定义一个PathAnimation:
其实我们自定义的这个PathAnimation播放Path的逻辑也非常简单:当start方法执行的时候,记录一下开始时间,然后一个while循环,条件就是: 当前时间 - 开始时间 < 动画时长, 然后根据当前动画已经播放的时长和总动画时长计算出当前动画的播放进度, 然后我们就可以用这个progress来获取Path上对应的点,看看完整的代码:


public class PathAnimation {

    private Keyframes mPathKeyframes;//关键帧
    private long mAnimationDuration;//动画时长
    private OnAnimationUpdateListener mOnAnimationUpdateListener;//动画更新监听
    private AnimationListener mAnimationListener;//动画事件监听
    private volatile boolean isAnimationRepeat, //反复播放的动画
            isAnimationStopped,//已停止
            isAnimationCanceled, //已取消 (停止和取消的区别: 取消是在动画播放完之前主动取消的,  停止是动画播放完,自动停止的)
            isAnimationEndListenerCalled;//动画已取消的监听已经回调

    PathAnimation(MyPath path) {
        updatePath(path);
    }

    public PathAnimation setDuration(long duration) {
        mAnimationDuration = duration;
        return this;
    }

    void updatePath(MyPath path) {
        //根据系统版本选择更合适的关键帧类
        mPathKeyframes = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? new PathKeyframes(path) : new PathKeyframesSupport(path);
    }

    OnAnimationUpdateListener getUpdateListener() {
        return mOnAnimationUpdateListener;
    }

    void setUpdateListener(OnAnimationUpdateListener listener) {
        mOnAnimationUpdateListener = listener;
    }

    
    public PathAnimation setRepeat(boolean isAnimationRepeat) {
        this.isAnimationRepeat = isAnimationRepeat;
        return this;
    }

    boolean isAnimationRepeat() {
        return isAnimationRepeat;
    }

    AnimationListener getAnimationListener() {
        return mAnimationListener;
    }

    void setAnimationListener(AnimationListener listener) {
        mAnimationListener = listener;
    }

    void start() {
        if (mAnimationDuration > 0) {
            ThreadPool.getInstance().execute(() -> {
                isAnimationStopped = false;
                isAnimationCanceled = false;
                isAnimationEndListenerCalled = false;
                final long startTime = SystemClock.uptimeMillis();
                long currentPlayedDuration;//当前动画已经播放的时长
                if (mAnimationListener != null) {
                    mAnimationListener.onAnimationStart();
                }
                while ((currentPlayedDuration = SystemClock.uptimeMillis() - startTime) < mAnimationDuration) {
                    //如果动画被打断则跳出循环
                    if (isAnimationInterrupted()) {
                        break;
                    }
                    //根据当前动画已经播放的时长和总动画时长计算出当前动画的播放进度
                    float progress = (float) currentPlayedDuration / (float) mAnimationDuration;
                    if (mOnAnimationUpdateListener != null) {
                        if (!isAnimationInterrupted()) {
                            mOnAnimationUpdateListener.onUpdate(progress, mPathKeyframes.getValue(progress));
                        }
                    }
                }
                if (isAnimationRepeat && !isAnimationInterrupted()) {
                    //如果是设置了重复并且还没有被取消,则重复播放动画
                    mPathKeyframes.reverse();
                    if (mAnimationListener != null) {
                        mAnimationListener.onAnimationRepeat();
                    }
                    start();
                } else {
                    isAnimationStopped = true;
                    if (mAnimationListener != null) {
                        //判断应该回调哪一个接口
                        if (isAnimationCanceled) {
                            mAnimationListener.onAnimationCanceled();
                        } else {
                            mAnimationListener.onAnimationEnd();
                        }
                    }
                    //标记接口已回调
                    isAnimationEndListenerCalled = true;
                }
            });
        }
    }

    
    private void waitStopped() {
        isAnimationStopped = true;
        //noinspection StatementWithEmptyBody
        while (!isAnimationEndListenerCalled) {
        }
    }

    
    private void waitCancel(){
        isAnimationCanceled = true;
        //noinspection StatementWithEmptyBody
        while (!isAnimationEndListenerCalled) {
        }
    }

    void stop() {
        waitStopped();
    }

    void cancel() {
        waitCancel();
    }

    
    private boolean isAnimationInterrupted() {
        return isAnimationCanceled || isAnimationStopped;
    }

    public interface OnAnimationUpdateListener {
        void onUpdate(float currentProgress, PointF position);
    }

    public interface AnimationListener {
        void onAnimationStart();//动画开始

        void onAnimationEnd();//动画结束

        void onAnimationCanceled();//动画取消

        void onAnimationRepeat();//动画重复播放
    }
}

我们通过setUpdateListener方法来监听动画进度, OnAnimationUpdateListener接口的onUpdate方法参数还有一个PointF, 这个PointF就是根据动画当前进度从mPathKeyframes中获取到Path所对应的坐标点.

我们来写一个demo来看看这个PathAnimation的效果:
一
哈哈, 可以了, 是我们想要的效果.

现在动画什么的都准备好了,就差怎么把出路变成Path了,我们先来看看怎么找出路:
上面说到,屏幕上都铺满了矩形,我们可以再创建一个int类型的二维数组,用来保存这些矩形的状态(空闲:0,小猪占用:1,树桩占用:2)
我们把这个数组打印出来是这样的:


    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   0   0   0   0   0

我们再看看这个数组:(空闲:0,小猪占用:1,树桩占用:2)


    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   2   2   0   0   0
      0   0   0   2   1   2   0   0   0
    0   0   0   2   0   2   0   0   0
      2   0   2   0   2   0   0   0   0
    2   0   0   0   0   0   0   0   0
      0   2   2   2   2   0   0   0   0
    0   2   0   0   0   0   0   0   0

这时候就要用到一个 “广度优先遍历” ,思路就是 (逻辑有点复杂,一次看不懂看多几次就明白了):


    先传入这个状态数组和当前小猪的坐标;
    创建一个List<List<Point>>,存放出路,名字就叫做footprints吧;
    创建一个队列,这个队列存放待查找的点;
    当前小猪的坐标先放进footprints;
    当前小猪的坐标入队;
    标记小猪坐标已经走过;
    进入循环 (循环条件就是队列不为空){
        创建一个临时的footprints;(因为最多可能有6个新的路径)
        队头出队;
        寻找周围6个方向(上,下,左,右,左上,右上,左下,右下)可以到达的位置 (不包括越界的、标记过的、不是空闲的);
        遍历这个可到达位置的数组{
            遍历footprints{
                检查队头的坐标是否跟footprints的元素(List<Point>)的最后一个元素(Point)的位置(x,y)是一样的(即可以链接)
                (比如: 现在footprints是[[(5,5), (5,4)], [(6,5), (6,4)]],队头的坐标是(5,4), 那可达位置的数组就可能是[(5,3), (5,5), (4,4), (4,5), (6,4), (6,5)]){
                    则创建一个新的List<Point>;
                    add footprints的元素(比如: [(5,5), (5,4)]);
                    再add可达位置的坐标(比如: (5,3);
                    临时的footprints add 这个新的List (那临时的footprints现在就是 [[(5,5), (5,4), (5,3)]]了);
                }                 
            }
            检查本次可达位置的坐标是否已经是在边界 (已经找到出路){
                footprints add 临时的footprints的元素;
                遍历footprints{
                    判断footprints的元素的最后一位是否边界位置{
                        return 这个footprints的元素; (必然是最短的路径);
                    }
                }
            }
            队列入队本次可达位置的坐标;
        }
        (本次没有找到出路) footprints addAll 临时的footprints的元素,准备下一轮循环;
    }
    执行到了这里, 即表示没有出路, 如果footprints里面是空的话,我们直接返回null,如果不为空,就返回footprints最后一个元素,即能走的最长的一条路径;

好了,我们看看代码是怎么写的 (WayData等同于Point, 里面也保存有x, y坐标点):


    public static List<WayData> findWay(int[][] items, WayData currentPos) {
        //获取数组的尺寸
        int verticalCount = items.length;
        int horizontalCount = items[0].length;
        //创建队列
        Queue<WayData> way = new ArrayDeque<>();
        //出路
        List<List<WayData>> footprints = new ArrayList<>();
        //复制一个新的数组 (因为要标记状态)
        int[][] pattern = new int[verticalCount][horizontalCount];
        for (int vertical = 0; vertical < verticalCount; vertical++) {
            System.arraycopy(items[vertical], 0, pattern[vertical], 0, horizontalCount);
        }
        //当前坐标入队
        way.offer(currentPos);
        //添加进集合
        List<WayData> temp = new ArrayList<>();
        temp.add(currentPos);
        footprints.add(temp);
        //标记状态 (已走过)
        pattern[currentPos.y][currentPos.x] = STATE_WALKED;

        while (!way.isEmpty()) {
            //队头出队
            WayData header = way.poll();
            //以header为中心,获取周围可以到达的点(即未被占用,未标记过的)(这个方法在获取到可到达的点时,会标记这个点为: 已走过)
            List<WayData> directions = getCanArrivePos(pattern, header);
            //创建临时的footprints
            List<List<WayData>> footprintsTemp = new ArrayList<>();
            //遍历可到达的点
            for (int i = 0; i < directions.size(); i++) {
                WayData direction = directions.get(i);

                for (List<WayData> tmp : footprints) {
                    //检查是否可以链接
                    if (canLinks(header, tmp)) {
                        List<WayData> list = new ArrayList<>();
                        list.addAll(tmp);
                        list.add(direction);
                        footprintsTemp.add(list);
                    }
                }
                //检查是否已达到边界
                if (isEdge(verticalCount, horizontalCount, direction)) {
                    if (!footprintsTemp.isEmpty()) {
                        footprints.addAll(footprintsTemp);
                    }
                    //返回最短的出路
                    for (List<WayData> tmp : footprints) {
                        if (!tmp.isEmpty() && isEdge2(verticalCount, horizontalCount, tmp)) {
                            return tmp;
                        }
                    }
                }
                //本次未找到出路,入队这个可到达的点
                way.offer(direction);

            }
            //准备下一轮循环
            if (!footprintsTemp.isEmpty()) {
                footprints.addAll(footprintsTemp);
            }
        }
        //没有出路,返回能走的最长的一条路径;
        return footprints.isEmpty() ? null : footprints.get(footprints.size() - 1);
    }

getCanArrivePos方法:


    
    public static List<WayData> getCanArrivePos(int[][] items, WayData currentPos) {
        int verticalCount = items.length;
        int horizontalCount = items[0].length;
        List<WayData> result = new ArrayList<>();
        int offset = currentPos.y % 2 == 0 ? 0 : 1, offset2 = currentPos.y % 2 == 0 ? 1 : 0;
        for (int i = 0; i < 6; i++) {
            WayData tmp = getNextPosition(currentPos, offset, offset2, i);
            if ((tmp.x > -1 && tmp.x < horizontalCount) && (tmp.y > -1 && tmp.y < verticalCount)) {
                if (items[tmp.y][tmp.x] != Item.STATE_SELECTED && items[tmp.y][tmp.x] != Item.STATE_OCCUPIED && items[tmp.y][tmp.x] != STATE_WALKED) {
                    result.add(tmp);
                    items[tmp.y][tmp.x] = STATE_WALKED;
                }
            }
        }
        //打乱它,为了让方向顺序不一样, 即每次都不同
        Collections.shuffle(result);
        return result;
    } 

getNextPosition方法:


    
    private static WayData getNextPosition(WayData currentPos, int offset, int offset2, int direction) {
        WayData result = new WayData(currentPos.x, currentPos.y);
        switch (direction) {
            case 0:
                //左
                result.x -= 1;
                break;
            case 1:
                //左上
                result.x -= offset;
                result.y -= 1;
                break;
            case 2:
                //左下
                result.x -= offset;
                result.y += 1;
                break;
            case 3:
                //右
                result.x += 1;
                break;
            case 4:
                //右上
                result.x += offset2;
                result.y -= 1;
                break;
            case 5:
                //右下
                result.x += offset2;
                result.y += 1;
                break;
        }
        return result;
    }

我们执行findWay方法,就会得到这个结果:


    0   0   0   0   0   0   0   0   0
      0   0   0   0   0   0   0   0   0
    0   0   0   0   2   2   0   0   0
      0   0   0   2   1   2   0   0   0
    0   0   0   2   *   2   0   0   0
      2   0   2   *   2   0   0   0   0
    2   *   *   *   0   0   0   0   0
      *   2   2   2   2   0   0   0   0
    0   2   0   0   0   0   0   0   0

哈哈,是不是很好玩, 我们将这条出路的坐标,分别获取到对应的Rect,再根据这个Rect的坐标来连接成Path, 然后我们的小猪就可以跑啦.

本文到此结束,有错误的地方请指出,谢谢大家!

SurfaceView开发[捉小猪]手机游戏 (二)

完整代码地址: https://GitHub.com/wuyr/CatchPiggy

游戏主页: Https://wuyr.github.io/

到此这篇关于SurfaceView开发[捉小猪]手机游戏 (一)的文章就介绍到这了,更多相关SurfaceView游戏内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!

--结束END--

本文标题: SurfaceView开发[捉小猪]手机游戏 (一)

本文链接: https://www.lsjlt.com/news/133594.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

本篇文章演示代码以及资料文档资料下载

下载Word文档到电脑,方便收藏和打印~

下载Word文档
猜你喜欢
  • SurfaceView开发[捉小猪]手机游戏 (一)
    先上效果图:   哈哈, 说下实现思路: 我们可以把每一个树桩, 小猪, 车厢都看成是一个Drawable, 这个Drawable里面保存了x, y坐标, 我们的Surf...
    99+
    2022-11-12
  • SurfaceView开发[捉小猪]手机游戏 (二)
    我们在上一回(Android使用SurfaceView开发《捉小猪》小游戏 (一))搞懂了这个模式的基本实现思路,小猪如何找出最短的逃跑路线和如何播放路径动画. 还封装了我们自己的P...
    99+
    2022-11-12
  • 手把手教你用vue3开发一个打砖块小游戏
    前言 用vue3写了几个实例,感觉Vue3的composition Api设计得还是很不错,改变了一下习惯,但写多两个就好了。 这次写一个也是儿时很觉得很好玩的游戏-打砖块, 无聊...
    99+
    2022-11-12
  • 微信跳一跳小程序游戏如何开发
    本篇内容主要讲解“微信跳一跳小程序游戏如何开发”,感兴趣的朋友不妨来看看。本文介绍的方法操作简单快捷,实用性强。下面就让小编来带大家学习“微信跳一跳小程序游戏如何开发”吧!  小游戏只有以下两个必要文件:  game.js 小游戏主程序入口...
    99+
    2023-06-26
  • 如何用vue3开发一个打砖块小游戏
    今天小编给大家分享一下如何用vue3开发一个打砖块小游戏的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来...
    99+
    2022-10-19
  • C++ 实战开发一个猜单词的小游戏
    目录前言效果展示一、函数接口二、重要函数接口详解1.菜单内容2.退出程序3.打开单词文件4.开始游戏5.查看玩家排名6.清空玩家排名7.玩家排名全部代码展示前言 程序内的单词全部保存...
    99+
    2022-11-12
  • 怎么使用Vue开发一个五子棋小游戏
    这篇文章主要讲解了“怎么使用Vue开发一个五子棋小游戏”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“怎么使用Vue开发一个五子棋小游戏”吧!1.绘制游戏区域和游戏元素开始写代码之前,一定要记...
    99+
    2023-07-02
  • 探索阿里云手机游戏服务器为游戏开发提供无限可能
    随着科技的发展,手机游戏的市场规模正在不断扩大,吸引了越来越多的玩家。作为开发者,如何为用户提供高质量的游戏体验呢?阿里云手机游戏服务器正是为此提供了解决方案。本文将详细介绍阿里云手机游戏服务器,让你了解它的功能、优势以及使用方法。 阿里云...
    99+
    2023-12-16
    阿里 游戏开发 服务器
  • 教你如何用python开发一款数字推盘小游戏
    目录1、布局设计2、算法设计2.1 创建并初始化数组2.2 移动算法2.3 是否胜利检测算法3、实现3.1 框架搭建3.2 数字方块实现3.3 将数字转换成方块添加到布局3.4 初始...
    99+
    2022-11-12
  • 【小程序】快来开发你的第一个微信小游戏(详细流程)
    🥳 作者:伯子南 😎 坚信: 好记性不如乱笔头,独乐乐不如众乐乐 💪 个人主页:https://blog.csdn.net/qq_34577234spm=1010.2135.3001.54...
    99+
    2023-08-16
    小程序 微信 微信小程序
  • 怎么使用Node.js和adb开发一个手机备份小工具
    这篇“怎么使用Node.js和adb开发一个手机备份小工具”文章的知识点大部分人都不太理解,所以小编给大家总结了以下内容,内容详细,步骤清晰,具有一定的借鉴价值,希望大家阅读完这篇文章能有所收获,下面我们一...
    99+
    2022-10-19
  • 【uniapp开发小程序】实现粘贴一段文字后,自动识别到姓名/手机号/收货地址
    需求 在uni-app中开发小程序,实现粘贴一段文字后自动识别到手机号,并将手机号前面的内容作为姓名,手机号后面的内容作为收货地址,并去除其中的特殊字符和前缀标识。 一、实现效果: 二、实现方式:...
    99+
    2023-09-03
    uni-app 小程序
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作