34岁!100天!学会Java编程(Day81-Day98)—Android与Web应用一站式开发

西城高铁
快到春节了,三年多没回过老家,今年打算回家看看,跟老妈在老家过个年,带点小酒给老爸扫扫墓。一个月前西城高铁开通,北京-成都坐火车只需要9个多小时,天知道这列火车是如何穿越大秦岭的。打算体验一次回家的高速列车,但是票一如既往的抢不着,只好买到初一的票,留在北京过除夕吧。

百日总结

这一篇主要是对之前学习Java的一点总结,Android应用与Web应用是企业应用常见的两种形式。对于当前的创业性公司来说,多是把重心放在移动端App的开发上。但是个人觉得一开始设计时将两者统一规划,将核心的业务都放在服务器端,增加一些Web前端设计的工作,就可以实现一站式开发,适应更广的业务场景,这样做还可以适当减轻Android端业务逻辑处理的压力。

本案从Java零基础开始,在工作之余用一百天的业余时间,对一个“娱乐社区”(代号CE)习作的不断迭代升级开发过程中,完成了Android客户端、Web前端、服务器后端完整框架的开发,掌握了基本的全栈开发能力。

Web网站浏览和安卓apk下载地址:娱乐社区习作

(一)整体框架

整体框架
这个框架很容易理解,只是在之前Web应用的框架的基础上,在服务器前端控制器增加了JSON数据的交互接口,用于与Android端进行远程交互。

(二)统一设计风格

Android与Web应用一站式开发要求将两者作为一个产品考虑,会要求统一的设计风格。

由于Web前端设计与Android前端设计的方法相差较大,通常不会是由一个设计师开展设计,甚至对于稍大的应用,光是其一就不只一个设计师进行设计。不同的设计师的设计语言与理念通常也会有所区别,而过多的设计理念对于产品呈现会是一场灾难。此时,设计规范可以很好地解决这个问题。

适用于Web前端与Android前端的通用设计规范通常有:色彩规范、文字规范等。

对于Android应用来说还需考虑布局规范、控件规范、图标规范等。对于适用于手机浏览器的动态响应Web前端,也应尽量遵守Android设计规范。

以下是某应用的部分设计规范(示例):
设计规范示例

(三)android客户端的工作

(1)CE(v7.0)APP客户端架构

CE(v7.0)APP客户端架构

在设计过程中,考虑下面几个数据层尽量使用外观模式,简化接口。

(2)开发文档结构

开发工具使用AS,此部分大体上是上述APP架构的具体实现,由于时间有限,对功能进行了部分阉割,所以实际的开发项目会比这个复杂。


文档结构
其中,资源res部分还有不少东西,限于篇幅就不展开了。在实现过程中发现各层和模块之间交叉太多,想要实现外观模式并不容易,下次再想想办法。

(3)AndroidManifest.xml

这部分重点是应用权限的获取,应用名称和图标的修改,四大天王的注册等基本设置。我曾经因为没有设置权限,访问不了网络;曾经编写一个新的Activity后没有注册,导致跳转失败还花了些时间找原因。

(4)build.gradle(Module:app)

这个文件很重要,其中包含了SDK版本的配置,app应用版本的管理,MVVP数据绑定设置,以及最重要的中央仓库管理。仅列举我这个应用所用到的框架和库:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    //Support
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    //Material Design
    implementation 'com.android.support:design:26.1.0'
    //Vectro-Drawable
    implementation 'com.android.support:support-vector-drawable:26.1.0'
    //RecyclerView
    implementation 'com.android.support:recyclerview-v7:26.1.0'
    //CardView
    implementation 'com.android.support:cardview-v7:26.1.0'
    //ButterKnife
    implementation 'com.jakewharton:butterknife:8.8.1'
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'
    //BottomTabBar
    implementation 'com.hjm:BottomTabBar:1.1.1'
    //Retrofit,OkHttp,RxJava
    implementation 'com.squareup.retrofit2:retrofit:2.3.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava:2.3.0'
    implementation 'com.google.code.gson:gson:2.6.1'
    implementation 'com.squareup.okhttp3:okhttp:3.9.1'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.9.1'
    implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.9.1'
    implementation 'com.squareup.okio:okio:1.13.0'
    implementation 'com.jakewharton.rxbinding:rxbinding:0.4.0'
    implementation 'io.reactivex:rxandroid:1.2.1'
    implementation 'io.reactivex:rxjava:1.1.6'
    //Test
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
    //media, animation
    implementation 'com.github.bumptech.glide:glide:4.0.0'
    implementation 'com.nineoldandroids:library:2.4.0'
    //servlet
    implementation 'javax.servlet:javax.servlet-api:4.0.0'
}
(5)主要功能开发要点

