快到春节了,三年多没回过老家,今年打算回家看看,跟老妈在老家过个年,带点小酒给老爸扫扫墓。一个月前西城高铁开通,北京-成都坐火车只需要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">用户名  </label>
<input type="text" name="username" class="form-control" placeholder="username">
</p>
<p class="form-inline">
<label class="input-group">密 码  </label>
<input type="password" name="password1" class="form-control" placeholder="password">
</p>
<p class="form-inline">
<label class="input-group">密 码  </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="注 册">
</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应用全栈编程的能力。目前看来基本能力已经具备,尚缺的是更加底层的知识、不同行业应用场景的应对、以及用户界面的设计。
这些都还需要花大量时间,不过基于对自己的定位,下一阶段我的目标是向行业专业领域进军,将自己对于生活的想象力释放出来。
眼下最紧要的,是……休息……休息,好好……补觉。
上两张图纪念我的第一个百日计划。
我的服务器
我的战果
网友评论