1.底部Tab主菜单的实现

实现底部Tab主菜单,有很多选择,我比较了一下material design库中的BottomNavigationView和第三方支持库BottomTabBar。

BottomTabBar的突出优点就是实现简单,只需要短短几行代码就可以搞定:

mBottomTabBar.init(getSupportFragmentManager())
                .addTabItem("首页", R.drawable.icon1, HomeFragment.class)
                .addTabItem("发现", R.drawable.icon2, DiscoverFragment.class)
                .addTabItem("发起", R.drawable.icon3, PublishFragment.class)
                .addTabItem("圈子", R.drawable.icon4, CircleFragment.class)
                .addTabItem("我的", R.drawable.icon5, MineFragment.class);

而BottomNavigationView实现起来代码则要多很多:

   private void initFragments() {
        fragment1 = new HomeFragment();
        fragment2 = new DiscoverFragment();
        fragment3 = new PublishFragment();
        fragment4 = new CircleFragment();
        fragment5 = new MineFragment();
    }

    public boolean switchFragment(int fragmentid){
        transaction=getSupportFragmentManager().beginTransaction();
        lastShowFragment=fragmentid;
        boolean flag;
        switch (fragmentid) {
            case R.id.navigation_home:
                transaction.replace(R.id.fragment_container,fragment1).commit();
                return true;
            case R.id.navigation_discover:
                transaction.replace(R.id.fragment_container,fragment2).commit();
                return true;
            case R.id.navigation_publish:
                flag=LoginInterceptor.ContinueOrLogin(this,R.id.navigation_publish);
                if(!flag)transaction.replace(R.id.fragment_container,fragment3).commit();
                return true;
            case R.id.navigation_circle:
                flag=LoginInterceptor.ContinueOrLogin(this,R.id.navigation_circle);
                if(!flag)transaction.replace(R.id.fragment_container,fragment4).commit();
                return true;
            case R.id.navigation_mine:
                flag=LoginInterceptor.ContinueOrLogin(this,R.id.navigation_mine);
                if(!flag)transaction.replace(R.id.fragment_container,fragment5).commit();
                return true;
            default:
                lastShowFragment=-1;
                return false;
        }
    }

    private BottomNavigationView.OnNavigationItemSelectedListener mOnNavigationItemSelectedListener=
            new BottomNavigationView.OnNavigationItemSelectedListener() {
            @Override
            public boolean onNavigationItemSelected(@NonNull MenuItem item) {
                return switchFragment(item.getItemId());
            }
        };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
         navigation = (BottomNavigationView) findViewById(R.id.navigation);
navigation.setOnNavigationItemSelectedListener(mOnNavigationItemSelectedListener);
        BottomNavigationViewHelper.disableShiftMode(navigation);
        initFragments();
        if(savedInstanceState==null){
            switchFragment(R.id.navigation_home);
        }else{
            switchFragment(savedInstanceState.getInt("index",R.id.navigation_home));
        }
    }

public class BottomNavigationViewHelper {
    @SuppressLint("RestrictedApi")
    public static void disableShiftMode(BottomNavigationView view) {
        BottomNavigationMenuView menuView = (BottomNavigationMenuView) view.getChildAt(0);
        try {
            Field shiftingMode = menuView.getClass().getDeclaredField("mShiftingMode");
            shiftingMode.setAccessible(true);
            shiftingMode.setBoolean(menuView, false);
            shiftingMode.setAccessible(false);
            for (int i = 0; i < menuView.getChildCount(); i++) {
                BottomNavigationItemView item = (BottomNavigationItemView) menuView.getChildAt(i);
                //noinspection RestrictedApi
                item.setShiftingMode(false);
                // set once again checked value, so view will be updated
                //noinspection RestrictedApi
                item.setChecked(item.getItemData().isChecked());
            }
        } catch (NoSuchFieldException e) {
            Log.e("BNVHelper", "Unable to get shift mode field", e);
        } catch (IllegalAccessException e) {
            Log.e("BNVHelper", "Unable to change value of shift mode", e);
        }
    }

但是,最终我仍然选择了BottomNavigationView,因为两个原因:一是BottomTabBar不能使用AS自带的大量矢量图标,这是一大浪费啊;二是因为,我都花了那么多时间把BottomNavigationView搞明白了,舍不得呀!!!

2.首页RecyclerView复合布局的实现

所有实用app的首页基本都采用的是复合布局,主框架采用RecyclerView,然后将主框架分成若干层,每一层采用不同的布局或者嵌套其他的View组件,比如ViewPager。

复合布局麻烦的地方在于,不同层的Item-Layout不同,需要使用不同的ViewHolder。具体的实现代码太多就不贴了,只贴一个示意图。
复合布局 RecyclerView
在此基础上可以进一步衍生嵌套其他的View组件。

3.矢量图形资源的使用

先上图
矢量图
这是一大宝库呀,几乎大部分常用图标都能在里面找到,牛人还可以尝试自建矢量图。具体步骤为:右键点击Drawable–>New–>Vector Asset。

4.TabLayout与ViewPager的联动

这个也是安卓应用中常用的一种方式,实现起来相对简单:

    private void initFragments() {
        fragment1 = new LoginFragment();
        fragment2 = new RegisterFragment();
        fragments=new Fragment[]{fragment1,fragment2};
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_log_reg);
        mViewPager=(ViewPager)findViewById(R.id.viewpager_log_reg);
        mTabLayout= (TabLayout) findViewById(R.id.tab_log_reg);
        //初始化
        initFragments();
        //设置viewpager Adapter
        FragmentManager fragmentManager=getSupportFragmentManager();
        mViewPager.setAdapter(new FragmentStatePagerAdapter(fragmentManager) {
            @Override
            public Fragment getItem(int position) {
                return fragments[position];
            }
            @Override
            public int getCount() {
                return fragments.length;
            }
            //解决TabLayout与ViewPager联动后无标题问题!!!
            @Override
            public CharSequence getPageTitle(int position) {
                CharSequence[] list_title=new String[]{"登    录","注    册"};
                return list_title[position];
            }
        });
        //重点来了,实现联动就靠这句
        mTabLayout.setupWithViewPager(mViewPager);
    }

5.使用SharedPreferences管理Cookie

SharedPreferences是安卓系统提供的一种数据持久化机制,使用较为简洁,常用来存储Cookie中的用户登录信息、应用版本信息等。

public class CookieUtils {
    protected static final String TAG="CE7";
    private final static String PREF_COOKIE_STRINGS="CE7_cookie_strings";
    private final static String LOGIN_STATUS="is_login";
    public static class AddCookiesInterceptor implements Interceptor {
        private Context context;
        public AddCookiesInterceptor(Context context) {
            this.context = context;
        }
        @Override
        public Response intercept(Chain chain) throws IOException {
            Request.Builder builder = chain.request().newBuilder();
            //读取SharedPreferences
            HashSet<String> preferences = (HashSet) PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()).getStringSet(PREF_COOKIE_STRINGS, new HashSet<String>());

            for (String cookie : preferences) {
                builder.addHeader("Cookie", cookie);
                Log.d(TAG, "Adding Header: " + cookie); // This is done so I know which headers are being added; this interceptor is used after the normal logging of OkHttp
            }
            return chain.proceed(builder.build());
        }
    }

    public static class ReceivedCookiesInterceptor implements Interceptor {
        private Context context;
        public ReceivedCookiesInterceptor(Context context) {
            this.context = context;
        }
        @Override
        public Response intercept(Chain chain) throws IOException {
            Response originalResponse = chain.proceed(chain.request());
            if (!originalResponse.headers("Set-Cookie").isEmpty()) {
                HashSet<String> cookies = new HashSet<>();
                for (String header : originalResponse.headers("Set-Cookie")) {
                    cookies.add(header);
                }
            //写入SharedPreferences
            PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()).edit()
                        .putStringSet(PREF_COOKIE_STRINGS, cookies)
                        .apply();
            }
            return originalResponse;
        }
    }
    //从SharedPreferences获取
    public static HashSet<String> getPreferences(Context context){
         HashSet<String> preferences =(HashSet) PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()).getStringSet(PREF_COOKIE_STRINGS, new HashSet<String>());
         return preferences;
    }
    //从SharedPreferences获取List<String>
    public static List<String> getCookieStringsList(Context context){
        List<String> mCookieStringsList=new ArrayList<>();
        for(String str:getPreferences(context)){
            mCookieStringsList.add(str);
        }
        return mCookieStringsList;
    }

    //从SharedPreferences或者response获取List<Cookie>
    public static List<Cookie> getCookieList(Context context, List<String> mCookieStringsList){
        List<Cookie> mCookieList=new ArrayList<>();
        for(int i=0;i<mCookieStringsList.size();i++){
            String[] content=SplitCookieString(mCookieStringsList.get(i));
            Cookie cookie=new Cookie(content[0],content[1]);
            mCookieList.add(cookie);
        }
        return mCookieList;
    }

    //从SharedPreferences获取CookiesKVMap
    public static Map<String,String> getCookieKvMap(Context context){
        Map<String,String> mCookieKvMap=new HashMap<>();
        List<Cookie> mCookieList=getCookieList(context,getCookieStringsList(context));
        for(int i=0;i<mCookieList.size();i++) {
          mCookieKvMap.put(mCookieList.get(i).getName(),mCookieList.get(i).getValue());
        }
        return  mCookieKvMap;
    }

    public static String[] SplitCookieString(String cookie_string){
        String[] firstsplit=cookie_string.split(";",2);
        String[] secondsplit=firstsplit[0].split("=",2);
        return secondsplit;
    }
}

先开发出一个基于SharedPreferences的CookieUtils类,然后基于此类在业务层可以开发出Cookie的拦截器、登录令牌管理、退出登录等业务逻辑。

(6)开发过程中的那些坑

简直太多了,这个过程中我倒是熟练掌握了AS的调试方法。这里仅列举我印象比较深刻的几个事。

1.使用MVVP绑定ViewModel和Layout中图片资源问题

问题描述:MVVP绑定传统数据不成问题,但是绑定ImageView:src属性时无法显示。
解决办法:在ActiveViewModel中添加一个适配器搞定:

    @BindingAdapter("android:src")
    public static void setSrc(ImageView view, int resId) {
        view.setImageResource(resId);
    }

2.在TableLayout中使用EditText:inputType=”textMultiLine”控件无法自动换行问题。

问题描述:我有一个最多输入100个字的EditText,放到TableLayout中无法自动换行了。
解决办法:在EditText的属性中加一行搞定:

android:layout_weight="0"

3.TabLayout与ViewPager联动时,会自动删除Item-Tab问题。

问题描述:这两者关联起来后,会莫名其妙删除TabLayout的Item-Tab标签。
解决办法:在TabLayout中删掉Item-Tab,然后在ViewPager的Adapter中加一段搞定:

            @Override
            public CharSequence getPageTitle(int position) {
                CharSequence[] list_title=new String[]{"登    录","注    册"};
                return list_title[position];
            }

还有太多槽点,就不一一列举了。

(四)服务器端的工作

详见完整Web应用开发与升级
这里补充JSON数据交互接口的编写。

@RestController
public class AndroidCon {
    @Autowired
    private BLLServer bllserver;

    @ResponseBody  
    @RequestMapping(value="androidlogin", produces = "text/json;charset=UTF-8")
    public String androidlogin(HttpServletRequest request,HttpServletResponse response) throws JsonGenerationException, JsonMappingException, IOException {
        //解析请求表单数据
                String username=request.getParameter("username");
        String password1=request.getParameter("password1");
        String password2=request.getParameter("password2");
                //获取cookies
                Cookie[] cookies=request.getCookies();
        //请求业务层
        int result=-1;
        if(username!=null&&password!=null) {
            result=bllserver.Login(username, password);
        }
                //添加cookie
        String mTime=String.valueOf(new SimpleDateFormat("yy-MM-dd HH:mm").format(new Date()));
        String   str=   java.net.URLEncoder.encode(mTime,"UTF-8"); 
        if(result==2) {                        
            Cookie mCookie_time=new Cookie("last_visit_time",str);
            Cookie mCookie_islogin=new Cookie("is_login", "true");
            mCookie_islogin.setMaxAge(60*60*24*10);
            str   =   java.net.URLEncoder.encode(username,"UTF-8"); 
            Cookie mCookie_username=new Cookie("user_name", str);
            response.addCookie(mCookie_time);
            response.addCookie(mCookie_islogin);
            response.addCookie(mCookie_username);
        }
        //返回响应数据
        HttpBean.Result r=new HttpBean().InitiateResult();
        r.setResult(String.valueOf(result));
        ObjectMapper mapper = new ObjectMapper(); 
        String jsonString = mapper.writeValueAsString(r);
        System.out.println("访问成功,result="+r.getResult());
        return jsonString;
    }

    @RequestMapping(value="androidregister", produces = "text/plain;charset=UTF-8")
    public @ResponseBody String androidregister(HttpServletRequest request,HttpServletResponse response) throws JsonGenerationException, JsonMappingException, IOException {
        //解析请求数据
        //请求业务层
        //添加cookie
        //返回响应数
    }

    @ResponseBody  
    @RequestMapping(value="androidpublish", produces = "text/plain;charset=UTF-8")
    public String androidpublish(HttpServletRequest request,HttpServletResponse response) throws JsonGenerationException, JsonMappingException, IOException {
        //解析请求数据
        //请求业务层
        //添加cookie
        //返回响应数据
    }
}

为了在网站提供android App下载,在控制层增加了下载功能模块:

@Controller
public class DownloadService {
    @Autowired
    private BLLServer bllserver;
    private final static String FILENAME="app.apk";

    @RequestMapping("apkdownload")
    public ResponseEntity<byte[]> download(HttpServletRequest request) throws IOException {
        String filePath=request.getServletContext().getRealPath("/WEB-INF/file/download/");
        String fileName=FILENAME;
        File file = new File(filePath+fileName);
        byte[] body = null;
        InputStream is = new FileInputStream(file);
        body = new byte[is.available()];
        is.read(body);
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Disposition", "attchement;filename=" + file.getName());
        HttpStatus statusCode = HttpStatus.OK;
        ResponseEntity<byte[]> entity = new ResponseEntity<byte[]>(body, headers, statusCode);
        is.close();
        return entity;
    }

为了在提供用户头像和活动宣传照片的上传,在控制层增加了上传功能模块:

@Controller
public class UploadService {
    @RequestMapping("upload")
    public String upload(HttpServletRequest request,
           @RequestParam(value="file") MultipartFile originalfile) throws Exception {
       //如果原始文件不为空,写入目标文件
       if(!originalfile.isEmpty()) {
           //上传文件路径        
           String path =request.getServletContext().getRealPath("/WEB-INF/file/upload/");
           //目标文件名
           String filename = originalfile.getOriginalFilename();
           //创建目标文件
           File targetfile = new File(path,filename);
           //判断目标文件路径是否存在,如果不存在就创建一个
           if (!targetfile.getParentFile().exists()) { 
               targetfile.getParentFile().mkdirs();
           }
           //将上传文件保存到一个目标文件当中
           originalfile.transferTo(targetfile);
           return "index";
       } else {
           return "hello";
       }
    }

    public static String uploadimg(HttpServletRequest request,MultipartFile originalfile) throws Exception {
        String visit_path=null;
       //如果原始文件不为空,写入目标文件
       if(!originalfile.isEmpty()) {
           //目标文件路径
           String path=request.getServletContext().getRealPath("/WEB-INF/file/upload/");
           //目标文件名
           String filename = originalfile.getOriginalFilename();
           String[] departname=filename.split("\\.",2);
           UUID fileid=UUID.randomUUID();
           String targetfilename=fileid.toString()+"."+departname[1];
           //创建目标文件
           File targetfile = new File(path,targetfilename);
           //判断目标文件路径是否存在,如果不存在就创建一个
           if (!targetfile.getParentFile().exists()) { 
               targetfile.getParentFile().mkdirs();
           }
           //将上传文件保存到一个目标文件当中
           originalfile.transferTo(targetfile);
           //创建访问路径
           visit_path="/CommunityEntertain6/file/upload/"+targetfilename;
           return visit_path;
       } else {
           return "error";
       }
    }
}

其中,第一个方法供客户端直接调用,第二个方法供服务器内部调用,为每一张上传的图片生成唯一的文件名存储在文件夹中,并且将文件路径保存在用户和活动数据库中。

(五)web前端的工作

详见Web前端编程
本部分补充了用户头像和活动照片上传功能,并且在图片上传前提供图片预览功能。图片预览的Html和JavaScript代码如下:

    <form method="post" action="postregister" enctype="multipart/form-data"  class="form-group">
      <h3>请您注册</h3>
      <br>
      <p class="form-inline">
        <label class="input-group">用户名&emsp;&emsp;</label>
        <input type="text" name="username" class="form-control" placeholder="username">
      </p>
      <p class="form-inline">
        <label class="input-group">密&emsp;码&emsp;&emsp;</label>
        <input type="password" name="password1" class="form-control" placeholder="password">
      </p>
      <p class="form-inline">
        <label class="input-group">密&emsp;码&emsp;&emsp;</label>
        <input type="password" name="password2" class="form-control" placeholder="confirm password">
      </p>
      <p class="form-inline" >
      <table width="100%" border="0" cellspacing="0" cellpadding="0">  
        <tbody>  
            <tr>  
                <td align="center" style="padding-top:10px;">
                    <label class="form-control btn-primary" for="xFile"  style="display: block; width: 100px;">上传头像</label>
                </td>  
                <td height="101" align="center">  
                    <div id="localImag">
                        <img id="preview" src="" alt="portrait"  style="display: block; width: 180px; height: 150px;">
                    </div>  
                </td>  
            </tr>   
        </tbody>  
    </table>
        <div id="InfoDiv"></div>        
        <input type="file"  id="xFile" name="originalfile" accept="image/*" onchange="PreviewImg(this)" style="position:absolute;clip:rect(0 0 0 0);">
    </p>
    <p>
        <input type="submit" name="submit" class="form-control btn-primary" value="注&emsp;册">
      </p>
</form>
<script type="text/javascript">  
//判断浏览器是否支持FileReader接口
if (typeof FileReader == 'undefined') {
    document.getElementById("InfoDiv").InnerHTML = "<h1>当前浏览器不支持FileReader接口</h1>";
    //使选择控件不可操作
    document.getElementById("xFile").setAttribute("disabled", "disabled");
}
//选择图片,马上预览
function PreviewImg(obj) {
    var file = obj.files[0];
    var reader = new FileReader();
    reader.onload = function (e) {
        var img = document.getElementById("preview");
        img.src = e.target.result;
        //或者 img.src = this.result;  //e.target == this
    }
    reader.readAsDataURL(file);
}
</script>

(六)毕业感言

学习Java是我设定的第一个百日计划,目的是通过上班之外的业余时间自学,基本掌握Android和Web应用全栈编程的能力。目前看来基本能力已经具备,尚缺的是更加底层的知识、不同行业应用场景的应对、以及用户界面的设计。

这些都还需要花大量时间,不过基于对自己的定位,下一阶段我的目标是向行业专业领域进军,将自己对于生活的想象力释放出来。

眼下最紧要的,是……休息……休息,好好……补觉。

上两张图纪念我的第一个百日计划。
我的服务器
我的服务器
我的战果
我的战果

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值